diff --git a/go.mod b/go.mod index 6dd186b56..68ab294b9 100644 --- a/go.mod +++ b/go.mod @@ -161,6 +161,7 @@ require ( github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect diff --git a/go.sum b/go.sum index 8966004b2..202734ace 100644 --- a/go.sum +++ b/go.sum @@ -980,6 +980,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182aff github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= +github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.2 h1:PvH+lL2B7IQ101xQL63Of8yFS2y+aDlsFcsqNc+u/Kw= diff --git a/internal/credential/credential.go b/internal/credential/credential.go index d09e4cc60..edee5fbf7 100644 --- a/internal/credential/credential.go +++ b/internal/credential/credential.go @@ -98,6 +98,13 @@ type Request struct { Purpose Purpose } +// SshCertificate is a credential containing a client certificate, username, +// and SSH private key. +type SshCertificate interface { + SshPrivateKey + Certificate() []byte +} + // Issuer issues dynamic credentials. type Issuer interface { // Issue issues dynamic credentials for a session from the requested diff --git a/internal/credential/vault/private_library.go b/internal/credential/vault/private_library.go index 712b84765..a161f4ce1 100644 --- a/internal/credential/vault/private_library.go +++ b/internal/credential/vault/private_library.go @@ -2,8 +2,18 @@ package vault import ( "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" "database/sql" + "encoding/base64" + "encoding/json" + "encoding/pem" "fmt" + "regexp" "strings" "time" @@ -17,6 +27,8 @@ import ( wrapping "github.com/hashicorp/go-kms-wrapping/v2" "github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping" vault "github.com/hashicorp/vault/api" + "github.com/mikesmitty/edkey" + "golang.org/x/crypto/ssh" "google.golang.org/protobuf/proto" ) @@ -25,13 +37,13 @@ var _ credential.Dynamic = (*baseCred)(nil) type baseCred struct { *Credential - lib *genericIssuingCredentialLibrary + lib issuingCredentialLibrary secretData map[string]any } func (bc *baseCred) Secret() credential.SecretData { return bc.secretData } func (bc *baseCred) Library() credential.Library { return bc.lib } -func (bc *baseCred) Purpose() credential.Purpose { return bc.lib.Purpose } +func (bc *baseCred) Purpose() credential.Purpose { return bc.lib.GetPurpose() } func (bc *baseCred) getExpiration() time.Duration { return bc.expiration } func (bc *baseCred) getCredential() *Credential { return bc.Credential } @@ -68,7 +80,12 @@ func baseToUsrPass(ctx context.Context, bc *baseCred) (*usrPassCred, error) { return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("invalid credential type")) } - uAttr, pAttr := bc.lib.UsernameAttribute, bc.lib.PasswordAttribute + lib, ok := bc.lib.(*genericIssuingCredentialLibrary) + if !ok { + return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("baseCred.lib is not of type genericIssuingCredentialLibrary")) + } + + uAttr, pAttr := lib.UsernameAttribute, lib.PasswordAttribute if uAttr == "" { uAttr = "username" } @@ -110,7 +127,12 @@ func baseToSshPriKey(ctx context.Context, bc *baseCred) (*sshPrivateKeyCred, err return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("invalid credential type")) } - uAttr, pkAttr, pAttr := bc.lib.UsernameAttribute, bc.lib.PrivateKeyAttribute, bc.lib.PrivateKeyPassphraseAttribute + lib, ok := bc.lib.(*genericIssuingCredentialLibrary) + if !ok { + return nil, errors.E(ctx, errors.WithCode(errors.InvalidParameter), errors.WithMsg("baseCred.lib is not of type genericIssuingCredentialLibrary")) + } + + uAttr, pkAttr, pAttr := lib.UsernameAttribute, lib.PrivateKeyAttribute, lib.PrivateKeyPassphraseAttribute if uAttr == "" { uAttr = "username" } @@ -133,10 +155,18 @@ func baseToSshPriKey(ctx context.Context, bc *baseCred) (*sshPrivateKeyCred, err }, nil } +type sshCertCred struct { + *sshPrivateKeyCred + certificate []byte +} + +func (c *sshCertCred) Certificate() []byte { return c.certificate } + var _ credential.Library = (*genericIssuingCredentialLibrary)(nil) type issuingCredentialLibrary interface { - GetPublicId() string + credential.Library + GetPurpose() credential.Purpose retrieveCredential(context.Context, errors.Op, ...credential.Option) (dynamicCred, error) } @@ -221,6 +251,7 @@ func (pl *genericIssuingCredentialLibrary) GetDescription() string func (pl *genericIssuingCredentialLibrary) GetVersion() uint32 { return pl.Version } func (pl *genericIssuingCredentialLibrary) GetCreateTime() *timestamp.Timestamp { return pl.CreateTime } func (pl *genericIssuingCredentialLibrary) GetUpdateTime() *timestamp.Timestamp { return pl.UpdateTime } +func (pl *genericIssuingCredentialLibrary) GetPurpose() credential.Purpose { return pl.Purpose } func (pl *genericIssuingCredentialLibrary) CredentialType() credential.Type { switch ct := pl.CredType; ct { @@ -444,6 +475,14 @@ type privateCredentialLibraryAllTypes struct { PrivateKeyAttribute string PrivateKeyPassphraseAttribute string Purpose credential.Purpose `gorm:"-"` + KeyType string + KeyBits int + Username string + Ttl string + KeyId string + CriticalOptions []byte + Extensions []byte + CredLibType string } func (pl *privateCredentialLibraryAllTypes) GetPublicId() string { return pl.PublicId } @@ -516,42 +555,88 @@ func (pl *privateCredentialLibraryAllTypes) clone() *privateCredentialLibraryAll CtClientKey: append(pl.CtClientKey[:0:0], pl.CtClientKey...), ClientKeyId: pl.ClientKeyId, Purpose: pl.Purpose, + KeyType: pl.KeyType, + KeyBits: pl.KeyBits, + Username: pl.Username, + Ttl: pl.Ttl, + KeyId: pl.KeyId, + CriticalOptions: pl.CriticalOptions, + Extensions: pl.Extensions, + CredLibType: pl.CredLibType, } } func (pl *privateCredentialLibraryAllTypes) toTypedIssuingCredentialLibrary() issuingCredentialLibrary { - return &genericIssuingCredentialLibrary{ - 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: pl.CreateTime, - UpdateTime: pl.UpdateTime, - Version: pl.Version, - ProjectId: pl.ProjectId, - VaultPath: pl.VaultPath, - HttpMethod: pl.HttpMethod, - HttpRequestBody: pl.HttpRequestBody, - VaultAddress: pl.VaultAddress, - Namespace: pl.Namespace, - CaCert: pl.CaCert, - TlsServerName: pl.TlsServerName, - TlsSkipVerify: pl.TlsSkipVerify, - WorkerFilter: pl.WorkerFilter, - TokenHmac: pl.TokenHmac, - Token: pl.Token, - CtToken: pl.CtToken, - TokenKeyId: pl.TokenKeyId, - ClientCert: pl.ClientCert, - ClientKey: pl.ClientKey, - CtClientKey: pl.CtClientKey, - ClientKeyId: pl.ClientKeyId, - Purpose: pl.Purpose, + switch pl.CredLibType { + case "ssh-signed-cert": + return &sshCertIssuingCredentialLibrary{ + PublicId: pl.PublicId, + StoreId: pl.StoreId, + CredType: pl.CredType, + Username: pl.Username, + Name: pl.Name, + Description: pl.Description, + CreateTime: pl.CreateTime, + UpdateTime: pl.UpdateTime, + Version: pl.Version, + ProjectId: pl.ProjectId, + VaultPath: pl.VaultPath, + VaultAddress: pl.VaultAddress, + Namespace: pl.Namespace, + CaCert: pl.CaCert, + TlsServerName: pl.TlsServerName, + TlsSkipVerify: pl.TlsSkipVerify, + WorkerFilter: pl.WorkerFilter, + TokenHmac: pl.TokenHmac, + Token: pl.Token, + CtToken: pl.CtToken, + TokenKeyId: pl.TokenKeyId, + ClientCert: pl.ClientCert, + ClientKey: pl.ClientKey, + CtClientKey: pl.CtClientKey, + ClientKeyId: pl.ClientKeyId, + Purpose: pl.Purpose, + KeyType: pl.KeyType, + KeyBits: pl.KeyBits, + Ttl: pl.Ttl, + KeyId: pl.KeyId, + CriticalOptions: pl.CriticalOptions, + Extensions: pl.Extensions, + } + default: + return &genericIssuingCredentialLibrary{ + 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: pl.CreateTime, + UpdateTime: pl.UpdateTime, + Version: pl.Version, + ProjectId: pl.ProjectId, + VaultPath: pl.VaultPath, + HttpMethod: pl.HttpMethod, + HttpRequestBody: pl.HttpRequestBody, + VaultAddress: pl.VaultAddress, + Namespace: pl.Namespace, + CaCert: pl.CaCert, + TlsServerName: pl.TlsServerName, + TlsSkipVerify: pl.TlsSkipVerify, + WorkerFilter: pl.WorkerFilter, + TokenHmac: pl.TokenHmac, + Token: pl.Token, + CtToken: pl.CtToken, + TokenKeyId: pl.TokenKeyId, + ClientCert: pl.ClientCert, + ClientKey: pl.ClientKey, + CtClientKey: pl.CtClientKey, + ClientKeyId: pl.ClientKeyId, + Purpose: pl.Purpose, + } } } @@ -597,3 +682,320 @@ func (m *requestMap) libIds() []string { func (m *requestMap) get(libraryId string) []credential.Purpose { return m.ids[libraryId] } + +type sshCertIssuingCredentialLibrary struct { + PublicId string + StoreId string + Name string + Description string + CreateTime *timestamp.Timestamp + UpdateTime *timestamp.Timestamp + Version uint32 + VaultPath string + CredType string + ProjectId string + VaultAddress string + Namespace string + CaCert []byte + TlsServerName string + TlsSkipVerify bool + WorkerFilter string + Token TokenSecret + CtToken []byte + TokenHmac []byte + TokenKeyId string + ClientCert []byte + ClientKey KeySecret + CtClientKey []byte + ClientKeyId string + Username string + KeyType string + KeyBits int + KeyId string + Ttl string + CriticalOptions []byte + Extensions []byte + Purpose credential.Purpose +} + +func (lib *sshCertIssuingCredentialLibrary) GetPublicId() string { return lib.PublicId } +func (lib *sshCertIssuingCredentialLibrary) GetStoreId() string { return lib.StoreId } +func (lib *sshCertIssuingCredentialLibrary) GetName() string { return lib.Name } +func (lib *sshCertIssuingCredentialLibrary) GetDescription() string { return lib.Description } +func (lib *sshCertIssuingCredentialLibrary) GetVersion() uint32 { return lib.Version } +func (lib *sshCertIssuingCredentialLibrary) GetPurpose() credential.Purpose { return lib.Purpose } +func (lib *sshCertIssuingCredentialLibrary) GetCreateTime() *timestamp.Timestamp { + return lib.CreateTime +} + +func (lib *sshCertIssuingCredentialLibrary) GetUpdateTime() *timestamp.Timestamp { + return lib.UpdateTime +} + +func (lib *sshCertIssuingCredentialLibrary) CredentialType() credential.Type { + switch ct := lib.CredType; ct { + case "": + return credential.UnspecifiedType + default: + return credential.Type(ct) + } +} + +func (lib *sshCertIssuingCredentialLibrary) client(ctx context.Context) (vaultClient, error) { + const op = "vault.(genericIssuingCredentialLibrary).client" + clientConfig := &clientConfig{ + Addr: lib.VaultAddress, + Token: lib.Token, + CaCert: lib.CaCert, + TlsServerName: lib.TlsServerName, + TlsSkipVerify: lib.TlsSkipVerify, + Namespace: lib.Namespace, + } + + if lib.ClientKey != nil { + clientConfig.ClientCert = lib.ClientCert + clientConfig.ClientKey = lib.ClientKey + } + + client, err := vaultClientFactoryFn(ctx, clientConfig, WithWorkerFilter(lib.WorkerFilter)) + if err != nil { + return nil, errors.WrapDeprecated(err, op, errors.WithMsg("unable to create vault client")) + } + return client, nil +} + +func generatePublicPrivateKeys(ctx context.Context, keyType string, keyBits int) (string, []byte, error) { + const op = "vault.generatePublicPrivateKeys" + pemBlock := pem.Block{} + var sshKey ssh.PublicKey + + switch keyType { + case KeyTypeRsa: + pemBlock.Type = "RSA PRIVATE KEY" // these values are copied from the crypto ssh library in ssh/keys.go + + key, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + if sshKey, err = ssh.NewPublicKey(&key.PublicKey); err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + + pemBlock.Bytes = x509.MarshalPKCS1PrivateKey(key) + + case KeyTypeEd25519: + pemBlock.Type = "OPENSSH PRIVATE KEY" // these values are copied from the crypto ssh library in ssh/keys.go + + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + if sshKey, err = ssh.NewPublicKey(pubKey); err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + + if pemBlock.Bytes = edkey.MarshalED25519PrivateKey(privKey); pemBlock.Bytes == nil { + return "", nil, errors.New(ctx, errors.Encode, op, "failed to marshal ed25519 private key") + } + + case KeyTypeEcdsa: + pemBlock.Type = "EC PRIVATE KEY" // these values are copied from the crypto ssh library in ssh/keys.go + + var curve elliptic.Curve + switch keyBits { + case 256: + curve = elliptic.P256() + case 384: + curve = elliptic.P384() + case 521: + curve = elliptic.P521() + default: + return "", nil, errors.New(ctx, errors.InvalidParameter, op, "invalid KeyBits. when KeyType=ecdsa, KeyBits must be one of: 256, 384, or 521") + } + + key, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + + if sshKey, err = ssh.NewPublicKey(&key.PublicKey); err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + + if pemBlock.Bytes, err = x509.MarshalECPrivateKey(key); err != nil { + return "", nil, errors.Wrap(ctx, err, op) + } + + default: + return "", nil, errors.New(ctx, errors.InvalidParameter, op, "invalid KeyType, must be one of: \"rsa\", \"ed25519\", or \"ecdsa\"") + } + + publicKey := base64.StdEncoding.EncodeToString(sshKey.Marshal()) + privateKey := pem.EncodeToMemory(&pemBlock) + if privateKey == nil { + return "", nil, errors.New(ctx, errors.Encode, op, "failed to encode private key to PEM format") + } + return publicKey, privateKey, nil +} + +type sshCertVaultBody struct { + KeyType string `json:"key_type,omitempty"` // must be "rsa", "ed25519", or "ecdsa" + KeyBits int `json:"key_bits,omitempty"` // with key_type=rsa, allowed values are: 2048 (default), 3072, or 4096; with key_type=ecdsa, allowed values are: 256 (default), 384, or 521; ignored with key_type=ed25519 + PublicKey string `json:"public_key,omitempty"` + TTL string `json:"ttl,omitempty"` + ValidPrincipals string `json:"valid_principals,omitempty"` // this needs to be "generated" off of the username provided in config + CertType string `json:"cert_type,omitempty"` // this should always be "user" + KeyId string `json:"key_id,omitempty"` // this will be loaded directly from lib + CriticalOptions map[string]string `json:"critical_options,omitempty"` // this will be loaded directly from lib + Extensions map[string]string `json:"extensions,omitempty"` // this will be loaded directly from lib +} + +var vaultPathRegexp = regexp.MustCompile(`^.+\/(sign|issue)\/[^\/\\\s]+$`) + +// retrieveCredential retrieves a dynamic connection credential from Vault +// for a specific session and connection. +// +// Supported options: credential.WithTemplateData +func (lib *sshCertIssuingCredentialLibrary) retrieveCredential(ctx context.Context, op errors.Op, opt ...credential.Option) (dynamicCred, error) { + opts, err := credential.GetOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + // Get the credential ID early. No need to get a secret from Vault + // if there is no way to save it in the database. + credId, err := newCredentialId() + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + client, err := lib.client(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + // build as much of the payload as we can, since sign/issue share many attributes + // Template the username + tplate, err := template.New(ctx, lib.Username) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + username, err := tplate.Generate(ctx, opts.WithTemplateData) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + var criticalOptions map[string]string + if lib.CriticalOptions != nil { + if json.Unmarshal(lib.CriticalOptions, &criticalOptions) != nil { + return nil, errors.Wrap(ctx, err, op) + } + } + + var extensions map[string]string + if lib.Extensions != nil { + if json.Unmarshal(lib.Extensions, &extensions) != nil { + return nil, errors.Wrap(ctx, err, op) + } + } + + payload := sshCertVaultBody{ + ValidPrincipals: username, + CertType: "user", + CriticalOptions: criticalOptions, + Extensions: extensions, + TTL: lib.Ttl, + KeyId: lib.KeyId, + } + + var privateKey credential.PrivateKey + var secret *vault.Secret + + match := vaultPathRegexp.FindStringSubmatch(lib.VaultPath) + if len(match) < 2 { + return nil, errors.New(ctx, errors.InvalidParameter, op, "vault path was not in an expected format. expected path containing \"sign\" or \"issue\"") + } + + // by definition, if match exists, then match[1] == "sign" or "issue" + switch match[1] { + case "sign": + payload.PublicKey, privateKey, err = generatePublicPrivateKeys(ctx, lib.KeyType, lib.KeyBits) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + secret, err = client.post(ctx, lib.VaultPath, body) + if err != nil { + // TODO(mgaffney) 05/2021: detect if the error is because of an + // expired or invalid token + return nil, errors.Wrap(ctx, err, op) + } + if secret == nil { + return nil, errors.E(ctx, errors.WithCode(errors.VaultEmptySecret), errors.WithOp(op)) + } + + case "issue": + payload.KeyBits = lib.KeyBits + if lib.KeyType == KeyTypeEcdsa { + // this is a special case where internal to boundary, we refer to the crypto + // library name, but vault refers to it simply as "ec" for "Elliptic Curve" + payload.KeyType = "ec" + } else { + payload.KeyType = lib.KeyType + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + secret, err = client.post(ctx, lib.VaultPath, body) + if err != nil { + // TODO(mgaffney) 05/2021: detect if the error is because of an + // expired or invalid token + return nil, errors.Wrap(ctx, err, op) + } + if secret == nil { + return nil, errors.E(ctx, errors.WithCode(errors.VaultEmptySecret), errors.WithOp(op)) + } + + pk, ok := secret.Data["private_key"].(string) + if !ok { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret did not contain a private key or response was not in the expected format") + } + + privateKey = []byte(pk) + } + + leaseDuration := time.Duration(secret.LeaseDuration) * time.Second + cred, err := newCredential(lib.GetPublicId(), secret.LeaseID, lib.TokenHmac, leaseDuration) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + cred.PublicId = credId + cred.IsRenewable = secret.Renewable // possibly set to just false + + // same location for both + cert, ok := secret.Data["signed_key"].(string) + if !ok { + return nil, errors.New(ctx, errors.VaultInvalidCredentialMapping, op, "vault secret did not contain a signed key or response was not in the expected format") + } + + return &sshCertCred{ + sshPrivateKeyCred: &sshPrivateKeyCred{ + baseCred: &baseCred{ + Credential: cred, + lib: lib, + secretData: secret.Data, + }, + username: username, + privateKey: privateKey, + }, + certificate: []byte(cert), + }, nil +} diff --git a/internal/credential/vault/private_library_test.go b/internal/credential/vault/private_library_test.go index dca8b671c..ed0fff6bf 100644 --- a/internal/credential/vault/private_library_test.go +++ b/internal/credential/vault/private_library_test.go @@ -2,7 +2,9 @@ package vault import ( "context" + "fmt" "testing" + "time" "github.com/hashicorp/boundary/internal/credential" "github.com/hashicorp/boundary/internal/db" @@ -10,6 +12,8 @@ import ( "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/scheduler" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/boundary/internal/util/template" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -63,7 +67,7 @@ func TestRepository_getPrivateLibraries(t *testing.T) { opts = append(opts, WithClientCert(clientCert)) } - _, token := v.CreateToken(t) + _, token := v.CreateToken(t, WithTokenPeriod(time.Hour)) credStoreIn, err := NewCredentialStore(prj.GetPublicId(), v.Addr, []byte(token), opts...) assert.NoError(err) @@ -1215,3 +1219,171 @@ func TestBaseToSshPriKey(t *testing.T) { }) } } + +func TestRepository_sshCertIssuingCredentialLibrary_retrieveCredential(t *testing.T) { + t.Parallel() + + // create test vault server + v := NewTestVaultServer(t, WithTestVaultTLS(TestNoTLS), WithVaultVersion("1.12.2")) + require.NotNil(t, v) + + vc := v.client(t).cl + mounts, err := vc.Sys().ListMounts() + assert.NoError(t, err) + require.NotEmpty(t, mounts) + beforeCount := len(mounts) + + // enable ssh secrets engine + v.MountSSH(t, WithAllowedExtension("permit-pty")) + mounts, err = vc.Sys().ListMounts() + fmt.Printf("mounts: %#v\n", mounts) + assert.NoError(t, err) + require.NotEmpty(t, mounts) + afterCount := len(mounts) + assert.Greater(t, afterCount, beforeCount) + + // create and setup db + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + sec, token := v.CreateToken(t, WithPolicies([]string{"default", "boundary-controller", "ssh"}), WithTokenPeriod(time.Hour)) + + sche := scheduler.TestScheduler(t, conn, wrapper) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(rw, rw, kms, sche) + require.NoError(t, err) + require.NotNil(t, repo) + + cs := TestCredentialStore(t, conn, wrapper, prj.GetPublicId(), v.Addr, token, sec.Auth.Accessor) + + tests := []struct { + name string + username string + vaulthPath string + opts []Option + retOpts []credential.Option + expected map[string]string + }{ + { + name: "vault sign boundary ed25519 key", + username: "username", + vaulthPath: "ssh/sign/boundary", + opts: []Option{WithKeyType(KeyTypeEd25519)}, + }, + { + name: "vault sign boundary rsa(4096) key", + username: "username-2-electric-boogaloo", + vaulthPath: "ssh/sign/boundary", + opts: []Option{WithKeyType(KeyTypeRsa), WithKeyBits(4096)}, + }, + { + name: "vault sign boundary ec(521) key", + username: "username-3-the-namening", + vaulthPath: "ssh/sign/boundary", + opts: []Option{WithKeyType(KeyTypeEcdsa), WithKeyBits(521)}, + }, + { + name: "vault issue ed25519 cert", + username: "username-4-revengeance", + vaulthPath: "ssh/issue/boundary", + opts: []Option{WithKeyType(KeyTypeEd25519)}, + }, + { + name: "vault issue rsa(4096) cert", + username: "username-5-this-time-its-personal", + vaulthPath: "ssh/issue/boundary", + opts: []Option{WithKeyType(KeyTypeRsa), WithKeyBits(4096)}, + }, + { + name: "vault issue ec(521) cert", + username: "username-6-the-holiday-episode", + vaulthPath: "ssh/issue/boundary", + opts: []Option{WithKeyType(KeyTypeEcdsa), WithKeyBits(521)}, + }, + { + name: "vault issue rsa(2048) cert with critical options and extensions", + username: "username-7-because-789", + vaulthPath: "ssh/issue/boundary", + opts: []Option{WithKeyType(KeyTypeRsa), WithKeyBits(2048), WithCriticalOptions("{ \"force-commnad\": \"/bin/some-script\" }"), WithExtensions("{ \"permit-pty\": \"\" }")}, + }, + { + name: "vault sign boundary rsa(3072) key with critical options and extensions", + username: "username-7-because-789", + vaulthPath: "ssh/sign/boundary", + opts: []Option{WithKeyType(KeyTypeRsa), WithKeyBits(3072), WithCriticalOptions("{ \"force-commnad\": \"/bin/some-script\" }"), WithExtensions("{ \"permit-pty\": \"\" }")}, + }, + { + name: "vault issue ec(256) cert with template username", + username: "username-8-{{ .User.Name }}", + expected: map[string]string{ + "username": "username-8-revenge-of-the-template", + }, + vaulthPath: "ssh/issue/boundary", + opts: []Option{WithKeyType(KeyTypeEcdsa), WithKeyBits(256)}, + retOpts: []credential.Option{credential.WithTemplateData(template.Data{User: template.User{Name: util.Pointer("revenge-of-the-template")}})}, + }, + { + name: "vault issue ec(384) cert with disallowed extension", + username: "username-10-because-789", + expected: map[string]string{ + "error": "extensions [permit-port-forwarding] are not on allowed list", + }, + vaulthPath: "ssh/issue/boundary", + opts: []Option{WithKeyType(KeyTypeEcdsa), WithKeyBits(384), WithExtensions("{ \"permit-port-forwarding\": \"\" }")}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + // create ssh library + lib, err := NewSSHCertificateCredentialLibrary(cs.GetPublicId(), tt.vaulthPath, tt.username, tt.opts...) + require.NoError(err) + require.NotNil(lib) + lib.PublicId, err = newSSHCertificateCredentialLibraryId() + require.NoError(err) + + _, err = rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, iw db.Writer) error { + return iw.Create(ctx, lib) + }, + ) + require.NoError(err) + + req := credential.Request{ + SourceId: lib.GetPublicId(), + Purpose: "doesn't matter", + } + + libs, err := repo.getIssueCredLibraries(ctx, []credential.Request{req}) + require.NoError(err) + require.NotEmpty(libs) + require.Equal(1, len(libs)) + + cred, err := libs[0].retrieveCredential(ctx, "op", tt.retOpts...) + if retErr, ok := tt.expected["error"]; ok { + require.ErrorContains(err, retErr) + return // retrieveCredential failed (as expected) don't do the rest of the checks + } + require.NoError(err) + + cert, ok := cred.(*sshCertCred) + require.True(ok) + + if usr, ok := tt.expected["username"]; ok { + require.Equal(usr, cert.username) + } + + // TODO: somehow check that the ssh cert matches the private key + // for now, manually check that private key and cert match and that sign/issue both return correct formats + fmt.Printf("test: %s\npriv:\n%s\ncert:\n%s\n", tt.name, string(cert.privateKey), string(cert.certificate)) + + // ensure credential matches expected SshCertificate + _, ok = cred.(credential.SshCertificate) + require.True(ok) + }) + } +} diff --git a/internal/credential/vault/supported.go b/internal/credential/vault/supported.go index 90b040217..37a8563a0 100644 --- a/internal/credential/vault/supported.go +++ b/internal/credential/vault/supported.go @@ -75,9 +75,14 @@ func gotNewServer(t testing.TB, opt ...TestOption) *TestVaultServer { pool: pool, } + vaultVersion := DefaultVaultVersion + if opts.vaultVersion != "" { + vaultVersion = opts.vaultVersion + } + dockerOptions := &dockertest.RunOptions{ Repository: "vault", - Tag: DefaultVaultVersion, + Tag: vaultVersion, Env: []string{fmt.Sprintf("VAULT_DEV_ROOT_TOKEN_ID=%s", server.RootToken)}, } diff --git a/internal/credential/vault/testing.go b/internal/credential/vault/testing.go index d87171f84..acdac4c60 100644 --- a/internal/credential/vault/testing.go +++ b/internal/credential/vault/testing.go @@ -140,10 +140,10 @@ func TestCredentialLibraries(t testing.TB, conn *db.DB, _ wrapping.Wrapper, stor return libs } -// TestSSHCertificateCredentialLibraries creates count number of vault credential -// libraries in the provided DB with the provided store id. If any errors -// are encountered during the creation of the credential libraries, the -// test will fail. +// TestSSHCertificateCredentialLibraries creates count number of vault ssh +// certificate credential libraries in the provided DB with the provided store +// id. If any errors are encountered during the creation of the credential +// libraries, the test will fail. func TestSSHCertificateCredentialLibraries(t testing.TB, conn *db.DB, _ wrapping.Wrapper, storeId string, count int) []*SSHCertificateCredentialLibrary { t.Helper() assert, require := assert.New(t), require.New(t) @@ -151,7 +151,7 @@ func TestSSHCertificateCredentialLibraries(t testing.TB, conn *db.DB, _ wrapping var libs []*SSHCertificateCredentialLibrary for i := 0; i < count; i++ { - lib, err := NewSSHCertificateCredentialLibrary(storeId, fmt.Sprintf("vault/path%d", i), "username", withKeyType(KeyTypeEd25519)) + lib, err := NewSSHCertificateCredentialLibrary(storeId, fmt.Sprintf("ssh/sign/role-%d", i), "username", WithKeyType(KeyTypeEd25519)) assert.NoError(err) require.NotNil(lib) id, err := newSSHCertificateCredentialLibraryId() @@ -509,17 +509,19 @@ type TestOption func(testing.TB, *testOptions) // options = how options are represented type testOptions struct { - orphan bool - periodic bool - renewable bool - policies []string - mountPath string - roleName string - vaultTLS TestVaultTLS - dockerNetwork bool - skipCleanup bool - tokenPeriod time.Duration - clientKey *ecdsa.PrivateKey + orphan bool + periodic bool + renewable bool + policies []string + allowedExtensions []string + mountPath string + roleName string + vaultTLS TestVaultTLS + dockerNetwork bool + skipCleanup bool + tokenPeriod time.Duration + clientKey *ecdsa.PrivateKey + vaultVersion string } func getDefaultTestOptions(t testing.TB) testOptions { @@ -625,6 +627,24 @@ func WithDontCleanUp() TestOption { } } +// WithVaultVersion sets the version of vault that will be started. +// defaults to the value stored in vault.supported.DefaultVaultVersion +func WithVaultVersion(s string) TestOption { + return func(t testing.TB, o *testOptions) { + t.Helper() + o.vaultVersion = s + } +} + +// WithAllowedExtension tells vault to allow a specific SSH extension +// to be used by vault's ssh secrets engine +func WithAllowedExtension(e string) TestOption { + return func(t testing.TB, o *testOptions) { + t.Helper() + o.allowedExtensions = append(o.allowedExtensions, e) + } +} + // TestOrphanToken sets the token orphan option to b. // The orphan option is true by default. func TestOrphanToken(b bool) TestOption { @@ -784,6 +804,95 @@ func (v *TestVaultServer) addPolicy(t testing.TB, name string, pc pathCapabiliti require.NoError(vc.Sys().PutPolicy(name, policy)) } +// MountSSH mounts the Vault SSH secret engine and initializes it by +// generating a root certificate authority and creating a default role on +// the mount. The root CA is returned. +// +// The default mount path is ssh and the default role name is boundary. +// WithTestMountPath, WithTestRoleName, and WithAllowedExtension are the +// test options supported. extensions are combined into allowed_extensions. +// allowed_extensions defaults to "*" (allow all) +// The role is defined as: +// +// { +// "key_type": "ca", +// "allowed_users": "*", +// "allowed_extensions": "*", +// "allow_user_certificates": true, +// "ttl": "12h0m0s" +// } +// +// MountSSH also adds a Vault policy named 'ssh' to v and adds it to the +// standard set of polices attached to tokens created with v.CreateToken. +// The policy is defined as: +// +// path "mountPath/*" { +// capabilities = ["create", "read", "update", "delete", "list"] +// } +func (v *TestVaultServer) MountSSH(t testing.TB, opt ...TestOption) *vault.Secret { + require := require.New(t) + opts := getTestOpts(t, opt...) + vc := v.client(t).cl + + // Mount SSH + maxTTL := 24 * time.Hour + if t, ok := t.(*testing.T); ok { + if deadline, ok := t.Deadline(); ok { + maxTTL = time.Until(deadline) * 2 + } + } + + defaultTTL := maxTTL / 2 + t.Logf("maxTTL: %s, defaultTTL: %s", maxTTL, defaultTTL) + mountInput := &vault.MountInput{ + Type: "ssh", + Description: t.Name(), + Config: vault.MountConfigInput{ + DefaultLeaseTTL: defaultTTL.String(), + MaxLeaseTTL: maxTTL.String(), + }, + } + mountPath := opts.mountPath + if mountPath == "" { + mountPath = "ssh/" + } + // this is the call that actually enables the secrets engine + require.NoError(vc.Sys().Mount(mountPath, mountInput)) + + policyPath := fmt.Sprintf("%s*", mountPath) + pc := pathCapabilities{ + policyPath: createCapability | readCapability | updateCapability | deleteCapability | listCapability, + } + v.addPolicy(t, "ssh", pc) + + // Generate a root CA + caPath := path.Join(mountPath, "config/ca") + caOptions := map[string]any{ + "generate_signing_key": true, + } + s, err := vc.Logical().Write(caPath, caOptions) + require.NoError(err) + require.NotEmpty(s) + + // Create default role + rolePath := path.Join(mountPath, "roles", opts.roleName) + allowExtensions := "*" + if len(opts.allowedExtensions) > 0 { + allowExtensions = strings.Join(opts.allowedExtensions, ",") + } + roleOptions := map[string]any{ + "key_type": "ca", + "allowed_users": "*", + "allowed_extensions": allowExtensions, + "allow_user_certificates": true, + "ttl": defaultTTL.String(), + } + _, err = vc.Logical().Write(rolePath, roleOptions) + require.NoError(err) + + return s +} + // MountPKI mounts the Vault PKI secret engine and initializes it by // generating a root certificate authority and creating a default role on // the mount. The root CA is returned. diff --git a/internal/db/schema/migrations/oss/postgres/56/02_add_data_key_foreign_key_references.up.sql b/internal/db/schema/migrations/oss/postgres/56/02_add_data_key_foreign_key_references.up.sql index 430918693..3ed79b07e 100644 --- a/internal/db/schema/migrations/oss/postgres/56/02_add_data_key_foreign_key_references.up.sql +++ b/internal/db/schema/migrations/oss/postgres/56/02_add_data_key_foreign_key_references.up.sql @@ -188,6 +188,7 @@ begin; 'The view returns the current token for the store, if the Vault token has expired this view will return an empty token_hmac and a token_status of ''expired'' ' 'Each row may contain encrypted data. This view should not be used to retrieve data which will be returned external to boundary.'; + -- Replaced in 63/02_add_ssh_cert_to_vault_cred_library_view.up.sql create view credential_vault_library_issue_credentials as with password_override (library_id, username_attribute, password_attribute) as ( diff --git a/internal/db/schema/migrations/oss/postgres/63/02_add_ssh_cert_to_vault_cred_library_view.up.sql b/internal/db/schema/migrations/oss/postgres/63/02_add_ssh_cert_to_vault_cred_library_view.up.sql new file mode 100644 index 000000000..4b09d965b --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/63/02_add_ssh_cert_to_vault_cred_library_view.up.sql @@ -0,0 +1,111 @@ +begin; + + -- Replaces view from 56/02_add_data_key_foreign_key_references.up.sql + drop view credential_vault_library_issue_credentials; + create view credential_vault_library_issue_credentials 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, + null as key_type, + null as key_bits, + null as username, + null as ttl, + null as key_id, + null as critical_options, + null as extensions, + store.project_id as project_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.ct_token as ct_token, -- encrypted + store.token_hmac as token_hmac, + store.token_status as token_status, + 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, + 'generic' as cred_lib_type -- used to switch on + from credential_vault_library library + join credential_vault_store_client store + on library.store_id = store.public_id + left join password_override upasso + on library.public_id = upasso.library_id + left join ssh_private_key_override sshpk + on library.public_id = sshpk.library_id + union + 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, + null as http_method, + null as http_request_body, + library.credential_type as credential_type, + library.key_type as key_type, + library.key_bits as key_bits, + library.username as username, + library.ttl as ttl, + library.key_id as key_id, + library.critical_options as critical_options, + library.extensions as extensions, + store.project_id as project_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.ct_token as ct_token, -- encrypted + store.token_hmac as token_hmac, + store.token_status as token_status, + 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, + null as username_attribute, + null as password_attribute, + null as private_key_attribute, + null as private_key_passphrase_attribute, + 'ssh-signed-cert' as cred_lib_type -- used to switch on + from credential_vault_ssh_cert_library library + join credential_vault_store_client store + on library.store_id = store.public_id; + comment on view credential_vault_library_issue_credentials is + 'credential_vault_library_issue_credentials is a view where each row contains a credential library and the credential library''s data needed to connect to Vault. ' + 'This view should only be used when issuing credentials from a Vault credential library. Each row may contain encrypted data. ' + 'This view should not be used to retrieve data which will be returned external to boundary.'; + +commit;