Allow to configure the JWK using the encrypted key.

This commit is contained in:
Mariano Cano 2021-03-24 19:05:56 -07:00
parent e727532963
commit a9297100d8
6 changed files with 311 additions and 81 deletions

View file

@ -6,6 +6,7 @@ import (
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
)
@ -16,7 +17,7 @@ type stepIssuer interface {
}
// newStepIssuer returns the configured step issuer.
func newStepIssuer(caURL *url.URL, iss *apiv1.CertificateIssuer) (stepIssuer, error) {
func newStepIssuer(caURL *url.URL, client *ca.Client, iss *apiv1.CertificateIssuer) (stepIssuer, error) {
if err := validateCertificateIssuer(iss); err != nil {
return nil, err
}
@ -25,7 +26,7 @@ func newStepIssuer(caURL *url.URL, iss *apiv1.CertificateIssuer) (stepIssuer, er
case "x5c":
return newX5CIssuer(caURL, iss)
case "jwk":
return newJWKIssuer(caURL, iss)
return newJWKIssuer(caURL, client, iss)
default:
return nil, errors.Errorf("stepCAS `certificateIssuer.type` %s is not supported", iss.Type)
}
@ -65,11 +66,11 @@ func validateX5CIssuer(iss *apiv1.CertificateIssuer) error {
}
}
// validateJWKIssuer validates the configuration of jwk issuer.
// validateJWKIssuer validates the configuration of jwk issuer. If the key is
// not given, then it will download it from the CA. If the password is not given
// it will be asked.
func validateJWKIssuer(iss *apiv1.CertificateIssuer) error {
switch {
case iss.Key == "":
return errors.New("stepCAS `certificateIssuer.key` cannot be empty")
case iss.Provisioner == "":
return errors.New("stepCAS `certificateIssuer.provisioner` cannot be empty")
default:

View file

@ -6,7 +6,9 @@ import (
"testing"
"time"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/crypto/jose"
)
type mockErrIssuer struct{}
@ -23,14 +25,26 @@ func (m mockErrIssuer) Lifetime(d time.Duration) time.Duration {
return d
}
type mockErrSigner struct{}
func (s *mockErrSigner) Sign(payload []byte) (*jose.JSONWebSignature, error) {
return nil, apiv1.ErrNotImplemented{}
}
func (s *mockErrSigner) Options() jose.SignerOptions {
return jose.SignerOptions{}
}
func Test_newStepIssuer(t *testing.T) {
caURL, err := url.Parse("https://ca.smallstep.com")
caURL, client := testCAHelper(t)
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type args struct {
caURL *url.URL
client *ca.Client
iss *apiv1.CertificateIssuer
}
tests := []struct {
@ -39,7 +53,7 @@ func Test_newStepIssuer(t *testing.T) {
want stepIssuer
wantErr bool
}{
{"x5c", args{caURL, &apiv1.CertificateIssuer{
{"x5c", args{caURL, client, &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
@ -50,16 +64,16 @@ func Test_newStepIssuer(t *testing.T) {
keyFile: testX5CKeyPath,
issuer: "X5C",
}, false},
{"jwk", args{caURL, &apiv1.CertificateIssuer{
{"jwk", args{caURL, client, &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Key: testX5CKeyPath,
}}, &jwkIssuer{
caURL: caURL,
keyFile: testX5CKeyPath,
issuer: "ra@doe.org",
signer: signer,
}, false},
{"fail", args{caURL, &apiv1.CertificateIssuer{
{"fail", args{caURL, client, &apiv1.CertificateIssuer{
Type: "unknown",
Provisioner: "ra@doe.org",
Key: testX5CKeyPath,
@ -67,11 +81,14 @@ func Test_newStepIssuer(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := newStepIssuer(tt.args.caURL, tt.args.iss)
got, err := newStepIssuer(tt.args.caURL, tt.args.client, tt.args.iss)
if (err != nil) != tt.wantErr {
t.Errorf("newStepIssuer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.args.iss.Type == "jwk" && got != nil && tt.want != nil {
got.(*jwkIssuer).signer = tt.want.(*jwkIssuer).signer
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("newStepIssuer() = %v, want %v", got, tt.want)
}

View file

@ -1,11 +1,16 @@
package stepcas
import (
"crypto"
"encoding/json"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/cli-utils/ui"
"go.step.sm/crypto/jose"
"go.step.sm/crypto/randutil"
)
@ -13,21 +18,38 @@ import (
type jwkIssuer struct {
caURL *url.URL
issuer string
keyFile string
password string
signer jose.Signer
}
func newJWKIssuer(caURL *url.URL, cfg *apiv1.CertificateIssuer) (*jwkIssuer, error) {
_, err := newJWKSigner(cfg.Key, cfg.Password)
func newJWKIssuer(caURL *url.URL, client *ca.Client, cfg *apiv1.CertificateIssuer) (*jwkIssuer, error) {
var err error
var signer jose.Signer
// Read the key from the CA if not provided.
// Or read it from a PEM file.
if cfg.Key == "" {
p, err := findProvisioner(client, provisioner.TypeJWK, cfg.Provisioner)
if err != nil {
return nil, err
}
kid, key, ok := p.GetEncryptedKey()
if !ok {
return nil, errors.Errorf("provisioner with name %s does not have an encrypted key", cfg.Provisioner)
}
signer, err = newJWKSignerFromEncryptedKey(kid, key, cfg.Password)
if err != nil {
return nil, err
}
} else {
signer, err = newJWKSigner(cfg.Key, cfg.Password)
if err != nil {
return nil, err
}
}
return &jwkIssuer{
caURL: caURL,
issuer: cfg.Provisioner,
keyFile: cfg.Key,
password: cfg.Password,
signer: signer,
}, nil
}
@ -50,18 +72,13 @@ func (i *jwkIssuer) Lifetime(d time.Duration) time.Duration {
}
func (i *jwkIssuer) createToken(aud, sub string, sans []string) (string, error) {
signer, err := newJWKSigner(i.keyFile, i.password)
if err != nil {
return "", err
}
id, err := randutil.Hex(64) // 256 bits
if err != nil {
return "", err
}
claims := defaultClaims(i.issuer, sub, aud, id)
builder := jose.Signed(signer).Claims(claims)
builder := jose.Signed(i.signer).Claims(claims)
if len(sans) > 0 {
builder = builder.Claims(map[string]interface{}{
"sans": sans,
@ -90,3 +107,51 @@ func newJWKSigner(keyFile, password string) (jose.Signer, error) {
so.WithHeader("kid", kid)
return newJoseSigner(signer, so)
}
func newJWKSignerFromEncryptedKey(kid, key, password string) (jose.Signer, error) {
var jwk jose.JSONWebKey
// If the password is empty it will use the password prompter.
b, err := jose.Decrypt([]byte(key),
jose.WithPassword([]byte(password)),
jose.WithPasswordPrompter("Please enter the password to decrypt the provisioner key", func(msg string) ([]byte, error) {
return ui.PromptPassword(msg)
}))
if err != nil {
return nil, err
}
// Decrypt returns the JSON representation of the JWK.
if err := json.Unmarshal(b, &jwk); err != nil {
return nil, errors.Wrap(err, "error parsing provisioner key")
}
signer, ok := jwk.Key.(crypto.Signer)
if !ok {
return nil, errors.New("error parsing provisioner key: key is not a crypto.Signer")
}
so := new(jose.SignerOptions)
so.WithType("JWT")
so.WithHeader("kid", kid)
return newJoseSigner(signer, so)
}
func findProvisioner(client *ca.Client, typ provisioner.Type, name string) (provisioner.Interface, error) {
cursor := ""
for {
ps, err := client.Provisioners(ca.WithProvisionerCursor(cursor))
if err != nil {
return nil, err
}
for _, p := range ps.Provisioners {
if p.GetType() == typ && p.GetName() == name {
return p, nil
}
}
if ps.NextCursor == "" {
return nil, errors.Errorf("provisioner with name %s was not found", name)
}
cursor = ps.NextCursor
}
}

View file

@ -14,11 +14,15 @@ func Test_jwkIssuer_SignToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
keyFile string
issuer string
signer jose.Signer
}
type args struct {
subject string
@ -35,16 +39,15 @@ func Test_jwkIssuer_SignToken(t *testing.T) {
args args
wantErr bool
}{
{"ok", fields{caURL, testX5CKeyPath, "ra@doe.org"}, args{"doe", []string{"doe.org"}}, false},
{"fail key", fields{caURL, "", "ra@doe.org"}, args{"doe", []string{"doe.org"}}, true},
{"fail no signer", fields{caURL, testIssPath, "ra@doe.org"}, args{"doe", []string{"doe.org"}}, true},
{"ok", fields{caURL, "ra@doe.org", signer}, args{"doe", []string{"doe.org"}}, false},
{"fail", fields{caURL, "ra@doe.org", &mockErrSigner{}}, args{"doe", []string{"doe.org"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &jwkIssuer{
caURL: tt.fields.caURL,
keyFile: tt.fields.keyFile,
issuer: tt.fields.issuer,
signer: tt.fields.signer,
}
got, err := i.SignToken(tt.args.subject, tt.args.sans)
if (err != nil) != tt.wantErr {
@ -78,11 +81,15 @@ func Test_jwkIssuer_RevokeToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
keyFile string
issuer string
signer jose.Signer
}
type args struct {
subject string
@ -98,16 +105,15 @@ func Test_jwkIssuer_RevokeToken(t *testing.T) {
args args
wantErr bool
}{
{"ok", fields{caURL, testX5CKeyPath, "ra@smallstep.com"}, args{"doe"}, false},
{"fail key", fields{caURL, "", "ra@smallstep.com"}, args{"doe"}, true},
{"fail no signer", fields{caURL, testIssPath, "ra@smallstep.com"}, args{"doe"}, true},
{"ok", fields{caURL, "ra@doe.org", signer}, args{"doe"}, false},
{"ok", fields{caURL, "ra@doe.org", &mockErrSigner{}}, args{"doe"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &jwkIssuer{
caURL: tt.fields.caURL,
keyFile: tt.fields.keyFile,
issuer: tt.fields.issuer,
signer: tt.fields.signer,
}
got, err := i.RevokeToken(tt.args.subject)
if (err != nil) != tt.wantErr {
@ -140,11 +146,15 @@ func Test_jwkIssuer_Lifetime(t *testing.T) {
if err != nil {
t.Fatal(err)
}
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
keyFile string
issuer string
signer jose.Signer
}
type args struct {
d time.Duration
@ -155,14 +165,14 @@ func Test_jwkIssuer_Lifetime(t *testing.T) {
args args
want time.Duration
}{
{"ok", fields{caURL, testX5CKeyPath, "ra@smallstep.com"}, args{time.Second}, time.Second},
{"ok", fields{caURL, "ra@smallstep.com", signer}, args{time.Second}, time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &jwkIssuer{
caURL: tt.fields.caURL,
keyFile: tt.fields.keyFile,
issuer: tt.fields.issuer,
signer: tt.fields.signer,
}
if got := i.Lifetime(tt.args.d); got != tt.want {
t.Errorf("jwkIssuer.Lifetime() = %v, want %v", got, tt.want)
@ -170,3 +180,56 @@ func Test_jwkIssuer_Lifetime(t *testing.T) {
})
}
}
func Test_newJWKSignerFromEncryptedKey(t *testing.T) {
encrypt := func(plaintext string) string {
recipient := jose.Recipient{
Algorithm: jose.PBES2_HS256_A128KW,
Key: testPassword,
PBES2Count: jose.PBKDF2Iterations,
PBES2Salt: []byte{0x01, 0x02},
}
opts := new(jose.EncrypterOptions)
opts.WithContentType(jose.ContentType("jwk+json"))
encrypter, err := jose.NewEncrypter(jose.DefaultEncAlgorithm, recipient, opts)
if err != nil {
t.Fatal(err)
}
jwe, err := encrypter.Encrypt([]byte(plaintext))
if err != nil {
t.Fatal(err)
}
ret, err := jwe.CompactSerialize()
if err != nil {
t.Fatal(err)
}
return ret
}
type args struct {
kid string
key string
password string
}
tests := []struct {
name string
args args
wantErr bool
}{
{"ok", args{testKeyID, testEncryptedJWKKey, testPassword}, false},
{"fail decrypt", args{testKeyID, testEncryptedJWKKey, "bad-password"}, true},
{"fail unmarshal", args{testKeyID, encrypt(`{not a json}`), testPassword}, true},
{"fail not signer", args{testKeyID, encrypt(`{"kty":"oct","k":"password"}`), testPassword}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := newJWKSignerFromEncryptedKey(tt.args.kid, tt.args.key, tt.args.password)
if (err != nil) != tt.wantErr {
t.Errorf("newJWKSignerFromEncryptedKey() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View file

@ -41,14 +41,14 @@ func New(ctx context.Context, opts apiv1.Options) (*StepCAS, error) {
return nil, errors.Wrap(err, "stepCAS `certificateAuthority` is not valid")
}
// Create configured issuer
iss, err := newStepIssuer(caURL, opts.CertificateIssuer)
// Create client.
client, err := ca.NewClient(opts.CertificateAuthority, ca.WithRootSHA256(opts.CertificateAuthorityFingerprint))
if err != nil {
return nil, err
}
// Create client.
client, err := ca.NewClient(opts.CertificateAuthority, ca.WithRootSHA256(opts.CertificateAuthorityFingerprint))
// Create configured issuer
iss, err := newStepIssuer(caURL, client, opts.CertificateIssuer)
if err != nil {
return nil, err
}

View file

@ -21,8 +21,10 @@ import (
"time"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/crypto/jose"
"go.step.sm/crypto/pemutil"
"go.step.sm/crypto/randutil"
"go.step.sm/crypto/x509util"
@ -42,6 +44,7 @@ var (
testX5CKey crypto.Signer
testX5CPath, testX5CKeyPath string
testPassword, testEncryptedKeyPath string
testKeyID, testEncryptedJWKKey string
testCR *x509.CertificateRequest
testCrt *x509.Certificate
@ -157,6 +160,27 @@ func testCAHelper(t *testing.T) (*url.URL, *ca.Client) {
writeJSON(w, api.RevokeResponse{
Status: "ok",
})
case r.RequestURI == "/provisioners":
w.WriteHeader(http.StatusOK)
writeJSON(w, api.ProvisionersResponse{
NextCursor: "cursor",
Provisioners: []provisioner.Interface{
&provisioner.JWK{
Type: "JWK",
Name: "ra@doe.org",
Key: &jose.JSONWebKey{KeyID: testKeyID, Key: testX5CKey.Public()},
EncryptedKey: testEncryptedJWKKey,
},
&provisioner.JWK{
Type: "JWK",
Name: "empty@doe.org",
Key: &jose.JSONWebKey{KeyID: testKeyID, Key: testX5CKey.Public()},
},
},
})
case r.RequestURI == "/provisioners?cursor=cursor":
w.WriteHeader(http.StatusOK)
writeJSON(w, api.ProvisionersResponse{})
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"not found"}`)
@ -203,12 +227,16 @@ func testX5CIssuer(t *testing.T, caURL *url.URL, password string) *x5cIssuer {
func testJWKIssuer(t *testing.T, caURL *url.URL, password string) *jwkIssuer {
t.Helper()
key, givenPassword := testX5CKeyPath, password
client, err := ca.NewClient(caURL.String(), ca.WithTransport(http.DefaultTransport))
if err != nil {
t.Fatal(err)
}
key := testX5CKeyPath
if password != "" {
key = testEncryptedKeyPath
password = testPassword
}
jwk, err := newJWKIssuer(caURL, &apiv1.CertificateIssuer{
jwk, err := newJWKIssuer(caURL, client, &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Key: key,
@ -217,7 +245,7 @@ func testJWKIssuer(t *testing.T, caURL *url.URL, password string) *jwkIssuer {
if err != nil {
t.Fatal(err)
}
jwk.password = givenPassword
return jwk
}
@ -225,6 +253,7 @@ func TestMain(m *testing.M) {
testRootCrt, testRootKey = mustSignCertificate("Test Root Certificate", nil, x509util.DefaultRootTemplate, nil, nil)
testIssCrt, testIssKey = mustSignCertificate("Test Intermediate Certificate", nil, x509util.DefaultIntermediateTemplate, testRootCrt, testRootKey)
testX5CCrt, testX5CKey = mustSignCertificate("Test X5C Certificate", nil, x509util.DefaultLeafTemplate, testIssCrt, testIssKey)
testRootFingerprint = x509util.Fingerprint(testRootCrt)
// Final certificate.
var err error
@ -241,14 +270,27 @@ func TestMain(m *testing.M) {
panic(err)
}
// Password used to encrypto the key
// Password used to encrypt the key.
testPassword, err = randutil.Hex(32)
if err != nil {
panic(err)
}
testRootFingerprint = x509util.Fingerprint(testRootCrt)
// Encrypted JWK key used when the key is downloaded from the CA.
jwe, err := jose.EncryptJWK(&jose.JSONWebKey{Key: testX5CKey}, []byte(testPassword))
if err != nil {
panic(err)
}
testEncryptedJWKKey, err = jwe.CompactSerialize()
if err != nil {
panic(err)
}
testKeyID, err = jose.Thumbprint(&jose.JSONWebKey{Key: testX5CKey})
if err != nil {
panic(err)
}
// Create test files.
path, err := ioutil.TempDir(os.TempDir(), "stepcas")
if err != nil {
panic(err)
@ -301,6 +343,11 @@ func Test_init(t *testing.T) {
func TestNew(t *testing.T) {
caURL, client := testCAHelper(t)
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type args struct {
ctx context.Context
opts apiv1.Options
@ -341,8 +388,25 @@ func TestNew(t *testing.T) {
}}, &StepCAS{
iss: &jwkIssuer{
caURL: caURL,
keyFile: testX5CKeyPath,
issuer: "ra@doe.org",
signer: signer,
},
client: client,
fingerprint: testRootFingerprint,
}, false},
{"ok jwk provisioners", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Password: testPassword,
},
}}, &StepCAS{
iss: &jwkIssuer{
caURL: caURL,
issuer: "ra@doe.org",
signer: signer,
},
client: client,
fingerprint: testRootFingerprint,
@ -396,6 +460,33 @@ func TestNew(t *testing.T) {
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail provisioner not found", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "notfound@doe.org",
Password: testPassword,
},
}}, nil, true},
{"fail invalid password", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Password: "bad-password",
},
}}, nil, true},
{"fail no key", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "empty@doe.org",
Password: testPassword,
},
}}, nil, true},
{"fail certificate", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
@ -496,9 +587,12 @@ func TestNew(t *testing.T) {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
// We cannot compare client
// We cannot compare neither the client nor the signer.
if got != nil && tt.want != nil {
got.client = tt.want.client
if jwk, ok := got.iss.(*jwkIssuer); ok {
jwk.signer = signer
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("New() = %v, want %v", got, tt.want)
@ -514,7 +608,6 @@ func TestStepCAS_CreateCertificate(t *testing.T) {
x5cEnc := testX5CIssuer(t, caURL, testPassword)
jwkEnc := testJWKIssuer(t, caURL, testPassword)
x5cBad := testX5CIssuer(t, caURL, "bad-password")
jwkBad := testJWKIssuer(t, caURL, "bad-password")
type fields struct {
iss stepIssuer
@ -579,10 +672,6 @@ func TestStepCAS_CreateCertificate(t *testing.T) {
CSR: testCR,
Lifetime: time.Hour,
}}, nil, true},
{"fail jwk password", fields{jwkBad, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -658,7 +747,6 @@ func TestStepCAS_RevokeCertificate(t *testing.T) {
x5cEnc := testX5CIssuer(t, caURL, testPassword)
jwkEnc := testJWKIssuer(t, caURL, testPassword)
x5cBad := testX5CIssuer(t, caURL, "bad-password")
jwkBad := testJWKIssuer(t, caURL, "bad-password")
type fields struct {
iss stepIssuer
@ -729,10 +817,6 @@ func TestStepCAS_RevokeCertificate(t *testing.T) {
SerialNumber: "ok",
Certificate: nil,
}}, nil, true},
{"fail jwk password", fields{jwkBad, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: nil,
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {