diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go index baf60e055..b4253ba1c 100644 --- a/builtin/logical/ssh/backend_test.go +++ b/builtin/logical/ssh/backend_test.go @@ -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) { diff --git a/builtin/logical/ssh/path_roles.go b/builtin/logical/ssh/path_roles.go index 5a4f47cb3..0b1ef84ec 100644 --- a/builtin/logical/ssh/path_roles.go +++ b/builtin/logical/ssh/path_roles.go @@ -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{}{ diff --git a/builtin/logical/ssh/path_sign.go b/builtin/logical/ssh/path_sign.go index 8ab26f0c9..acd7d2118 100644 --- a/builtin/logical/ssh/path_sign.go +++ b/builtin/logical/ssh/path_sign.go @@ -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 diff --git a/changelog/11495.txt b/changelog/11495.txt new file mode 100644 index 000000000..d529872f3 --- /dev/null +++ b/changelog/11495.txt @@ -0,0 +1,3 @@ +```release-note:feature +ssh: add support for templated values in SSH CA DefaultExtensions +``` \ No newline at end of file