Add support for templated values in SSH CA DefaultExtensions. (#11495)

* Add support for templated values in SSH CA DefaultExtensions.

* Reworking the logic per feedback, adding basic test.

* Adding test, so we cover both default extension templating & ignoring default when user-provided extensions are present.

* Fixed up an unintentional extension handling defect, added test to cover the case.

* Refactor Default Extension tests into `enabled` and `disabled`.
This commit is contained in:
Robison Jacka 2021-05-13 14:37:22 -07:00 committed by GitHub
parent e56982f782
commit 491f71faf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 366 additions and 78 deletions

View File

@ -19,8 +19,10 @@ import (
"golang.org/x/crypto/ssh"
"github.com/hashicorp/vault/builtin/credential/userpass"
"github.com/hashicorp/vault/helper/testhelpers/docker"
logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/vault"
"github.com/mitchellh/mapstructure"
)
@ -122,6 +124,7 @@ SjOQL/GkH1nkRcDS9++aAAAAAmNhAQID
dockerImageTagSupportsRSA1 = "8.1_p1-r0-ls20"
dockerImageTagSupportsNoRSA1 = "8.4_p1-r3-ls48"
)
func prepareTestContainer(t *testing.T, tag, caPublicKeyPEM string) (func(), string) {
@ -158,7 +161,7 @@ func prepareTestContainer(t *testing.T, tag, caPublicKeyPEM string) (func(), str
// Install util-linux for non-busybox flock that supports timeout option
err = testSSH("vaultssh", sshAddress, ssh.PublicKeys(signer), fmt.Sprintf(`
set -e;
set -e;
sudo ln -s /config /home/vaultssh
sudo apk add util-linux;
echo "LogLevel DEBUG" | sudo tee -a /config/ssh_host_keys/sshd_config;
@ -1318,6 +1321,252 @@ func TestBackend_DisallowUserProvidedKeyIDs(t *testing.T) {
logicaltest.Test(t, testCase)
}
func TestBackend_DefExtTemplatingEnabled(t *testing.T) {
cluster, userpassToken := getSshCaTestCluster(t, testUserName)
defer cluster.Cleanup()
client := cluster.Cores[0].Client
// Get auth accessor for identity template.
auths, err := client.Sys().ListAuth()
if err != nil {
t.Fatal(err)
}
userpassAccessor := auths["userpass/"].Accessor
// Write SSH role.
_, err = client.Logical().Write("ssh/roles/test", map[string]interface{}{
"key_type": "ca",
"allowed_extensions": "login@zipzap.com",
"allow_user_certificates": true,
"allowed_users": "tuber",
"default_user": "tuber",
"default_extensions_template": true,
"default_extensions": map[string]interface{}{
"login@foobar.com": "{{identity.entity.aliases." + userpassAccessor + ".name}}",
},
})
if err != nil {
t.Fatal(err)
}
sshKeyID := "vault-userpass-"+testUserName+"-9bd0f01b7dfc50a13aa5e5cd11aea19276968755c8f1f9c98965d04147f30ed0"
// Issue SSH certificate with default extensions templating enabled, and no user-provided extensions
client.SetToken(userpassToken)
resp, err := client.Logical().Write("ssh/sign/test", map[string]interface{}{
"public_key": publicKey4096,
})
if err != nil {
t.Fatal(err)
}
signedKey := resp.Data["signed_key"].(string)
key, _ := base64.StdEncoding.DecodeString(strings.Split(signedKey, " ")[1])
parsedKey, err := ssh.ParsePublicKey(key)
if err != nil {
t.Fatal(err)
}
defaultExtensionPermissions := map[string]string{
"login@foobar.com": testUserName,
}
err = validateSSHCertificate(parsedKey.(*ssh.Certificate), sshKeyID, ssh.UserCert, []string{"tuber"}, map[string]string{}, defaultExtensionPermissions, 16*time.Hour)
if err != nil {
t.Fatal(err)
}
// Issue SSH certificate with default extensions templating enabled, and user-provided extensions
// The certificate should only have the user-provided extensions, and no templated extensions
userProvidedExtensionPermissions := map[string]string{
"login@zipzap.com": "some_other_user_name",
}
resp, err = client.Logical().Write("ssh/sign/test", map[string]interface{}{
"public_key": publicKey4096,
"extensions": userProvidedExtensionPermissions,
})
if err != nil {
t.Fatal(err)
}
signedKey = resp.Data["signed_key"].(string)
key, _ = base64.StdEncoding.DecodeString(strings.Split(signedKey, " ")[1])
parsedKey, err = ssh.ParsePublicKey(key)
if err != nil {
t.Fatal(err)
}
err = validateSSHCertificate(parsedKey.(*ssh.Certificate), sshKeyID, ssh.UserCert, []string{"tuber"}, map[string]string{}, userProvidedExtensionPermissions, 16*time.Hour)
if err != nil {
t.Fatal(err)
}
// Issue SSH certificate with default extensions templating enabled, and invalid user-provided extensions - it should fail
invalidUserProvidedExtensionPermissions := map[string]string{
"login@foobar.com": "{{identity.entity.metadata}}",
}
resp, err = client.Logical().Write("ssh/sign/test", map[string]interface{}{
"public_key": publicKey4096,
"extensions": invalidUserProvidedExtensionPermissions,
})
if err == nil {
t.Fatal("expected an error while attempting to sign a key with invalid permissions")
}
}
func TestBackend_DefExtTemplatingDisabled(t *testing.T) {
cluster, userpassToken := getSshCaTestCluster(t, testUserName)
defer cluster.Cleanup()
client := cluster.Cores[0].Client
// Get auth accessor for identity template.
auths, err := client.Sys().ListAuth()
if err != nil {
t.Fatal(err)
}
userpassAccessor := auths["userpass/"].Accessor
// Write SSH role to test with any extension. We also provide a templated default extension,
// to verify that it's not actually being evaluated
_, err = client.Logical().Write("ssh/roles/test_allow_all_extensions", map[string]interface{}{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "tuber",
"default_user": "tuber",
"default_extensions_template": false,
"default_extensions": map[string]interface{}{
"login@foobar.com": "{{identity.entity.aliases." + userpassAccessor + ".name}}",
},
})
if err != nil {
t.Fatal(err)
}
sshKeyID := "vault-userpass-"+testUserName+"-9bd0f01b7dfc50a13aa5e5cd11aea19276968755c8f1f9c98965d04147f30ed0"
// Issue SSH certificate with default extensions templating disabled, and no user-provided extensions
client.SetToken(userpassToken)
defaultExtensionPermissions := map[string]string{
"login@foobar.com": "{{identity.entity.aliases." + userpassAccessor + ".name}}",
"login@zipzap.com": "some_other_user_name",
}
resp, err := client.Logical().Write("ssh/sign/test_allow_all_extensions", map[string]interface{}{
"public_key": publicKey4096,
"extensions": defaultExtensionPermissions,
})
if err != nil {
t.Fatal(err)
}
signedKey := resp.Data["signed_key"].(string)
key, _ := base64.StdEncoding.DecodeString(strings.Split(signedKey, " ")[1])
parsedKey, err := ssh.ParsePublicKey(key)
if err != nil {
t.Fatal(err)
}
err = validateSSHCertificate(parsedKey.(*ssh.Certificate), sshKeyID, ssh.UserCert, []string{"tuber"}, map[string]string{}, defaultExtensionPermissions, 16*time.Hour)
if err != nil {
t.Fatal(err)
}
// Issue SSH certificate with default extensions templating disabled, and user-provided extensions
client.SetToken(userpassToken)
userProvidedAnyExtensionPermissions := map[string]string{
"login@foobar.com": "not_userpassname",
"login@zipzap.com": "some_other_user_name",
}
resp, err = client.Logical().Write("ssh/sign/test_allow_all_extensions", map[string]interface{}{
"public_key": publicKey4096,
"extensions": userProvidedAnyExtensionPermissions,
})
if err != nil {
t.Fatal(err)
}
signedKey = resp.Data["signed_key"].(string)
key, _ = base64.StdEncoding.DecodeString(strings.Split(signedKey, " ")[1])
parsedKey, err = ssh.ParsePublicKey(key)
if err != nil {
t.Fatal(err)
}
err = validateSSHCertificate(parsedKey.(*ssh.Certificate), sshKeyID, ssh.UserCert, []string{"tuber"}, map[string]string{}, userProvidedAnyExtensionPermissions, 16*time.Hour)
if err != nil {
t.Fatal(err)
}
}
func getSshCaTestCluster(t *testing.T, userIdentity string) (*vault.TestCluster, string) {
coreConfig := &vault.CoreConfig{
CredentialBackends: map[string]logical.Factory{
"userpass": userpass.Factory,
},
LogicalBackends: map[string]logical.Factory{
"ssh": Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
client := cluster.Cores[0].Client
// Write test policy for userpass auth method.
err := client.Sys().PutPolicy("test", `
path "ssh/*" {
capabilities = ["update"]
}`)
if err != nil {
t.Fatal(err)
}
// Enable userpass auth method.
if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil {
t.Fatal(err)
}
// Configure test role for userpass.
if _, err := client.Logical().Write("auth/userpass/users/"+userIdentity, map[string]interface{}{
"password": "test",
"policies": "test",
}); err != nil {
t.Fatal(err)
}
// Login userpass for test role and keep client token.
secret, err := client.Logical().Write("auth/userpass/login/"+userIdentity, map[string]interface{}{
"password": "test",
})
if err != nil || secret == nil {
t.Fatal(err)
}
userpassToken := secret.Auth.ClientToken
// Mount SSH.
err = client.Sys().Mount("ssh", &api.MountInput{
Type: "ssh",
Config: api.MountConfigInput{
DefaultLeaseTTL: "16h",
MaxLeaseTTL: "60h",
},
})
if err != nil {
t.Fatal(err)
}
// Configure SSH CA.
_, err = client.Logical().Write("ssh/config/ca", map[string]interface{}{
"public_key": testCAPublicKey,
"private_key": testCAPrivateKey,
})
if err != nil {
t.Fatal(err)
}
return cluster, userpassToken
}
func configCaStep(caPublicKey, caPrivateKey string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
@ -1391,7 +1640,7 @@ func validateSSHCertificate(cert *ssh.Certificate, keyID string, certType int, v
actualTTL := time.Unix(int64(cert.ValidBefore), 0).Add(-30 * time.Second).Sub(time.Unix(int64(cert.ValidAfter), 0))
if actualTTL != ttl {
return fmt.Errorf("incorrect ttl: expected: %v, actualL %v", ttl, actualTTL)
return fmt.Errorf("incorrect ttl: expected: %v, actual %v", ttl, actualTTL)
}
if !reflect.DeepEqual(cert.ValidPrincipals, validPrincipals) {

View File

@ -26,33 +26,34 @@ const (
// for both OTP and Dynamic roles. Not all the fields are mandatory for both type.
// Some are applicable for one and not for other. It doesn't matter.
type sshRole struct {
KeyType string `mapstructure:"key_type" json:"key_type"`
KeyName string `mapstructure:"key" json:"key"`
KeyBits int `mapstructure:"key_bits" json:"key_bits"`
AdminUser string `mapstructure:"admin_user" json:"admin_user"`
DefaultUser string `mapstructure:"default_user" json:"default_user"`
CIDRList string `mapstructure:"cidr_list" json:"cidr_list"`
ExcludeCIDRList string `mapstructure:"exclude_cidr_list" json:"exclude_cidr_list"`
Port int `mapstructure:"port" json:"port"`
InstallScript string `mapstructure:"install_script" json:"install_script"`
AllowedUsers string `mapstructure:"allowed_users" json:"allowed_users"`
AllowedUsersTemplate bool `mapstructure:"allowed_users_template" json:"allowed_users_template"`
AllowedDomains string `mapstructure:"allowed_domains" json:"allowed_domains"`
KeyOptionSpecs string `mapstructure:"key_option_specs" json:"key_option_specs"`
MaxTTL string `mapstructure:"max_ttl" json:"max_ttl"`
TTL string `mapstructure:"ttl" json:"ttl"`
DefaultCriticalOptions map[string]string `mapstructure:"default_critical_options" json:"default_critical_options"`
DefaultExtensions map[string]string `mapstructure:"default_extensions" json:"default_extensions"`
AllowedCriticalOptions string `mapstructure:"allowed_critical_options" json:"allowed_critical_options"`
AllowedExtensions string `mapstructure:"allowed_extensions" json:"allowed_extensions"`
AllowUserCertificates bool `mapstructure:"allow_user_certificates" json:"allow_user_certificates"`
AllowHostCertificates bool `mapstructure:"allow_host_certificates" json:"allow_host_certificates"`
AllowBareDomains bool `mapstructure:"allow_bare_domains" json:"allow_bare_domains"`
AllowSubdomains bool `mapstructure:"allow_subdomains" json:"allow_subdomains"`
AllowUserKeyIDs bool `mapstructure:"allow_user_key_ids" json:"allow_user_key_ids"`
KeyIDFormat string `mapstructure:"key_id_format" json:"key_id_format"`
AllowedUserKeyLengths map[string]int `mapstructure:"allowed_user_key_lengths" json:"allowed_user_key_lengths"`
AlgorithmSigner string `mapstructure:"algorithm_signer" json:"algorithm_signer"`
KeyType string `mapstructure:"key_type" json:"key_type"`
KeyName string `mapstructure:"key" json:"key"`
KeyBits int `mapstructure:"key_bits" json:"key_bits"`
AdminUser string `mapstructure:"admin_user" json:"admin_user"`
DefaultUser string `mapstructure:"default_user" json:"default_user"`
CIDRList string `mapstructure:"cidr_list" json:"cidr_list"`
ExcludeCIDRList string `mapstructure:"exclude_cidr_list" json:"exclude_cidr_list"`
Port int `mapstructure:"port" json:"port"`
InstallScript string `mapstructure:"install_script" json:"install_script"`
AllowedUsers string `mapstructure:"allowed_users" json:"allowed_users"`
AllowedUsersTemplate bool `mapstructure:"allowed_users_template" json:"allowed_users_template"`
AllowedDomains string `mapstructure:"allowed_domains" json:"allowed_domains"`
KeyOptionSpecs string `mapstructure:"key_option_specs" json:"key_option_specs"`
MaxTTL string `mapstructure:"max_ttl" json:"max_ttl"`
TTL string `mapstructure:"ttl" json:"ttl"`
DefaultCriticalOptions map[string]string `mapstructure:"default_critical_options" json:"default_critical_options"`
DefaultExtensions map[string]string `mapstructure:"default_extensions" json:"default_extensions"`
DefaultExtensionsTemplate bool `mapstructure:"default_extensions_template" json:"default_extensions_template"`
AllowedCriticalOptions string `mapstructure:"allowed_critical_options" json:"allowed_critical_options"`
AllowedExtensions string `mapstructure:"allowed_extensions" json:"allowed_extensions"`
AllowUserCertificates bool `mapstructure:"allow_user_certificates" json:"allow_user_certificates"`
AllowHostCertificates bool `mapstructure:"allow_host_certificates" json:"allow_host_certificates"`
AllowBareDomains bool `mapstructure:"allow_bare_domains" json:"allow_bare_domains"`
AllowSubdomains bool `mapstructure:"allow_subdomains" json:"allow_subdomains"`
AllowUserKeyIDs bool `mapstructure:"allow_user_key_ids" json:"allow_user_key_ids"`
KeyIDFormat string `mapstructure:"key_id_format" json:"key_id_format"`
AllowedUserKeyLengths map[string]int `mapstructure:"allowed_user_key_lengths" json:"allowed_user_key_lengths"`
AlgorithmSigner string `mapstructure:"algorithm_signer" json:"algorithm_signer"`
}
func pathListRoles(b *backend) *framework.Path {
@ -267,6 +268,15 @@ func pathRoles(b *backend) *framework.Path {
"allowed_extensions". Defaults to none.
`,
},
"default_extensions_template": {
Type: framework.TypeBool,
Description: `
[Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type]
If set, Default extension values can be specified using identity template policies.
Non-templated extension values are also permitted.
`,
Default: false,
},
"allow_user_certificates": {
Type: framework.TypeBool,
Description: `
@ -334,7 +344,7 @@ func pathRoles(b *backend) *framework.Path {
"algorithm_signer": {
Type: framework.TypeString,
Description: `
When supplied, this value specifies a signing algorithm for the key. Possible values:
When supplied, this value specifies a signing algorithm for the key. Possible values:
ssh-rsa, rsa-sha2-256, rsa-sha2-512.
`,
DisplayAttrs: &framework.DisplayAttributes{
@ -514,20 +524,21 @@ func (b *backend) createCARole(allowedUsers, defaultUser, signer string, data *f
ttl := time.Duration(data.Get("ttl").(int)) * time.Second
maxTTL := time.Duration(data.Get("max_ttl").(int)) * time.Second
role := &sshRole{
AllowedCriticalOptions: data.Get("allowed_critical_options").(string),
AllowedExtensions: data.Get("allowed_extensions").(string),
AllowUserCertificates: data.Get("allow_user_certificates").(bool),
AllowHostCertificates: data.Get("allow_host_certificates").(bool),
AllowedUsers: allowedUsers,
AllowedUsersTemplate: data.Get("allowed_users_template").(bool),
AllowedDomains: data.Get("allowed_domains").(string),
DefaultUser: defaultUser,
AllowBareDomains: data.Get("allow_bare_domains").(bool),
AllowSubdomains: data.Get("allow_subdomains").(bool),
AllowUserKeyIDs: data.Get("allow_user_key_ids").(bool),
KeyIDFormat: data.Get("key_id_format").(string),
KeyType: KeyTypeCA,
AlgorithmSigner: signer,
AllowedCriticalOptions: data.Get("allowed_critical_options").(string),
AllowedExtensions: data.Get("allowed_extensions").(string),
AllowUserCertificates: data.Get("allow_user_certificates").(bool),
AllowHostCertificates: data.Get("allow_host_certificates").(bool),
AllowedUsers: allowedUsers,
AllowedUsersTemplate: data.Get("allowed_users_template").(bool),
AllowedDomains: data.Get("allowed_domains").(string),
DefaultUser: defaultUser,
AllowBareDomains: data.Get("allow_bare_domains").(bool),
AllowSubdomains: data.Get("allow_subdomains").(bool),
AllowUserKeyIDs: data.Get("allow_user_key_ids").(bool),
DefaultExtensionsTemplate: data.Get("default_extensions_template").(bool),
KeyIDFormat: data.Get("key_id_format").(string),
KeyType: KeyTypeCA,
AlgorithmSigner: signer,
}
if !role.AllowUserCertificates && !role.AllowHostCertificates {
@ -600,26 +611,27 @@ func (b *backend) parseRole(role *sshRole) (map[string]interface{}, error) {
}
result = map[string]interface{}{
"allowed_users": role.AllowedUsers,
"allowed_users_template": role.AllowedUsersTemplate,
"allowed_domains": role.AllowedDomains,
"default_user": role.DefaultUser,
"ttl": int64(ttl.Seconds()),
"max_ttl": int64(maxTTL.Seconds()),
"allowed_critical_options": role.AllowedCriticalOptions,
"allowed_extensions": role.AllowedExtensions,
"allow_user_certificates": role.AllowUserCertificates,
"allow_host_certificates": role.AllowHostCertificates,
"allow_bare_domains": role.AllowBareDomains,
"allow_subdomains": role.AllowSubdomains,
"allow_user_key_ids": role.AllowUserKeyIDs,
"key_id_format": role.KeyIDFormat,
"key_type": role.KeyType,
"key_bits": role.KeyBits,
"default_critical_options": role.DefaultCriticalOptions,
"default_extensions": role.DefaultExtensions,
"allowed_user_key_lengths": role.AllowedUserKeyLengths,
"algorithm_signer": role.AlgorithmSigner,
"allowed_users": role.AllowedUsers,
"allowed_users_template": role.AllowedUsersTemplate,
"allowed_domains": role.AllowedDomains,
"default_user": role.DefaultUser,
"ttl": int64(ttl.Seconds()),
"max_ttl": int64(maxTTL.Seconds()),
"allowed_critical_options": role.AllowedCriticalOptions,
"allowed_extensions": role.AllowedExtensions,
"allow_user_certificates": role.AllowUserCertificates,
"allow_host_certificates": role.AllowHostCertificates,
"allow_bare_domains": role.AllowBareDomains,
"allow_subdomains": role.AllowSubdomains,
"allow_user_key_ids": role.AllowUserKeyIDs,
"key_id_format": role.KeyIDFormat,
"key_type": role.KeyType,
"key_bits": role.KeyBits,
"default_critical_options": role.DefaultCriticalOptions,
"default_extensions": role.DefaultExtensions,
"default_extensions_template": role.DefaultExtensionsTemplate,
"allowed_user_key_lengths": role.AllowedUserKeyLengths,
"algorithm_signer": role.AlgorithmSigner,
}
case KeyTypeDynamic:
result = map[string]interface{}{

View File

@ -155,7 +155,7 @@ func (b *backend) pathSignCertificate(ctx context.Context, req *logical.Request,
return logical.ErrorResponse(err.Error()), nil
}
extensions, err := b.calculateExtensions(data, role)
extensions, err := b.calculateExtensions(data, req, role)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
@ -356,27 +356,51 @@ func (b *backend) calculateCriticalOptions(data *framework.FieldData, role *sshR
return criticalOptions, nil
}
func (b *backend) calculateExtensions(data *framework.FieldData, role *sshRole) (map[string]string, error) {
func (b *backend) calculateExtensions(data *framework.FieldData, req *logical.Request, role *sshRole) (map[string]string, error) {
unparsedExtensions := data.Get("extensions").(map[string]interface{})
if len(unparsedExtensions) == 0 {
return role.DefaultExtensions, nil
}
extensions := make(map[string]string)
extensions := convertMapToStringValue(unparsedExtensions)
if len(unparsedExtensions) > 0 {
extensions := convertMapToStringValue(unparsedExtensions)
if role.AllowedExtensions != "" {
notAllowed := []string{}
allowedExtensions := strings.Split(role.AllowedExtensions, ",")
if role.AllowedExtensions != "" {
notAllowed := []string{}
allowedExtensions := strings.Split(role.AllowedExtensions, ",")
for extensionKey, _ := range extensions {
if !strutil.StrListContains(allowedExtensions, extensionKey) {
notAllowed = append(notAllowed, extensionKey)
}
}
for extension := range extensions {
if !strutil.StrListContains(allowedExtensions, extension) {
notAllowed = append(notAllowed, extension)
if len(notAllowed) != 0 {
return nil, fmt.Errorf("extensions %v are not on allowed list", notAllowed)
}
}
return extensions, nil
}
if len(notAllowed) != 0 {
return nil, fmt.Errorf("extensions %v are not on allowed list", notAllowed)
if role.DefaultExtensionsTemplate {
for extensionKey, extensionValue := range role.DefaultExtensions {
// Look for templating markers {{ .* }}
matched, _ := regexp.MatchString(`^{{.+?}}$`, extensionValue)
if matched {
if req.EntityID != "" {
// Retrieve extension value based on template + entityID from request.
templateExtensionValue, err := framework.PopulateIdentityTemplate(extensionValue, req.EntityID, b.System())
if err == nil {
// Template returned an extension value that we can use
extensions[extensionKey] = templateExtensionValue
} else {
return nil, fmt.Errorf("template '%s' could not be rendered -> %s", extensionValue, err)
}
}
} else {
// Static extension value or err template
extensions[extensionKey] = extensionValue
}
}
} else {
extensions = role.DefaultExtensions
}
return extensions, nil

3
changelog/11495.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ssh: add support for templated values in SSH CA DefaultExtensions
```