From 02dd28f587dc23a294c5b6e0f24767d3f2f6d87f Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Mon, 8 Aug 2022 15:15:03 -0400 Subject: [PATCH] Add support for SSH private key passphrases (#2331) This adds support for encrypted SSH keys via passphrase. Co-authored-by: Louis Ruch Co-authored-by: Michael Gaffney --- api/credentials/option.gen.go | 24 +++ .../ssh_private_key_attributes.gen.go | 8 +- internal/cmd/commands/connect/credentials.go | 1 + internal/cmd/commands/connect/ssh.go | 20 ++- internal/cmd/commands/credentialscmd/funcs.go | 14 +- .../credentialscmd/ssh-private-key_funcs.go | 87 ++++++++- internal/credential/credential.go | 4 +- internal/credential/static/fields.go | 11 +- internal/credential/static/options.go | 16 +- internal/credential/static/options_test.go | 7 + .../static/repository_credential.go | 68 +++++-- .../static/repository_credential_test.go | 143 ++++++++++++++- .../static/repository_credentials_test.go | 12 +- .../static/ssh_private_key_credential.go | 107 +++++++++-- .../static/ssh_private_key_credential_test.go | 38 ++++ internal/credential/static/store/static.pb.go | 68 ++++++- .../internal/sshprivatekey/sshprivatekey.go | 73 +++++--- .../sshprivatekey/sshprivatekey_test.go | 125 +++++++------ .../credential/vault/mapping_overriders.go | 12 +- internal/credential/vault/options.go | 17 +- internal/credential/vault/options_test.go | 7 + internal/credential/vault/private_library.go | 136 +++++++------- .../credential/vault/private_library_test.go | 109 +++++++++++ .../vault/repository_credential_library.go | 32 ++-- .../repository_credential_library_test.go | 111 ++++++++++-- internal/credential/vault/store/vault.pb.go | 34 +++- .../credentiallibrary_service.go | 24 ++- .../credentiallibrary_service_test.go | 159 ++++++++++++++-- .../credentials/credential_service.go | 93 +++++++--- .../credentials/credential_service_test.go | 169 +++++++++++++++++- .../handlers/targets/credentials.go | 23 ++- .../targets/tcp/target_service_test.go | 144 ++++++++++++++- .../01_worker_filter_vault_cred_store.up.sql | 2 + .../42/01_ssh_private_key_passphrase.up.sql | 106 +++++++++++ .../sqltest/initdb.d/03_widgets_persona.sql | 4 +- ...brary_ssh_private_key_mapping_override.sql | 14 +- .../servers/services/credential.pb.go | 39 ++-- .../resources/credentials/v1/credential.proto | 19 ++ .../api/resources/targets/v1/target.proto | 3 + .../servers/services/v1/credential.proto | 6 +- .../credential/static/store/v1/static.proto | 22 +++ .../credential/vault/store/v1/vault.proto | 11 ++ .../tests/api/credentials/credentials_test.go | 82 +++++++++ .../resources/credentials/credential.pb.go | 63 +++++-- .../api/resources/targets/target.pb.go | 36 ++-- 45 files changed, 1921 insertions(+), 382 deletions(-) create mode 100644 internal/db/schema/migrations/oss/postgres/42/01_ssh_private_key_passphrase.up.sql diff --git a/api/credentials/option.gen.go b/api/credentials/option.gen.go index fd2739fbc..2d6539eeb 100644 --- a/api/credentials/option.gen.go +++ b/api/credentials/option.gen.go @@ -134,6 +134,30 @@ func WithSshPrivateKeyCredentialPrivateKey(inPrivateKey string) Option { } } +func WithSshPrivateKeyCredentialPrivateKeyPassphrase(inPrivateKeyPassphrase string) Option { + return func(o *options) { + raw, ok := o.postMap["attributes"] + if !ok { + raw = interface{}(map[string]interface{}{}) + } + val := raw.(map[string]interface{}) + val["private_key_passphrase"] = inPrivateKeyPassphrase + o.postMap["attributes"] = val + } +} + +func DefaultSshPrivateKeyCredentialPrivateKeyPassphrase() Option { + return func(o *options) { + raw, ok := o.postMap["attributes"] + if !ok { + raw = interface{}(map[string]interface{}{}) + } + val := raw.(map[string]interface{}) + val["private_key_passphrase"] = nil + o.postMap["attributes"] = val + } +} + func WithSshPrivateKeyCredentialUsername(inUsername string) Option { return func(o *options) { raw, ok := o.postMap["attributes"] diff --git a/api/credentials/ssh_private_key_attributes.gen.go b/api/credentials/ssh_private_key_attributes.gen.go index fc1ecc2a3..891743c8a 100644 --- a/api/credentials/ssh_private_key_attributes.gen.go +++ b/api/credentials/ssh_private_key_attributes.gen.go @@ -8,9 +8,11 @@ import ( ) type SshPrivateKeyAttributes struct { - Username string `json:"username,omitempty"` - PrivateKey string `json:"private_key,omitempty"` - PrivateKeyHmac string `json:"private_key_hmac,omitempty"` + Username string `json:"username,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + PrivateKeyHmac string `json:"private_key_hmac,omitempty"` + PrivateKeyPassphrase string `json:"private_key_passphrase,omitempty"` + PrivateKeyPassphraseHmac string `json:"private_key_passphrase_hmac,omitempty"` } func AttributesMapToSshPrivateKeyAttributes(in map[string]interface{}) (*SshPrivateKeyAttributes, error) { diff --git a/internal/cmd/commands/connect/credentials.go b/internal/cmd/commands/connect/credentials.go index db64b31cf..f390e52e6 100644 --- a/internal/cmd/commands/connect/credentials.go +++ b/internal/cmd/commands/connect/credentials.go @@ -19,6 +19,7 @@ type usernamePassword struct { type sshPrivateKey struct { Username string `mapstructure:"username"` PrivateKey string `mapstructure:"private_key"` + Passphrase string `mapstructure:"private_key_passphrase"` raw *targets.SessionCredential consumed bool diff --git a/internal/cmd/commands/connect/ssh.go b/internal/cmd/commands/connect/ssh.go index 82bb49d53..32569d21d 100644 --- a/internal/cmd/commands/connect/ssh.go +++ b/internal/cmd/commands/connect/ssh.go @@ -71,7 +71,7 @@ func (s *sshFlags) buildArgs(c *Command, port, ip, addr string, creds credential var password string if len(retCreds.usernamePassword) > 0 { // For now just grab the first username password credential brokered - // Mark credential as consumed so it is not printed to user + // Mark credential as consumed so that it is not printed to user retCreds.usernamePassword[0].consumed = true username = retCreds.usernamePassword[0].Username @@ -109,11 +109,17 @@ func (s *sshFlags) buildArgs(c *Command, port, ip, addr string, creds credential // If we want to consume check if we have a private key available first case len(creds.sshPrivateKey) > 0: // For now just grab the first ssh private key credential brokered - // Mark credential as consumed so it is not printed to user - retCreds.sshPrivateKey[0].consumed = true + cred := retCreds.sshPrivateKey[0] - username = retCreds.sshPrivateKey[0].Username - privateKey := retCreds.sshPrivateKey[0].PrivateKey + username = cred.Username + privateKey := cred.PrivateKey + cred.consumed = true + if cred.Passphrase != "" { + // Don't re-print everything, but print the passphrase they'll need + cred.consumed = false + delete(cred.raw.Credential, "username") + delete(cred.raw.Credential, "private_key") + } pkFile, err := ioutil.TempFile("", "*") if err != nil { @@ -126,7 +132,7 @@ func (s *sshFlags) buildArgs(c *Command, port, ip, addr string, creds credential return nil }) // SSH requires the private key file to end with a newline. - // When injesting a ssh_private_key from a file:// Boundary calls strings.TrimSpace + // When ingesting an ssh_private_key from a file:// Boundary calls strings.TrimSpace // which will also trim newlines. if !strings.HasSuffix(privateKey, "\n") { privateKey = fmt.Sprintln(privateKey) @@ -142,7 +148,7 @@ func (s *sshFlags) buildArgs(c *Command, port, ip, addr string, creds credential // Next check if we have a username password credential case len(creds.usernamePassword) > 0: - // We cannot use the password of the credential outside of sshpass but we + // We cannot use the password of the credential outside of sshpass, but we // can use the username. // N.B. Do not mark credential as consumed, as user will still need enter // the password when prompted. diff --git a/internal/cmd/commands/credentialscmd/funcs.go b/internal/cmd/commands/credentialscmd/funcs.go index 6a6e4062c..e7794a455 100644 --- a/internal/cmd/commands/credentialscmd/funcs.go +++ b/internal/cmd/commands/credentialscmd/funcs.go @@ -10,9 +10,10 @@ import ( ) const ( - usernameFlagName = "username" - passwordFlagName = "password" - privateKeyFlagName = "private-key" + usernameFlagName = "username" + passwordFlagName = "password" + privateKeyFlagName = "private-key" + privateKeyPassphraseFlagName = "private-key-passphrase" ) func (c *Command) extraHelpFunc(helpMap map[string]func() string) string { @@ -177,7 +178,8 @@ func printItemTable(item *credentials.Credential, resp *api.Response) string { } var keySubstMap = map[string]string{ - "username": "Username", - "password_hmac": "Password HMAC", - "private_key_hmac": "Private Key HMAC", + "username": "Username", + "password_hmac": "Password HMAC", + "private_key_hmac": "Private Key HMAC", + "private_key_passphrase_hmac": "Private Key Passphrase HMAC", } diff --git a/internal/cmd/commands/credentialscmd/ssh-private-key_funcs.go b/internal/cmd/commands/credentialscmd/ssh-private-key_funcs.go index 1d77f89ce..e9b8ea85d 100644 --- a/internal/cmd/commands/credentialscmd/ssh-private-key_funcs.go +++ b/internal/cmd/commands/credentialscmd/ssh-private-key_funcs.go @@ -3,10 +3,14 @@ package credentialscmd import ( "errors" "fmt" + "os" + "strings" "github.com/hashicorp/boundary/api/credentials" "github.com/hashicorp/boundary/internal/cmd/base" "github.com/hashicorp/go-secure-stdlib/parseutil" + "github.com/hashicorp/go-secure-stdlib/password" + "golang.org/x/crypto/ssh" ) func init() { @@ -16,8 +20,9 @@ func init() { } type extraSshPrivateKeyCmdVars struct { - flagUsername string - flagPrivateKey string + flagUsername string + flagPrivateKey string + flagPrivateKeyPassphrase string } func extraSshPrivateKeyActionsFlagsMapFuncImpl() map[string][]string { @@ -25,6 +30,7 @@ func extraSshPrivateKeyActionsFlagsMapFuncImpl() map[string][]string { "create": { usernameFlagName, privateKeyFlagName, + privateKeyPassphraseFlagName, }, } flags["update"] = flags["create"] @@ -46,7 +52,13 @@ func extraSshPrivateKeyFlagsFuncImpl(c *SshPrivateKeyCommand, set *base.FlagSets f.StringVar(&base.StringVar{ Name: privateKeyFlagName, Target: &c.flagPrivateKey, - Usage: "The SSH private key associated with the credential. This can be the value itself, refer to a file on disk (file://) from which the value will be read, or an env var (env://) from which the value will be read.", + Usage: "The SSH private key associated with the credential. This can refer to a file on disk (file://) from which the value will be read or an env var (env://) from which the value will be read.", + }) + case privateKeyPassphraseFlagName: + f.StringVar(&base.StringVar{ + Name: privateKeyPassphraseFlagName, + Target: &c.flagPrivateKeyPassphrase, + Usage: "The passphrase associated with the SSH private key. This value is ignored if the private key does not require a passphrase or if no private key is supplied. This can refer to a file on disk (file://) from which the value will be read, or an env var (env://) from which the value will be read. Or, if left empty, if the key requires a passphrase it can be entered manually.", }) } } @@ -58,22 +70,79 @@ func extraSshPrivateKeyFlagHandlingFuncImpl(c *SshPrivateKeyCommand, _ *base.Fla default: *opts = append(*opts, credentials.WithSshPrivateKeyCredentialUsername(c.flagUsername)) } - switch c.flagPrivateKey { - case "": + + // If private key not set (e.g. just a username update) then don't check + // either private key or passphrase + if c.flagPrivateKey == "" { + return true + } + + // First parse private key + privateKey, err := parseutil.MustParsePath(c.flagPrivateKey) + switch { + case err == nil: + case errors.Is(err, parseutil.ErrNotParsed): + c.UI.Error("Private key flag must be used with env:// or file:// syntax") + return false default: - privateKey, err := parseutil.MustParsePath(c.flagPrivateKey) + c.UI.Error(fmt.Sprintf("Error parsing private key flag: %v", err)) + return false + } + + // Now validate it + _, err = ssh.ParsePrivateKey([]byte(privateKey)) + + // If nil, parsed successfully without passphrase, ignore passphrase flag + if err == nil { + if c.flagPrivateKeyPassphrase != "" { + c.UI.Warn("Ignoring private key passphrase as private key is not encrypted.") + } + *opts = append(*opts, credentials.WithSshPrivateKeyCredentialPrivateKey(privateKey)) + return true + } + + // If an error but it's not a passphrase missing error, send the error back + if err.Error() != (&ssh.PassphraseMissingError{}).Error() { + c.UI.Error(fmt.Sprintf("Error parsing private key: %v", err)) + return false + } + + // Passphrase required + var privateKeyPassphrase string + switch c.flagPrivateKeyPassphrase { + case "": + fmt.Print("Please enter the passphrase for the private key (it will be hidden): ") + privateKeyPassphrase, err = password.Read(os.Stdin) + fmt.Print("\n") + if err != nil { + c.UI.Error(fmt.Sprintf("An error occurred attempting to read the passphrase. The raw error message is shown below but usually this is because you attempted to pipe a value into the command or you are executing outside of a terminal (TTY). The raw error was:\n\n%s", err.Error())) + return false + } + privateKeyPassphrase = strings.TrimSpace(privateKeyPassphrase) + + default: + privateKeyPassphrase, err = parseutil.MustParsePath(c.flagPrivateKeyPassphrase) switch { case err == nil: case errors.Is(err, parseutil.ErrNotParsed): - c.UI.Error("Private key flag must be used with env:// or file:// syntax") + c.UI.Error("Private key passphrase flag must be used with env:// or file:// syntax") return false default: - c.UI.Error(fmt.Sprintf("Error parsing private key flag: %v", err)) + c.UI.Error(fmt.Sprintf("Error parsing private key passphrase flag: %v", err)) return false } - *opts = append(*opts, credentials.WithSshPrivateKeyCredentialPrivateKey(privateKey)) } + if _, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(privateKeyPassphrase)); err != nil { + c.UI.Error(fmt.Sprintf("Error parsing private key passphrase: %v", err)) + return false + } + + *opts = append(*opts, + credentials.WithSshPrivateKeyCredentialPrivateKey(privateKey), + credentials.WithSshPrivateKeyCredentialPrivateKeyPassphrase(privateKeyPassphrase), + ) + return true } diff --git a/internal/credential/credential.go b/internal/credential/credential.go index a6d11ec80..24154dc79 100644 --- a/internal/credential/credential.go +++ b/internal/credential/credential.go @@ -126,9 +126,11 @@ type UsernamePassword interface { Password() Password } -// SshPrivateKey is a credential containing a username and a SSH private key. +// SshPrivateKey is a credential containing a username an SSH private key and +// an optional private key passphrase. type SshPrivateKey interface { Credential Username() string PrivateKey() PrivateKey + PrivateKeyPassphrase() []byte } diff --git a/internal/credential/static/fields.go b/internal/credential/static/fields.go index 0551e1273..1f832cfae 100644 --- a/internal/credential/static/fields.go +++ b/internal/credential/static/fields.go @@ -2,9 +2,10 @@ package static // These constants are the field names used in the static related field masks. const ( - nameField = "Name" - descriptionField = "Description" - usernameField = "Username" - passwordField = "Password" - privateKeyField = "PrivateKey" + nameField = "Name" + descriptionField = "Description" + usernameField = "Username" + passwordField = "Password" + privateKeyField = "PrivateKey" + PrivateKeyPassphraseField = "PrivateKeyPassphrase" ) diff --git a/internal/credential/static/options.go b/internal/credential/static/options.go index e325cca93..d545fa60c 100644 --- a/internal/credential/static/options.go +++ b/internal/credential/static/options.go @@ -14,10 +14,11 @@ type Option func(*options) // options = how options are represented type options struct { - withName string - withDescription string - withLimit int - withPublicId string + withName string + withDescription string + withLimit int + withPublicId string + withPrivateKeyPassphrase []byte } func getDefaultOptions() options { @@ -53,3 +54,10 @@ func WithPublicId(name string) Option { o.withPublicId = name } } + +// WithPrivateKeyPassphrase provides an optional SSH private key passphrase to use. +func WithPrivateKeyPassphrase(with []byte) Option { + return func(o *options) { + o.withPrivateKeyPassphrase = with + } +} diff --git a/internal/credential/static/options_test.go b/internal/credential/static/options_test.go index 1c3d2e0f6..0e4ddcbc2 100644 --- a/internal/credential/static/options_test.go +++ b/internal/credential/static/options_test.go @@ -26,4 +26,11 @@ func Test_GetOpts(t *testing.T) { testOpts.withLimit = 5 assert.Equal(t, opts, testOpts) }) + t.Run("WithPrivateKeyPassphrase", func(t *testing.T) { + opts := getOpts(WithPrivateKeyPassphrase([]byte("my-pass"))) + testOpts := getDefaultOptions() + assert.NotEqual(t, opts, testOpts) + testOpts.withPrivateKeyPassphrase = []byte("my-pass") + assert.Equal(t, opts, testOpts) + }) } diff --git a/internal/credential/static/repository_credential.go b/internal/credential/static/repository_credential.go index 08cad4384..2e2586224 100644 --- a/internal/credential/static/repository_credential.go +++ b/internal/credential/static/repository_credential.go @@ -106,8 +106,10 @@ func (r *Repository) CreateUsernamePasswordCredential( // assigned by this method. c must contain a valid StoreId. // // The private key is encrypted and a HmacSha256 of the private key is -// calculated. Only the PrivateKeyHmac is returned, the plain-text and encrypted -// private key is not returned. +// calculated. If a passphrase is supplied, it is also encrypted and an HmacSha256 +// of passphrase is calculated. Only the PrivateKeyHmac (and +// PrivateKeyPassphraseHmac) is returned, the plain-text and encrypted private +// key and passphrase are not returned. // // Both c.Name and c.Description are optional. If c.Name is set, it must be // unique within c.ScopeId. Both c.CreateTime and c.UpdateTime are ignored. @@ -183,6 +185,10 @@ func (r *Repository) CreateSshPrivateKeyCredential( newCred.PrivateKeyEncrypted = nil newCred.PrivateKey = nil + // Clear passphrase fields, only PrivateKeyPassphraseHmac should be returned if it exists + newCred.PrivateKeyPassphraseEncrypted = nil + newCred.PrivateKeyPassphrase = nil + return newCred, nil } @@ -224,6 +230,9 @@ func (r *Repository) LookupCredential(ctx context.Context, publicId string, _ .. // Clear private key fields, only privateKeyHmac should be returned spkCred.PrivateKeyEncrypted = nil spkCred.PrivateKey = nil + // Clear passphrase fields, only PrivateKeyPassphraseHmac should be returned if it exists + spkCred.PrivateKeyPassphraseEncrypted = nil + spkCred.PrivateKeyPassphrase = nil cred = spkCred } @@ -349,9 +358,9 @@ func (r *Repository) UpdateUsernamePasswordCredential(ctx context.Context, // new SshPrivateKeyCredential containing the updated values and a count of the // number of records updated. c is not changed. // -// c must contain a valid PublicId. Only Name, Description, Username and -// PrivateKey can be changed. If c.Name is set to a non-empty string, it must be -// unique within c.ScopeId. +// c must contain a valid PublicId. Only Name, Description, Username, +// PrivateKey and PrivateKeyPassphrase can be changed. If c.Name is set to a non-empty string, it +// must be unique within c.ScopeId. // // An attribute of c will be set to NULL in the database if the attribute in c // is the zero value and it is included in fieldMaskPaths. @@ -389,16 +398,18 @@ func (r *Repository) UpdateSshPrivateKeyCredential(ctx context.Context, case strings.EqualFold(descriptionField, f): case strings.EqualFold(usernameField, f): case strings.EqualFold(privateKeyField, f): + case strings.EqualFold(PrivateKeyPassphraseField, f): default: return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidFieldMask, op, f) } } dbMask, nullFields := dbcommon.BuildUpdatePaths( map[string]interface{}{ - nameField: c.Name, - descriptionField: c.Description, - usernameField: c.Username, - privateKeyField: c.PrivateKey, + nameField: c.Name, + descriptionField: c.Description, + usernameField: c.Username, + privateKeyField: c.PrivateKey, + PrivateKeyPassphraseField: c.PrivateKeyPassphrase, }, fieldMaskPaths, nil, @@ -407,19 +418,30 @@ func (r *Repository) UpdateSshPrivateKeyCredential(ctx context.Context, return nil, db.NoRowsAffected, errors.New(ctx, errors.EmptyFieldMask, op, "missing field mask") } + var performedEncryption bool for _, f := range fieldMaskPaths { - if strings.EqualFold(privateKeyField, f) { - // Private key has been updated, re-encrypt and recalculate hmac - databaseWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase) - if err != nil { - return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get database wrapper")) - } - if err := c.encrypt(ctx, databaseWrapper); err != nil { - return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op) + if strings.EqualFold(privateKeyField, f) || strings.EqualFold(PrivateKeyPassphraseField, f) { + if !performedEncryption { + // We don't need to encrypt twice so keep track + performedEncryption = true + + databaseWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase) + if err != nil { + return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get database wrapper")) + } + if err := c.encrypt(ctx, databaseWrapper); err != nil { + return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op) + } } - // Set PrivateKeyHmac and PrivateKeyEncrypted masks for update. - dbMask = append(dbMask, "PrivateKeyHmac", "PrivateKeyEncrypted") + if strings.EqualFold(privateKeyField, f) { + // Set PrivateKeyHmac and PrivateKeyEncrypted masks for update. + dbMask = append(dbMask, "PrivateKeyHmac", "PrivateKeyEncrypted") + } + if strings.EqualFold(PrivateKeyPassphraseField, f) { + // Set PassphraseHmac and PassphraseEncrypted masks for update. + dbMask = append(dbMask, "PrivateKeyPassphraseHmac", "PrivateKeyPassphraseEncrypted") + } } } @@ -456,6 +478,10 @@ func (r *Repository) UpdateSshPrivateKeyCredential(ctx context.Context, returnedCredential.PrivateKeyEncrypted = nil returnedCredential.PrivateKey = nil + // Clear passphrase fields, only PrivateKeyPassphraseHmac should be returned if it exists + returnedCredential.PrivateKeyPassphraseEncrypted = nil + returnedCredential.PrivateKeyPassphrase = nil + return returnedCredential, rowsUpdated, nil } @@ -499,6 +525,10 @@ func (r *Repository) ListCredentials(ctx context.Context, storeId string, opt .. // Clear private key fields, only PrivateKeyHmac should be returned c.PrivateKeyEncrypted = nil c.PrivateKey = nil + + // Clear passphrase fields, only PrivateKeyPassphraseHmac should be returned if it exists + c.PrivateKeyPassphraseEncrypted = nil + c.PrivateKeyPassphrase = nil ret = append(ret, c) } diff --git a/internal/credential/static/repository_credential_test.go b/internal/credential/static/repository_credential_test.go index 377b0752f..dae7cc7e1 100644 --- a/internal/credential/static/repository_credential_test.go +++ b/internal/credential/static/repository_credential_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/boundary/internal/oplog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh/testdata" ) func TestRepository_CreateUsernamePasswordCredential(t *testing.T) { @@ -276,6 +277,18 @@ func TestRepository_CreateSshPrivateKeyCredential(t *testing.T) { }, }, }, + { + name: "valid-with-passphrase", + scopeId: prj.PublicId, + cred: &SshPrivateKeyCredential{ + SshPrivateKeyCredential: &store.SshPrivateKeyCredential{ + Username: "my-user", + PrivateKey: []byte(TestSshPrivateKeyPem), + StoreId: cs.PublicId, + PrivateKeyPassphrase: []byte("passphrase"), + }, + }, + }, } for _, tt := range tests { tt := tt @@ -298,8 +311,10 @@ func TestRepository_CreateSshPrivateKeyCredential(t *testing.T) { assert.Equal(tt.cred.Username, got.Username) assert.Nil(got.PrivateKey) assert.Nil(got.PrivateKeyEncrypted) + assert.Nil(got.PrivateKeyPassphrase) + assert.Nil(got.PrivateKeyPassphraseEncrypted) - // Validate password + // Validate private key and passphrase lookupCred := allocSshPrivateKeyCredential() lookupCred.PublicId = got.PublicId require.NoError(rw.LookupById(ctx, lookupCred)) @@ -318,6 +333,15 @@ func TestRepository_CreateSshPrivateKeyCredential(t *testing.T) { require.NoError(err) assert.Equal([]byte(hm), got.PrivateKeyHmac) + // Validate passphrase + assert.Equal(tt.cred.PrivateKeyPassphrase, lookupCred.PrivateKeyPassphrase) + if len(tt.cred.PrivateKeyPassphrase) > 0 { + assert.NotEmpty(got.PrivateKeyPassphraseHmac) + hm, err := crypto.HmacSha256(ctx, tt.cred.PrivateKeyPassphrase, databaseWrapper, []byte(tt.cred.StoreId), nil) + require.NoError(err) + assert.Equal([]byte(hm), got.PrivateKeyPassphraseHmac) + } + // Validate oplog assert.NoError(db.TestVerifyOplog(t, rw, got.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second))) }) @@ -372,6 +396,8 @@ func TestRepository_LookupCredential(t *testing.T) { store := TestCredentialStore(t, conn, wrapper, prj.PublicId) upCred := TestUsernamePasswordCredential(t, conn, wrapper, "username", "password", store.PublicId, prj.PublicId) spkCred := TestSshPrivateKeyCredential(t, conn, wrapper, "username", TestSshPrivateKeyPem, store.PublicId, prj.PublicId) + spkCredWithPass := TestSshPrivateKeyCredential(t, conn, wrapper, "username", string(testdata.PEMEncryptedKeys[0].PEMBytes), + store.PublicId, prj.PublicId, WithPrivateKeyPassphrase([]byte(testdata.PEMEncryptedKeys[0].EncryptionKey))) tests := []struct { name string @@ -389,6 +415,11 @@ func TestRepository_LookupCredential(t *testing.T) { id: spkCred.GetPublicId(), want: spkCred, }, + { + name: "spk-valid-with-passphrase", + id: spkCredWithPass.GetPublicId(), + want: spkCredWithPass, + }, { name: "empty-public-id", id: "", @@ -433,6 +464,14 @@ func TestRepository_LookupCredential(t *testing.T) { assert.Empty(v.PrivateKey) assert.Empty(v.PrivateKeyEncrypted) assert.NotEmpty(v.PrivateKeyHmac) + + want, ok := tt.want.(*SshPrivateKeyCredential) + require.True(ok) + assert.Empty(v.PrivateKeyPassphrase) + assert.Empty(v.PrivateKeyPassphraseEncrypted) + if len(want.PrivateKeyPassphrase) > 0 { + assert.NotEmpty(v.PrivateKeyPassphraseHmac) + } default: require.Fail("unknown type") } @@ -1175,6 +1214,13 @@ Hdtbe1Kk0rHxN0yIKqXNAAAACWplZmZAYXJjaAECAwQ= } } + changePrivateKeyPassphrase := func(d string) func(*SshPrivateKeyCredential) *SshPrivateKeyCredential { + return func(c *SshPrivateKeyCredential) *SshPrivateKeyCredential { + c.PrivateKeyPassphrase = []byte(d) + return c + } + } + combine := func(fns ...func(cs *SshPrivateKeyCredential) *SshPrivateKeyCredential) func(*SshPrivateKeyCredential) *SshPrivateKeyCredential { return func(c *SshPrivateKeyCredential) *SshPrivateKeyCredential { for _, fn := range fns { @@ -1385,7 +1431,7 @@ Hdtbe1Kk0rHxN0yIKqXNAAAACWplZmZAYXJjaAECAwQ= wantCount: 1, }, { - name: "change-private-keyu", + name: "change-private-key", orig: &SshPrivateKeyCredential{ SshPrivateKeyCredential: &store.SshPrivateKeyCredential{ Username: "user", @@ -1402,6 +1448,26 @@ Hdtbe1Kk0rHxN0yIKqXNAAAACWplZmZAYXJjaAECAwQ= }, wantCount: 1, }, + { + name: "change-private-key-and-passphrase", + orig: &SshPrivateKeyCredential{ + SshPrivateKeyCredential: &store.SshPrivateKeyCredential{ + Username: "user", + PrivateKey: []byte(TestSshPrivateKeyPem), + PrivateKeyPassphrase: []byte("foobar"), + }, + }, + chgFn: combine(changePrivateKey(testSecondarySshPrivateKeyPem), changePrivateKeyPassphrase("barfoo")), + masks: []string{"PrivateKeyPassphrase"}, + want: &SshPrivateKeyCredential{ + SshPrivateKeyCredential: &store.SshPrivateKeyCredential{ + Username: "user", + PrivateKey: []byte(testSecondarySshPrivateKeyPem), + PrivateKeyPassphrase: []byte("barfoo"), + }, + }, + wantCount: 1, + }, { name: "change-username-and-private-key", orig: &SshPrivateKeyCredential{ @@ -1620,3 +1686,76 @@ Hdtbe1Kk0rHxN0yIKqXNAAAACWplZmZAYXJjaAECAwQ= }) } } + +func TestSshPrivateKeyConstraints(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + assert, require := assert.New(t), require.New(t) + ctx := context.Background() + kkms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(ctx, rw, rw, kkms) + assert.NoError(err) + require.NotNil(repo) + + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + store := TestCredentialStore(t, conn, wrapper, prj.GetPublicId()) + + // Base case: should work fine + cred, err := NewSshPrivateKeyCredential( + ctx, + store.PublicId, + "foobar", + credential.PrivateKey(testdata.PEMEncryptedKeys[0].PEMBytes), + WithPrivateKeyPassphrase([]byte(testdata.PEMEncryptedKeys[0].EncryptionKey))) + require.NoError(err) + cred.PublicId, err = credential.NewSshPrivateKeyCredentialId(ctx) + require.NoError(err) + databaseWrapper, err := kkms.GetWrapper(ctx, prj.PublicId, kms.KeyPurposeDatabase) + require.NoError(cred.encrypt(ctx, databaseWrapper)) + + tests := []struct { + name string + nilEncrypted bool + nilHmac bool + }{ + { + name: "valid", + }, + { + name: "nil-encrypted", + nilEncrypted: true, + }, + { + name: "nil-hmac", + nilHmac: true, + }, + { + name: "valid-both-nil", + nilEncrypted: true, + nilHmac: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + c := cred.clone() + if tt.nilEncrypted { + c.PrivateKeyPassphraseEncrypted = nil + } + if tt.nilHmac { + c.PrivateKeyPassphraseHmac = nil + } + c.PublicId, err = credential.NewSshPrivateKeyCredentialId(ctx) + require.NoError(err) + err := rw.Create(ctx, c) + switch { + case !tt.nilEncrypted && !tt.nilHmac, tt.nilEncrypted && tt.nilHmac: + assert.NoError(err) + default: + assert.Error(err) + } + }) + } +} diff --git a/internal/credential/static/repository_credentials_test.go b/internal/credential/static/repository_credentials_test.go index c59d061a8..2b926bcc9 100644 --- a/internal/credential/static/repository_credentials_test.go +++ b/internal/credential/static/repository_credentials_test.go @@ -35,6 +35,9 @@ func TestRepository_Retrieve(t *testing.T) { upCred2 := TestUsernamePasswordCredential(t, conn, wrapper, "different user", "better password", staticStore.GetPublicId(), prj.GetPublicId()) spkCred1 := TestSshPrivateKeyCredential(t, conn, wrapper, "final user", string(testdata.PEMBytes["ed25519"]), staticStore.GetPublicId(), prj.GetPublicId()) spkCred2 := TestSshPrivateKeyCredential(t, conn, wrapper, "last user", string(testdata.PEMBytes["rsa-openssh-format"]), staticStore.GetPublicId(), prj.GetPublicId()) + spkCredWithPass := TestSshPrivateKeyCredential(t, conn, wrapper, "another last user", + string(testdata.PEMEncryptedKeys[0].PEMBytes), staticStore.GetPublicId(), prj.GetPublicId(), + WithPrivateKeyPassphrase([]byte(testdata.PEMEncryptedKeys[0].EncryptionKey))) type args struct { credIds []string @@ -95,20 +98,20 @@ func TestRepository_Retrieve(t *testing.T) { name: "valid-multiple-ssh-pk-creds", args: args{ scopeId: prj.GetPublicId(), - credIds: []string{spkCred1.GetPublicId(), spkCred2.GetPublicId()}, + credIds: []string{spkCred1.GetPublicId(), spkCred2.GetPublicId(), spkCredWithPass.GetPublicId()}, }, wantCreds: []credential.Static{ - spkCred1, spkCred2, + spkCred1, spkCred2, spkCredWithPass, }, }, { name: "valid-mixed-creds", args: args{ scopeId: prj.GetPublicId(), - credIds: []string{upCred1.GetPublicId(), spkCred1.GetPublicId(), spkCred2.GetPublicId(), upCred2.GetPublicId()}, + credIds: []string{upCred1.GetPublicId(), spkCred1.GetPublicId(), spkCredWithPass.GetPublicId(), spkCred2.GetPublicId(), upCred2.GetPublicId()}, }, wantCreds: []credential.Static{ - upCred1, spkCred1, spkCred2, upCred2, + upCred1, spkCred1, spkCred2, upCred2, spkCredWithPass, }, }, } @@ -130,6 +133,7 @@ func TestRepository_Retrieve(t *testing.T) { UsernamePasswordCredential{}, store.UsernamePasswordCredential{}, SshPrivateKeyCredential{}, store.SshPrivateKeyCredential{}), cmpopts.IgnoreTypes(×tamp.Timestamp{}), + cmpopts.IgnoreFields(SshPrivateKeyCredential{}, "PassphraseUnneeded"), cmpopts.SortSlices(func(x, y credential.Static) bool { return x.GetPublicId() < y.GetPublicId() }), diff --git a/internal/credential/static/ssh_private_key_credential.go b/internal/credential/static/ssh_private_key_credential.go index 108699c18..234c5b89c 100644 --- a/internal/credential/static/ssh_private_key_credential.go +++ b/internal/credential/static/ssh_private_key_credential.go @@ -9,7 +9,6 @@ import ( "github.com/hashicorp/boundary/internal/libs/crypto" "github.com/hashicorp/boundary/internal/oplog" wrapping "github.com/hashicorp/go-kms-wrapping/v2" - "github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping" "golang.org/x/crypto/ssh" "google.golang.org/protobuf/proto" ) @@ -20,7 +19,8 @@ var _ credential.Static = (*SshPrivateKeyCredential)(nil) // It is owned by a credential store. type SshPrivateKeyCredential struct { *store.SshPrivateKeyCredential - tableName string `gorm:"-"` + tableName string `gorm:"-"` + PassphraseUnneeded bool `gorm:"-"` } // NewSshPrivateKeyCredential creates a new in memory static Credential containing a @@ -35,8 +35,14 @@ func NewSshPrivateKeyCredential( ) (*SshPrivateKeyCredential, error) { const op = "static.NewSshPrivateKeyCredential" + opts := getOpts(opt...) if len(privateKey) != 0 { - _, err := ssh.ParsePrivateKey(privateKey) + var err error + if len(opts.withPrivateKeyPassphrase) == 0 { + _, err = ssh.ParsePrivateKey(privateKey) + } else { + _, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, opts.withPrivateKeyPassphrase) + } switch { case err == nil: case err.Error() == (&ssh.PassphraseMissingError{}).Error(): @@ -46,16 +52,27 @@ func NewSshPrivateKeyCredential( } } - opts := getOpts(opt...) l := &SshPrivateKeyCredential{ SshPrivateKeyCredential: &store.SshPrivateKeyCredential{ - StoreId: storeId, - Name: opts.withName, - Description: opts.withDescription, - Username: username, - PrivateKey: privateKey, + StoreId: storeId, + Name: opts.withName, + Description: opts.withDescription, + Username: username, + PrivateKey: privateKey, + PrivateKeyPassphrase: opts.withPrivateKeyPassphrase, }, } + + // If a private key was given and no passphrase was given and everything is + // okay, we can nil out any passphrase we have, so set this as a hint + if len(privateKey) != 0 && len(opts.withPrivateKeyPassphrase) == 0 { + l.PassphraseUnneeded = true + // These shouldn't be set, but safety + l.PrivateKeyPassphrase = nil + l.PrivateKeyPassphraseEncrypted = nil + l.PrivateKeyPassphraseHmac = nil + } + return l, nil } @@ -99,28 +116,77 @@ func (c *SshPrivateKeyCredential) oplog(op oplog.OpType) oplog.Metadata { func (c *SshPrivateKeyCredential) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { const op = "static.(SshPrivateKeyCredential).encrypt" + if len(c.PrivateKey) == 0 { return errors.New(ctx, errors.InvalidParameter, op, "no private key defined") } - if err := structwrapping.WrapStruct(ctx, cipher, c.SshPrivateKeyCredential, nil); err != nil { - return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt)) - } + keyId, err := cipher.KeyId(ctx) if err != nil { return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt), errors.WithMsg("error reading cipher key id")) } c.KeyId = keyId + + // Encrypt private key + blobInfo, err := cipher.Encrypt(ctx, c.PrivateKey) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt)) + } + protoBytes, err := proto.Marshal(blobInfo) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encode)) + } + c.PrivateKeyEncrypted = protoBytes if err := c.hmacPrivateKey(ctx, cipher); err != nil { return errors.Wrap(ctx, err, op) } + + if len(c.PrivateKeyPassphrase) > 0 { + // Encrypt passphrase + blobInfo, err := cipher.Encrypt(ctx, c.PrivateKeyPassphrase) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt)) + } + protoBytes, err := proto.Marshal(blobInfo) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encode)) + } + c.PrivateKeyPassphraseEncrypted = protoBytes + if err := c.hmacPrivateKeyPassphrase(ctx, cipher); err != nil { + return errors.Wrap(ctx, err, op) + } + } + return nil } func (c *SshPrivateKeyCredential) decrypt(ctx context.Context, cipher wrapping.Wrapper) error { const op = "static.(SshPrivateKeyCredential).decrypt" - if err := structwrapping.UnwrapStruct(ctx, cipher, c.SshPrivateKeyCredential, nil); err != nil { - return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt)) + + if len(c.PrivateKeyEncrypted) > 0 { + dec := new(wrapping.BlobInfo) + if err := proto.Unmarshal(c.PrivateKeyEncrypted, dec); err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decode)) + } + pt, err := cipher.Decrypt(ctx, dec) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt)) + } + c.PrivateKey = pt } + + if len(c.PrivateKeyPassphraseEncrypted) > 0 { + dec := new(wrapping.BlobInfo) + if err := proto.Unmarshal(c.PrivateKeyPassphraseEncrypted, dec); err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decode)) + } + pt, err := cipher.Decrypt(ctx, dec) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt)) + } + c.PrivateKeyPassphrase = pt + } + return nil } @@ -136,3 +202,16 @@ func (c *SshPrivateKeyCredential) hmacPrivateKey(ctx context.Context, cipher wra c.PrivateKeyHmac = []byte(hm) return nil } + +func (c *SshPrivateKeyCredential) hmacPrivateKeyPassphrase(ctx context.Context, cipher wrapping.Wrapper) error { + const op = "static.(SshPrivateKeyCredential).hmacPrivateKeyPassphrase" + if cipher == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing cipher") + } + hm, err := crypto.HmacSha256(ctx, c.PrivateKeyPassphrase, cipher, []byte(c.StoreId), nil) + if err != nil { + return errors.Wrap(ctx, err, op) + } + c.PrivateKeyPassphraseHmac = []byte(hm) + return nil +} diff --git a/internal/credential/static/ssh_private_key_credential_test.go b/internal/credential/static/ssh_private_key_credential_test.go index 4dd9fe980..c06f9f8a3 100644 --- a/internal/credential/static/ssh_private_key_credential_test.go +++ b/internal/credential/static/ssh_private_key_credential_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/boundary/internal/libs/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh/testdata" "google.golang.org/protobuf/testing/protocmp" ) @@ -24,6 +25,8 @@ func TestSshPrivateKeyCredential_New(t *testing.T) { rw := db.New(conn) privKey := []byte(TestSshPrivateKeyPem) + ePrivKey := testdata.PEMEncryptedKeys[0].PEMBytes + ePrivKeyPass := testdata.PEMEncryptedKeys[0].EncryptionKey _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) cs := TestCredentialStore(t, conn, wrapper, prj.PublicId) @@ -129,6 +132,33 @@ func TestSshPrivateKeyCredential_New(t *testing.T) { }, }, }, + { + name: "invalid-passphrase", + args: args{ + username: "test-user", + sshPrivateKey: privKey, + storeId: cs.PublicId, + options: []Option{WithPrivateKeyPassphrase([]byte(ePrivKeyPass))}, + }, + wantAllocError: true, + }, + { + name: "valid-with-passphrase", + args: args{ + username: "test-user", + sshPrivateKey: ePrivKey, + storeId: cs.PublicId, + options: []Option{WithPrivateKeyPassphrase([]byte(ePrivKeyPass))}, + }, + want: &SshPrivateKeyCredential{ + SshPrivateKeyCredential: &store.SshPrivateKeyCredential{ + Username: "test-user", + PrivateKey: ePrivKey, + StoreId: cs.PublicId, + PrivateKeyPassphrase: []byte(ePrivKeyPass), + }, + }, + }, } for _, tt := range tests { @@ -184,12 +214,20 @@ func TestSshPrivateKeyCredential_New(t *testing.T) { // KeyId is allocated via kms no need to validate in this test tt.want.KeyId = got2.KeyId got2.PrivateKeyEncrypted = nil + got2.PrivateKeyPassphraseEncrypted = nil // encrypt also calculates the hmac, validate it is correct hm, err := crypto.HmacSha256(ctx, got.PrivateKey, databaseWrapper, []byte(got.StoreId), nil) require.NoError(err) tt.want.PrivateKeyHmac = []byte(hm) + if len(got.PrivateKeyPassphrase) > 0 { + // encrypt also calculates the hmac, validate it is correct + hm, err = crypto.HmacSha256(ctx, got.PrivateKeyPassphrase, databaseWrapper, []byte(got.StoreId), nil) + require.NoError(err) + tt.want.PrivateKeyPassphraseHmac = []byte(hm) + } + assert.Empty(cmp.Diff(tt.want, got2.clone(), protocmp.Transform())) }) } diff --git a/internal/credential/static/store/static.pb.go b/internal/credential/static/store/static.pb.go index 4199f445d..077d156c1 100644 --- a/internal/credential/static/store/static.pb.go +++ b/internal/credential/static/store/static.pb.go @@ -347,6 +347,19 @@ type SshPrivateKeyCredential struct { // It must be set. // @inject_tag: `gorm:"not_null"` KeyId string `protobuf:"bytes,12,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty" gorm:"not_null"` + // private_key_passphrase is the plain-text of the passphrase of the SSH private + // key associated with the credential. We are not storing this plain-text + // private key passphrase in the database. + // @inject_tag: `gorm:"-"` + PrivateKeyPassphrase []byte `protobuf:"bytes,13,opt,name=private_key_passphrase,json=privateKeyPassphrase,proto3" json:"private_key_passphrase,omitempty" gorm:"-"` + // private_key_passphrase_encrypted is the ciphertext of the private key passphrase. It is stored in + // the database. + // @inject_tag: `gorm:"column:private_key_passphrase_encrypted;not_null"` + PrivateKeyPassphraseEncrypted []byte `protobuf:"bytes,14,opt,name=private_key_passphrase_encrypted,json=privateKeyPassphraseEncrypted,proto3" json:"private_key_passphrase_encrypted,omitempty" gorm:"column:private_key_passphrase_encrypted;not_null"` + // private_key_passphrase_hmac is a sha256-hmac of the unencrypted private key passphrase. It is recalculated + // everytime the private key passphrase is updated. + // @inject_tag: `gorm:"not_null"` + PrivateKeyPassphraseHmac []byte `protobuf:"bytes,15,opt,name=private_key_passphrase_hmac,json=privateKeyPassphraseHmac,proto3" json:"private_key_passphrase_hmac,omitempty" gorm:"not_null"` } func (x *SshPrivateKeyCredential) Reset() { @@ -465,6 +478,27 @@ func (x *SshPrivateKeyCredential) GetKeyId() string { return "" } +func (x *SshPrivateKeyCredential) GetPrivateKeyPassphrase() []byte { + if x != nil { + return x.PrivateKeyPassphrase + } + return nil +} + +func (x *SshPrivateKeyCredential) GetPrivateKeyPassphraseEncrypted() []byte { + if x != nil { + return x.PrivateKeyPassphraseEncrypted + } + return nil +} + +func (x *SshPrivateKeyCredential) GetPrivateKeyPassphraseHmac() []byte { + if x != nil { + return x.PrivateKeyPassphraseHmac + } + return nil +} + var File_controller_storage_credential_static_store_v1_static_proto protoreflect.FileDescriptor var file_controller_storage_credential_static_store_v1_static_proto_rawDesc = []byte{ @@ -543,7 +577,7 @@ var file_controller_storage_credential_static_store_v1_static_proto_rawDesc = [] 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x52, 0x0c, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x48, 0x6d, 0x61, 0x63, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, - 0x22, 0xa1, 0x05, 0x0a, 0x17, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, + 0x22, 0xe7, 0x07, 0x0a, 0x17, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, 0x63, 0x72, 0x65, @@ -585,12 +619,32 @@ var file_controller_storage_credential_static_store_v1_static_proto_rawDesc = [] 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x52, 0x0e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x48, 0x6d, 0x61, 0x63, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, - 0x65, 0x79, 0x49, 0x64, 0x42, 0x46, 0x5a, 0x44, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, - 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, - 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, - 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x79, 0x49, 0x64, 0x12, 0x73, 0x0a, 0x16, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, + 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x0c, 0x42, 0x3d, 0xc2, 0xdd, 0x29, 0x39, 0x0a, 0x14, 0x50, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, + 0x12, 0x21, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, + 0x61, 0x73, 0x65, 0x52, 0x14, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, + 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x12, 0x47, 0x0a, 0x20, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, + 0x61, 0x73, 0x65, 0x5f, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x1d, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, + 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x12, 0x85, 0x01, 0x0a, 0x1b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, + 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x5f, 0x68, 0x6d, + 0x61, 0x63, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0c, 0x42, 0x46, 0xc2, 0xdd, 0x29, 0x42, 0x0a, 0x18, + 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, 0x73, 0x73, 0x70, 0x68, + 0x72, 0x61, 0x73, 0x65, 0x48, 0x6d, 0x61, 0x63, 0x12, 0x26, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, + 0x75, 0x74, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, + 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x5f, 0x68, 0x6d, 0x61, 0x63, + 0x52, 0x18, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, 0x73, 0x73, + 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x48, 0x6d, 0x61, 0x63, 0x42, 0x46, 0x5a, 0x44, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, + 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x2f, + 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, + 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/internal/credential/vault/internal/sshprivatekey/sshprivatekey.go b/internal/credential/vault/internal/sshprivatekey/sshprivatekey.go index 18a89c378..dd51bb574 100644 --- a/internal/credential/vault/internal/sshprivatekey/sshprivatekey.go +++ b/internal/credential/vault/internal/sshprivatekey/sshprivatekey.go @@ -13,34 +13,36 @@ type ( // extractFunc attempts to extract the username and private key // from sd using the provided attribute names, using a known // Vault data response format. - extractFunc func(sd data, usernameAttr, privateKeyAttr string) (string, credential.PrivateKey) + extractFunc func(sd data, usernameAttr, privateKeyAttr, passphraseAttr string) (string, credential.PrivateKey, []byte) ) -// Extract attempts to extract the values of the username and private key -// stored within the provided data using the given attribute names. +// Extract attempts to extract the values of the username, private key and optional +// passphrase stored within the provided data using the given attribute names. // // Extract does not return partial results, i.e. if one of the attributes // were extracted but not the other ("", nil) will be returned. -func Extract(d data, usernameAttr, privateKeyAttr string) (string, credential.PrivateKey) { +func Extract(d data, usernameAttr, privateKeyAttr, passphraseAttr string) (string, credential.PrivateKey, []byte) { for _, f := range []extractFunc{ defaultExtract, kv2Extract, } { - username, privateKey := f(d, usernameAttr, privateKeyAttr) + username, privateKey, passphrase := f(d, usernameAttr, privateKeyAttr, passphraseAttr) if username != "" && privateKey != nil { // got valid username and privateKey from secret - return username, privateKey + return username, privateKey, passphrase } } - return "", nil + return "", nil, nil } // defaultExtract looks for the usernameAttr and privateKeyAttr in the data map -func defaultExtract(sd data, usernameAttr, privateKeyAttr string) (username string, privateKey credential.PrivateKey) { +func defaultExtract(sd data, usernameAttr, privateKeyAttr, passphraseAttr string) ( + username string, privateKey credential.PrivateKey, passphrase []byte, +) { if sd == nil { // nothing to do return early - return "", nil + return "", nil, nil } var u any @@ -49,9 +51,8 @@ func defaultExtract(sd data, usernameAttr, privateKeyAttr string) (username stri var err error u, err = pointerstructure.Get(sd, usernameAttr) if err != nil { - return "", nil + return "", nil, nil } - default: u = sd[usernameAttr] } @@ -59,24 +60,37 @@ func defaultExtract(sd data, usernameAttr, privateKeyAttr string) (username stri username = u } - var p any + var pk any switch { case strings.HasPrefix(privateKeyAttr, "/"): var err error - p, err = pointerstructure.Get(sd, privateKeyAttr) + pk, err = pointerstructure.Get(sd, privateKeyAttr) if err != nil { - return "", nil + return "", nil, nil } - default: - p = sd[privateKeyAttr] + pk = sd[privateKeyAttr] } - - if p, ok := p.(string); ok { + if p, ok := pk.(string); ok { privateKey = credential.PrivateKey(p) } - return username, privateKey + var pass any + switch { + case strings.HasPrefix(passphraseAttr, "/"): + var err error + pass, err = pointerstructure.Get(sd, passphraseAttr) + if err != nil { + return "", nil, nil + } + default: + pass = sd[passphraseAttr] + } + if p, ok := pass.(string); ok { + passphrase = []byte(p) + } + + return username, privateKey, passphrase } // kv2Extract looks for the the usernameAttr and privateKeyAttr in the embedded @@ -89,10 +103,12 @@ func defaultExtract(sd data, usernameAttr, privateKeyAttr string) (username stri // } // If the format does not match, it returns ("", ""). See: // https://www.vaultproject.io/api/secret/kv/kv-v2#sample-response-1 -func kv2Extract(sd data, usernameAttr, privateKeyAttr string) (username string, privateKey credential.PrivateKey) { +func kv2Extract(sd data, usernameAttr, privateKeyAttr, passphraseAttr string) ( + username string, privateKey credential.PrivateKey, passphrase []byte, +) { if sd == nil { // nothing to do return early - return "", nil + return "", nil, nil } var data, metadata map[string]any @@ -102,22 +118,22 @@ func kv2Extract(sd data, usernameAttr, privateKeyAttr string) (username string, var ok bool if data, ok = v.(map[string]any); !ok { // data field should be of type map[string]any in KV-v2 - return "", nil + return "", nil, nil } case "metadata": var ok bool if metadata, ok = v.(map[string]any); !ok { // metadata field should be of type map[string]any in KV-v2 - return "", nil + return "", nil, nil } default: // secretData contains a non valid KV-v2 top level field - return "", nil + return "", nil, nil } } if data == nil || metadata == nil { // missing required KV-v2 field - return "", nil + return "", nil, nil } if u, ok := data[usernameAttr]; ok { @@ -130,6 +146,11 @@ func kv2Extract(sd data, usernameAttr, privateKeyAttr string) (username string, privateKey = credential.PrivateKey(p) } } + if p, ok := data[passphraseAttr]; ok { + if p, ok := p.(string); ok { + passphrase = []byte(p) + } + } - return username, privateKey + return username, privateKey, passphrase } diff --git a/internal/credential/vault/internal/sshprivatekey/sshprivatekey_test.go b/internal/credential/vault/internal/sshprivatekey/sshprivatekey_test.go index 92bb37ba8..07c81cea8 100644 --- a/internal/credential/vault/internal/sshprivatekey/sshprivatekey_test.go +++ b/internal/credential/vault/internal/sshprivatekey/sshprivatekey_test.go @@ -15,13 +15,15 @@ func TestExtract(t *testing.T) { rsaKey := testdata.PEMBytes["rsa"] type args struct { - s data - uAttr string - pAttr string + s data + uAttr string + pkAttr string + pAttr string } type sshpk struct { user string privateKey credential.PrivateKey + passphrase []byte } tests := []struct { name string @@ -30,20 +32,20 @@ func TestExtract(t *testing.T) { }{ { name: "nil-input", - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "no-input", given: args{}, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "no-secret", given: args{ - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "no-match-username-secret", @@ -52,10 +54,10 @@ func TestExtract(t *testing.T) { "username-wrong": "user", "private_key": string(edKey), }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "no-match-private-key-secret", @@ -64,10 +66,10 @@ func TestExtract(t *testing.T) { "username": "user", "private_key-wrong": string(edKey), }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "valid-default", @@ -76,22 +78,24 @@ func TestExtract(t *testing.T) { "username": "user", "private_key": string(edKey), }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "user", privateKey: edKey}, + want: sshpk{user: "user", privateKey: edKey, passphrase: nil}, }, { - name: "valid-default-private-key-string", + name: "valid-default-with-passphrase", given: args{ s: data{ "username": "user", "private_key": string(edKey), + "passphrase": "my-pass", }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", + pAttr: "passphrase", }, - want: sshpk{user: "user", privateKey: edKey}, + want: sshpk{user: "user", privateKey: edKey, passphrase: []byte("my-pass")}, }, { name: "no-match-username-secret-kv2", @@ -103,10 +107,10 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "no-match-private-key-secret-kv2", @@ -118,10 +122,10 @@ func TestExtract(t *testing.T) { "private_key-wrong": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "valid-kv2", @@ -133,25 +137,27 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "user", privateKey: edKey}, + want: sshpk{user: "user", privateKey: edKey, passphrase: nil}, }, { - name: "valid-kv2-private-key-string", + name: "valid-kv2-with-passphrase", given: args{ s: data{ "metadata": map[string]interface{}{}, "data": map[string]interface{}{ "username": "user", "private_key": string(edKey), + "passphrase": "my-pass", }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", + pAttr: "passphrase", }, - want: sshpk{user: "user", privateKey: edKey}, + want: sshpk{user: "user", privateKey: edKey, passphrase: []byte("my-pass")}, }, { name: "no-metadata-kv2", @@ -162,10 +168,10 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "invalid-metadata-kv2", @@ -177,10 +183,10 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "invalid-field-kv2", @@ -193,10 +199,10 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "", privateKey: nil}, + want: sshpk{user: "", privateKey: nil, passphrase: nil}, }, { name: "valid-order-default-first", @@ -210,10 +216,10 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "private_key", + uAttr: "username", + pkAttr: "private_key", }, - want: sshpk{user: "default-user", privateKey: rsaKey}, + want: sshpk{user: "default-user", privateKey: rsaKey, passphrase: nil}, }, { name: "default-user-json-pointer-pk", @@ -224,10 +230,10 @@ func TestExtract(t *testing.T) { "private_key": string(edKey), }, }, - uAttr: "username", - pAttr: "/testing/private_key", + uAttr: "username", + pkAttr: "/testing/private_key", }, - want: sshpk{user: "default-user", privateKey: edKey}, + want: sshpk{user: "default-user", privateKey: edKey, passphrase: nil}, }, { name: "default-pk-json-pointer-user", @@ -238,13 +244,13 @@ func TestExtract(t *testing.T) { "special": "not-so-special", }, }, - uAttr: "/testing/special", - pAttr: "private_key", + uAttr: "/testing/special", + pkAttr: "private_key", }, - want: sshpk{user: "not-so-special", privateKey: edKey}, + want: sshpk{user: "not-so-special", privateKey: edKey, passphrase: nil}, }, { - name: "both-json-pointer", + name: "all-json-pointer", given: args{ s: data{ "first-path": map[string]interface{}{ @@ -255,20 +261,25 @@ func TestExtract(t *testing.T) { "testing": map[string]interface{}{ "private_key": string(edKey), }, + "hidden": map[string]interface{}{ + "pass": "my-pass", + }, }, - uAttr: "/first-path/deeper-path/my-special-user", - pAttr: "/testing/private_key", + uAttr: "/first-path/deeper-path/my-special-user", + pkAttr: "/testing/private_key", + pAttr: "/hidden/pass", }, - want: sshpk{user: "you-found-me", privateKey: edKey}, + want: sshpk{user: "you-found-me", privateKey: edKey, passphrase: []byte("my-pass")}, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { assert := assert.New(t) - user, privateKey := Extract(tt.given.s, tt.given.uAttr, tt.given.pAttr) + user, privateKey, passphrase := Extract(tt.given.s, tt.given.uAttr, tt.given.pkAttr, tt.given.pAttr) assert.Equal(tt.want.user, user) assert.Equal(tt.want.privateKey, privateKey) + assert.Equal(tt.want.passphrase, passphrase) }) } } diff --git a/internal/credential/vault/mapping_overriders.go b/internal/credential/vault/mapping_overriders.go index fbc5633ba..29364cb4a 100644 --- a/internal/credential/vault/mapping_overriders.go +++ b/internal/credential/vault/mapping_overriders.go @@ -110,14 +110,15 @@ type SshPrivateKeyOverride struct { var _ MappingOverride = (*SshPrivateKeyOverride)(nil) // NewSshPrivateKeyOverride creates a new in memory SshPrivateKeyOverride. -// WithOverrideUsernameAttribute and WithOverridePasswordAttribute are the -// only valid options. All other options are ignored. +// WithOverrideUsernameAttribute, WithOverridePrivateKeyAttribute and WithOverridePrivateKeyPassphraseAttribute +// are the only valid options. All other options are ignored. func NewSshPrivateKeyOverride(opt ...Option) *SshPrivateKeyOverride { opts := getOpts(opt...) o := &SshPrivateKeyOverride{ SshPrivateKeyOverride: &store.SshPrivateKeyOverride{ - UsernameAttribute: sanitize.String(opts.withOverrideUsernameAttribute), - PrivateKeyAttribute: sanitize.String(opts.withOverridePrivateKeyAttribute), + UsernameAttribute: sanitize.String(opts.withOverrideUsernameAttribute), + PrivateKeyAttribute: sanitize.String(opts.withOverridePrivateKeyAttribute), + PrivateKeyPassphraseAttribute: sanitize.String(opts.withOverridePrivateKeyPassphraseAttribute), }, } return o @@ -147,6 +148,9 @@ func (o *SshPrivateKeyOverride) sanitize() { if sentinel.Is(o.PrivateKeyAttribute) { o.PrivateKeyAttribute = "" } + if sentinel.Is(o.PrivateKeyPassphraseAttribute) { + o.PrivateKeyPassphraseAttribute = "" + } } // TableName returns the table name. diff --git a/internal/credential/vault/options.go b/internal/credential/vault/options.go index 3e80c6f56..cc21022fd 100644 --- a/internal/credential/vault/options.go +++ b/internal/credential/vault/options.go @@ -29,10 +29,11 @@ type options struct { withRequestBody []byte withCredentialType credential.Type - withOverrideUsernameAttribute string - withOverridePasswordAttribute string - withOverridePrivateKeyAttribute string - withMappingOverride MappingOverride + withOverrideUsernameAttribute string + withOverridePasswordAttribute string + withOverridePrivateKeyAttribute string + withOverridePrivateKeyPassphraseAttribute string + withMappingOverride MappingOverride } func getDefaultOptions() options { @@ -158,6 +159,14 @@ func WithOverridePrivateKeyAttribute(s string) Option { } } +// WithOverridePrivateKeyPassphraseAttribute provides the name of an attribute in the +// Data field of a Vault api.Secret that maps to a passphrase value. +func WithOverridePrivateKeyPassphraseAttribute(s string) Option { + return func(o *options) { + o.withOverridePrivateKeyPassphraseAttribute = s + } +} + // WithMappingOverride provides an optional mapping override to use for // mapping the Data fields of a Vault api.Secret to a credential. func WithMappingOverride(m MappingOverride) Option { diff --git a/internal/credential/vault/options_test.go b/internal/credential/vault/options_test.go index ea6dd2ff7..9bce2a38e 100644 --- a/internal/credential/vault/options_test.go +++ b/internal/credential/vault/options_test.go @@ -114,6 +114,13 @@ func Test_GetOpts(t *testing.T) { testOpts.withOverridePrivateKeyAttribute = "test" assert.Equal(t, opts, testOpts) }) + t.Run("WithOverridePrivateKeyPassphraseAttribute", func(t *testing.T) { + opts := getOpts(WithOverridePrivateKeyPassphraseAttribute("test")) + testOpts := getDefaultOptions() + assert.NotEqual(t, opts, testOpts) + testOpts.withOverridePrivateKeyPassphraseAttribute = "test" + assert.Equal(t, opts, testOpts) + }) t.Run("WithMappingOverride", func(t *testing.T) { opts := getOpts(WithMappingOverride(unknownMapper(1))) testOpts := getDefaultOptions() diff --git a/internal/credential/vault/private_library.go b/internal/credential/vault/private_library.go index 75c83f7ea..95f71d28d 100644 --- a/internal/credential/vault/private_library.go +++ b/internal/credential/vault/private_library.go @@ -91,10 +91,12 @@ type sshPrivateKeyCred struct { *baseCred username string privateKey credential.PrivateKey + passphrase []byte } func (c *sshPrivateKeyCred) Username() string { return c.username } func (c *sshPrivateKeyCred) PrivateKey() credential.PrivateKey { return c.privateKey } +func (c *sshPrivateKeyCred) PrivateKeyPassphrase() []byte { return c.passphrase } func baseToSshPriKey(ctx context.Context, bc *baseCred) (*sshPrivateKeyCred, error) { switch { @@ -106,14 +108,17 @@ func baseToSshPriKey(ctx context.Context, bc *baseCred) (*sshPrivateKeyCred, err return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("invalid credential type")) } - uAttr, pAttr := bc.lib.UsernameAttribute, bc.lib.PrivateKeyAttribute + uAttr, pkAttr, pAttr := bc.lib.UsernameAttribute, bc.lib.PrivateKeyAttribute, bc.lib.PrivateKeyPassphraseAttribute if uAttr == "" { uAttr = "username" } - if pAttr == "" { - pAttr = "private_key" + if pkAttr == "" { + pkAttr = "private_key" } - username, pk := sshprivatekey.Extract(bc.secretData, uAttr, pAttr) + if pAttr == "" { + pAttr = "private_key_passphrase" + } + username, pk, pass := sshprivatekey.Extract(bc.secretData, uAttr, pkAttr, pAttr) if username == "" || pk == nil { return nil, errors.E(ctx, errors.WithCode(errors.VaultInvalidCredentialMapping)) } @@ -122,6 +127,7 @@ func baseToSshPriKey(ctx context.Context, bc *baseCred) (*sshPrivateKeyCred, err baseCred: bc, username: username, privateKey: pk, + passphrase: pass, }, nil } @@ -130,72 +136,74 @@ var _ credential.Library = (*privateLibrary)(nil) // A privateLibrary contains all the values needed to connect to Vault and // retrieve credentials. type privateLibrary struct { - PublicId string `gorm:"primary_key"` - StoreId string - CredType string `gorm:"column:credential_type"` - UsernameAttribute string - PasswordAttribute string - PrivateKeyAttribute string - Name string - Description string - CreateTime *timestamp.Timestamp - UpdateTime *timestamp.Timestamp - Version uint32 - ScopeId string - VaultPath string - HttpMethod string - HttpRequestBody []byte - VaultAddress string - Namespace string - CaCert []byte - TlsServerName string - TlsSkipVerify bool - WorkerFilter string - TokenHmac []byte - Token TokenSecret - CtToken []byte - TokenKeyId string - ClientCert []byte - ClientKey KeySecret - CtClientKey []byte - ClientKeyId string - Purpose credential.Purpose `gorm:"-"` + PublicId string `gorm:"primary_key"` + StoreId string + CredType string `gorm:"column:credential_type"` + UsernameAttribute string + PasswordAttribute string + PrivateKeyAttribute string + PrivateKeyPassphraseAttribute string + Name string + Description string + CreateTime *timestamp.Timestamp + UpdateTime *timestamp.Timestamp + Version uint32 + ScopeId string + VaultPath string + HttpMethod string + HttpRequestBody []byte + VaultAddress string + Namespace string + CaCert []byte + TlsServerName string + TlsSkipVerify bool + WorkerFilter string + TokenHmac []byte + Token TokenSecret + CtToken []byte + TokenKeyId string + ClientCert []byte + ClientKey KeySecret + CtClientKey []byte + ClientKeyId string + Purpose credential.Purpose `gorm:"-"` } func (pl *privateLibrary) clone() *privateLibrary { // The 'append(a[:0:0], a...)' comes from // https://github.com/go101/go101/wiki/How-to-perfectly-clone-a-slice%3F return &privateLibrary{ - PublicId: pl.PublicId, - StoreId: pl.StoreId, - CredType: pl.CredType, - UsernameAttribute: pl.UsernameAttribute, - PasswordAttribute: pl.PasswordAttribute, - PrivateKeyAttribute: pl.PrivateKeyAttribute, - Name: pl.Name, - Description: pl.Description, - CreateTime: proto.Clone(pl.CreateTime).(*timestamp.Timestamp), - UpdateTime: proto.Clone(pl.UpdateTime).(*timestamp.Timestamp), - Version: pl.Version, - ScopeId: pl.ScopeId, - VaultPath: pl.VaultPath, - HttpMethod: pl.HttpMethod, - HttpRequestBody: append(pl.HttpRequestBody[:0:0], pl.HttpRequestBody...), - VaultAddress: pl.VaultAddress, - Namespace: pl.Namespace, - CaCert: append(pl.CaCert[:0:0], pl.CaCert...), - TlsServerName: pl.TlsServerName, - TlsSkipVerify: pl.TlsSkipVerify, - WorkerFilter: pl.WorkerFilter, - TokenHmac: append(pl.TokenHmac[:0:0], pl.TokenHmac...), - Token: append(pl.Token[:0:0], pl.Token...), - CtToken: append(pl.CtToken[:0:0], pl.CtToken...), - TokenKeyId: pl.TokenKeyId, - ClientCert: append(pl.ClientCert[:0:0], pl.ClientCert...), - ClientKey: append(pl.ClientKey[:0:0], pl.ClientKey...), - CtClientKey: append(pl.CtClientKey[:0:0], pl.CtClientKey...), - ClientKeyId: pl.ClientKeyId, - Purpose: pl.Purpose, + PublicId: pl.PublicId, + StoreId: pl.StoreId, + CredType: pl.CredType, + UsernameAttribute: pl.UsernameAttribute, + PasswordAttribute: pl.PasswordAttribute, + PrivateKeyAttribute: pl.PrivateKeyAttribute, + PrivateKeyPassphraseAttribute: pl.PrivateKeyPassphraseAttribute, + Name: pl.Name, + Description: pl.Description, + CreateTime: proto.Clone(pl.CreateTime).(*timestamp.Timestamp), + UpdateTime: proto.Clone(pl.UpdateTime).(*timestamp.Timestamp), + Version: pl.Version, + ScopeId: pl.ScopeId, + VaultPath: pl.VaultPath, + HttpMethod: pl.HttpMethod, + HttpRequestBody: append(pl.HttpRequestBody[:0:0], pl.HttpRequestBody...), + VaultAddress: pl.VaultAddress, + Namespace: pl.Namespace, + CaCert: append(pl.CaCert[:0:0], pl.CaCert...), + TlsServerName: pl.TlsServerName, + TlsSkipVerify: pl.TlsSkipVerify, + WorkerFilter: pl.WorkerFilter, + TokenHmac: append(pl.TokenHmac[:0:0], pl.TokenHmac...), + Token: append(pl.Token[:0:0], pl.Token...), + CtToken: append(pl.CtToken[:0:0], pl.CtToken...), + TokenKeyId: pl.TokenKeyId, + ClientCert: append(pl.ClientCert[:0:0], pl.ClientCert...), + ClientKey: append(pl.ClientKey[:0:0], pl.ClientKey...), + CtClientKey: append(pl.CtClientKey[:0:0], pl.CtClientKey...), + ClientKeyId: pl.ClientKeyId, + Purpose: pl.Purpose, } } diff --git a/internal/credential/vault/private_library_test.go b/internal/credential/vault/private_library_test.go index 1ebdd0be0..242af3db8 100644 --- a/internal/credential/vault/private_library_test.go +++ b/internal/credential/vault/private_library_test.go @@ -227,12 +227,30 @@ func TestRepository_getPrivateLibraries(t *testing.T) { req := credential.Request{SourceId: lib.GetPublicId(), Purpose: credential.BrokeredPurpose} requests = append(requests, req) } + { + opts := []Option{ + WithCredentialType(credential.SshPrivateKeyType), + WithMappingOverride(NewSshPrivateKeyOverride( + WithOverridePrivateKeyPassphraseAttribute("test-passphrase"), + )), + } + libIn, err := NewCredentialLibrary(origStore.GetPublicId(), "/vault/path", opts...) + assert.NoError(err) + require.NotNil(libIn) + lib, err := repo.CreateCredentialLibrary(ctx, prj.GetPublicId(), libIn) + assert.NoError(err) + require.NotNil(lib) + libs[lib.GetPublicId()] = lib + req := credential.Request{SourceId: lib.GetPublicId(), Purpose: credential.BrokeredPurpose} + requests = append(requests, req) + } { opts := []Option{ WithCredentialType(credential.SshPrivateKeyType), WithMappingOverride(NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("test-username"), WithOverridePrivateKeyAttribute("test-private-key"), + WithOverridePrivateKeyPassphraseAttribute("test-passphrase"), )), } libIn, err := NewCredentialLibrary(origStore.GetPublicId(), "/vault/path", opts...) @@ -273,6 +291,7 @@ func TestRepository_getPrivateLibraries(t *testing.T) { case *SshPrivateKeyOverride: assert.Equal(w.UsernameAttribute, got.UsernameAttribute) assert.Equal(w.PrivateKeyAttribute, got.PrivateKeyAttribute) + assert.Equal(w.PrivateKeyPassphraseAttribute, got.PrivateKeyPassphraseAttribute) default: assert.Fail("unknown mapping override type") } @@ -806,6 +825,24 @@ func TestBaseToSshPriKey(t *testing.T) { privateKey: credential.PrivateKey("my-pk"), }, }, + { + name: "valid-default-attributes-with-passphrase", + given: &baseCred{ + lib: &privateLibrary{ + CredType: string(credential.SshPrivateKeyType), + }, + secretData: map[string]interface{}{ + "username": "my-username", + "private_key": "my-pk", + "private_key_passphrase": "my-pass", + }, + }, + want: &sshPrivateKeyCred{ + username: "my-username", + privateKey: credential.PrivateKey("my-pk"), + passphrase: []byte("my-pass"), + }, + }, { name: "valid-override-attributes", given: &baseCred{ @@ -826,6 +863,30 @@ func TestBaseToSshPriKey(t *testing.T) { privateKey: credential.PrivateKey("override-pk"), }, }, + { + name: "valid-override-attributes-with-passphrase", + given: &baseCred{ + lib: &privateLibrary{ + CredType: string(credential.SshPrivateKeyType), + UsernameAttribute: "test-username", + PrivateKeyAttribute: "test-pk", + PrivateKeyPassphraseAttribute: "test-pass", + }, + secretData: map[string]interface{}{ + "username": "default-username", + "private_key": "default-pk", + "passphrase": "default-pass", + "test-username": "override-username", + "test-pk": "override-pk", + "test-pass": "override-pass", + }, + }, + want: &sshPrivateKeyCred{ + username: "override-username", + privateKey: credential.PrivateKey("override-pk"), + passphrase: []byte("override-pass"), + }, + }, { name: "valid-default-username-override-pk", given: &baseCred{ @@ -1018,6 +1079,27 @@ func TestBaseToSshPriKey(t *testing.T) { privateKey: credential.PrivateKey("default-pk"), }, }, + { + name: "valid-kv2-default-attributes-with-passphrase", + given: &baseCred{ + lib: &privateLibrary{ + CredType: string(credential.SshPrivateKeyType), + }, + secretData: map[string]interface{}{ + "metadata": map[string]interface{}{}, + "data": map[string]interface{}{ + "username": "default-username", + "private_key": "default-pk", + "private_key_passphrase": "default-pass", + }, + }, + }, + want: &sshPrivateKeyCred{ + username: "default-username", + privateKey: credential.PrivateKey("default-pk"), + passphrase: []byte("default-pass"), + }, + }, { name: "valid-kv2-override-attributes", given: &baseCred{ @@ -1041,6 +1123,33 @@ func TestBaseToSshPriKey(t *testing.T) { privateKey: credential.PrivateKey("override-pk"), }, }, + { + name: "valid-kv2-override-attributes-with-passphrase", + given: &baseCred{ + lib: &privateLibrary{ + CredType: string(credential.SshPrivateKeyType), + UsernameAttribute: "test-username", + PrivateKeyAttribute: "test-pk", + PrivateKeyPassphraseAttribute: "test-pass", + }, + secretData: map[string]interface{}{ + "metadata": map[string]interface{}{}, + "data": map[string]interface{}{ + "username": "default-username", + "private_key": "default-pk", + "passphrase": "default-pass", + "test-username": "override-username", + "test-pk": "override-pk", + "test-pass": "override-pass", + }, + }, + }, + want: &sshPrivateKeyCred{ + username: "override-username", + privateKey: credential.PrivateKey("override-pk"), + passphrase: []byte("override-pass"), + }, + }, { name: "valid-kv2-default-username-override-pk", given: &baseCred{ diff --git a/internal/credential/vault/repository_credential_library.go b/internal/credential/vault/repository_credential_library.go index 5c405a83d..9d642597e 100644 --- a/internal/credential/vault/repository_credential_library.go +++ b/internal/credential/vault/repository_credential_library.go @@ -345,20 +345,21 @@ func (r *Repository) LookupCredentialLibrary(ctx context.Context, publicId strin // mapping overrides. It does not include encrypted data and is safe to // return external to boundary. type publicLibrary struct { - PublicId string `gorm:"primary_key"` - StoreId string - Name string - Description string - CreateTime *timestamp.Timestamp - UpdateTime *timestamp.Timestamp - Version uint32 - VaultPath string - HttpMethod string - HttpRequestBody []byte - CredentialType string - UsernameAttribute string - PasswordAttribute string - PrivateKeyAttribute string + PublicId string `gorm:"primary_key"` + StoreId string + Name string + Description string + CreateTime *timestamp.Timestamp + UpdateTime *timestamp.Timestamp + Version uint32 + VaultPath string + HttpMethod string + HttpRequestBody []byte + CredentialType string + UsernameAttribute string + PasswordAttribute string + PrivateKeyAttribute string + PrivateKeyPassphraseAttribute string } func allocPublicLibrary() *publicLibrary { @@ -390,11 +391,12 @@ func (pl *publicLibrary) toCredentialLibrary() *CredentialLibrary { cl.MappingOverride = up } case string(credential.SshPrivateKeyType): - if pl.UsernameAttribute != "" || pl.PrivateKeyAttribute != "" { + if pl.UsernameAttribute != "" || pl.PrivateKeyAttribute != "" || pl.PrivateKeyPassphraseAttribute != "" { pk := allocSshPrivateKeyOverride() pk.LibraryId = pl.PublicId pk.UsernameAttribute = pl.UsernameAttribute pk.PrivateKeyAttribute = pl.PrivateKeyAttribute + pk.PrivateKeyPassphraseAttribute = pl.PrivateKeyPassphraseAttribute pk.sanitize() cl.MappingOverride = pk } diff --git a/internal/credential/vault/repository_credential_library_test.go b/internal/credential/vault/repository_credential_library_test.go index 54bfd0bb5..4de9ffb72 100644 --- a/internal/credential/vault/repository_credential_library_test.go +++ b/internal/credential/vault/repository_credential_library_test.go @@ -381,11 +381,37 @@ func TestRepository_CreateCredentialLibrary(t *testing.T) { }, }, { - name: "valid-ssh-private-keyt-credential-type-with-username-and-private-key-override", + name: "valid-ssh-private-key-credential-type-with-passphrase-override", + in: &CredentialLibrary{ + MappingOverride: NewSshPrivateKeyOverride( + WithOverridePrivateKeyPassphraseAttribute("passtest"), + ), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(credential.SshPrivateKeyType), + }, + }, + want: &CredentialLibrary{ + MappingOverride: NewSshPrivateKeyOverride( + WithOverridePrivateKeyPassphraseAttribute("passtest"), + ), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(credential.SshPrivateKeyType), + }, + }, + }, + { + name: "valid-ssh-private-keyt-credential-type-with-all-override", in: &CredentialLibrary{ MappingOverride: NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("utest"), - WithOverridePrivateKeyAttribute("ptest"), + WithOverridePrivateKeyAttribute("pktest"), + WithOverridePrivateKeyPassphraseAttribute("ptest"), ), CredentialLibrary: &store.CredentialLibrary{ StoreId: cs.GetPublicId(), @@ -397,7 +423,8 @@ func TestRepository_CreateCredentialLibrary(t *testing.T) { want: &CredentialLibrary{ MappingOverride: NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("utest"), - WithOverridePrivateKeyAttribute("ptest"), + WithOverridePrivateKeyAttribute("pktest"), + WithOverridePrivateKeyPassphraseAttribute("ptest"), ), CredentialLibrary: &store.CredentialLibrary{ StoreId: cs.GetPublicId(), @@ -456,6 +483,7 @@ func TestRepository_CreateCredentialLibrary(t *testing.T) { require.True(ok) assert.Equal(w.UsernameAttribute, g.UsernameAttribute) assert.Equal(w.PrivateKeyAttribute, g.PrivateKeyAttribute) + assert.Equal(w.PrivateKeyPassphraseAttribute, g.PrivateKeyPassphraseAttribute) // verify it was persisted in the database override := allocSshPrivateKeyOverride() @@ -1302,11 +1330,10 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { wantCount: 1, }, { - name: "ssh-private-key-attributes-change-username-and-private-key-attributes", + name: "ssh-private-key-attributes-change-passphrase-attribute", orig: &CredentialLibrary{ MappingOverride: NewSshPrivateKeyOverride( - WithOverrideUsernameAttribute("orig-username"), - WithOverridePrivateKeyAttribute("orig-private-key"), + WithOverridePrivateKeyPassphraseAttribute("orig-passphrase"), ), CredentialLibrary: &store.CredentialLibrary{ HttpMethod: "GET", @@ -1317,15 +1344,13 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { }, chgFn: changeMappingOverride( NewSshPrivateKeyOverride( - WithOverrideUsernameAttribute("changed-username"), - WithOverridePrivateKeyAttribute("changed-private-key"), + WithOverridePrivateKeyPassphraseAttribute("changed-passphrase"), ), ), masks: []string{"MappingOverride"}, want: &CredentialLibrary{ MappingOverride: NewSshPrivateKeyOverride( - WithOverrideUsernameAttribute("changed-username"), - WithOverridePrivateKeyAttribute("changed-private-key"), + WithOverridePrivateKeyPassphraseAttribute("changed-passphrase"), ), CredentialLibrary: &store.CredentialLibrary{ HttpMethod: "GET", @@ -1337,7 +1362,45 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { wantCount: 1, }, { - name: "no-mapping-override-change-username-and-private-key-attributes", + name: "ssh-private-key-attributes-change-all-attributes", + orig: &CredentialLibrary{ + MappingOverride: NewSshPrivateKeyOverride( + WithOverrideUsernameAttribute("orig-username"), + WithOverridePrivateKeyAttribute("orig-private-key"), + WithOverridePrivateKeyPassphraseAttribute("orig-passphrase"), + ), + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-ssh-private-key-repo", + CredentialType: string(credential.SshPrivateKeyType), + }, + }, + chgFn: changeMappingOverride( + NewSshPrivateKeyOverride( + WithOverrideUsernameAttribute("changed-username"), + WithOverridePrivateKeyAttribute("changed-private-key"), + WithOverridePrivateKeyPassphraseAttribute("changed-passphrase"), + ), + ), + masks: []string{"MappingOverride"}, + want: &CredentialLibrary{ + MappingOverride: NewSshPrivateKeyOverride( + WithOverrideUsernameAttribute("changed-username"), + WithOverridePrivateKeyAttribute("changed-private-key"), + WithOverridePrivateKeyPassphraseAttribute("changed-passphrase"), + ), + CredentialLibrary: &store.CredentialLibrary{ + HttpMethod: "GET", + VaultPath: "/some/path", + Name: "test-ssh-private-key-repo", + CredentialType: string(credential.SshPrivateKeyType), + }, + }, + wantCount: 1, + }, + { + name: "no-mapping-override-change-all-attributes", orig: &CredentialLibrary{ CredentialLibrary: &store.CredentialLibrary{ HttpMethod: "GET", @@ -1350,6 +1413,7 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("changed-username"), WithOverridePrivateKeyAttribute("changed-private-key"), + WithOverridePrivateKeyPassphraseAttribute("changed-passphrase"), ), ), masks: []string{"MappingOverride"}, @@ -1357,6 +1421,7 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { MappingOverride: NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("changed-username"), WithOverridePrivateKeyAttribute("changed-private-key"), + WithOverridePrivateKeyPassphraseAttribute("changed-passphrase"), ), CredentialLibrary: &store.CredentialLibrary{ HttpMethod: "GET", @@ -1373,6 +1438,7 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { MappingOverride: NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("orig-username"), WithOverridePrivateKeyAttribute("orig-private-key"), + WithOverridePrivateKeyPassphraseAttribute("orig-passphrase"), ), CredentialLibrary: &store.CredentialLibrary{ HttpMethod: "GET", @@ -1424,7 +1490,7 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { assert.Nil(got) return } - assert.NoError(err) + require.NoError(err) assert.Empty(tt.orig.PublicId) require.NotNil(got) assertPublicId(t, CredentialLibraryPrefix, got.GetPublicId()) @@ -1466,6 +1532,7 @@ func TestRepository_UpdateCredentialLibrary(t *testing.T) { require.True(ok) assert.Equal(w.UsernameAttribute, g.UsernameAttribute) assert.Equal(w.PrivateKeyAttribute, g.PrivateKeyAttribute) + assert.Equal(w.PrivateKeyPassphraseAttribute, g.PrivateKeyPassphraseAttribute) default: assert.Fail("Unknown mapping override") } @@ -1705,11 +1772,26 @@ func TestRepository_LookupCredentialLibrary(t *testing.T) { }, }, { - name: "valid-ssh-private-key-credential-type-with-username-and-private-key-override", + name: "valid-ssh-private-key-credential-type-with-passphrase-override", + in: &CredentialLibrary{ + MappingOverride: NewSshPrivateKeyOverride( + WithOverridePrivateKeyPassphraseAttribute("ptest"), + ), + CredentialLibrary: &store.CredentialLibrary{ + StoreId: cs.GetPublicId(), + HttpMethod: "GET", + VaultPath: "/some/path", + CredentialType: string(credential.SshPrivateKeyType), + }, + }, + }, + { + name: "valid-ssh-private-key-credential-type-with-all-overrides", in: &CredentialLibrary{ MappingOverride: NewSshPrivateKeyOverride( WithOverrideUsernameAttribute("utest"), - WithOverridePrivateKeyAttribute("ptest"), + WithOverridePrivateKeyAttribute("pktest"), + WithOverridePrivateKeyPassphraseAttribute("ptest"), ), CredentialLibrary: &store.CredentialLibrary{ StoreId: cs.GetPublicId(), @@ -1756,6 +1838,7 @@ func TestRepository_LookupCredentialLibrary(t *testing.T) { require.True(ok) assert.Equal(w.UsernameAttribute, g.UsernameAttribute) assert.Equal(w.PrivateKeyAttribute, g.PrivateKeyAttribute) + assert.Equal(w.PrivateKeyPassphraseAttribute, g.PrivateKeyPassphraseAttribute) default: assert.Fail("Unknown mapping override") } diff --git a/internal/credential/vault/store/vault.pb.go b/internal/credential/vault/store/vault.pb.go index b0629d382..316e05c24 100644 --- a/internal/credential/vault/store/vault.pb.go +++ b/internal/credential/vault/store/vault.pb.go @@ -904,6 +904,16 @@ type SshPrivateKeyOverride struct { // // @inject_tag: `gorm:"default:null"` PrivateKeyAttribute string `protobuf:"bytes,3,opt,name=private_key_attribute,json=privateKeyAttribute,proto3" json:"private_key_attribute,omitempty" gorm:"default:null"` + // private_key_passphrase_attribute is the name of the attribute in the Data + // field of a Vault api.Secret that maps to a private key passphrase. If set, + // it overrides any default attribute names the system uses to find a + // private_key_passphrase attribute. + // + // See + // https://github.com/hashicorp/vault/blob/5e505ec039177e8212cbbab74ccb644c46e62e63/api/secret.go#L25 + // + // @inject_tag: `gorm:"default:null"` + PrivateKeyPassphraseAttribute string `protobuf:"bytes,4,opt,name=private_key_passphrase_attribute,json=privateKeyPassphraseAttribute,proto3" json:"private_key_passphrase_attribute,omitempty" gorm:"default:null"` } func (x *SshPrivateKeyOverride) Reset() { @@ -959,6 +969,13 @@ func (x *SshPrivateKeyOverride) GetPrivateKeyAttribute() string { return "" } +func (x *SshPrivateKeyOverride) GetPrivateKeyPassphraseAttribute() string { + if x != nil { + return x.PrivateKeyPassphraseAttribute + } + return "" +} + var File_controller_storage_credential_vault_store_v1_vault_proto protoreflect.FileDescriptor var file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc = []byte{ @@ -1171,7 +1188,7 @@ var file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc = []by 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x12, 0x2d, 0x0a, 0x12, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x41, 0x74, 0x74, 0x72, - 0x69, 0x62, 0x75, 0x74, 0x65, 0x22, 0x99, 0x01, 0x0a, 0x15, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, + 0x69, 0x62, 0x75, 0x74, 0x65, 0x22, 0xe2, 0x01, 0x0a, 0x15, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x49, 0x64, 0x12, 0x2d, @@ -1181,11 +1198,16 @@ var file_controller_storage_credential_vault_store_v1_vault_proto_rawDesc = []by 0x15, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, - 0x65, 0x42, 0x45, 0x5a, 0x43, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, - 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x72, 0x65, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x74, 0x6f, - 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x12, 0x47, 0x0a, 0x20, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, + 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x5f, 0x61, 0x74, 0x74, 0x72, + 0x69, 0x62, 0x75, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1d, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, + 0x65, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x42, 0x45, 0x5a, 0x43, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, + 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x2f, + 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, + 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service.go b/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service.go index 05eb597c5..667913f43 100644 --- a/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service.go +++ b/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service.go @@ -38,9 +38,10 @@ const ( // Credential mapping override attributes const ( - usernameAttribute string = "username_attribute" - passwordAttribute string = "password_attribute" - privateKeyAttribute string = "private_key_attribute" + usernameAttribute string = "username_attribute" + passwordAttribute string = "password_attribute" + privateKeyAttribute string = "private_key_attribute" + pkPassphraseAttribute string = "private_key_passphrase_attribute" ) var ( @@ -540,6 +541,9 @@ func toProto(in credential.Library, opt ...handlers.Option) (*pb.CredentialLibra if mapping.PrivateKeyAttribute != "" { m[privateKeyAttribute] = mapping.PrivateKeyAttribute } + if mapping.PrivateKeyPassphraseAttribute != "" { + m[pkPassphraseAttribute] = mapping.PrivateKeyPassphraseAttribute + } } if len(m) > 0 { mp, err := structpb.NewStruct(m) @@ -612,6 +616,9 @@ func toStorageVaultLibrary(storeId string, in *pb.CredentialLibrary) (out *vault if pk := overrides[privateKeyAttribute]; pk != nil { mapOpts = append(mapOpts, vault.WithOverridePrivateKeyAttribute(pk.(string))) } + if pass := overrides[pkPassphraseAttribute]; pass != nil { + mapOpts = append(mapOpts, vault.WithOverridePrivateKeyPassphraseAttribute(pass.(string))) + } if len(mapOpts) > 0 { opts = append(opts, vault.WithMappingOverride(vault.NewSshPrivateKeyOverride(mapOpts...))) } @@ -723,6 +730,7 @@ func validateMapping(badFields map[string]string, credentialType credential.Type case credential.SshPrivateKeyType: validFields[usernameAttribute] = true validFields[privateKeyAttribute] = true + validFields[pkPassphraseAttribute] = true default: badFields[globals.CredentialTypeField] = fmt.Sprintf("Unknown credential type %q", credentialType) return @@ -782,10 +790,11 @@ func getMappingUpdates(credentialType credential.Type, current vault.MappingOver } case credential.SshPrivateKeyType: - var currentUser, currentPk interface{} + var currentUser, currentpPass, currentPk interface{} if overrides, ok := current.(*vault.SshPrivateKeyOverride); ok { currentUser = overrides.UsernameAttribute currentPk = overrides.PrivateKeyAttribute + currentpPass = overrides.PrivateKeyPassphraseAttribute } switch { @@ -801,6 +810,13 @@ func getMappingUpdates(credentialType credential.Type, current vault.MappingOver default: ret[privateKeyAttribute] = currentPk } + + switch { + case masks[pkPassphraseAttribute]: + ret[pkPassphraseAttribute] = new[pkPassphraseAttribute] + default: + ret[pkPassphraseAttribute] = currentpPass + } } return ret, true diff --git a/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service_test.go b/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service_test.go index ac9d9018f..71e06f9c9 100644 --- a/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service_test.go +++ b/internal/daemon/controller/handlers/credentiallibraries/credentiallibrary_service_test.go @@ -542,6 +542,92 @@ func TestCreate(t *testing.T) { }, }, }, + { + name: "Create a valid vault CredentialLibrary ssh_private_key type", + req: &pbs.CreateCredentialLibraryRequest{Item: &pb.CredentialLibrary{ + CredentialStoreId: store.GetPublicId(), + Attrs: &pb.CredentialLibrary_VaultCredentialLibraryAttributes{ + VaultCredentialLibraryAttributes: &pb.VaultCredentialLibraryAttributes{ + Path: wrapperspb.String("something"), + }, + }, + CredentialType: string(credential.SshPrivateKeyType), + }}, + idPrefix: vault.CredentialLibraryPrefix + "_", + res: &pbs.CreateCredentialLibraryResponse{ + Uri: fmt.Sprintf("credential-libraries/%s_", vault.CredentialLibraryPrefix), + Item: &pb.CredentialLibrary{ + Id: store.GetPublicId(), + CredentialStoreId: store.GetPublicId(), + CreatedTime: store.GetCreateTime().GetTimestamp(), + UpdatedTime: store.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: prj.GetPublicId(), Type: prj.GetType(), ParentScopeId: prj.GetParentId()}, + Version: 1, + Type: vault.Subtype.String(), + Attrs: &pb.CredentialLibrary_VaultCredentialLibraryAttributes{ + VaultCredentialLibraryAttributes: &pb.VaultCredentialLibraryAttributes{ + Path: wrapperspb.String("something"), + HttpMethod: wrapperspb.String("GET"), + }, + }, + CredentialType: string(credential.SshPrivateKeyType), + AuthorizedActions: testAuthorizedActions, + }, + }, + }, + { + name: "Create a valid vault CredentialLibrary ssh_private_key type with mapping", + req: &pbs.CreateCredentialLibraryRequest{Item: &pb.CredentialLibrary{ + CredentialStoreId: store.GetPublicId(), + Attrs: &pb.CredentialLibrary_VaultCredentialLibraryAttributes{ + VaultCredentialLibraryAttributes: &pb.VaultCredentialLibraryAttributes{ + Path: wrapperspb.String("something"), + }, + }, + CredentialMappingOverrides: func() *structpb.Struct { + v := map[string]interface{}{ + usernameAttribute: "user-test", + privateKeyAttribute: "pk-test", + pkPassphraseAttribute: "pass-test", + } + ret, err := structpb.NewStruct(v) + require.NoError(t, err) + return ret + }(), + CredentialType: string(credential.SshPrivateKeyType), + }}, + idPrefix: vault.CredentialLibraryPrefix + "_", + res: &pbs.CreateCredentialLibraryResponse{ + Uri: fmt.Sprintf("credential-libraries/%s_", vault.CredentialLibraryPrefix), + Item: &pb.CredentialLibrary{ + Id: store.GetPublicId(), + CredentialStoreId: store.GetPublicId(), + CreatedTime: store.GetCreateTime().GetTimestamp(), + UpdatedTime: store.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: prj.GetPublicId(), Type: prj.GetType(), ParentScopeId: prj.GetParentId()}, + Version: 1, + Type: vault.Subtype.String(), + Attrs: &pb.CredentialLibrary_VaultCredentialLibraryAttributes{ + VaultCredentialLibraryAttributes: &pb.VaultCredentialLibraryAttributes{ + Path: wrapperspb.String("something"), + HttpMethod: wrapperspb.String("GET"), + }, + }, + CredentialType: string(credential.SshPrivateKeyType), + CredentialMappingOverrides: func() *structpb.Struct { + v := map[string]interface{}{ + usernameAttribute: "user-test", + privateKeyAttribute: "pk-test", + pkPassphraseAttribute: "pass-test", + } + ret, err := structpb.NewStruct(v) + require.NoError(t, err) + return ret + }(), + AuthorizedActions: testAuthorizedActions, + }, + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -624,6 +710,7 @@ func TestGet(t *testing.T) { vault.NewSshPrivateKeyOverride( vault.WithOverrideUsernameAttribute("user"), vault.WithOverridePrivateKeyAttribute("pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("pass"), ))) require.NoError(t, err) sshPkLib, err := repo.CreateCredentialLibrary(context.Background(), prj.GetPublicId(), lib1) @@ -711,8 +798,9 @@ func TestGet(t *testing.T) { CredentialType: "ssh_private_key", CredentialMappingOverrides: func() *structpb.Struct { v := map[string]interface{}{ - usernameAttribute: "user", - privateKeyAttribute: "pk", + usernameAttribute: "user", + privateKeyAttribute: "pk", + pkPassphraseAttribute: "pass", } ret, err := structpb.NewStruct(v) require.NoError(t, err) @@ -864,6 +952,7 @@ func TestUpdate(t *testing.T) { usernameAttrField := fmt.Sprintf("%v.%v", credentialMappingPathField, usernameAttribute) passwordAttrField := fmt.Sprintf("%v.%v", credentialMappingPathField, passwordAttribute) privateKeyAttrField := fmt.Sprintf("%v.%v", credentialMappingPathField, privateKeyAttribute) + passphraseAttrField := fmt.Sprintf("%v.%v", credentialMappingPathField, pkPassphraseAttribute) successCases := []struct { name string @@ -1167,6 +1256,7 @@ func TestUpdate(t *testing.T) { vault.NewSshPrivateKeyOverride( vault.WithOverrideUsernameAttribute("orig-user"), vault.WithOverridePrivateKeyAttribute("orig-pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("orig-pass"), )), }, req: &pbs.UpdateCredentialLibraryRequest{ @@ -1196,6 +1286,7 @@ func TestUpdate(t *testing.T) { vault.NewSshPrivateKeyOverride( vault.WithOverrideUsernameAttribute("orig-user"), vault.WithOverridePrivateKeyAttribute("orig-pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("orig-pass"), )), }, req: &pbs.UpdateCredentialLibraryRequest{ @@ -1218,22 +1309,54 @@ func TestUpdate(t *testing.T) { }, }, { - name: "ssh-private-key-attributes-change-username-and-private-key-attributes", + name: "ssh-private-key-attributes-change-passphrase-attribute", opts: []vault.Option{ vault.WithCredentialType("ssh_private_key"), vault.WithMappingOverride( vault.NewSshPrivateKeyOverride( vault.WithOverrideUsernameAttribute("orig-user"), vault.WithOverridePrivateKeyAttribute("orig-pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("orig-pass"), )), }, req: &pbs.UpdateCredentialLibraryRequest{ - UpdateMask: fieldmask(privateKeyAttrField, usernameAttrField), + UpdateMask: fieldmask(passphraseAttrField), Item: &pb.CredentialLibrary{ CredentialMappingOverrides: func() *structpb.Struct { v := map[string]interface{}{ - usernameAttribute: "changed-user", - privateKeyAttribute: "changed-pk", + pkPassphraseAttribute: "changed-pass", + } + ret, err := structpb.NewStruct(v) + require.NoError(t, err) + return ret + }(), + }, + }, + res: func(in *pb.CredentialLibrary) *pb.CredentialLibrary { + out := proto.Clone(in).(*pb.CredentialLibrary) + out.CredentialMappingOverrides.Fields[pkPassphraseAttribute] = structpb.NewStringValue("changed-pass") + return out + }, + }, + { + name: "ssh-private-key-attributes-change-all-attributes", + opts: []vault.Option{ + vault.WithCredentialType("ssh_private_key"), + vault.WithMappingOverride( + vault.NewSshPrivateKeyOverride( + vault.WithOverrideUsernameAttribute("orig-user"), + vault.WithOverridePrivateKeyAttribute("orig-pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("orig-pass"), + )), + }, + req: &pbs.UpdateCredentialLibraryRequest{ + UpdateMask: fieldmask(privateKeyAttrField, usernameAttrField, passphraseAttrField), + Item: &pb.CredentialLibrary{ + CredentialMappingOverrides: func() *structpb.Struct { + v := map[string]interface{}{ + usernameAttribute: "changed-user", + privateKeyAttribute: "changed-pk", + pkPassphraseAttribute: "changed-pass", } ret, err := structpb.NewStruct(v) require.NoError(t, err) @@ -1245,21 +1368,23 @@ func TestUpdate(t *testing.T) { out := proto.Clone(in).(*pb.CredentialLibrary) out.CredentialMappingOverrides.Fields[usernameAttribute] = structpb.NewStringValue("changed-user") out.CredentialMappingOverrides.Fields[privateKeyAttribute] = structpb.NewStringValue("changed-pk") + out.CredentialMappingOverrides.Fields[pkPassphraseAttribute] = structpb.NewStringValue("changed-pass") return out }, }, { - name: "no-mapping-override-change-username-and-private-key-attributes", + name: "no-mapping-override-change-all-attributes", opts: []vault.Option{ vault.WithCredentialType("ssh_private_key"), }, req: &pbs.UpdateCredentialLibraryRequest{ - UpdateMask: fieldmask(privateKeyAttrField, usernameAttrField), + UpdateMask: fieldmask(privateKeyAttrField, usernameAttrField, passphraseAttrField), Item: &pb.CredentialLibrary{ CredentialMappingOverrides: func() *structpb.Struct { v := map[string]interface{}{ - usernameAttribute: "new-user", - privateKeyAttribute: "new-pk", + usernameAttribute: "new-user", + privateKeyAttribute: "new-pk", + pkPassphraseAttribute: "new-pass", } ret, err := structpb.NewStruct(v) require.NoError(t, err) @@ -1270,8 +1395,9 @@ func TestUpdate(t *testing.T) { res: func(in *pb.CredentialLibrary) *pb.CredentialLibrary { out := proto.Clone(in).(*pb.CredentialLibrary) v := map[string]interface{}{ - usernameAttribute: "new-user", - privateKeyAttribute: "new-pk", + usernameAttribute: "new-user", + privateKeyAttribute: "new-pk", + pkPassphraseAttribute: "new-pass", } var err error out.CredentialMappingOverrides, err = structpb.NewStruct(v) @@ -1287,6 +1413,7 @@ func TestUpdate(t *testing.T) { vault.NewSshPrivateKeyOverride( vault.WithOverrideUsernameAttribute("orig-user"), vault.WithOverridePrivateKeyAttribute("orig-pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("orig-pass"), )), }, req: &pbs.UpdateCredentialLibraryRequest{ @@ -1326,15 +1453,17 @@ func TestUpdate(t *testing.T) { vault.NewSshPrivateKeyOverride( vault.WithOverrideUsernameAttribute("orig-user"), vault.WithOverridePrivateKeyAttribute("orig-pk"), + vault.WithOverridePrivateKeyPassphraseAttribute("orig-pass"), )), }, req: &pbs.UpdateCredentialLibraryRequest{ - UpdateMask: fieldmask(privateKeyAttrField, usernameAttrField), + UpdateMask: fieldmask(privateKeyAttrField, usernameAttrField, passphraseAttrField), Item: &pb.CredentialLibrary{ CredentialMappingOverrides: func() *structpb.Struct { v := map[string]interface{}{ - usernameAttribute: nil, - privateKeyAttribute: nil, + usernameAttribute: nil, + privateKeyAttribute: nil, + pkPassphraseAttribute: nil, } ret, err := structpb.NewStruct(v) require.NoError(t, err) diff --git a/internal/daemon/controller/handlers/credentials/credential_service.go b/internal/daemon/controller/handlers/credentials/credential_service.go index ff98140d2..16a11a48f 100644 --- a/internal/daemon/controller/handlers/credentials/credential_service.go +++ b/internal/daemon/controller/handlers/credentials/credential_service.go @@ -2,6 +2,7 @@ package credentials import ( "context" + "crypto/x509" "encoding/base64" "fmt" @@ -27,10 +28,11 @@ import ( ) const ( - usernameField = "attributes.username" - passwordField = "attributes.password" - privateKeyField = "attributes.private_key" - domain = "credential" + usernameField = "attributes.username" + passwordField = "attributes.password" + privateKeyField = "attributes.private_key" + privateKeyPassphraseField = "attributes.private_key_passphrase" + domain = "credential" ) var ( @@ -409,6 +411,16 @@ func (s Service) updateInRepo( if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to convert to ssh private key storage credential")) } + if cred.PassphraseUnneeded { + // This happens when we have a private key given and no passphrase + // given and everything parses correctly. In that case we want to + // ensure that if a passphrase was in the database for the previous + // key that we get rid of it. We'll have nilled out several values + // above. Note that adding the passphrase field will, once we get to + // the repo, result in the mask for the other two related fields as + // well. + dbMasks = append(dbMasks, static.PrivateKeyPassphraseField) + } cred.PublicId = id repo, err := s.repoFn() if err != nil { @@ -553,8 +565,9 @@ func toProto(in credential.Static, opt ...handlers.Option) (*pb.Credential, erro if outputFields.Has(globals.AttributesField) { out.Attrs = &pb.Credential_SshPrivateKeyAttributes{ SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ - Username: wrapperspb.String(cred.GetUsername()), - PrivateKeyHmac: base64.RawURLEncoding.EncodeToString(cred.GetPrivateKeyHmac()), + Username: wrapperspb.String(cred.GetUsername()), + PrivateKeyHmac: base64.RawURLEncoding.EncodeToString(cred.GetPrivateKeyHmac()), + PrivateKeyPassphraseHmac: base64.RawURLEncoding.EncodeToString(cred.GetPrivateKeyPassphraseHmac()), }, } } @@ -596,6 +609,9 @@ func toSshPrivateKeyStorageCredential(ctx context.Context, storeId string, in *p } attrs := in.GetSshPrivateKeyAttributes() + if attrs.GetPrivateKeyPassphrase() != nil { + opts = append(opts, static.WithPrivateKeyPassphrase([]byte(attrs.GetPrivateKeyPassphrase().GetValue()))) + } cs, err := static.NewSshPrivateKeyCredential( ctx, storeId, @@ -645,16 +661,27 @@ func validateCreateRequest(req *pbs.CreateCredentialRequest) error { badFields[usernameField] = "Field required for creating an SSH private key credential." } privateKey := req.Item.GetSshPrivateKeyAttributes().GetPrivateKey().GetValue() + passphrase := req.Item.GetSshPrivateKeyAttributes().GetPrivateKeyPassphrase().GetValue() if privateKey == "" { badFields[privateKeyField] = "Field required for creating an SSH private key credential." } else { - _, err := ssh.ParsePrivateKey([]byte(privateKey)) - switch { - case err == nil: - case err.Error() == (&ssh.PassphraseMissingError{}).Error(): - // This is okay, if it's brokered and the client can use it, no worries + switch passphrase { + case "": + if _, err := ssh.ParsePrivateKey([]byte(privateKey)); err != nil { + badFields[privateKeyField] = fmt.Sprintf("Unable to parse given private key value: %v.", err) + } default: - badFields[privateKeyField] = "Unable to parse given private key value." + if _, err := ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase)); err != nil { + if errors.Is(err, x509.IncorrectPasswordError) { + badFields[privateKeyPassphraseField] = "Incorrect private key passphrase." + } else { + if _, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil { + badFields[privateKeyPassphraseField] = "Passphrase supplied for unencrypted key." + } else { + badFields[privateKeyField] = fmt.Sprintf("Unable to parse given private key value: %v.", err) + } + } + } } } @@ -669,9 +696,8 @@ func validateCreateRequest(req *pbs.CreateCredentialRequest) error { func validateUpdateRequest(req *pbs.UpdateCredentialRequest) error { return handlers.ValidateUpdateRequest(req, req.GetItem(), func() map[string]string { badFields := map[string]string{} - switch req.GetItem().GetType() { - case "": - case credential.UsernamePasswordSubtype.String(): + switch subtypes.SubtypeFromId(domain, req.GetId()) { + case credential.UsernamePasswordSubtype: attrs := req.GetItem().GetUsernamePasswordAttributes() if handlers.MaskContains(req.GetUpdateMask().GetPaths(), usernameField) && attrs.GetUsername().GetValue() == "" { badFields[usernameField] = "This is a required field and cannot be set to empty." @@ -680,27 +706,40 @@ func validateUpdateRequest(req *pbs.UpdateCredentialRequest) error { badFields[passwordField] = "This is a required field and cannot be set to empty." } - case credential.SshPrivateKeySubtype.String(): + case credential.SshPrivateKeySubtype: attrs := req.GetItem().GetSshPrivateKeyAttributes() if handlers.MaskContains(req.GetUpdateMask().GetPaths(), usernameField) && attrs.GetUsername().GetValue() == "" { badFields[usernameField] = "This is a required field and cannot be set to empty." } privateKey := attrs.GetPrivateKey().GetValue() - if handlers.MaskContains(req.GetUpdateMask().GetPaths(), privateKeyField) && privateKey == "" { - badFields[privateKeyField] = "This is a required field and cannot be set to empty." - } else { - _, err := ssh.ParsePrivateKey([]byte(privateKey)) - switch { - case err == nil: - case err.Error() == (&ssh.PassphraseMissingError{}).Error(): - // This is okay, if it's brokered and the client can use it, no worries - default: - badFields[privateKeyField] = "Unable to parse given private key value." + passphrase := attrs.GetPrivateKeyPassphrase().GetValue() + if handlers.MaskContains(req.GetUpdateMask().GetPaths(), privateKeyField) { + if privateKey == "" { + badFields[privateKeyField] = "This is a required field and cannot be set to empty." + } else { + switch passphrase { + case "": + if _, err := ssh.ParsePrivateKey([]byte(privateKey)); err != nil { + badFields[privateKeyField] = fmt.Sprintf("Unable to parse given private key value: %v.", err) + } + default: + if _, err := ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase)); err != nil { + if errors.Is(err, x509.IncorrectPasswordError) { + badFields[privateKeyPassphraseField] = "Incorrect private key passphrase." + } else { + if _, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil { + badFields[privateKeyPassphraseField] = "Passphrase supplied for unencrypted key." + } else { + badFields[privateKeyField] = fmt.Sprintf("Unable to parse given private key value: %v.", err) + } + } + } + } } } default: - badFields[globals.TypeField] = "Cannot modify resource type." + badFields[globals.IdField] = "Unknown credential type." } return badFields diff --git a/internal/daemon/controller/handlers/credentials/credential_service_test.go b/internal/daemon/controller/handlers/credentials/credential_service_test.go index deb26dc6b..39a7ee91e 100644 --- a/internal/daemon/controller/handlers/credentials/credential_service_test.go +++ b/internal/daemon/controller/handlers/credentials/credential_service_test.go @@ -25,6 +25,7 @@ import ( scopepb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/scopes" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh/testdata" "google.golang.org/grpc/codes" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" @@ -205,6 +206,15 @@ func TestGet(t *testing.T) { spkHm, err := crypto.HmacSha256(context.Background(), []byte(static.TestSshPrivateKeyPem), databaseWrapper, []byte(store.GetPublicId()), nil) require.NoError(t, err) + spkCredWithPass := static.TestSshPrivateKeyCredential(t, conn, wrapper, + "user", string(testdata.PEMEncryptedKeys[0].PEMBytes), + store.GetPublicId(), prj.GetPublicId(), + static.WithPrivateKeyPassphrase([]byte(testdata.PEMEncryptedKeys[0].EncryptionKey))) + spkWithPassHm, err := crypto.HmacSha256(context.Background(), []byte(testdata.PEMEncryptedKeys[0].PEMBytes), databaseWrapper, []byte(store.GetPublicId()), nil) + require.NoError(t, err) + passHm, err := crypto.HmacSha256(context.Background(), []byte(testdata.PEMEncryptedKeys[0].EncryptionKey), databaseWrapper, []byte(store.GetPublicId()), nil) + require.NoError(t, err) + cases := []struct { name string id string @@ -277,6 +287,29 @@ func TestGet(t *testing.T) { }, }, }, + { + name: "success-spk-with-pass", + id: spkCredWithPass.GetPublicId(), + res: &pbs.GetCredentialResponse{ + Item: &pb.Credential{ + Id: spkCredWithPass.GetPublicId(), + CredentialStoreId: spkCredWithPass.GetStoreId(), + Scope: &scopepb.ScopeInfo{Id: store.GetScopeId(), Type: scope.Project.String(), ParentScopeId: prj.GetParentId()}, + Type: credential.SshPrivateKeySubtype.String(), + AuthorizedActions: testAuthorizedActions, + CreatedTime: spkCredWithPass.CreateTime.GetTimestamp(), + UpdatedTime: spkCredWithPass.UpdateTime.GetTimestamp(), + Version: 1, + Attrs: &pb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ + Username: wrapperspb.String("user"), + PrivateKeyHmac: base64.RawURLEncoding.EncodeToString([]byte(spkWithPassHm)), + PrivateKeyPassphraseHmac: base64.RawURLEncoding.EncodeToString([]byte(passHm)), + }, + }, + }, + }, + }, { name: "not found error", id: fmt.Sprintf("%s_1234567890", credential.UsernamePasswordCredentialPrefix), @@ -576,6 +609,34 @@ func TestCreate(t *testing.T) { }, }, }, + { + name: "valid-spk-with-passphrase", + req: &pbs.CreateCredentialRequest{Item: &pb.Credential{ + CredentialStoreId: store.GetPublicId(), + Type: credential.SshPrivateKeySubtype.String(), + Attrs: &pb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ + Username: wrapperspb.String("username"), + PrivateKey: wrapperspb.String(string(testdata.PEMEncryptedKeys[0].PEMBytes)), + PrivateKeyPassphrase: wrapperspb.String(testdata.PEMEncryptedKeys[0].EncryptionKey), + }, + }, + }}, + idPrefix: credential.SshPrivateKeyCredentialPrefix + "_", + res: &pbs.CreateCredentialResponse{ + Uri: fmt.Sprintf("credentials/%s_", credential.SshPrivateKeyCredentialPrefix), + Item: &pb.Credential{ + Id: store.GetPublicId(), + CredentialStoreId: store.GetPublicId(), + CreatedTime: store.GetCreateTime().GetTimestamp(), + UpdatedTime: store.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: prj.GetPublicId(), Type: prj.GetType(), ParentScopeId: prj.GetParentId()}, + Version: 1, + Type: credential.SshPrivateKeySubtype.String(), + AuthorizedActions: testAuthorizedActions, + }, + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -623,7 +684,8 @@ func TestCreate(t *testing.T) { assert.Empty(got.GetItem().GetUsernamePasswordAttributes().GetPassword()) case credential.SshPrivateKeySubtype.String(): - hm, err := crypto.HmacSha256(context.Background(), []byte(static.TestSshPrivateKeyPem), databaseWrapper, []byte(store.GetPublicId()), nil) + pk := tc.req.GetItem().GetSshPrivateKeyAttributes().GetPrivateKey().GetValue() + hm, err := crypto.HmacSha256(context.Background(), []byte(pk), databaseWrapper, []byte(store.GetPublicId()), nil) require.NoError(err) // Validate attributes equal @@ -632,6 +694,14 @@ func TestCreate(t *testing.T) { assert.Equal(base64.RawURLEncoding.EncodeToString([]byte(hm)), got.GetItem().GetSshPrivateKeyAttributes().GetPrivateKeyHmac()) assert.Empty(got.GetItem().GetSshPrivateKeyAttributes().GetPrivateKey()) + if pass := tc.req.GetItem().GetSshPrivateKeyAttributes().GetPrivateKeyPassphrase().GetValue(); pass != "" { + hm, err := crypto.HmacSha256(context.Background(), []byte(pass), databaseWrapper, []byte(store.GetPublicId()), nil) + require.NoError(err) + + assert.Equal(base64.RawURLEncoding.EncodeToString([]byte(hm)), got.GetItem().GetSshPrivateKeyAttributes().GetPrivateKeyPassphraseHmac()) + assert.Empty(got.GetItem().GetSshPrivateKeyAttributes().GetPrivateKeyPassphrase()) + } + default: require.Fail("unknown type") } @@ -708,10 +778,11 @@ func TestUpdate(t *testing.T) { databaseWrapper, err := kkms.GetWrapper(context.Background(), prj.GetPublicId(), kms.KeyPurposeDatabase) require.NoError(t, err) - successCases := []struct { - name string - req *pbs.UpdateCredentialRequest - res func(cred *pb.Credential) *pb.Credential + successFailCases := []struct { + name string + req *pbs.UpdateCredentialRequest + res func(cred *pb.Credential) *pb.Credential + expErrorContains string }{ { name: "name", @@ -897,9 +968,87 @@ func TestUpdate(t *testing.T) { return out }, }, + { + name: "update-spk-with-passphrase", + req: &pbs.UpdateCredentialRequest{ + UpdateMask: fieldmask("attributes.private_key", "attributes.private_key_passphrase"), + Item: &pb.Credential{ + Attrs: &pb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ + PrivateKey: wrapperspb.String(string(testdata.PEMEncryptedKeys[0].PEMBytes)), + PrivateKeyPassphrase: wrapperspb.String(testdata.PEMEncryptedKeys[0].EncryptionKey), + }, + }, + }, + }, + res: func(in *pb.Credential) *pb.Credential { + out := proto.Clone(in).(*pb.Credential) + + hm, err := crypto.HmacSha256(context.Background(), testdata.PEMEncryptedKeys[0].PEMBytes, databaseWrapper, []byte(store.GetPublicId()), nil) + require.NoError(t, err) + out.GetSshPrivateKeyAttributes().PrivateKeyHmac = base64.RawURLEncoding.EncodeToString([]byte(hm)) + + hm, err = crypto.HmacSha256(context.Background(), []byte(testdata.PEMEncryptedKeys[0].EncryptionKey), databaseWrapper, []byte(store.GetPublicId()), nil) + require.NoError(t, err) + out.GetSshPrivateKeyAttributes().PrivateKeyPassphraseHmac = base64.RawURLEncoding.EncodeToString([]byte(hm)) + return out + }, + }, + { + name: "update-spk-with-bad-passphrase", + req: &pbs.UpdateCredentialRequest{ + UpdateMask: fieldmask("attributes.private_key", "attributes.private_key_passphrase"), + Item: &pb.Credential{ + Attrs: &pb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ + PrivateKey: wrapperspb.String(string(testdata.PEMEncryptedKeys[0].PEMBytes)), + PrivateKeyPassphrase: wrapperspb.String(strings.ToLower(testdata.PEMEncryptedKeys[0].EncryptionKey)), + }, + }, + }, + }, + expErrorContains: "Incorrect private key passphrase", + }, + { + name: "update-non-passphrase-spk-with-passphrase", + req: &pbs.UpdateCredentialRequest{ + UpdateMask: fieldmask("attributes.private_key"), + Item: &pb.Credential{ + Attrs: &pb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ + PrivateKey: wrapperspb.String(static.TestSshPrivateKeyPem), + PrivateKeyPassphrase: wrapperspb.String(testdata.PEMEncryptedKeys[0].EncryptionKey), + }, + }, + }, + }, + expErrorContains: "Passphrase supplied for unencrypted key", + }, + { + name: "update-non-passphrase-spk-with-no-passphrase", + req: &pbs.UpdateCredentialRequest{ + UpdateMask: fieldmask("attributes.private_key"), + Item: &pb.Credential{ + Attrs: &pb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &pb.SshPrivateKeyAttributes{ + PrivateKey: wrapperspb.String(static.TestSshPrivateKeyPem), + }, + }, + }, + }, + res: func(in *pb.Credential) *pb.Credential { + out := proto.Clone(in).(*pb.Credential) + + hm, err := crypto.HmacSha256(context.Background(), []byte(static.TestSshPrivateKeyPem), databaseWrapper, []byte(store.GetPublicId()), nil) + require.NoError(t, err) + out.GetSshPrivateKeyAttributes().PrivateKeyHmac = base64.RawURLEncoding.EncodeToString([]byte(hm)) + out.GetSshPrivateKeyAttributes().PrivateKeyPassphraseHmac = "" + return out + }, + }, } - for _, tc := range successCases { + for _, tc := range successFailCases { t.Run(tc.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) var cred credential.Static @@ -920,12 +1069,18 @@ func TestUpdate(t *testing.T) { } resToChange, err := s.GetCredential(ctx, &pbs.GetCredentialRequest{Id: cred.GetPublicId()}) require.NoError(err) - want := &pbs.UpdateCredentialResponse{Item: tc.res(resToChange.GetItem())} got, gErr := s.UpdateCredential(ctx, tc.req) + if tc.expErrorContains != "" { + require.NotNil(gErr) + assert.Contains(gErr.Error(), tc.expErrorContains) + return + } require.NoError(gErr) require.NotNil(got) + want := &pbs.UpdateCredentialResponse{Item: tc.res(resToChange.GetItem())} + gotUpdateTime := got.GetItem().GetUpdatedTime() created := cred.GetCreateTime().GetTimestamp() assert.True(gotUpdateTime.AsTime().After(created.AsTime()), "Should have been updated after it's creation. Was updated %v, which is after %v", gotUpdateTime, created) diff --git a/internal/daemon/controller/handlers/targets/credentials.go b/internal/daemon/controller/handlers/targets/credentials.go index 13ca0b3be..c42499d1a 100644 --- a/internal/daemon/controller/handlers/targets/credentials.go +++ b/internal/daemon/controller/handlers/targets/credentials.go @@ -38,8 +38,9 @@ func dynamicToWorkerCredential(ctx context.Context, cred credential.Dynamic) (se workerCred = &serverpb.Credential{ Credential: &serverpb.Credential_SshPrivateKey{ SshPrivateKey: &serverpb.SshPrivateKey{ - Username: c.Username(), - PrivateKey: string(c.PrivateKey()), + Username: c.Username(), + PrivateKey: string(c.PrivateKey()), + PrivateKeyPassphrase: string(c.PrivateKeyPassphrase()), }, }, } @@ -103,8 +104,9 @@ func dynamicToSessionCredential(ctx context.Context, cred credential.Dynamic) (* case credential.SshPrivateKey: credData, err = handlers.ProtoToStruct( &pb.SshPrivateKeyCredential{ - Username: c.Username(), - PrivateKey: string(c.PrivateKey()), + Username: c.Username(), + PrivateKey: string(c.PrivateKey()), + PrivateKeyPassphrase: string(c.PrivateKeyPassphrase()), }, ) if err != nil { @@ -153,8 +155,9 @@ func staticToWorkerCredential(ctx context.Context, cred credential.Static) (sess workerCred = &serverpb.Credential{ Credential: &serverpb.Credential_SshPrivateKey{ SshPrivateKey: &serverpb.SshPrivateKey{ - Username: c.GetUsername(), - PrivateKey: string(c.GetPrivateKey()), + Username: c.GetUsername(), + PrivateKey: string(c.GetPrivateKey()), + PrivateKeyPassphrase: string(c.GetPrivateKeyPassphrase()), }, }, } @@ -199,14 +202,18 @@ func staticToSessionCredential(ctx context.Context, cred credential.Static) (*pb credType = string(credential.SshPrivateKeyType) credData, err = handlers.ProtoToStruct( &pb.SshPrivateKeyCredential{ - Username: c.GetUsername(), - PrivateKey: string(c.GetPrivateKey()), + Username: c.GetUsername(), + PrivateKey: string(c.GetPrivateKey()), + PrivateKeyPassphrase: string(c.GetPrivateKeyPassphrase()), }, ) secret = map[string]interface{}{ "username": c.GetUsername(), "private_key": string(c.GetPrivateKey()), } + if len(c.GetPrivateKeyPassphrase()) > 0 { + secret["private_key_passphrase"] = string(c.GetPrivateKeyPassphrase()) + } if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("creating proto struct for ssh private key credential")) } diff --git a/internal/daemon/controller/handlers/targets/tcp/target_service_test.go b/internal/daemon/controller/handlers/targets/tcp/target_service_test.go index 3d14379fb..88a18a941 100644 --- a/internal/daemon/controller/handlers/targets/tcp/target_service_test.go +++ b/internal/daemon/controller/handlers/targets/tcp/target_service_test.go @@ -2772,7 +2772,23 @@ func TestAuthorizeSessionTypedCredentials(t *testing.T) { require.NoError(t, err) require.NotNil(t, sshPkCredResp) - // Create secret in vault with default username and password fields + sshPkWithPassCredResp, err := credService.CreateCredential(ctx, &pbs.CreateCredentialRequest{Item: &credpb.Credential{ + CredentialStoreId: staticStore.GetPublicId(), + Type: credential.SshPrivateKeySubtype.String(), + Name: wrapperspb.String("Cred SSH With Pass Name"), + Description: wrapperspb.String("Cred SSH Description"), + Attrs: &credpb.Credential_SshPrivateKeyAttributes{ + SshPrivateKeyAttributes: &credpb.SshPrivateKeyAttributes{ + Username: wrapperspb.String("static-username"), + PrivateKey: wrapperspb.String(string(testdata.PEMEncryptedKeys[0].PEMBytes)), + PrivateKeyPassphrase: wrapperspb.String(testdata.PEMEncryptedKeys[0].EncryptionKey), + }, + }, + }}) + require.NoError(t, err) + require.NotNil(t, sshPkWithPassCredResp) + + // Create secret in vault with default username and private key fields defaultSshPrivateKey := v.CreateKVSecret(t, "default-sshpk", []byte(`{"data": {"username": "my-user", "private_key": "my-pk"}}`)) require.NotNil(t, defaultSshPrivateKey) @@ -2790,7 +2806,7 @@ func TestAuthorizeSessionTypedCredentials(t *testing.T) { }}) require.NoError(t, err) - // Create secret in vault with non default username and password fields + // Create secret in vault with non default username and private key fields nonDefaultSshPrivateKey := v.CreateKVSecret(t, "non-default-sshpk", []byte(`{"data": {"non-default-user": "my-user", "non-default-pk": "my-special-pk"}}`)) require.NotNil(t, nonDefaultSshPrivateKey) @@ -2812,6 +2828,49 @@ func TestAuthorizeSessionTypedCredentials(t *testing.T) { }}) require.NoError(t, err) + // Create secret in vault with default username, private key and passphrase fields + defaultSshPrivateKeyWithPass := v.CreateKVSecret(t, "default-sshpk-with-pass", []byte(`{"data": {"username": "my-user", "private_key": "my-pk", "private_key_passphrase": "my-pass"}}`)) + require.NotNil(t, defaultSshPrivateKeyWithPass) + + clsRespSshPrivateKeyWithPass, err := credLibService.CreateCredentialLibrary(ctx, &pbs.CreateCredentialLibraryRequest{Item: &credlibpb.CredentialLibrary{ + CredentialStoreId: vaultStore.GetPublicId(), + Name: wrapperspb.String("SSH Private Key With Passphrase Library"), + Description: wrapperspb.String("SSH Private Key Library Description"), + Attrs: &credlibpb.CredentialLibrary_VaultCredentialLibraryAttributes{ + VaultCredentialLibraryAttributes: &credlibpb.VaultCredentialLibraryAttributes{ + Path: wrapperspb.String(path.Join("secret", "data", "default-sshpk-with-pass")), + HttpMethod: wrapperspb.String("GET"), + }, + }, + CredentialType: "ssh_private_key", + }}) + require.NoError(t, err) + require.NotNil(t, clsRespSshPrivateKeyWithPass) + + // Create secret in vault with non default username, private key and private key passphrase fields + nonDefaultSshPrivateKeyWithPass := v.CreateKVSecret(t, "non-default-sshpk-with-pass", []byte(`{"data": {"non-default-user": "my-user", "non-default-pk": "my-special-pk", "embedded": {"secret-pass": "my-special-pass"}}}}`)) + require.NotNil(t, nonDefaultSshPrivateKeyWithPass) + + clsRespSshPrivateKeyWithPassWithMapping, err := credLibService.CreateCredentialLibrary(ctx, &pbs.CreateCredentialLibraryRequest{Item: &credlibpb.CredentialLibrary{ + CredentialStoreId: vaultStore.GetPublicId(), + Name: wrapperspb.String("SSH Private Key With Passphrase Mapping Library"), + Description: wrapperspb.String("SSH Private Key Mapping Library Description"), + Attrs: &credlibpb.CredentialLibrary_VaultCredentialLibraryAttributes{ + VaultCredentialLibraryAttributes: &credlibpb.VaultCredentialLibraryAttributes{ + Path: wrapperspb.String(path.Join("secret", "data", "non-default-sshpk-with-pass")), + HttpMethod: wrapperspb.String("GET"), + }, + }, + CredentialType: "ssh_private_key", + CredentialMappingOverrides: &structpb.Struct{Fields: map[string]*structpb.Value{ + "username_attribute": structpb.NewStringValue("/data/non-default-user"), + "private_key_attribute": structpb.NewStringValue("/data/non-default-pk"), + "private_key_passphrase_attribute": structpb.NewStringValue("/data/embedded/secret-pass"), + }}, + }}) + require.NoError(t, err) + require.NotNil(t, clsRespSshPrivateKeyWithPassWithMapping) + cases := []struct { name string hostSourceId string @@ -2992,6 +3051,87 @@ func TestAuthorizeSessionTypedCredentials(t *testing.T) { }(), }, }, + { + name: "static-ssh-private-key-with-pass", + hostSourceId: shs.GetPublicId(), + credSourceId: sshPkWithPassCredResp.GetItem().GetId(), + wantedHostId: h.GetPublicId(), + wantedEndpoint: h.GetAddress(), + wantedCred: &pb.SessionCredential{ + CredentialSource: &pb.CredentialSource{ + Id: sshPkWithPassCredResp.GetItem().GetId(), + Name: sshPkWithPassCredResp.GetItem().GetName().GetValue(), + Description: sshPkWithPassCredResp.GetItem().GetDescription().GetValue(), + CredentialStoreId: staticStore.GetPublicId(), + Type: credstatic.Subtype.String(), + CredentialType: string(credential.SshPrivateKeyType), + }, + Credential: func() *structpb.Struct { + data := map[string]interface{}{ + "private_key_passphrase": testdata.PEMEncryptedKeys[0].EncryptionKey, + "private_key": string(testdata.PEMEncryptedKeys[0].PEMBytes), + "username": "static-username", + } + st, err := structpb.NewStruct(data) + require.NoError(t, err) + return st + }(), + }, + }, + { + name: "vault-ssh-private-key-with-pass", + hostSourceId: shs.GetPublicId(), + credSourceId: clsRespSshPrivateKeyWithPass.GetItem().GetId(), + wantedHostId: h.GetPublicId(), + wantedEndpoint: h.GetAddress(), + wantedCred: &pb.SessionCredential{ + CredentialSource: &pb.CredentialSource{ + Id: clsRespSshPrivateKeyWithPass.GetItem().GetId(), + Name: clsRespSshPrivateKeyWithPass.GetItem().GetName().GetValue(), + Description: clsRespSshPrivateKeyWithPass.GetItem().GetDescription().GetValue(), + CredentialStoreId: vaultStore.GetPublicId(), + Type: vault.Subtype.String(), + CredentialType: string(credential.SshPrivateKeyType), + }, + Credential: func() *structpb.Struct { + data := map[string]interface{}{ + "username": "my-user", + "private_key": "my-pk", + "private_key_passphrase": "my-pass", + } + st, err := structpb.NewStruct(data) + require.NoError(t, err) + return st + }(), + }, + }, + { + name: "vault-ssh-private-key-with-pass-with-mapping", + hostSourceId: shs.GetPublicId(), + credSourceId: clsRespSshPrivateKeyWithPassWithMapping.GetItem().GetId(), + wantedHostId: h.GetPublicId(), + wantedEndpoint: h.GetAddress(), + wantedCred: &pb.SessionCredential{ + CredentialSource: &pb.CredentialSource{ + Id: clsRespSshPrivateKeyWithPassWithMapping.GetItem().GetId(), + Name: clsRespSshPrivateKeyWithPassWithMapping.GetItem().GetName().GetValue(), + Description: clsRespSshPrivateKeyWithPassWithMapping.GetItem().GetDescription().GetValue(), + CredentialStoreId: vaultStore.GetPublicId(), + Type: vault.Subtype.String(), + CredentialType: string(credential.SshPrivateKeyType), + }, + Credential: func() *structpb.Struct { + data := map[string]interface{}{ + "username": "my-user", + "private_key": "my-special-pk", + "private_key_passphrase": "my-special-pass", + } + st, err := structpb.NewStruct(data) + require.NoError(t, err) + return st + }(), + }, + }, } for _, tc := range cases { diff --git a/internal/db/schema/migrations/oss/postgres/41/01_worker_filter_vault_cred_store.up.sql b/internal/db/schema/migrations/oss/postgres/41/01_worker_filter_vault_cred_store.up.sql index 41c307020..66e4a2daf 100644 --- a/internal/db/schema/migrations/oss/postgres/41/01_worker_filter_vault_cred_store.up.sql +++ b/internal/db/schema/migrations/oss/postgres/41/01_worker_filter_vault_cred_store.up.sql @@ -95,6 +95,7 @@ comment on view credential_vault_store_public is 'No encrypted data is returned. This view can be used to retrieve data which will be returned external to boundary.'; -- Replaces view from 39/02_vault_ssh_private_key_override.up.sql +-- Replaced in 42/01_ssh_private_key_passphrase.up.sql create view credential_vault_library_private as with password_override (library_id, username_attribute, password_attribute) as ( @@ -151,6 +152,7 @@ comment on view credential_vault_library_private is 'Each row may contain encrypted data. This view should not be used to retrieve data which will be returned external to boundary.'; -- Replaces view from 39/02_vault_ssh_private_key_override.up.sql +-- Replaced in 42/01_ssh_private_key_passphrase.up.sql create view credential_vault_library_public as select public_id, store_id, diff --git a/internal/db/schema/migrations/oss/postgres/42/01_ssh_private_key_passphrase.up.sql b/internal/db/schema/migrations/oss/postgres/42/01_ssh_private_key_passphrase.up.sql new file mode 100644 index 000000000..5cd0ce6f6 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/42/01_ssh_private_key_passphrase.up.sql @@ -0,0 +1,106 @@ +begin; + +alter table credential_static_ssh_private_key_credential + add column private_key_passphrase_encrypted bytea, + add column private_key_passphrase_hmac bytea, + add constraint private_key_passphrase_both_null_or_not_null + check ( + (private_key_passphrase_encrypted is null and private_key_passphrase_hmac is null) + or + (private_key_passphrase_encrypted is not null and private_key_passphrase_hmac is not null) + ) + ; + +alter table credential_vault_library_ssh_private_key_mapping_override + add column private_key_passphrase_attribute wt_sentinel + default wt_to_sentinel('no override') + not null; + +-- Replaces view from 41/01_worker_filter_vault_cred_store.up.sql +drop view credential_vault_library_public; +drop view credential_vault_library_private; + +-- Replaces view from 41/01_worker_filter_vault_cred_store.up.sql +create view credential_vault_library_private as +with + password_override (library_id, username_attribute, password_attribute) as ( + select library_id, + nullif(username_attribute, wt_to_sentinel('no override')), + nullif(password_attribute, wt_to_sentinel('no override')) + from credential_vault_library_username_password_mapping_override + ), + ssh_private_key_override (library_id, username_attribute, private_key_attribute, private_key_passphrase_attribute) as ( + select library_id, + nullif(username_attribute, wt_to_sentinel('no override')), + nullif(private_key_attribute, wt_to_sentinel('no override')), + nullif(private_key_passphrase_attribute, wt_to_sentinel('no override')) + from credential_vault_library_ssh_private_key_mapping_override + ) + select library.public_id as public_id, + library.store_id as store_id, + library.name as name, + library.description as description, + library.create_time as create_time, + library.update_time as update_time, + library.version as version, + library.vault_path as vault_path, + library.http_method as http_method, + library.http_request_body as http_request_body, + library.credential_type as credential_type, + store.scope_id as scope_id, + store.vault_address as vault_address, + store.namespace as namespace, + store.ca_cert as ca_cert, + store.tls_server_name as tls_server_name, + store.tls_skip_verify as tls_skip_verify, + store.worker_filter as worker_filter, + store.token_hmac as token_hmac, + store.ct_token as ct_token, -- encrypted + store.token_key_id as token_key_id, + store.client_cert as client_cert, + store.ct_client_key as ct_client_key, -- encrypted + store.client_key_id as client_key_id, + coalesce(upasso.username_attribute,sshpk.username_attribute) + as username_attribute, + upasso.password_attribute as password_attribute, + sshpk.private_key_attribute as private_key_attribute, + sshpk.private_key_passphrase_attribute as private_key_passphrase_attribute + from credential_vault_library library + join credential_vault_store_private store + on library.store_id = store.public_id + left join password_override upasso + on library.public_id = upasso.library_id + and store.token_status = 'current' + left join ssh_private_key_override sshpk + on library.public_id = sshpk.library_id + and store.token_status = 'current'; +comment on view credential_vault_library_private is + 'credential_vault_library_private is a view where each row contains a credential library and the credential library''s data needed to connect to Vault. ' + 'Each row may contain encrypted data. This view should not be used to retrieve data which will be returned external to boundary.'; + +-- Replaces view from 41/01_worker_filter_vault_cred_store.up.sql +-- Replaces view from 39/02_vault_ssh_private_key_override.up.sql +-- Replaced in 42/01_ssh_private_key_passphrase.up.sql +create view credential_vault_library_public as +select public_id, + store_id, + name, + description, + create_time, + update_time, + version, + vault_path, + http_method, + http_request_body, + credential_type, + worker_filter, + username_attribute, + password_attribute, + private_key_attribute, + private_key_passphrase_attribute +from credential_vault_library_private; +comment on view credential_vault_library_public is + 'credential_vault_library_public is a view where each row contains a credential library and any of library''s credential mapping overrides. ' + 'No encrypted data is returned. This view can be used to retrieve data which will be returned external to boundary.'; + +commit; \ No newline at end of file diff --git a/internal/db/sqltest/initdb.d/03_widgets_persona.sql b/internal/db/sqltest/initdb.d/03_widgets_persona.sql index 09c2fa82c..a695b1028 100644 --- a/internal/db/sqltest/initdb.d/03_widgets_persona.sql +++ b/internal/db/sqltest/initdb.d/03_widgets_persona.sql @@ -393,9 +393,9 @@ begin; ('vl______wvl11', 'my_private_key'); insert into credential_vault_library_ssh_private_key_mapping_override - (library_id, username_attribute, private_key_attribute) + (library_id, username_attribute, private_key_attribute, private_key_passphrase_attribute) values - ('vl______wvl12', 'my_username', 'my_private_key'); + ('vl______wvl12', 'my_username', 'my_private_key', 'my_passphrase'); insert into target_credential_library (target_id, credential_library_id, credential_purpose) diff --git a/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql b/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql index 9382eae38..f08dd3e27 100644 --- a/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql +++ b/internal/db/sqltest/tests/credential/vault/credential_vault_library_ssh_private_key_mapping_override.sql @@ -17,7 +17,7 @@ begin; where library_id in ('vl______wvl9', 'vl______wvl10', 'vl______wvl11', 'vl______wvl12'); prepare select_private_libraries as - select public_id::text, credential_type::text, username_attribute::text, private_key_attribute::text + select public_id::text, credential_type::text, username_attribute::text, private_key_attribute::text, private_key_passphrase_attribute::text from credential_vault_library_private where public_id in ('vl______wvl2', 'vl______wvl8', 'vl______wvl9', 'vl______wvl10', 'vl______wvl11', 'vl______wvl12') order by public_id; @@ -25,12 +25,12 @@ begin; select results_eq( 'select_private_libraries', $$VALUES - ('vl______wvl10', 'ssh_private_key', 'my_username', null), - ('vl______wvl11', 'ssh_private_key', null, 'my_private_key'), - ('vl______wvl12', 'ssh_private_key', 'my_username', 'my_private_key'), - ('vl______wvl2', 'unspecified', null, null), - ('vl______wvl8', 'ssh_private_key', null, null), - ('vl______wvl9', 'ssh_private_key', null, null)$$ + ('vl______wvl10', 'ssh_private_key', 'my_username', null, null), + ('vl______wvl11', 'ssh_private_key', null, 'my_private_key', null), + ('vl______wvl12', 'ssh_private_key', 'my_username', 'my_private_key', 'my_passphrase'), + ('vl______wvl2', 'unspecified', null, null, null), + ('vl______wvl8', 'ssh_private_key', null, null, null), + ('vl______wvl9', 'ssh_private_key', null, null, null)$$ ); -- validate the insert triggers diff --git a/internal/gen/controller/servers/services/credential.pb.go b/internal/gen/controller/servers/services/credential.pb.go index 7a7ab0cfc..3ed26f67b 100644 --- a/internal/gen/controller/servers/services/credential.pb.go +++ b/internal/gen/controller/servers/services/credential.pb.go @@ -158,7 +158,8 @@ func (x *UsernamePassword) GetPassword() string { return "" } -// SshPrivateKey is a credential containing a username and a private key. +// SshPrivateKey is a credential containing a username a private key and an optional +// private key passphrase. type SshPrivateKey struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -168,6 +169,8 @@ type SshPrivateKey struct { Username string `protobuf:"bytes,10,opt,name=username,proto3" json:"username,omitempty"` // @gotags: `class:"public"` // The private key of the credential PrivateKey string `protobuf:"bytes,20,opt,name=private_key,json=privateKey,proto3" json:"private_key,omitempty"` // @gotags: `class:"secret"` + // The optional passphrase of the private_key + PrivateKeyPassphrase string `protobuf:"bytes,30,opt,name=private_key_passphrase,json=privateKeyPassphrase,proto3" json:"private_key_passphrase,omitempty"` // @gotags: `class:"secret"` } func (x *SshPrivateKey) Reset() { @@ -216,6 +219,13 @@ func (x *SshPrivateKey) GetPrivateKey() string { return "" } +func (x *SshPrivateKey) GetPrivateKeyPassphrase() string { + if x != nil { + return x.PrivateKeyPassphrase + } + return "" +} + var File_controller_servers_services_v1_credential_proto protoreflect.FileDescriptor var file_controller_servers_services_v1_credential_proto_rawDesc = []byte{ @@ -243,18 +253,21 @@ var file_controller_servers_services_v1_credential_proto_rawDesc = []byte{ 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4c, 0x0a, 0x0d, 0x53, 0x73, 0x68, - 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, - 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, - 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x42, 0x51, 0x5a, 0x4f, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, - 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, - 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, - 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x73, 0x3b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x82, 0x01, 0x0a, 0x0d, 0x53, 0x73, + 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x75, + 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, + 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x16, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, + 0x73, 0x65, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x42, 0x51, + 0x5a, 0x4f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, + 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, + 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x2f, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x3b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/internal/proto/controller/api/resources/credentials/v1/credential.proto b/internal/proto/controller/api/resources/credentials/v1/credential.proto index eb479dc49..4410779a5 100644 --- a/internal/proto/controller/api/resources/credentials/v1/credential.proto +++ b/internal/proto/controller/api/resources/credentials/v1/credential.proto @@ -135,4 +135,23 @@ message SshPrivateKeyAttributes { that: "PrivateKeyHmac" } ]; // @gotags: `class:"public"` + + // Input only. The passphrase for the SSH private key associated with the credential. + google.protobuf.StringValue private_key_passphrase = 40 [ + json_name = "private_key_passphrase", + (custom_options.v1.generate_sdk_option) = true, + (custom_options.v1.mask_mapping) = { + this: "attributes.private_key_passphrase" + that: "PrivateKeyPassphrase" + } + ]; // @gotags: `class:"secret"` + + // Output only. The hmac value of the SSH private key passphrase. + string private_key_passphrase_hmac = 50 [ + json_name = "private_key_passphrase_hmac", + (custom_options.v1.mask_mapping) = { + this: "attributes.private_key_passphrase_hmac" + that: "PrivateKeyPassphraseHmac" + } + ]; // @gotags: `class:"public"` } diff --git a/internal/proto/controller/api/resources/targets/v1/target.proto b/internal/proto/controller/api/resources/targets/v1/target.proto index f3227a40f..eddc72c40 100644 --- a/internal/proto/controller/api/resources/targets/v1/target.proto +++ b/internal/proto/controller/api/resources/targets/v1/target.proto @@ -330,4 +330,7 @@ message SshPrivateKeyCredential { // Private key of the credential string private_key = 2; // @gotags: `class:"secret"` + + // The optional passphrase of the private_key + string private_key_passphrase = 3; // @gotags: `class:"secret"` } diff --git a/internal/proto/controller/servers/services/v1/credential.proto b/internal/proto/controller/servers/services/v1/credential.proto index f9c05beb9..8943defff 100644 --- a/internal/proto/controller/servers/services/v1/credential.proto +++ b/internal/proto/controller/servers/services/v1/credential.proto @@ -23,11 +23,15 @@ message UsernamePassword { string password = 20; // @gotags: `class:"secret"` } -// SshPrivateKey is a credential containing a username and a private key. +// SshPrivateKey is a credential containing a username a private key and an optional +// private key passphrase. message SshPrivateKey { // The username of the credential string username = 10; // @gotags: `class:"public"` // The private key of the credential string private_key = 20; // @gotags: `class:"secret"` + + // The optional passphrase of the private_key + string private_key_passphrase = 30; // @gotags: `class:"secret"` } diff --git a/internal/proto/controller/storage/credential/static/store/v1/static.proto b/internal/proto/controller/storage/credential/static/store/v1/static.proto index 4a455818e..76ca2f14b 100644 --- a/internal/proto/controller/storage/credential/static/store/v1/static.proto +++ b/internal/proto/controller/storage/credential/static/store/v1/static.proto @@ -186,4 +186,26 @@ message SshPrivateKeyCredential { // It must be set. // @inject_tag: `gorm:"not_null"` string key_id = 12; + + // private_key_passphrase is the plain-text of the passphrase of the SSH private + // key associated with the credential. We are not storing this plain-text + // private key passphrase in the database. + // @inject_tag: `gorm:"-"` + bytes private_key_passphrase = 13 [(custom_options.v1.mask_mapping) = { + this: "PrivateKeyPassphrase" + that: "attributes.private_key_passphrase" + }]; + + // private_key_passphrase_encrypted is the ciphertext of the private key passphrase. It is stored in + // the database. + // @inject_tag: `gorm:"column:private_key_passphrase_encrypted;not_null"` + bytes private_key_passphrase_encrypted = 14; + + // private_key_passphrase_hmac is a sha256-hmac of the unencrypted private key passphrase. It is recalculated + // everytime the private key passphrase is updated. + // @inject_tag: `gorm:"not_null"` + bytes private_key_passphrase_hmac = 15 [(custom_options.v1.mask_mapping) = { + this: "PrivateKeyPassphraseHmac" + that: "attributes.private_key_passphrase_hmac" + }]; } diff --git a/internal/proto/controller/storage/credential/vault/store/v1/vault.proto b/internal/proto/controller/storage/credential/vault/store/v1/vault.proto index ce07d58c4..c4da476a0 100644 --- a/internal/proto/controller/storage/credential/vault/store/v1/vault.proto +++ b/internal/proto/controller/storage/credential/vault/store/v1/vault.proto @@ -376,4 +376,15 @@ message SshPrivateKeyOverride { // // @inject_tag: `gorm:"default:null"` string private_key_attribute = 3; + + // private_key_passphrase_attribute is the name of the attribute in the Data + // field of a Vault api.Secret that maps to a private key passphrase. If set, + // it overrides any default attribute names the system uses to find a + // private_key_passphrase attribute. + // + // See + // https://github.com/hashicorp/vault/blob/5e505ec039177e8212cbbab74ccb644c46e62e63/api/secret.go#L25 + // + // @inject_tag: `gorm:"default:null"` + string private_key_passphrase_attribute = 4; } diff --git a/internal/tests/api/credentials/credentials_test.go b/internal/tests/api/credentials/credentials_test.go index d0ff589e6..13d7ac065 100644 --- a/internal/tests/api/credentials/credentials_test.go +++ b/internal/tests/api/credentials/credentials_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/boundary/internal/iam" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh/testdata" ) func TestList(t *testing.T) { @@ -152,6 +153,87 @@ func TestCrud(t *testing.T) { assert.EqualValues(http.StatusNotFound, apiErr.Response().StatusCode()) } +func TestCrudSpk(t *testing.T) { + assert, require := assert.New(t), require.New(t) + tc := controller.NewTestController(t, nil) + defer tc.Shutdown() + + client := tc.Client() + token := tc.Token() + client.SetToken(token.Token) + _, proj := iam.TestScopes(t, tc.IamRepo(), iam.WithUserId(token.UserId)) + + cs, err := credentialstores.NewClient(client).Create(tc.Context(), "static", proj.GetPublicId()) + require.NoError(err) + require.NotNil(cs) + + checkResource := func(step string, c *credentials.Credential, wantedName, wantedUser string, wantVersion uint32) { + assert.NotNil(c, "returned no resource", step) + assert.Equal(wantedName, c.Name, step) + gotUser, ok := c.Attributes["username"] + require.True(ok) + assert.Equal(wantedUser, gotUser, step) + assert.Equal(wantVersion, c.Version) + } + credClient := credentials.NewClient(client) + + spk := string(testdata.PEMBytes["rsa"]) + spkWithPass := string(testdata.PEMEncryptedKeys[0].PEMBytes) + pass := testdata.PEMEncryptedKeys[0].EncryptionKey + + cred, err := credClient.Create(tc.Context(), credential.SshPrivateKeySubtype.String(), cs.Item.Id, credentials.WithName("foo"), + credentials.WithSshPrivateKeyCredentialUsername("user"), + credentials.WithSshPrivateKeyCredentialPrivateKey(spkWithPass), + credentials.WithSshPrivateKeyCredentialPrivateKeyPassphrase(pass)) + require.NoError(err) + require.NotNil(cs) + checkResource("create", cred.Item, "foo", "user", 1) + + // Validate passphrase hmac was set and passpharse is not set + passHmac, ok := cred.GetItem().Attributes["private_key_passphrase_hmac"].(string) + require.True(ok) + require.NotNil(passHmac) + + cred, err = credClient.Read(tc.Context(), cred.Item.Id) + require.NoError(err) + require.NotNil(cs) + checkResource("read", cred.Item, "foo", "user", 1) + + cred, err = credClient.Update(tc.Context(), cred.Item.Id, cred.Item.Version, credentials.WithName("bar")) + require.NoError(err) + require.NotNil(cs) + checkResource("update", cred.Item, "bar", "user", 2) + + cred, err = credClient.Update(tc.Context(), cred.Item.Id, cred.Item.Version, credentials.WithSshPrivateKeyCredentialUsername("newuser")) + require.NoError(err) + require.NotNil(cs) + checkResource("update", cred.Item, "bar", "newuser", 3) + + cred, err = credClient.Update(tc.Context(), cred.Item.Id, cred.Item.Version, credentials.DefaultName()) + require.NoError(err) + require.NotNil(cs) + checkResource("update", cred.Item, "", "newuser", 4) + + // Update to non-encrypted key + cred, err = credClient.Update(tc.Context(), cred.Item.Id, cred.Item.Version, credentials.WithSshPrivateKeyCredentialPrivateKey(spk)) + require.NoError(err) + require.NotNil(cs) + checkResource("update", cred.Item, "", "newuser", 5) + + // Validate passphrase hmac is no longer set + _, ok = cred.GetItem().Attributes["private_key_passphrase_hmac"].(string) + require.False(ok) + + _, err = credClient.Delete(tc.Context(), cred.Item.Id) + assert.NoError(err) + + _, err = credClient.Delete(tc.Context(), cred.Item.Id) + require.Error(err) + apiErr := api.AsServerError(err) + assert.NotNil(apiErr) + assert.EqualValues(http.StatusNotFound, apiErr.Response().StatusCode()) +} + func TestErrors(t *testing.T) { assert, require := assert.New(t), require.New(t) tc := controller.NewTestController(t, nil) diff --git a/sdk/pbs/controller/api/resources/credentials/credential.pb.go b/sdk/pbs/controller/api/resources/credentials/credential.pb.go index e53b6d389..9b93a71e5 100644 --- a/sdk/pbs/controller/api/resources/credentials/credential.pb.go +++ b/sdk/pbs/controller/api/resources/credentials/credential.pb.go @@ -292,6 +292,10 @@ type SshPrivateKeyAttributes struct { PrivateKey *wrapperspb.StringValue `protobuf:"bytes,20,opt,name=private_key,proto3" json:"private_key,omitempty" class:"secret"` // @gotags: `class:"secret"` // Output only. The hmac value of the SSH private key. PrivateKeyHmac string `protobuf:"bytes,30,opt,name=private_key_hmac,proto3" json:"private_key_hmac,omitempty" class:"public"` // @gotags: `class:"public"` + // Input only. The passphrase for the SSH private key associated with the credential. + PrivateKeyPassphrase *wrapperspb.StringValue `protobuf:"bytes,40,opt,name=private_key_passphrase,proto3" json:"private_key_passphrase,omitempty" class:"secret"` // @gotags: `class:"secret"` + // Output only. The hmac value of the SSH private key passphrase. + PrivateKeyPassphraseHmac string `protobuf:"bytes,50,opt,name=private_key_passphrase_hmac,proto3" json:"private_key_passphrase_hmac,omitempty" class:"public"` // @gotags: `class:"public"` } func (x *SshPrivateKeyAttributes) Reset() { @@ -347,6 +351,20 @@ func (x *SshPrivateKeyAttributes) GetPrivateKeyHmac() string { return "" } +func (x *SshPrivateKeyAttributes) GetPrivateKeyPassphrase() *wrapperspb.StringValue { + if x != nil { + return x.PrivateKeyPassphrase + } + return nil +} + +func (x *SshPrivateKeyAttributes) GetPrivateKeyPassphraseHmac() string { + if x != nil { + return x.PrivateKeyPassphraseHmac + } + return "" +} + var File_controller_api_resources_credentials_v1_credential_proto protoreflect.FileDescriptor var file_controller_api_resources_credentials_v1_credential_proto_rawDesc = []byte{ @@ -450,7 +468,7 @@ var file_controller_api_resources_credentials_v1_credential_proto_rawDesc = []by 0xdd, 0x29, 0x28, 0x0a, 0x18, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x2e, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x12, 0x0c, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x48, 0x6d, 0x61, 0x63, 0x52, 0x0d, 0x70, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x22, 0xc9, 0x02, 0x0a, 0x17, 0x53, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x22, 0xee, 0x04, 0x0a, 0x17, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x61, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, @@ -471,13 +489,31 @@ var file_controller_api_resources_credentials_v1_credential_proto_rawDesc = []by 0x74, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x12, 0x0e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x48, 0x6d, 0x61, 0x63, 0x52, 0x10, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, - 0x79, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x42, 0x58, 0x5a, 0x56, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, - 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x62, 0x73, 0x2f, - 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, - 0x69, 0x61, 0x6c, 0x73, 0x3b, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x79, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x12, 0x97, 0x01, 0x0a, 0x16, 0x70, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, + 0x65, 0x18, 0x28, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x41, 0xa0, 0xda, 0x29, 0x01, 0xc2, 0xdd, 0x29, 0x39, 0x0a, + 0x21, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, + 0x73, 0x65, 0x12, 0x14, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, + 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x52, 0x16, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, + 0x12, 0x88, 0x01, 0x0a, 0x1b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, + 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x5f, 0x68, 0x6d, 0x61, 0x63, + 0x18, 0x32, 0x20, 0x01, 0x28, 0x09, 0x42, 0x46, 0xc2, 0xdd, 0x29, 0x42, 0x0a, 0x26, 0x61, 0x74, + 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x5f, + 0x68, 0x6d, 0x61, 0x63, 0x12, 0x18, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, + 0x50, 0x61, 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x48, 0x6d, 0x61, 0x63, 0x52, 0x1b, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x73, 0x73, + 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x5f, 0x68, 0x6d, 0x61, 0x63, 0x42, 0x58, 0x5a, 0x56, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, + 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x64, 0x6b, + 0x2f, 0x70, 0x62, 0x73, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x63, 0x72, + 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x3b, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x61, 0x6c, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -515,11 +551,12 @@ var file_controller_api_resources_credentials_v1_credential_proto_depIdxs = []in 4, // 9: controller.api.resources.credentials.v1.UsernamePasswordAttributes.password:type_name -> google.protobuf.StringValue 4, // 10: controller.api.resources.credentials.v1.SshPrivateKeyAttributes.username:type_name -> google.protobuf.StringValue 4, // 11: controller.api.resources.credentials.v1.SshPrivateKeyAttributes.private_key:type_name -> google.protobuf.StringValue - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 4, // 12: controller.api.resources.credentials.v1.SshPrivateKeyAttributes.private_key_passphrase:type_name -> google.protobuf.StringValue + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_controller_api_resources_credentials_v1_credential_proto_init() } diff --git a/sdk/pbs/controller/api/resources/targets/target.pb.go b/sdk/pbs/controller/api/resources/targets/target.pb.go index 5569c9d04..43cbb6f99 100644 --- a/sdk/pbs/controller/api/resources/targets/target.pb.go +++ b/sdk/pbs/controller/api/resources/targets/target.pb.go @@ -1166,6 +1166,8 @@ type SshPrivateKeyCredential struct { Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty" class:"sensitive"` // @gotags: `class:"sensitive"` // Private key of the credential PrivateKey string `protobuf:"bytes,2,opt,name=private_key,json=privateKey,proto3" json:"private_key,omitempty" class:"secret"` // @gotags: `class:"secret"` + // The optional passphrase of the private_key + PrivateKeyPassphrase string `protobuf:"bytes,3,opt,name=private_key_passphrase,json=privateKeyPassphrase,proto3" json:"private_key_passphrase,omitempty" class:"secret"` // @gotags: `class:"secret"` } func (x *SshPrivateKeyCredential) Reset() { @@ -1214,6 +1216,13 @@ func (x *SshPrivateKeyCredential) GetPrivateKey() string { return "" } +func (x *SshPrivateKeyCredential) GetPrivateKeyPassphrase() string { + if x != nil { + return x.PrivateKeyPassphrase + } + return "" +} + var File_controller_api_resources_targets_v1_target_proto protoreflect.FileDescriptor var file_controller_api_resources_targets_v1_target_proto_rawDesc = []byte{ @@ -1513,18 +1522,21 @@ var file_controller_api_resources_targets_v1_target_proto_rawDesc = []byte{ 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x56, 0x0a, 0x17, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, - 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, - 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, - 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0a, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x42, 0x50, 0x5a, - 0x4e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, - 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, - 0x64, 0x6b, 0x2f, 0x70, 0x62, 0x73, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, - 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, - 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x3b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x8c, 0x01, 0x0a, 0x17, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, + 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, + 0x0b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x34, + 0x0a, 0x16, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, + 0x73, 0x73, 0x70, 0x68, 0x72, 0x61, 0x73, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x50, 0x61, 0x73, 0x73, 0x70, 0x68, + 0x72, 0x61, 0x73, 0x65, 0x42, 0x50, 0x5a, 0x4e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, + 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x62, 0x73, 0x2f, 0x63, 0x6f, + 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x3b, 0x74, + 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var (