feat(vault): Support Issuing SSH Cert Credentials

Allows the new credential library type to be pulled from the db and used
for issuing ssh certificate type credentials. The ssh cert library
interfaces with vault's ssh secrets engine to either issu certificates
directly from vault, or to generate them within boundary and have vault
sign them as a certificate authority for authentication.

See:
  https://developer.hashicorp.com/vault/api-docs/secret/ssh#sign-ssh-key
  https://developer.hashicorp.com/vault/api-docs/secret/ssh#generate-certificate-and-key
This commit is contained in:
Danielle authored and Timothy Messier committed 2023-02-02 17:43:16 +00:00
1 parent 823b8cdfc6
commit 3a78a74571
9 files changed
+865 -55

No files matched your search

+1
View File
@@ -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
+2
View File
@@ -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=
+7
View File
@@ -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
+439 -37
View File
@@ -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
}
@@ -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)
})
}
}
+6 -1
View File
@@ -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)},
}
+125 -16
View File
@@ -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.
@@ -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 (
@@ -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;