Merge branch 'master' into herman/scep-macos-renewal-fixes

pull/797/head
Herman Slatman 2 years ago
commit c7c5c3c94e
No known key found for this signature in database
GPG Key ID: F4D8A44EA0A75A4F

@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased - 0.18.1] - DATE
### Added
- Support for ACME revocation.
- Replace hash function with an RSA SSH CA to "rsa-sha2-256".
### Changed
### Deprecated
### Removed

@ -4,6 +4,7 @@ import (
"crypto"
"encoding/base64"
"encoding/json"
"time"
"go.step.sm/crypto/jose"
)
@ -11,11 +12,12 @@ import (
// Account is a subset of the internal account type containing only those
// attributes required for responses in the ACME protocol.
type Account struct {
ID string `json:"-"`
Key *jose.JSONWebKey `json:"-"`
Contact []string `json:"contact,omitempty"`
Status Status `json:"status"`
OrdersURL string `json:"orders"`
ID string `json:"-"`
Key *jose.JSONWebKey `json:"-"`
Contact []string `json:"contact,omitempty"`
Status Status `json:"status"`
OrdersURL string `json:"orders"`
ExternalAccountBinding interface{} `json:"externalAccountBinding,omitempty"`
}
// ToLog enables response logging.
@ -40,3 +42,32 @@ func KeyToID(jwk *jose.JSONWebKey) (string, error) {
}
return base64.RawURLEncoding.EncodeToString(kid), nil
}
// ExternalAccountKey is an ACME External Account Binding key.
type ExternalAccountKey struct {
ID string `json:"id"`
ProvisionerID string `json:"provisionerID"`
Reference string `json:"reference"`
AccountID string `json:"-"`
KeyBytes []byte `json:"-"`
CreatedAt time.Time `json:"createdAt"`
BoundAt time.Time `json:"boundAt,omitempty"`
}
// AlreadyBound returns whether this EAK is already bound to
// an ACME Account or not.
func (eak *ExternalAccountKey) AlreadyBound() bool {
return !eak.BoundAt.IsZero()
}
// BindTo binds the EAK to an Account.
// It returns an error if it's already bound.
func (eak *ExternalAccountKey) BindTo(account *Account) error {
if eak.AlreadyBound() {
return NewError(ErrorUnauthorizedType, "external account binding key with id '%s' was already bound to account '%s' on %s", eak.ID, eak.AccountID, eak.BoundAt)
}
eak.AccountID = account.ID
eak.BoundAt = time.Now()
eak.KeyBytes = []byte{} // clearing the key bytes; can only be used once
return nil
}

@ -4,6 +4,7 @@ import (
"crypto"
"encoding/base64"
"testing"
"time"
"github.com/pkg/errors"
"github.com/smallstep/assert"
@ -79,3 +80,67 @@ func TestAccount_IsValid(t *testing.T) {
})
}
}
func TestExternalAccountKey_BindTo(t *testing.T) {
boundAt := time.Now()
tests := []struct {
name string
eak *ExternalAccountKey
acct *Account
err *Error
}{
{
name: "ok",
eak: &ExternalAccountKey{
ID: "eakID",
ProvisionerID: "provID",
Reference: "ref",
KeyBytes: []byte{1, 3, 3, 7},
},
acct: &Account{
ID: "accountID",
},
err: nil,
},
{
name: "fail/already-bound",
eak: &ExternalAccountKey{
ID: "eakID",
ProvisionerID: "provID",
Reference: "ref",
KeyBytes: []byte{1, 3, 3, 7},
AccountID: "someAccountID",
BoundAt: boundAt,
},
acct: &Account{
ID: "accountID",
},
err: NewError(ErrorUnauthorizedType, "external account binding key with id '%s' was already bound to account '%s' on %s", "eakID", "someAccountID", boundAt),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eak := tt.eak
acct := tt.acct
err := eak.BindTo(acct)
wantErr := tt.err != nil
gotErr := err != nil
if wantErr != gotErr {
t.Errorf("ExternalAccountKey.BindTo() error = %v, wantErr %v", err, tt.err)
}
if wantErr {
assert.NotNil(t, err)
assert.Type(t, &Error{}, err)
ae, _ := err.(*Error)
assert.Equals(t, ae.Type, tt.err.Type)
assert.Equals(t, ae.Detail, tt.err.Detail)
assert.Equals(t, ae.Identifier, tt.err.Identifier)
assert.Equals(t, ae.Subproblems, tt.err.Subproblems)
} else {
assert.Equals(t, eak.AccountID, acct.ID)
assert.Equals(t, eak.KeyBytes, []byte{})
assert.NotNil(t, eak.BoundAt)
}
})
}
}

@ -12,9 +12,10 @@ import (
// NewAccountRequest represents the payload for a new account request.
type NewAccountRequest struct {
Contact []string `json:"contact"`
OnlyReturnExisting bool `json:"onlyReturnExisting"`
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
Contact []string `json:"contact"`
OnlyReturnExisting bool `json:"onlyReturnExisting"`
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
ExternalAccountBinding *ExternalAccountBinding `json:"externalAccountBinding,omitempty"`
}
func validateContacts(cs []string) error {
@ -83,8 +84,14 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
return
}
prov, err := acmeProvisionerFromContext(ctx)
if err != nil {
api.WriteError(w, err)
return
}
httpStatus := http.StatusCreated
acc, err := accountFromContext(r.Context())
acc, err := accountFromContext(ctx)
if err != nil {
acmeErr, ok := err.(*acme.Error)
if !ok || acmeErr.Status != http.StatusBadRequest {
@ -99,12 +106,19 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
"account does not exist"))
return
}
jwk, err := jwkFromContext(ctx)
if err != nil {
api.WriteError(w, err)
return
}
eak, err := h.validateExternalAccountBinding(ctx, &nar)
if err != nil {
api.WriteError(w, err)
return
}
acc = &acme.Account{
Key: jwk,
Contact: nar.Contact,
@ -114,8 +128,21 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
api.WriteError(w, acme.WrapErrorISE(err, "error creating account"))
return
}
if eak != nil { // means that we have a (valid) External Account Binding key that should be bound, updated and sent in the response
err := eak.BindTo(acc)
if err != nil {
api.WriteError(w, err)
return
}
if err := h.db.UpdateExternalAccountKey(ctx, prov.ID, eak); err != nil {
api.WriteError(w, acme.WrapErrorISE(err, "error updating external account binding key"))
return
}
acc.ExternalAccountBinding = nar.ExternalAccountBinding
}
} else {
// Account exists //
// Account exists
httpStatus = http.StatusOK
}

@ -12,6 +12,7 @@ import (
"time"
"github.com/go-chi/chi"
"github.com/pkg/errors"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/acme"
"github.com/smallstep/certificates/authority/provisioner"
@ -40,6 +41,66 @@ func newProv() acme.Provisioner {
return p
}
func newACMEProv(t *testing.T) *provisioner.ACME {
p := newProv()
a, ok := p.(*provisioner.ACME)
if !ok {
t.Fatal("not a valid ACME provisioner")
}
return a
}
func createEABJWS(jwk *jose.JSONWebKey, hmacKey []byte, keyID, u string) (*jose.JSONWebSignature, error) {
signer, err := jose.NewSigner(
jose.SigningKey{
Algorithm: jose.SignatureAlgorithm("HS256"),
Key: hmacKey,
},
&jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]interface{}{
"kid": keyID,
"url": u,
},
EmbedJWK: false,
},
)
if err != nil {
return nil, err
}
jwkJSONBytes, err := jwk.Public().MarshalJSON()
if err != nil {
return nil, err
}
jws, err := signer.Sign(jwkJSONBytes)
if err != nil {
return nil, err
}
raw, err := jws.CompactSerialize()
if err != nil {
return nil, err
}
parsedJWS, err := jose.ParseJWS(raw)
if err != nil {
return nil, err
}
return parsedJWS, nil
}
func createRawEABJWS(jwk *jose.JSONWebKey, hmacKey []byte, keyID, u string) ([]byte, error) {
jws, err := createEABJWS(jwk, hmacKey, keyID, u)
if err != nil {
return nil, err
}
rawJWS := jws.FullSerialize()
return []byte(rawJWS), nil
}
func TestNewAccountRequest_Validate(t *testing.T) {
type test struct {
nar *NewAccountRequest
@ -290,6 +351,7 @@ func TestHandler_NewAccount(t *testing.T) {
prov := newProv()
escProvName := url.PathEscape(prov.GetName())
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
provID := prov.GetID()
type test struct {
db acme.DB
@ -343,6 +405,7 @@ func TestHandler_NewAccount(t *testing.T) {
b, err := json.Marshal(nar)
assert.FatalError(t, err)
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx = context.WithValue(ctx, provisionerContextKey, prov)
return test{
ctx: ctx,
statusCode: 400,
@ -355,7 +418,8 @@ func TestHandler_NewAccount(t *testing.T) {
}
b, err := json.Marshal(nar)
assert.FatalError(t, err)
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: b})
return test{
ctx: ctx,
statusCode: 500,
@ -368,7 +432,8 @@ func TestHandler_NewAccount(t *testing.T) {
}
b, err := json.Marshal(nar)
assert.FatalError(t, err)
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: b})
ctx = context.WithValue(ctx, jwkContextKey, nil)
return test{
ctx: ctx,
@ -376,6 +441,27 @@ func TestHandler_NewAccount(t *testing.T) {
err: acme.NewErrorISE("jwk expected in request context"),
}
},
"fail/new-account-no-eab-provided": func(t *testing.T) test {
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
ExternalAccountBinding: nil,
}
b, err := json.Marshal(nar)
assert.FatalError(t, err)
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
assert.FatalError(t, err)
prov := newACMEProv(t)
prov.RequireEAB = true
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx = context.WithValue(ctx, jwkContextKey, jwk)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
ctx = context.WithValue(ctx, provisionerContextKey, prov)
return test{
ctx: ctx,
statusCode: 400,
err: acme.NewError(acme.ErrorExternalAccountRequiredType, "no external account binding provided"),
}
},
"fail/db.CreateAccount-error": func(t *testing.T) test {
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
@ -385,6 +471,7 @@ func TestHandler_NewAccount(t *testing.T) {
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
assert.FatalError(t, err)
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx = context.WithValue(ctx, provisionerContextKey, prov)
ctx = context.WithValue(ctx, jwkContextKey, jwk)
return test{
db: &acme.MockDB{
@ -399,6 +486,109 @@ func TestHandler_NewAccount(t *testing.T) {
err: acme.NewErrorISE("force"),
}
},
"fail/acmeProvisionerFromContext": func(t *testing.T) test {
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
assert.FatalError(t, err)
url := fmt.Sprintf("%s/acme/%s/account/new-account", baseURL.String(), escProvName)
rawEABJWS, err := createRawEABJWS(jwk, []byte{1, 3, 3, 7}, "eakID", url)
assert.FatalError(t, err)
eab := &ExternalAccountBinding{}
err = json.Unmarshal(rawEABJWS, &eab)
assert.FatalError(t, err)
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
ExternalAccountBinding: eab,
}
b, err := json.Marshal(nar)
assert.FatalError(t, err)
scepProvisioner := &provisioner.SCEP{
Type: "SCEP",
Name: "test@scep-<test>provisioner.com",
}
if err := scepProvisioner.Init(provisioner.Config{Claims: globalProvisionerClaims}); err != nil {
assert.FatalError(t, err)
}
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx = context.WithValue(ctx, jwkContextKey, jwk)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
ctx = context.WithValue(ctx, provisionerContextKey, scepProvisioner)
return test{
ctx: ctx,
statusCode: 500,
err: acme.NewError(acme.ErrorServerInternalType, "provisioner in context is not an ACME provisioner"),
}
},
"fail/db.UpdateExternalAccountKey-error": func(t *testing.T) test {
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
assert.FatalError(t, err)
url := fmt.Sprintf("%s/acme/%s/account/new-account", baseURL.String(), escProvName)
rawEABJWS, err := createRawEABJWS(jwk, []byte{1, 3, 3, 7}, "eakID", url)
assert.FatalError(t, err)
eab := &ExternalAccountBinding{}
err = json.Unmarshal(rawEABJWS, &eab)
assert.FatalError(t, err)
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
ExternalAccountBinding: eab,
}
payloadBytes, err := json.Marshal(nar)
assert.FatalError(t, err)
so := new(jose.SignerOptions)
so.WithHeader("alg", jose.SignatureAlgorithm(jwk.Algorithm))
so.WithHeader("url", url)
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.SignatureAlgorithm(jwk.Algorithm),
Key: jwk.Key,
}, so)
assert.FatalError(t, err)
jws, err := signer.Sign(payloadBytes)
assert.FatalError(t, err)
raw, err := jws.CompactSerialize()
assert.FatalError(t, err)
parsedJWS, err := jose.ParseJWS(raw)
assert.FatalError(t, err)
prov := newACMEProv(t)
prov.RequireEAB = true
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: payloadBytes})
ctx = context.WithValue(ctx, jwkContextKey, jwk)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
ctx = context.WithValue(ctx, provisionerContextKey, prov)
ctx = context.WithValue(ctx, jwsContextKey, parsedJWS)
eak := &acme.ExternalAccountKey{
ID: "eakID",
ProvisionerID: provID,
Reference: "testeak",
KeyBytes: []byte{1, 3, 3, 7},
CreatedAt: time.Now(),
}
return test{
db: &acme.MockDB{
MockCreateAccount: func(ctx context.Context, acc *acme.Account) error {
acc.ID = "accountID"
assert.Equals(t, acc.Contact, nar.Contact)
assert.Equals(t, acc.Key, jwk)
return nil
},
MockGetExternalAccountKey: func(ctx context.Context, provisionerName, keyID string) (*acme.ExternalAccountKey, error) {
return eak, nil
},
MockUpdateExternalAccountKey: func(ctx context.Context, provisionerName string, eak *acme.ExternalAccountKey) error {
return errors.New("force")
},
},
acc: &acme.Account{
ID: "accountID",
Key: jwk,
Status: acme.StatusValid,
Contact: []string{"foo", "bar"},
OrdersURL: fmt.Sprintf("%s/acme/%s/account/accountID/orders", baseURL.String(), escProvName),
ExternalAccountBinding: eab,
},
ctx: ctx,
statusCode: 500,
err: acme.NewError(acme.ErrorServerInternalType, "error updating external account binding key"),
}
},
"ok/new-account": func(t *testing.T) test {
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
@ -455,6 +645,116 @@ func TestHandler_NewAccount(t *testing.T) {
statusCode: 200,
}
},
"ok/new-account-no-eab-required": func(t *testing.T) test {
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
assert.FatalError(t, err)
url := fmt.Sprintf("%s/acme/%s/account/new-account", baseURL.String(), escProvName)
rawEABJWS, err := createRawEABJWS(jwk, []byte{1, 3, 3, 7}, "eakID", url)
assert.FatalError(t, err)
eab := &ExternalAccountBinding{}
err = json.Unmarshal(rawEABJWS, &eab)
assert.FatalError(t, err)
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
ExternalAccountBinding: eab,
}
b, err := json.Marshal(nar)
assert.FatalError(t, err)
prov := newACMEProv(t)
prov.RequireEAB = false
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
ctx = context.WithValue(ctx, jwkContextKey, jwk)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
ctx = context.WithValue(ctx, provisionerContextKey, prov)
return test{
db: &acme.MockDB{
MockCreateAccount: func(ctx context.Context, acc *acme.Account) error {
acc.ID = "accountID"
assert.Equals(t, acc.Contact, nar.Contact)
assert.Equals(t, acc.Key, jwk)
return nil
},
},
acc: &acme.Account{
ID: "accountID",
Key: jwk,
Status: acme.StatusValid,
Contact: []string{"foo", "bar"},
OrdersURL: fmt.Sprintf("%s/acme/%s/account/accountID/orders", baseURL.String(), escProvName),
},
ctx: ctx,
statusCode: 201,
}
},
"ok/new-account-with-eab": func(t *testing.T) test {
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
assert.FatalError(t, err)
url := fmt.Sprintf("%s/acme/%s/account/new-account", baseURL.String(), escProvName)
rawEABJWS, err := createRawEABJWS(jwk, []byte{1, 3, 3, 7}, "eakID", url)
assert.FatalError(t, err)
eab := &ExternalAccountBinding{}
err = json.Unmarshal(rawEABJWS, &eab)
assert.FatalError(t, err)
nar := &NewAccountRequest{
Contact: []string{"foo", "bar"},
ExternalAccountBinding: eab,
}
payloadBytes, err := json.Marshal(nar)
assert.FatalError(t, err)
so := new(jose.SignerOptions)
so.WithHeader("alg", jose.SignatureAlgorithm(jwk.Algorithm))
so.WithHeader("url", url)
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.SignatureAlgorithm(jwk.Algorithm),
Key: jwk.Key,
}, so)
assert.FatalError(t, err)
jws, err := signer.Sign(payloadBytes)
assert.FatalError(t, err)
raw, err := jws.CompactSerialize()
assert.FatalError(t, err)
parsedJWS, err := jose.ParseJWS(raw)
assert.FatalError(t, err)
prov := newACMEProv(t)
prov.RequireEAB = true
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: payloadBytes})
ctx = context.WithValue(ctx, jwkContextKey, jwk)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
ctx = context.WithValue(ctx, provisionerContextKey, prov)
ctx = context.WithValue(ctx, jwsContextKey, parsedJWS)
return test{
db: &acme.MockDB{
MockCreateAccount: func(ctx context.Context, acc *acme.Account) error {
acc.ID = "accountID"
assert.Equals(t, acc.Contact, nar.Contact)
assert.Equals(t, acc.Key, jwk)
return nil
},
MockGetExternalAccountKey: func(ctx context.Context, provisionerName, keyID string) (*acme.ExternalAccountKey, error) {
return &acme.ExternalAccountKey{
ID: "eakID",
ProvisionerID: provID,
Reference: "testeak",
KeyBytes: []byte{1, 3, 3, 7},
CreatedAt: time.Now(),
}, nil
},
MockUpdateExternalAccountKey: func(ctx context.Context, provisionerName string, eak *acme.ExternalAccountKey) error {
return nil
},
},
acc: &acme.Account{
ID: "accountID",
Key: jwk,
Status: acme.StatusValid,
Contact: []string{"foo", "bar"},
OrdersURL: fmt.Sprintf("%s/acme/%s/account/accountID/orders", baseURL.String(), escProvName),
ExternalAccountBinding: eab,
},
ctx: ctx,
statusCode: 201,
}
},
}
for name, run := range tests {
tc := run(t)

@ -0,0 +1,155 @@
package api
import (
"context"
"encoding/json"
"github.com/smallstep/certificates/acme"
"go.step.sm/crypto/jose"
)
// ExternalAccountBinding represents the ACME externalAccountBinding JWS
type ExternalAccountBinding struct {
Protected string `json:"protected"`
Payload string `json:"payload"`
Sig string `json:"signature"`
}
// validateExternalAccountBinding validates the externalAccountBinding property in a call to new-account.
func (h *Handler) validateExternalAccountBinding(ctx context.Context, nar *NewAccountRequest) (*acme.ExternalAccountKey, error) {
acmeProv, err := acmeProvisionerFromContext(ctx)
if err != nil {
return nil, acme.WrapErrorISE(err, "could not load ACME provisioner from context")
}
if !acmeProv.RequireEAB {
return nil, nil
}
if nar.ExternalAccountBinding == nil {
return nil, acme.NewError(acme.ErrorExternalAccountRequiredType, "no external account binding provided")
}
eabJSONBytes, err := json.Marshal(nar.ExternalAccountBinding)
if err != nil {
return nil, acme.WrapErrorISE(err, "error marshaling externalAccountBinding into bytes")
}
eabJWS, err := jose.ParseJWS(string(eabJSONBytes))
if err != nil {
return nil, acme.WrapErrorISE(err, "error parsing externalAccountBinding jws")
}
// TODO(hs): implement strategy pattern to allow for different ways of verification (i.e. webhook call) based on configuration?
keyID, acmeErr := validateEABJWS(ctx, eabJWS)
if acmeErr != nil {
return nil, acmeErr
}
externalAccountKey, err := h.db.GetExternalAccountKey(ctx, acmeProv.ID, keyID)
if err != nil {
if _, ok := err.(*acme.Error); ok {
return nil, acme.WrapError(acme.ErrorUnauthorizedType, err, "the field 'kid' references an unknown key")
}
return nil, acme.WrapErrorISE(err, "error retrieving external account key")
}
if externalAccountKey.AlreadyBound() {
return nil, acme.NewError(acme.ErrorUnauthorizedType, "external account binding key with id '%s' was already bound to account '%s' on %s", keyID, externalAccountKey.AccountID, externalAccountKey.BoundAt)
}
payload, err := eabJWS.Verify(externalAccountKey.KeyBytes)
if err != nil {
return nil, acme.WrapErrorISE(err, "error verifying externalAccountBinding signature")
}
jwk, err := jwkFromContext(ctx)
if err != nil {
return nil, err
}
var payloadJWK *jose.JSONWebKey
if err = json.Unmarshal(payload, &payloadJWK); err != nil {
return nil, acme.WrapError(acme.ErrorMalformedType, err, "error unmarshaling payload into jwk")
}
if !keysAreEqual(jwk, payloadJWK) {
return nil, acme.NewError(acme.ErrorUnauthorizedType, "keys in jws and eab payload do not match")
}
return externalAccountKey, nil
}
// keysAreEqual performs an equality check on two JWKs by comparing
// the (base64 encoding) of the Key IDs.
func keysAreEqual(x, y *jose.JSONWebKey) bool {
if x == nil || y == nil {
return false
}
digestX, errX := acme.KeyToID(x)
digestY, errY := acme.KeyToID(y)
if errX != nil || errY != nil {
return false
}
return digestX == digestY
}
// validateEABJWS verifies the contents of the External Account Binding JWS.
// The protected header of the JWS MUST meet the following criteria:
// o The "alg" field MUST indicate a MAC-based algorithm
// o The "kid" field MUST contain the key identifier provided by the CA
// o The "nonce" field MUST NOT be present
// o The "url" field MUST be set to the same value as the outer JWS
func validateEABJWS(ctx context.Context, jws *jose.JSONWebSignature) (string, *acme.Error) {
if jws == nil {
return "", acme.NewErrorISE("no JWS provided")
}
if len(jws.Signatures) != 1 {
return "", acme.NewError(acme.ErrorMalformedType, "JWS must have one signature")
}
header := jws.Signatures[0].Protected
algorithm := header.Algorithm
keyID := header.KeyID
nonce := header.Nonce
if !(algorithm == jose.HS256 || algorithm == jose.HS384 || algorithm == jose.HS512) {
return "", acme.NewError(acme.ErrorMalformedType, "'alg' field set to invalid algorithm '%s'", algorithm)
}
if keyID == "" {
return "", acme.NewError(acme.ErrorMalformedType, "'kid' field is required")
}
if nonce != "" {
return "", acme.NewError(acme.ErrorMalformedType, "'nonce' must not be present")
}
jwsURL, ok := header.ExtraHeaders["url"]
if !ok {
return "", acme.NewError(acme.ErrorMalformedType, "'url' field is required")
}
outerJWS, err := jwsFromContext(ctx)
if err != nil {
return "", acme.WrapErrorISE(err, "could not retrieve outer JWS from context")
}
if len(outerJWS.Signatures) != 1 {
return "", acme.NewError(acme.ErrorMalformedType, "outer JWS must have one signature")
}
outerJWSURL, ok := outerJWS.Signatures[0].Protected.ExtraHeaders["url"]
if !ok {
return "", acme.NewError(acme.ErrorMalformedType, "'url' field must be set in outer JWS")
}
if jwsURL != outerJWSURL {
return "", acme.NewError(acme.ErrorMalformedType, "'url' field is not the same value as the outer JWS")
}
return keyID, nil
}

File diff suppressed because it is too large Load Diff

@ -136,6 +136,13 @@ func (h *Handler) GetNonce(w http.ResponseWriter, r *http.Request) {
}
}
type Meta struct {
TermsOfService string `json:"termsOfService,omitempty"`
Website string `json:"website,omitempty"`
CaaIdentities []string `json:"caaIdentities,omitempty"`
ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"`
}
// Directory represents an ACME directory for configuring clients.
type Directory struct {
NewNonce string `json:"newNonce"`
@ -143,6 +150,7 @@ type Directory struct {
NewOrder string `json:"newOrder"`
RevokeCert string `json:"revokeCert"`
KeyChange string `json:"keyChange"`
Meta Meta `json:"meta"`
}
// ToLog enables response logging for the Directory type.
@ -158,12 +166,21 @@ func (d *Directory) ToLog() (interface{}, error) {
// for client configuration.
func (h *Handler) GetDirectory(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
acmeProv, err := acmeProvisionerFromContext(ctx)
if err != nil {
api.WriteError(w, err)
return
}
api.JSON(w, &Directory{
NewNonce: h.linker.GetLink(ctx, NewNonceLinkType),
NewAccount: h.linker.GetLink(ctx, NewAccountLinkType),
NewOrder: h.linker.GetLink(ctx, NewOrderLinkType),
RevokeCert: h.linker.GetLink(ctx, RevokeCertLinkType),
KeyChange: h.linker.GetLink(ctx, KeyChangeLinkType),
Meta: Meta{
ExternalAccountRequired: acmeProv.RequireEAB,
},
})
}

@ -15,9 +15,11 @@ import (
"time"
"github.com/go-chi/chi"
"github.com/google/go-cmp/cmp"
"github.com/pkg/errors"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/acme"
"github.com/smallstep/certificates/authority/provisioner"
"go.step.sm/crypto/jose"
"go.step.sm/crypto/pemutil"
)
@ -51,28 +53,76 @@ func TestHandler_GetNonce(t *testing.T) {
func TestHandler_GetDirectory(t *testing.T) {
linker := NewLinker("ca.smallstep.com", "acme")
prov := newProv()
provName := url.PathEscape(prov.GetName())
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
expDir := Directory{
NewNonce: fmt.Sprintf("%s/acme/%s/new-nonce", baseURL.String(), provName),
NewAccount: fmt.Sprintf("%s/acme/%s/new-account", baseURL.String(), provName),
NewOrder: fmt.Sprintf("%s/acme/%s/new-order", baseURL.String(), provName),
RevokeCert: fmt.Sprintf("%s/acme/%s/revoke-cert", baseURL.String(), provName),
KeyChange: fmt.Sprintf("%s/acme/%s/key-change", baseURL.String(), provName),
}
type test struct {
ctx context.Context
statusCode int
dir Directory
err *acme.Error
}
var tests = map[string]func(t *testing.T) test{
"fail/no-provisioner": func(t *testing.T) test {
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
ctx := context.WithValue(context.Background(), provisionerContextKey, nil)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
return test{
ctx: ctx,
statusCode: 500,
err: acme.NewErrorISE("provisioner in context is not an ACME provisioner"),
}
},
"fail/different-provisioner": func(t *testing.T) test {
prov := &provisioner.SCEP{
Type: "SCEP",
Name: "test@scep-<test>provisioner.com",
}
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
return test{
ctx: ctx,
statusCode: 500,
err: acme.NewErrorISE("provisioner in context is not an ACME provisioner"),
}
},
"ok": func(t *testing.T) test {
prov := newProv()
provName := url.PathEscape(prov.GetName())
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
expDir := Directory{
NewNonce: fmt.Sprintf("%s/acme/%s/new-nonce", baseURL.String(), provName),
NewAccount: fmt.Sprintf("%s/acme/%s/new-account", baseURL.String(), provName),
NewOrder: fmt.Sprintf("%s/acme/%s/new-order", baseURL.String(), provName),
RevokeCert: fmt.Sprintf("%s/acme/%s/revoke-cert", baseURL.String(), provName),
KeyChange: fmt.Sprintf("%s/acme/%s/key-change", baseURL.String(), provName),
}
return test{
ctx: ctx,
dir: expDir,
statusCode: 200,
}
},
"ok/eab-required": func(t *testing.T) test {
prov := newACMEProv(t)
prov.RequireEAB = true
provName := url.PathEscape(prov.GetName())
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
expDir := Directory{
NewNonce: fmt.Sprintf("%s/acme/%s/new-nonce", baseURL.String(), provName),
NewAccount: fmt.Sprintf("%s/acme/%s/new-account", baseURL.String(), provName),
NewOrder: fmt.Sprintf("%s/acme/%s/new-order", baseURL.String(), provName),
RevokeCert: fmt.Sprintf("%s/acme/%s/revoke-cert", baseURL.String(), provName),
KeyChange: fmt.Sprintf("%s/acme/%s/key-change", baseURL.String(), provName),
Meta: Meta{
ExternalAccountRequired: true,
},
}
return test{
ctx: ctx,
dir: expDir,
statusCode: 200,
}
},
@ -82,7 +132,7 @@ func TestHandler_GetDirectory(t *testing.T) {
t.Run(name, func(t *testing.T) {
h := &Handler{linker: linker}
req := httptest.NewRequest("GET", "/foo/bar", nil)
req = req.WithContext(ctx)
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.GetDirectory(w, req)
res := w.Result()
@ -105,7 +155,9 @@ func TestHandler_GetDirectory(t *testing.T) {
} else {
var dir Directory
json.Unmarshal(bytes.TrimSpace(body), &dir)
assert.Equals(t, dir, expDir)
if !cmp.Equal(tc.dir, dir) {
t.Errorf("GetDirectory() diff =\n%s", cmp.Diff(tc.dir, dir))
}
assert.Equals(t, res.Header["Content-Type"], []string{"application/json"})
}
})

@ -507,6 +507,20 @@ func provisionerFromContext(ctx context.Context) (acme.Provisioner, error) {
return pval, nil
}
// acmeProvisionerFromContext searches the context for an ACME provisioner. Returns
// pointer to an ACME provisioner or an error.
func acmeProvisionerFromContext(ctx context.Context) (*provisioner.ACME, error) {
prov, err := provisionerFromContext(ctx)
if err != nil {
return nil, err
}
acmeProv, ok := prov.(*provisioner.ACME)
if !ok || acmeProv == nil {
return nil, acme.NewErrorISE("provisioner in context is not an ACME provisioner")
}
return acmeProv, nil
}
// payloadFromContext searches the context for a payload. Returns the payload
// or an error.
func payloadFromContext(ctx context.Context) (*payloadInfo, error) {

@ -19,6 +19,13 @@ type DB interface {
GetAccountByKeyID(ctx context.Context, kid string) (*Account, error)
UpdateAccount(ctx context.Context, acc *Account) error
CreateExternalAccountKey(ctx context.Context, provisionerID, reference string) (*ExternalAccountKey, error)
GetExternalAccountKey(ctx context.Context, provisionerID, keyID string) (*ExternalAccountKey, error)
GetExternalAccountKeys(ctx context.Context, provisionerID, cursor string, limit int) ([]*ExternalAccountKey, string, error)
GetExternalAccountKeyByReference(ctx context.Context, provisionerID, reference string) (*ExternalAccountKey, error)
DeleteExternalAccountKey(ctx context.Context, provisionerID, keyID string) error
UpdateExternalAccountKey(ctx context.Context, provisionerID string, eak *ExternalAccountKey) error
CreateNonce(ctx context.Context) (Nonce, error)
DeleteNonce(ctx context.Context, nonce Nonce) error
@ -49,6 +56,13 @@ type MockDB struct {
MockGetAccountByKeyID func(ctx context.Context, kid string) (*Account, error)
MockUpdateAccount func(ctx context.Context, acc *Account) error
MockCreateExternalAccountKey func(ctx context.Context, provisionerID, reference string) (*ExternalAccountKey, error)
MockGetExternalAccountKey func(ctx context.Context, provisionerID, keyID string) (*ExternalAccountKey, error)
MockGetExternalAccountKeys func(ctx context.Context, provisionerID, cursor string, limit int) ([]*ExternalAccountKey, string, error)
MockGetExternalAccountKeyByReference func(ctx context.Context, provisionerID, reference string) (*ExternalAccountKey, error)
MockDeleteExternalAccountKey func(ctx context.Context, provisionerID, keyID string) error
MockUpdateExternalAccountKey func(ctx context.Context, provisionerID string, eak *ExternalAccountKey) error
MockCreateNonce func(ctx context.Context) (Nonce, error)
MockDeleteNonce func(ctx context.Context, nonce Nonce) error
@ -114,6 +128,66 @@ func (m *MockDB) UpdateAccount(ctx context.Context, acc *Account) error {
return m.MockError
}
// CreateExternalAccountKey mock
func (m *MockDB) CreateExternalAccountKey(ctx context.Context, provisionerID, reference string) (*ExternalAccountKey, error) {
if m.MockCreateExternalAccountKey != nil {
return m.MockCreateExternalAccountKey(ctx, provisionerID, reference)
} else if m.MockError != nil {
return nil, m.MockError
}
return m.MockRet1.(*ExternalAccountKey), m.MockError
}
// GetExternalAccountKey mock
func (m *MockDB) GetExternalAccountKey(ctx context.Context, provisionerID, keyID string) (*ExternalAccountKey, error) {
if m.MockGetExternalAccountKey != nil {
return m.MockGetExternalAccountKey(ctx, provisionerID, keyID)
} else if m.MockError != nil {
return nil, m.MockError
}
return m.MockRet1.(*ExternalAccountKey), m.MockError
}
// GetExternalAccountKeys mock
func (m *MockDB) GetExternalAccountKeys(ctx context.Context, provisionerID, cursor string, limit int) ([]*ExternalAccountKey, string, error) {
if m.MockGetExternalAccountKeys != nil {
return m.MockGetExternalAccountKeys(ctx, provisionerID, cursor, limit)
} else if m.MockError != nil {
return nil, "", m.MockError
}
return m.MockRet1.([]*ExternalAccountKey), "", m.MockError
}
// GetExternalAccountKeyByReference mock
func (m *MockDB) GetExternalAccountKeyByReference(ctx context.Context, provisionerID, reference string) (*ExternalAccountKey, error) {
if m.MockGetExternalAccountKeyByReference != nil {
return m.MockGetExternalAccountKeyByReference(ctx, provisionerID, reference)
} else if m.MockError != nil {
return nil, m.MockError
}
return m.MockRet1.(*ExternalAccountKey), m.MockError
}
// DeleteExternalAccountKey mock
func (m *MockDB) DeleteExternalAccountKey(ctx context.Context, provisionerID, keyID string) error {
if m.MockDeleteExternalAccountKey != nil {
return m.MockDeleteExternalAccountKey(ctx, provisionerID, keyID)
} else if m.MockError != nil {
return m.MockError
}
return m.MockError
}
// UpdateExternalAccountKey mock
func (m *MockDB) UpdateExternalAccountKey(ctx context.Context, provisionerID string, eak *ExternalAccountKey) error {
if m.MockUpdateExternalAccountKey != nil {
return m.MockUpdateExternalAccountKey(ctx, provisionerID, eak)
} else if m.MockError != nil {
return m.MockError
}
return m.MockError
}
// CreateNonce mock
func (m *MockDB) CreateNonce(ctx context.Context) (Nonce, error) {
if m.MockCreateNonce != nil {

@ -307,7 +307,7 @@ func TestDB_GetAccountByKeyID(t *testing.T) {
assert.Equals(t, string(key), accID)
return nil, errors.New("force")
default:
assert.FatalError(t, errors.Errorf("unrecognized bucket %s", string(bucket)))
assert.FatalError(t, errors.Errorf("unexpected bucket %s", string(bucket)))
return nil, errors.New("force")
}
},
@ -340,7 +340,7 @@ func TestDB_GetAccountByKeyID(t *testing.T) {
assert.Equals(t, string(key), accID)
return b, nil
default:
assert.FatalError(t, errors.Errorf("unrecognized bucket %s", string(bucket)))
assert.FatalError(t, errors.Errorf("unexpected bucket %s", string(bucket)))
return nil, errors.New("force")
}
},
@ -462,7 +462,7 @@ func TestDB_CreateAccount(t *testing.T) {
assert.True(t, dbacc.DeactivatedAt.IsZero())
return nil, false, errors.New("force")
default:
assert.FatalError(t, errors.Errorf("unrecognized bucket %s", string(bucket)))
assert.FatalError(t, errors.Errorf("unexpected bucket %s", string(bucket)))
return nil, false, errors.New("force")
}
},
@ -506,7 +506,7 @@ func TestDB_CreateAccount(t *testing.T) {
assert.True(t, dbacc.DeactivatedAt.IsZero())
return nu, true, nil
default:
assert.FatalError(t, errors.Errorf("unrecognized bucket %s", string(bucket)))
assert.FatalError(t, errors.Errorf("unexpected bucket %s", string(bucket)))
return nil, false, errors.New("force")
}
},

@ -0,0 +1,380 @@
package nosql
import (
"context"
"crypto/rand"
"encoding/json"
"sync"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/acme"
nosqlDB "github.com/smallstep/nosql"
)
// externalAccountKeyMutex for read/write locking of EAK operations.
var externalAccountKeyMutex sync.RWMutex
// referencesByProvisionerIndexMutex for locking referencesByProvisioner index operations.
var referencesByProvisionerIndexMutex sync.Mutex
type dbExternalAccountKey struct {
ID string `json:"id"`
ProvisionerID string `json:"provisionerID"`
Reference string `json:"reference"`
AccountID string `json:"accountID,omitempty"`
KeyBytes []byte `json:"key"`
CreatedAt time.Time `json:"createdAt"`
BoundAt time.Time `json:"boundAt"`
}
type dbExternalAccountKeyReference struct {
Reference string `json:"reference"`
ExternalAccountKeyID string `json:"externalAccountKeyID"`
}
// getDBExternalAccountKey retrieves and unmarshals dbExternalAccountKey.
func (db *DB) getDBExternalAccountKey(ctx context.Context, id string) (*dbExternalAccountKey, error) {
data, err := db.db.Get(externalAccountKeyTable, []byte(id))
if err != nil {
if nosqlDB.IsErrNotFound(err) {
return nil, acme.ErrNotFound
}
return nil, errors.Wrapf(err, "error loading external account key %s", id)
}
dbeak := new(dbExternalAccountKey)
if err = json.Unmarshal(data, dbeak); err != nil {
return nil, errors.Wrapf(err, "error unmarshaling external account key %s into dbExternalAccountKey", id)
}
return dbeak, nil
}
// CreateExternalAccountKey creates a new External Account Binding key with a name
func (db *DB) CreateExternalAccountKey(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) {
externalAccountKeyMutex.Lock()
defer externalAccountKeyMutex.Unlock()
keyID, err := randID()
if err != nil {
return nil, err
}
random := make([]byte, 32)
_, err = rand.Read(random)
if err != nil {
return nil, err
}
dbeak := &dbExternalAccountKey{
ID: keyID,
ProvisionerID: provisionerID,
Reference: reference,
KeyBytes: random,
CreatedAt: clock.Now(),
}
if err := db.save(ctx, keyID, dbeak, nil, "external_account_key", externalAccountKeyTable); err != nil {
return nil, err
}
if err := db.addEAKID(ctx, provisionerID, dbeak.ID); err != nil {
return nil, err
}
if dbeak.Reference != "" {
dbExternalAccountKeyReference := &dbExternalAccountKeyReference{
Reference: dbeak.Reference,
ExternalAccountKeyID: dbeak.ID,
}
if err := db.save(ctx, referenceKey(provisionerID, dbeak.Reference), dbExternalAccountKeyReference, nil, "external_account_key_reference", externalAccountKeyIDsByReferenceTable); err != nil {
return nil, err
}
}
return &acme.ExternalAccountKey{
ID: dbeak.ID,
ProvisionerID: dbeak.ProvisionerID,
Reference: dbeak.Reference,
AccountID: dbeak.AccountID,
KeyBytes: dbeak.KeyBytes,
CreatedAt: dbeak.CreatedAt,
BoundAt: dbeak.BoundAt,
}, nil
}
// GetExternalAccountKey retrieves an External Account Binding key by KeyID
func (db *DB) GetExternalAccountKey(ctx context.Context, provisionerID, keyID string) (*acme.ExternalAccountKey, error) {
externalAccountKeyMutex.RLock()
defer externalAccountKeyMutex.RUnlock()
dbeak, err := db.getDBExternalAccountKey(ctx, keyID)
if err != nil {
return nil, err
}
if dbeak.ProvisionerID != provisionerID {
return nil, acme.NewError(acme.ErrorUnauthorizedType, "provisioner does not match provisioner for which the EAB key was created")
}
return &acme.ExternalAccountKey{
ID: dbeak.ID,
ProvisionerID: dbeak.ProvisionerID,
Reference: dbeak.Reference,
AccountID: dbeak.AccountID,
KeyBytes: dbeak.KeyBytes,
CreatedAt: dbeak.CreatedAt,
BoundAt: dbeak.BoundAt,
}, nil
}
func (db *DB) DeleteExternalAccountKey(ctx context.Context, provisionerID, keyID string) error {
externalAccountKeyMutex.Lock()
defer externalAccountKeyMutex.Unlock()
dbeak, err := db.getDBExternalAccountKey(ctx, keyID)
if err != nil {
return errors.Wrapf(err, "error loading ACME EAB Key with Key ID %s", keyID)
}
if dbeak.ProvisionerID != provisionerID {
return errors.New("provisioner does not match provisioner for which the EAB key was created")
}
if dbeak.Reference != "" {
if err := db.db.Del(externalAccountKeyIDsByReferenceTable, []byte(referenceKey(provisionerID, dbeak.Reference))); err != nil {
return errors.Wrapf(err, "error deleting ACME EAB Key reference with Key ID %s and reference %s", keyID, dbeak.Reference)
}
}
if err := db.db.Del(externalAccountKeyTable, []byte(keyID)); err != nil {
return errors.Wrapf(err, "error deleting ACME EAB Key with Key ID %s", keyID)
}
if err := db.deleteEAKID(ctx, provisionerID, keyID); err != nil {
return errors.Wrapf(err, "error removing ACME EAB Key ID %s", keyID)
}
return nil
}
// GetExternalAccountKeys retrieves all External Account Binding keys for a provisioner
func (db *DB) GetExternalAccountKeys(ctx context.Context, provisionerID, cursor string, limit int) ([]*acme.ExternalAccountKey, string, error) {
externalAccountKeyMutex.RLock()
defer externalAccountKeyMutex.RUnlock()
// cursor and limit are ignored in open source, at least for now.
var eakIDs []string
r, err := db.db.Get(externalAccountKeyIDsByProvisionerIDTable, []byte(provisionerID))
if err != nil {
if !nosqlDB.IsErrNotFound(err) {
return nil, "", errors.Wrapf(err, "error loading ACME EAB Key IDs for provisioner %s", provisionerID)
}
// it may happen that no record is found; we'll continue with an empty slice
} else {
if err := json.Unmarshal(r, &eakIDs); err != nil {
return nil, "", errors.Wrapf(err, "error unmarshaling ACME EAB Key IDs for provisioner %s", provisionerID)
}
}
keys := []*acme.ExternalAccountKey{}
for _, eakID := range eakIDs {
if eakID == "" {
continue // shouldn't happen; just in case
}
eak, err := db.getDBExternalAccountKey(ctx, eakID)
if err != nil {
if !nosqlDB.IsErrNotFound(err) {
return nil, "", errors.Wrapf(err, "error retrieving ACME EAB Key for provisioner %s and keyID %s", provisionerID, eakID)
}
}
keys = append(keys, &acme.ExternalAccountKey{
ID: eak.ID,
KeyBytes: eak.KeyBytes,
ProvisionerID: eak.ProvisionerID,
Reference: eak.Reference,
AccountID: eak.AccountID,
CreatedAt: eak.CreatedAt,
BoundAt: eak.BoundAt,
})
}
return keys, "", nil
}
// GetExternalAccountKeyByReference retrieves an External Account Binding key with unique reference
func (db *DB) GetExternalAccountKeyByReference(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) {
externalAccountKeyMutex.RLock()
defer externalAccountKeyMutex.RUnlock()
if reference == "" {
return nil, nil
}
k, err := db.db.Get(externalAccountKeyIDsByReferenceTable, []byte(referenceKey(provisionerID, reference)))
if nosqlDB.IsErrNotFound(err) {
return nil, acme.ErrNotFound
} else if err != nil {
return nil, errors.Wrapf(err, "error loading ACME EAB key for reference %s", reference)
}
dbExternalAccountKeyReference := new(dbExternalAccountKeyReference)
if err := json.Unmarshal(k, dbExternalAccountKeyReference); err != nil {
return nil, errors.Wrapf(err, "error unmarshaling ACME EAB key for reference %s", reference)
}
return db.GetExternalAccountKey(ctx, provisionerID, dbExternalAccountKeyReference.ExternalAccountKeyID)
}
func (db *DB) UpdateExternalAccountKey(ctx context.Context, provisionerID string, eak *acme.ExternalAccountKey) error {
externalAccountKeyMutex.Lock()
defer externalAccountKeyMutex.Unlock()
old, err := db.getDBExternalAccountKey(ctx, eak.ID)
if err != nil {
return err
}
if old.ProvisionerID != provisionerID {
return errors.New("provisioner does not match provisioner for which the EAB key was created")
}
if old.ProvisionerID != eak.ProvisionerID {
return errors.New("cannot change provisioner for an existing ACME EAB Key")
}
if old.Reference != eak.Reference {
return errors.New("cannot change reference for an existing ACME EAB Key")
}
nu := dbExternalAccountKey{
ID: eak.ID,
ProvisionerID: eak.ProvisionerID,
Reference: eak.Reference,
AccountID: eak.AccountID,
KeyBytes: eak.KeyBytes,
CreatedAt: eak.CreatedAt,
BoundAt: eak.BoundAt,
}
return db.save(ctx, nu.ID, nu, old, "external_account_key", externalAccountKeyTable)
}
func (db *DB) addEAKID(ctx context.Context, provisionerID, eakID string) error {
referencesByProvisionerIndexMutex.Lock()
defer referencesByProvisionerIndexMutex.Unlock()
if eakID == "" {
return errors.Errorf("can't add empty eakID for provisioner %s", provisionerID)
}
var eakIDs []string
b, err := db.db.Get(externalAccountKeyIDsByProvisionerIDTable, []byte(provisionerID))
if err != nil {
if !nosqlDB.IsErrNotFound(err) {
return errors.Wrapf(err, "error loading eakIDs for provisioner %s", provisionerID)
}
// it may happen that no record is found; we'll continue with an empty slice
} else {
if err := json.Unmarshal(b, &eakIDs); err != nil {
return errors.Wrapf(err, "error unmarshaling eakIDs for provisioner %s", provisionerID)
}
}
for _, id := range eakIDs {
if id == eakID {
// return an error when a duplicate ID is found
return errors.Errorf("eakID %s already exists for provisioner %s", eakID, provisionerID)
}
}
var newEAKIDs []string
newEAKIDs = append(newEAKIDs, eakIDs...)
newEAKIDs = append(newEAKIDs, eakID)
var (
_old interface{} = eakIDs
_new interface{} = newEAKIDs
)
// ensure that the DB gets the expected value when the slice is empty; otherwise
// it'll return with an error that indicates that the DBs view of the data is
// different from the last read (i.e. _old is different from what the DB has).
if len(eakIDs) == 0 {
_old = nil
}
if err = db.save(ctx, provisionerID, _new, _old, "externalAccountKeyIDsByProvisionerID", externalAccountKeyIDsByProvisionerIDTable); err != nil {
return errors.Wrapf(err, "error saving eakIDs index for provisioner %s", provisionerID)
}
return nil
}
func (db *DB) deleteEAKID(ctx context.Context, provisionerID, eakID string) error {
referencesByProvisionerIndexMutex.Lock()
defer referencesByProvisionerIndexMutex.Unlock()
var eakIDs []string
b, err := db.db.Get(externalAccountKeyIDsByProvisionerIDTable, []byte(provisionerID))
if err != nil {
if !nosqlDB.IsErrNotFound(err) {
return errors.Wrapf(err, "error loading eakIDs for provisioner %s", provisionerID)
}
// it may happen that no record is found; we'll continue with an empty slice
} else {
if err := json.Unmarshal(b, &eakIDs); err != nil {
return errors.Wrapf(err, "error unmarshaling eakIDs for provisioner %s", provisionerID)
}
}
newEAKIDs := removeElement(eakIDs, eakID)
var (
_old interface{} = eakIDs
_new interface{} = newEAKIDs
)
// ensure that the DB gets the expected value when the slice is empty; otherwise
// it'll return with an error that indicates that the DBs view of the data is
// different from the last read (i.e. _old is different from what the DB has).
if len(eakIDs) == 0 {
_old = nil
}
if err = db.save(ctx, provisionerID, _new, _old, "externalAccountKeyIDsByProvisionerID", externalAccountKeyIDsByProvisionerIDTable); err != nil {
return errors.Wrapf(err, "error saving eakIDs index for provisioner %s", provisionerID)
}
return nil
}
// referenceKey returns a unique key for a reference per provisioner
func referenceKey(provisionerID, reference string) string {
return provisionerID + "." + reference
}
// sliceIndex finds the index of item in slice
func sliceIndex(slice []string, item string) int {
for i := range slice {
if slice[i] == item {
return i
}
}
return -1
}
// removeElement deletes the item if it exists in the
// slice. It returns a new slice, keeping the old one intact.
func removeElement(slice []string, item string) []string {
newSlice := make([]string, 0)
index := sliceIndex(slice, item)
if index < 0 {
newSlice = append(newSlice, slice...)
return newSlice
}
newSlice = append(newSlice, slice[:index]...)
return append(newSlice, slice[index+1:]...)
}

File diff suppressed because it is too large Load Diff

@ -11,15 +11,18 @@ import (
)
var (
accountTable = []byte("acme_accounts")
accountByKeyIDTable = []byte("acme_keyID_accountID_index")
authzTable = []byte("acme_authzs")
challengeTable = []byte("acme_challenges")
nonceTable = []byte("nonces")
orderTable = []byte("acme_orders")
ordersByAccountIDTable = []byte("acme_account_orders_index")
certTable = []byte("acme_certs")
certBySerialTable = []byte("acme_serial_certs_index")
accountTable = []byte("acme_accounts")
accountByKeyIDTable = []byte("acme_keyID_accountID_index")
authzTable = []byte("acme_authzs")
challengeTable = []byte("acme_challenges")
nonceTable = []byte("nonces")
orderTable = []byte("acme_orders")
ordersByAccountIDTable = []byte("acme_account_orders_index")
certTable = []byte("acme_certs")
certBySerialTable = []byte("acme_serial_certs_index")
externalAccountKeyTable = []byte("acme_external_account_keys")
externalAccountKeyIDsByReferenceTable = []byte("acme_external_account_keyID_reference_index")
externalAccountKeyIDsByProvisionerIDTable = []byte("acme_external_account_keyID_provisionerID_index")
)
// DB is a struct that implements the AcmeDB interface.
@ -30,7 +33,10 @@ type DB struct {
// New configures and returns a new ACME DB backend implemented using a nosql DB.
func New(db nosqlDB.DB) (*DB, error) {
tables := [][]byte{accountTable, accountByKeyIDTable, authzTable,
challengeTable, nonceTable, orderTable, ordersByAccountIDTable, certTable, certBySerialTable}
challengeTable, nonceTable, orderTable, ordersByAccountIDTable,
certTable, certBySerialTable, externalAccountKeyTable,
externalAccountKeyIDsByReferenceTable, externalAccountKeyIDsByProvisionerIDTable,
}
for _, b := range tables {
if err := db.CreateTable(b); err != nil {
return nil, errors.Wrapf(err, "error creating table %s",

@ -167,6 +167,208 @@ func parseCertificateRequest(data string) *x509.CertificateRequest {
return csr
}
type mockAuthority struct {
ret1, ret2 interface{}
err error
authorizeSign func(ott string) ([]provisioner.SignOption, error)
getTLSOptions func() *authority.TLSOptions
root func(shasum string) (*x509.Certificate, error)
sign func(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error)
renew func(cert *x509.Certificate) ([]*x509.Certificate, error)
rekey func(oldCert *x509.Certificate, pk crypto.PublicKey) ([]*x509.Certificate, error)
loadProvisionerByCertificate func(cert *x509.Certificate) (provisioner.Interface, error)
loadProvisionerByName func(name string) (provisioner.Interface, error)
getProvisioners func(nextCursor string, limit int) (provisioner.List, string, error)
revoke func(context.Context, *authority.RevokeOptions) error
getEncryptedKey func(kid string) (string, error)
getRoots func() ([]*x509.Certificate, error)
getFederation func() ([]*x509.Certificate, error)
signSSH func(ctx context.Context, key ssh.PublicKey, opts provisioner.SignSSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error)
signSSHAddUser func(ctx context.Context, key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error)
renewSSH func(ctx context.Context, cert *ssh.Certificate) (*ssh.Certificate, error)
rekeySSH func(ctx context.Context, cert *ssh.Certificate, key ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error)
getSSHHosts func(ctx context.Context, cert *x509.Certificate) ([]authority.Host, error)
getSSHRoots func(ctx context.Context) (*authority.SSHKeys, error)
getSSHFederation func(ctx context.Context) (*authority.SSHKeys, error)
getSSHConfig func(ctx context.Context, typ string, data map[string]string) ([]templates.Output, error)
checkSSHHost func(ctx context.Context, principal, token string) (bool, error)
getSSHBastion func(ctx context.Context, user string, hostname string) (*authority.Bastion, error)
version func() authority.Version
}
// TODO: remove once Authorize is deprecated.
func (m *mockAuthority) Authorize(ctx context.Context, ott string) ([]provisioner.SignOption, error) {
return m.AuthorizeSign(ott)
}
func (m *mockAuthority) AuthorizeSign(ott string) ([]provisioner.SignOption, error) {
if m.authorizeSign != nil {
return m.authorizeSign(ott)
}
return m.ret1.([]provisioner.SignOption), m.err
}
func (m *mockAuthority) GetTLSOptions() *authority.TLSOptions {
if m.getTLSOptions != nil {
return m.getTLSOptions()
}
return m.ret1.(*authority.TLSOptions)
}
func (m *mockAuthority) Root(shasum string) (*x509.Certificate, error) {
if m.root != nil {
return m.root(shasum)
}
return m.ret1.(*x509.Certificate), m.err
}
func (m *mockAuthority) Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) {
if m.sign != nil {
return m.sign(cr, opts, signOpts...)
}
return []*x509.Certificate{m.ret1.(*x509.Certificate), m.ret2.(*x509.Certificate)}, m.err
}
func (m *mockAuthority) Renew(cert *x509.Certificate) ([]*x509.Certificate, error) {
if m.renew != nil {
return m.renew(cert)
}
return []*x509.Certificate{m.ret1.(*x509.Certificate), m.ret2.(*x509.Certificate)}, m.err
}
func (m *mockAuthority) Rekey(oldcert *x509.Certificate, pk crypto.PublicKey) ([]*x509.Certificate, error) {
if m.rekey != nil {
return m.rekey(oldcert, pk)
}
return []*x509.Certificate{m.ret1.(*x509.Certificate), m.ret2.(*x509.Certificate)}, m.err
}
func (m *mockAuthority) GetProvisioners(nextCursor string, limit int) (provisioner.List, string, error) {
if m.getProvisioners != nil {
return m.getProvisioners(nextCursor, limit)
}
return m.ret1.(provisioner.List), m.ret2.(string), m.err
}
func (m *mockAuthority) LoadProvisionerByCertificate(cert *x509.Certificate) (provisioner.Interface, error) {
if m.loadProvisionerByCertificate != nil {
return m.loadProvisionerByCertificate(cert)
}
return m.ret1.(provisioner.Interface), m.err
}
func (m *mockAuthority) LoadProvisionerByName(name string) (provisioner.Interface, error) {
if m.loadProvisionerByName != nil {
return m.loadProvisionerByName(name)
}
return m.ret1.(provisioner.Interface), m.err
}
func (m *mockAuthority) Revoke(ctx context.Context, opts *authority.RevokeOptions) error {
if m.revoke != nil {
return m.revoke(ctx, opts)
}
return m.err
}
func (m *mockAuthority) GetEncryptedKey(kid string) (string, error) {
if m.getEncryptedKey != nil {
return m.getEncryptedKey(kid)
}
return m.ret1.(string), m.err
}
func (m *mockAuthority) GetRoots() ([]*x509.Certificate, error) {
if m.getRoots != nil {
return m.getRoots()
}
return m.ret1.([]*x509.Certificate), m.err
}
func (m *mockAuthority) GetFederation() ([]*x509.Certificate, error) {
if m.getFederation != nil {
return m.getFederation()
}
return m.ret1.([]*x509.Certificate), m.err
}
func (m *mockAuthority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisioner.SignSSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) {
if m.signSSH != nil {
return m.signSSH(ctx, key, opts, signOpts...)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) SignSSHAddUser(ctx context.Context, key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error) {
if m.signSSHAddUser != nil {
return m.signSSHAddUser(ctx, key, cert)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) RenewSSH(ctx context.Context, cert *ssh.Certificate) (*ssh.Certificate, error) {
if m.renewSSH != nil {
return m.renewSSH(ctx, cert)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) RekeySSH(ctx context.Context, cert *ssh.Certificate, key ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) {
if m.rekeySSH != nil {
return m.rekeySSH(ctx, cert, key, signOpts...)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([]authority.Host, error) {
if m.getSSHHosts != nil {
return m.getSSHHosts(ctx, cert)
}
return m.ret1.([]authority.Host), m.err
}
func (m *mockAuthority) GetSSHRoots(ctx context.Context) (*authority.SSHKeys, error) {
if m.getSSHRoots != nil {
return m.getSSHRoots(ctx)
}
return m.ret1.(*authority.SSHKeys), m.err
}
func (m *mockAuthority) GetSSHFederation(ctx context.Context) (*authority.SSHKeys, error) {
if m.getSSHFederation != nil {
return m.getSSHFederation(ctx)
}
return m.ret1.(*authority.SSHKeys), m.err
}
func (m *mockAuthority) GetSSHConfig(ctx context.Context, typ string, data map[string]string) ([]templates.Output, error) {
if m.getSSHConfig != nil {
return m.getSSHConfig(ctx, typ, data)
}
return m.ret1.([]templates.Output), m.err
}
func (m *mockAuthority) CheckSSHHost(ctx context.Context, principal, token string) (bool, error) {
if m.checkSSHHost != nil {
return m.checkSSHHost(ctx, principal, token)
}
return m.ret1.(bool), m.err
}
func (m *mockAuthority) GetSSHBastion(ctx context.Context, user, hostname string) (*authority.Bastion, error) {
if m.getSSHBastion != nil {
return m.getSSHBastion(ctx, user, hostname)
}
return m.ret1.(*authority.Bastion), m.err
}
func (m *mockAuthority) Version() authority.Version {
if m.version != nil {
return m.version()
}
return m.ret1.(authority.Version)
}
func TestNewCertificate(t *testing.T) {
cert := parseCertificate(rootPEM)
if !reflect.DeepEqual(Certificate{Certificate: cert}, NewCertificate(cert)) {
@ -551,208 +753,6 @@ func (m *mockProvisioner) AuthorizeSSHRekey(ctx context.Context, token string) (
return m.ret1.(*ssh.Certificate), m.ret2.([]provisioner.SignOption), m.err
}
type mockAuthority struct {
ret1, ret2 interface{}
err error
authorizeSign func(ott string) ([]provisioner.SignOption, error)
getTLSOptions func() *authority.TLSOptions
root func(shasum string) (*x509.Certificate, error)
sign func(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error)
renew func(cert *x509.Certificate) ([]*x509.Certificate, error)
rekey func(oldCert *x509.Certificate, pk crypto.PublicKey) ([]*x509.Certificate, error)
loadProvisionerByCertificate func(cert *x509.Certificate) (provisioner.Interface, error)
loadProvisionerByName func(name string) (provisioner.Interface, error)
getProvisioners func(nextCursor string, limit int) (provisioner.List, string, error)
revoke func(context.Context, *authority.RevokeOptions) error
getEncryptedKey func(kid string) (string, error)
getRoots func() ([]*x509.Certificate, error)
getFederation func() ([]*x509.Certificate, error)
signSSH func(ctx context.Context, key ssh.PublicKey, opts provisioner.SignSSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error)
signSSHAddUser func(ctx context.Context, key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error)
renewSSH func(ctx context.Context, cert *ssh.Certificate) (*ssh.Certificate, error)
rekeySSH func(ctx context.Context, cert *ssh.Certificate, key ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error)
getSSHHosts func(ctx context.Context, cert *x509.Certificate) ([]authority.Host, error)
getSSHRoots func(ctx context.Context) (*authority.SSHKeys, error)
getSSHFederation func(ctx context.Context) (*authority.SSHKeys, error)
getSSHConfig func(ctx context.Context, typ string, data map[string]string) ([]templates.Output, error)
checkSSHHost func(ctx context.Context, principal, token string) (bool, error)
getSSHBastion func(ctx context.Context, user string, hostname string) (*authority.Bastion, error)
version func() authority.Version
}
// TODO: remove once Authorize is deprecated.
func (m *mockAuthority) Authorize(ctx context.Context, ott string) ([]provisioner.SignOption, error) {
return m.AuthorizeSign(ott)
}
func (m *mockAuthority) AuthorizeSign(ott string) ([]provisioner.SignOption, error) {
if m.authorizeSign != nil {
return m.authorizeSign(ott)
}
return m.ret1.([]provisioner.SignOption), m.err
}
func (m *mockAuthority) GetTLSOptions() *authority.TLSOptions {
if m.getTLSOptions != nil {
return m.getTLSOptions()
}
return m.ret1.(*authority.TLSOptions)
}
func (m *mockAuthority) Root(shasum string) (*x509.Certificate, error) {
if m.root != nil {
return m.root(shasum)
}
return m.ret1.(*x509.Certificate), m.err
}
func (m *mockAuthority) Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) {
if m.sign != nil {
return m.sign(cr, opts, signOpts...)
}
return []*x509.Certificate{m.ret1.(*x509.Certificate), m.ret2.(*x509.Certificate)}, m.err
}
func (m *mockAuthority) Renew(cert *x509.Certificate) ([]*x509.Certificate, error) {
if m.renew != nil {
return m.renew(cert)
}
return []*x509.Certificate{m.ret1.(*x509.Certificate), m.ret2.(*x509.Certificate)}, m.err
}
func (m *mockAuthority) Rekey(oldcert *x509.Certificate, pk crypto.PublicKey) ([]*x509.Certificate, error) {
if m.rekey != nil {
return m.rekey(oldcert, pk)
}
return []*x509.Certificate{m.ret1.(*x509.Certificate), m.ret2.(*x509.Certificate)}, m.err
}
func (m *mockAuthority) GetProvisioners(nextCursor string, limit int) (provisioner.List, string, error) {
if m.getProvisioners != nil {
return m.getProvisioners(nextCursor, limit)
}
return m.ret1.(provisioner.List), m.ret2.(string), m.err
}
func (m *mockAuthority) LoadProvisionerByCertificate(cert *x509.Certificate) (provisioner.Interface, error) {
if m.loadProvisionerByCertificate != nil {
return m.loadProvisionerByCertificate(cert)
}
return m.ret1.(provisioner.Interface), m.err
}
func (m *mockAuthority) LoadProvisionerByName(name string) (provisioner.Interface, error) {
if m.loadProvisionerByName != nil {
return m.loadProvisionerByName(name)
}
return m.ret1.(provisioner.Interface), m.err
}
func (m *mockAuthority) Revoke(ctx context.Context, opts *authority.RevokeOptions) error {
if m.revoke != nil {
return m.revoke(ctx, opts)
}
return m.err
}
func (m *mockAuthority) GetEncryptedKey(kid string) (string, error) {
if m.getEncryptedKey != nil {
return m.getEncryptedKey(kid)
}
return m.ret1.(string), m.err
}
func (m *mockAuthority) GetRoots() ([]*x509.Certificate, error) {
if m.getRoots != nil {
return m.getRoots()
}
return m.ret1.([]*x509.Certificate), m.err
}
func (m *mockAuthority) GetFederation() ([]*x509.Certificate, error) {
if m.getFederation != nil {
return m.getFederation()
}
return m.ret1.([]*x509.Certificate), m.err
}
func (m *mockAuthority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisioner.SignSSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) {
if m.signSSH != nil {
return m.signSSH(ctx, key, opts, signOpts...)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) SignSSHAddUser(ctx context.Context, key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error) {
if m.signSSHAddUser != nil {
return m.signSSHAddUser(ctx, key, cert)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) RenewSSH(ctx context.Context, cert *ssh.Certificate) (*ssh.Certificate, error) {
if m.renewSSH != nil {
return m.renewSSH(ctx, cert)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) RekeySSH(ctx context.Context, cert *ssh.Certificate, key ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) {
if m.rekeySSH != nil {
return m.rekeySSH(ctx, cert, key, signOpts...)
}
return m.ret1.(*ssh.Certificate), m.err
}
func (m *mockAuthority) GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([]authority.Host, error) {
if m.getSSHHosts != nil {
return m.getSSHHosts(ctx, cert)
}
return m.ret1.([]authority.Host), m.err
}
func (m *mockAuthority) GetSSHRoots(ctx context.Context) (*authority.SSHKeys, error) {
if m.getSSHRoots != nil {
return m.getSSHRoots(ctx)
}
return m.ret1.(*authority.SSHKeys), m.err
}
func (m *mockAuthority) GetSSHFederation(ctx context.Context) (*authority.SSHKeys, error) {
if m.getSSHFederation != nil {
return m.getSSHFederation(ctx)
}
return m.ret1.(*authority.SSHKeys), m.err
}
func (m *mockAuthority) GetSSHConfig(ctx context.Context, typ string, data map[string]string) ([]templates.Output, error) {
if m.getSSHConfig != nil {
return m.getSSHConfig(ctx, typ, data)
}
return m.ret1.([]templates.Output), m.err
}
func (m *mockAuthority) CheckSSHHost(ctx context.Context, principal, token string) (bool, error) {
if m.checkSSHHost != nil {
return m.checkSSHHost(ctx, principal, token)
}
return m.ret1.(bool), m.err
}
func (m *mockAuthority) GetSSHBastion(ctx context.Context, user, hostname string) (*authority.Bastion, error) {
if m.getSSHBastion != nil {
return m.getSSHBastion(ctx, user, hostname)
}
return m.ret1.(*authority.Bastion), m.err
}
func (m *mockAuthority) Version() authority.Version {
if m.version != nil {
return m.version()
}
return m.ret1.(authority.Version)
}
func Test_caHandler_Route(t *testing.T) {
type fields struct {
Authority Authority

@ -0,0 +1,246 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi"
"github.com/smallstep/certificates/acme"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/provisioner"
"go.step.sm/linkedca"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
// provisionerContextKey provisioner key
provisionerContextKey = ContextKey("provisioner")
)
// CreateExternalAccountKeyRequest is the type for POST /admin/acme/eab requests
type CreateExternalAccountKeyRequest struct {
Reference string `json:"reference"`
}
// Validate validates a new ACME EAB Key request body.
func (r *CreateExternalAccountKeyRequest) Validate() error {
if len(r.Reference) > 256 { // an arbitrary, but sensible (IMO), limit
return fmt.Errorf("reference length %d exceeds the maximum (256)", len(r.Reference))
}
return nil
}
// GetExternalAccountKeysResponse is the type for GET /admin/acme/eab responses
type GetExternalAccountKeysResponse struct {
EAKs []*linkedca.EABKey `json:"eaks"`
NextCursor string `json:"nextCursor"`
}
// requireEABEnabled is a middleware that ensures ACME EAB is enabled
// before serving requests that act on ACME EAB credentials.
func (h *Handler) requireEABEnabled(next nextHTTP) nextHTTP {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
provName := chi.URLParam(r, "provisionerName")
eabEnabled, prov, err := h.provisionerHasEABEnabled(ctx, provName)
if err != nil {
api.WriteError(w, err)
return
}
if !eabEnabled {
api.WriteError(w, admin.NewError(admin.ErrorBadRequestType, "ACME EAB not enabled for provisioner %s", prov.GetName()))
return
}
ctx = context.WithValue(ctx, provisionerContextKey, prov)
next(w, r.WithContext(ctx))
}
}
// provisionerHasEABEnabled determines if the "requireEAB" setting for an ACME
// provisioner is set to true and thus has EAB enabled.
func (h *Handler) provisionerHasEABEnabled(ctx context.Context, provisionerName string) (bool, *linkedca.Provisioner, error) {
var (
p provisioner.Interface
err error
)
if p, err = h.auth.LoadProvisionerByName(provisionerName); err != nil {
return false, nil, admin.WrapErrorISE(err, "error loading provisioner %s", provisionerName)
}
prov, err := h.db.GetProvisioner(ctx, p.GetID())
if err != nil {
return false, nil, admin.WrapErrorISE(err, "error getting provisioner with ID: %s", p.GetID())
}
details := prov.GetDetails()
if details == nil {
return false, nil, admin.NewErrorISE("error getting details for provisioner with ID: %s", p.GetID())
}
acmeProvisioner := details.GetACME()
if acmeProvisioner == nil {
return false, nil, admin.NewErrorISE("error getting ACME details for provisioner with ID: %s", p.GetID())
}
return acmeProvisioner.GetRequireEab(), prov, nil
}
// provisionerFromContext searches the context for a provisioner. Returns the
// provisioner or an error.
func provisionerFromContext(ctx context.Context) (*linkedca.Provisioner, error) {
val := ctx.Value(provisionerContextKey)
if val == nil {
return nil, admin.NewErrorISE("provisioner expected in request context")
}
pval, ok := val.(*linkedca.Provisioner)
if !ok || pval == nil {
return nil, admin.NewErrorISE("provisioner in context is not a linkedca.Provisioner")
}
return pval, nil
}
// CreateExternalAccountKey creates a new External Account Binding key
func (h *Handler) CreateExternalAccountKey(w http.ResponseWriter, r *http.Request) {
var body CreateExternalAccountKeyRequest
if err := api.ReadJSON(r.Body, &body); err != nil {
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err, "error reading request body"))
return
}
if err := body.Validate(); err != nil {
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating request body"))
return
}
ctx := r.Context()
prov, err := provisionerFromContext(ctx)
if err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error getting provisioner from context"))
return
}
// check if a key with the reference does not exist (only when a reference was in the request)
reference := body.Reference
if reference != "" {
k, err := h.acmeDB.GetExternalAccountKeyByReference(ctx, prov.GetId(), reference)
// retrieving an EAB key from DB results in an error if it doesn't exist, which is what we're looking for,
// but other errors can also happen. Return early if that happens; continuing if it was acme.ErrNotFound.
if shouldWriteError := err != nil && !errors.Is(err, acme.ErrNotFound); shouldWriteError {
api.WriteError(w, admin.WrapErrorISE(err, "could not lookup external account key by reference"))
return
}
// if a key was found, return HTTP 409 conflict
if k != nil {
err := admin.NewError(admin.ErrorBadRequestType, "an ACME EAB key for provisioner '%s' with reference '%s' already exists", prov.GetName(), reference)
err.Status = 409
api.WriteError(w, err)
return
}
// continue execution if no key was found for the reference
}
eak, err := h.acmeDB.CreateExternalAccountKey(ctx, prov.GetId(), reference)
if err != nil {
msg := fmt.Sprintf("error creating ACME EAB key for provisioner '%s'", prov.GetName())
if reference != "" {
msg += fmt.Sprintf(" and reference '%s'", reference)
}
api.WriteError(w, admin.WrapErrorISE(err, msg))
return
}
response := &linkedca.EABKey{
Id: eak.ID,
HmacKey: eak.KeyBytes,
Provisioner: prov.GetName(),
Reference: eak.Reference,
}
api.ProtoJSONStatus(w, response, http.StatusCreated)
}
// DeleteExternalAccountKey deletes an ACME External Account Key.
func (h *Handler) DeleteExternalAccountKey(w http.ResponseWriter, r *http.Request) {
keyID := chi.URLParam(r, "id")
ctx := r.Context()
prov, err := provisionerFromContext(ctx)
if err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error getting provisioner from context"))
return
}
if err := h.acmeDB.DeleteExternalAccountKey(ctx, prov.GetId(), keyID); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error deleting ACME EAB Key '%s'", keyID))
return
}
api.JSON(w, &DeleteResponse{Status: "ok"})
}
// GetExternalAccountKeys returns ACME EAB Keys. If a reference is specified,
// only the ExternalAccountKey with that reference is returned. Otherwise all
// ExternalAccountKeys in the system for a specific provisioner are returned.
func (h *Handler) GetExternalAccountKeys(w http.ResponseWriter, r *http.Request) {
var (
key *acme.ExternalAccountKey
keys []*acme.ExternalAccountKey
err error
cursor string
nextCursor string
limit int
)
ctx := r.Context()
prov, err := provisionerFromContext(ctx)
if err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error getting provisioner from context"))
return
}
if cursor, limit, err = api.ParseCursor(r); err != nil {
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err,
"error parsing cursor and limit from query params"))
return
}
reference := chi.URLParam(r, "reference")
if reference != "" {
if key, err = h.acmeDB.GetExternalAccountKeyByReference(ctx, prov.GetId(), reference); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error retrieving external account key with reference '%s'", reference))
return
}
if key != nil {
keys = []*acme.ExternalAccountKey{key}
}
} else {
if keys, nextCursor, err = h.acmeDB.GetExternalAccountKeys(ctx, prov.GetId(), cursor, limit); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error retrieving external account keys"))
return
}
}
provisionerName := prov.GetName()
eaks := make([]*linkedca.EABKey, len(keys))
for i, k := range keys {
eaks[i] = &linkedca.EABKey{
Id: k.ID,
HmacKey: []byte{},
Provisioner: provisionerName,
Reference: k.Reference,
Account: k.AccountID,
CreatedAt: timestamppb.New(k.CreatedAt),
BoundAt: timestamppb.New(k.BoundAt),
}
}
api.JSON(w, &GetExternalAccountKeysResponse{
EAKs: eaks,
NextCursor: nextCursor,
})
}

File diff suppressed because it is too large Load Diff

@ -1,14 +1,32 @@
package api
import (
"context"
"net/http"
"github.com/go-chi/chi"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/provisioner"
"go.step.sm/linkedca"
)
type adminAuthority interface {
LoadProvisionerByName(string) (provisioner.Interface, error)
GetProvisioners(cursor string, limit int) (provisioner.List, string, error)
IsAdminAPIEnabled() bool
LoadAdminByID(id string) (*linkedca.Admin, bool)
GetAdmins(cursor string, limit int) ([]*linkedca.Admin, string, error)
StoreAdmin(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error
UpdateAdmin(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error)
RemoveAdmin(ctx context.Context, id string) error
AuthorizeAdminToken(r *http.Request, token string) (*linkedca.Admin, error)
StoreProvisioner(ctx context.Context, prov *linkedca.Provisioner) error
LoadProvisionerByID(id string) (provisioner.Interface, error)
UpdateProvisioner(ctx context.Context, nu *linkedca.Provisioner) error
RemoveProvisioner(ctx context.Context, id string) error
}
// CreateAdminRequest represents the body for a CreateAdmin request.
type CreateAdminRequest struct {
Subject string `json:"subject"`

@ -0,0 +1,919 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/provisioner"
"go.step.sm/linkedca"
"google.golang.org/protobuf/types/known/timestamppb"
)
type mockAdminAuthority struct {
MockLoadProvisionerByName func(name string) (provisioner.Interface, error)
MockGetProvisioners func(nextCursor string, limit int) (provisioner.List, string, error)
MockRet1, MockRet2 interface{} // TODO: refactor the ret1/ret2 into those two
MockErr error
MockIsAdminAPIEnabled func() bool
MockLoadAdminByID func(id string) (*linkedca.Admin, bool)
MockGetAdmins func(cursor string, limit int) ([]*linkedca.Admin, string, error)
MockStoreAdmin func(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error
MockUpdateAdmin func(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error)
MockRemoveAdmin func(ctx context.Context, id string) error
MockAuthorizeAdminToken func(r *http.Request, token string) (*linkedca.Admin, error)
MockStoreProvisioner func(ctx context.Context, prov *linkedca.Provisioner) error
MockLoadProvisionerByID func(id string) (provisioner.Interface, error)
MockUpdateProvisioner func(ctx context.Context, nu *linkedca.Provisioner) error
MockRemoveProvisioner func(ctx context.Context, id string) error
}
func (m *mockAdminAuthority) IsAdminAPIEnabled() bool {
if m.MockIsAdminAPIEnabled != nil {
return m.MockIsAdminAPIEnabled()
}
return m.MockRet1.(bool)
}
func (m *mockAdminAuthority) LoadProvisionerByName(name string) (provisioner.Interface, error) {
if m.MockLoadProvisionerByName != nil {
return m.MockLoadProvisionerByName(name)
}
return m.MockRet1.(provisioner.Interface), m.MockErr
}
func (m *mockAdminAuthority) GetProvisioners(nextCursor string, limit int) (provisioner.List, string, error) {
if m.MockGetProvisioners != nil {
return m.MockGetProvisioners(nextCursor, limit)
}
return m.MockRet1.(provisioner.List), m.MockRet2.(string), m.MockErr
}
func (m *mockAdminAuthority) LoadAdminByID(id string) (*linkedca.Admin, bool) {
if m.MockLoadAdminByID != nil {
return m.MockLoadAdminByID(id)
}
return m.MockRet1.(*linkedca.Admin), m.MockRet2.(bool)
}
func (m *mockAdminAuthority) GetAdmins(cursor string, limit int) ([]*linkedca.Admin, string, error) {
if m.MockGetAdmins != nil {
return m.MockGetAdmins(cursor, limit)
}
return m.MockRet1.([]*linkedca.Admin), m.MockRet2.(string), m.MockErr
}
func (m *mockAdminAuthority) StoreAdmin(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error {
if m.MockStoreAdmin != nil {
return m.MockStoreAdmin(ctx, adm, prov)
}
return m.MockErr
}
func (m *mockAdminAuthority) UpdateAdmin(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error) {
if m.MockUpdateAdmin != nil {
return m.MockUpdateAdmin(ctx, id, nu)
}
return m.MockRet1.(*linkedca.Admin), m.MockErr
}
func (m *mockAdminAuthority) RemoveAdmin(ctx context.Context, id string) error {
if m.MockRemoveAdmin != nil {
return m.MockRemoveAdmin(ctx, id)
}
return m.MockErr
}
func (m *mockAdminAuthority) AuthorizeAdminToken(r *http.Request, token string) (*linkedca.Admin, error) {
if m.MockAuthorizeAdminToken != nil {
return m.MockAuthorizeAdminToken(r, token)
}
return m.MockRet1.(*linkedca.Admin), m.MockErr
}
func (m *mockAdminAuthority) StoreProvisioner(ctx context.Context, prov *linkedca.Provisioner) error {
if m.MockStoreProvisioner != nil {
return m.MockStoreProvisioner(ctx, prov)
}
return m.MockErr
}
func (m *mockAdminAuthority) LoadProvisionerByID(id string) (provisioner.Interface, error) {
if m.MockLoadProvisionerByID != nil {
return m.MockLoadProvisionerByID(id)
}
return m.MockRet1.(provisioner.Interface), m.MockErr
}
func (m *mockAdminAuthority) UpdateProvisioner(ctx context.Context, nu *linkedca.Provisioner) error {
if m.MockUpdateProvisioner != nil {
return m.MockUpdateProvisioner(ctx, nu)
}
return m.MockErr
}
func (m *mockAdminAuthority) RemoveProvisioner(ctx context.Context, id string) error {
if m.MockRemoveProvisioner != nil {
return m.MockRemoveProvisioner(ctx, id)
}
return m.MockErr
}
func TestCreateAdminRequest_Validate(t *testing.T) {
type fields struct {
Subject string
Provisioner string
Type linkedca.Admin_Type
}
tests := []struct {
name string
fields fields
err *admin.Error
}{
{
name: "fail/subject-empty",
fields: fields{
Subject: "",
Provisioner: "",
Type: 0,
},
err: admin.NewError(admin.ErrorBadRequestType, "subject cannot be empty"),
},
{
name: "fail/provisioner-empty",
fields: fields{
Subject: "admin",
Provisioner: "",
Type: 0,
},
err: admin.NewError(admin.ErrorBadRequestType, "provisioner cannot be empty"),
},
{
name: "fail/invalid-type",
fields: fields{
Subject: "admin",
Provisioner: "prov",
Type: -1,
},
err: admin.NewError(admin.ErrorBadRequestType, "invalid value for admin type"),
},
{
name: "ok",
fields: fields{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
car := &CreateAdminRequest{
Subject: tt.fields.Subject,
Provisioner: tt.fields.Provisioner,
Type: tt.fields.Type,
}
err := car.Validate()
if (err != nil) != (tt.err != nil) {
t.Errorf("CreateAdminRequest.Validate() error = %v, wantErr %v", err, (tt.err != nil))
return
}
if err != nil {
assert.Type(t, &admin.Error{}, err)
adminErr, _ := err.(*admin.Error)
assert.Equals(t, tt.err.Type, adminErr.Type)
assert.Equals(t, tt.err.Detail, adminErr.Detail)
assert.Equals(t, tt.err.Status, adminErr.Status)
assert.Equals(t, tt.err.Message, adminErr.Message)
}
})
}
}
func TestUpdateAdminRequest_Validate(t *testing.T) {
type fields struct {
Type linkedca.Admin_Type
}
tests := []struct {
name string
fields fields
err *admin.Error
}{
{
name: "fail/invalid-type",
fields: fields{
Type: -1,
},
err: admin.NewError(admin.ErrorBadRequestType, "invalid value for admin type"),
},
{
name: "ok",
fields: fields{
Type: linkedca.Admin_SUPER_ADMIN,
},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uar := &UpdateAdminRequest{
Type: tt.fields.Type,
}
err := uar.Validate()
if (err != nil) != (tt.err != nil) {
t.Errorf("CreateAdminRequest.Validate() error = %v, wantErr %v", err, (tt.err != nil))
return
}
if err != nil {
assert.Type(t, &admin.Error{}, err)
adminErr, _ := err.(*admin.Error)
assert.Equals(t, tt.err.Type, adminErr.Type)
assert.Equals(t, tt.err.Detail, adminErr.Detail)
assert.Equals(t, tt.err.Status, adminErr.Status)
assert.Equals(t, tt.err.Message, adminErr.Message)
}
})
}
}
func TestHandler_GetAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
statusCode int
err *admin.Error
adm *linkedca.Admin
}
var tests = map[string]func(t *testing.T) test{
"fail/auth.LoadAdminByID-not-found": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &mockAdminAuthority{
MockLoadAdminByID: func(id string) (*linkedca.Admin, bool) {
assert.Equals(t, "adminID", id)
return nil, false
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 404,
err: &admin.Error{
Type: admin.ErrorNotFoundType.String(),
Status: 404,
Detail: "resource not found",
Message: "admin adminID not found",
},
}
},
"ok": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
createdAt := time.Now()
var deletedAt time.Time
adm := &linkedca.Admin{
Id: "adminID",
AuthorityId: "authorityID",
Subject: "admin",
ProvisionerId: "provID",
Type: linkedca.Admin_SUPER_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
auth := &mockAdminAuthority{
MockLoadAdminByID: func(id string) (*linkedca.Admin, bool) {
assert.Equals(t, "adminID", id)
return adm, true
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 200,
err: nil,
adm: adm,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", nil) // chi routing is prepared in test setup
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.GetAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
adm := &linkedca.Admin{}
err := readProtoJSON(res.Body, adm)
assert.FatalError(t, err)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.adm, adm, opts...) {
t.Errorf("linkedca.Admin diff =\n%s", cmp.Diff(tc.adm, adm, opts...))
}
})
}
}
func TestHandler_GetAdmins(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
req *http.Request
statusCode int
err *admin.Error
resp GetAdminsResponse
}
var tests = map[string]func(t *testing.T) test{
"fail/parse-cursor": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo?limit=A", nil)
return test{
ctx: context.Background(),
req: req,
statusCode: 400,
err: &admin.Error{
Status: 400,
Type: admin.ErrorBadRequestType.String(),
Detail: "bad request",
Message: "error parsing cursor and limit from query params: limit 'A' is not an integer: strconv.Atoi: parsing \"A\": invalid syntax",
},
}
},
"fail/auth.GetAdmins": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
auth := &mockAdminAuthority{
MockGetAdmins: func(cursor string, limit int) ([]*linkedca.Admin, string, error) {
assert.Equals(t, "", cursor)
assert.Equals(t, 0, limit)
return nil, "", errors.New("force")
},
}
return test{
ctx: context.Background(),
req: req,
auth: auth,
statusCode: 500,
err: &admin.Error{
Status: 500,
Type: admin.ErrorServerInternalType.String(),
Detail: "the server experienced an internal error",
Message: "error retrieving paginated admins: force",
},
}
},
"ok": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
createdAt := time.Now()
var deletedAt time.Time
adm1 := &linkedca.Admin{
Id: "adminID1",
AuthorityId: "authorityID1",
Subject: "admin1",
ProvisionerId: "provID",
Type: linkedca.Admin_SUPER_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
adm2 := &linkedca.Admin{
Id: "adminID2",
AuthorityId: "authorityID",
Subject: "admin2",
ProvisionerId: "provID",
Type: linkedca.Admin_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
auth := &mockAdminAuthority{
MockGetAdmins: func(cursor string, limit int) ([]*linkedca.Admin, string, error) {
assert.Equals(t, "", cursor)
assert.Equals(t, 0, limit)
return []*linkedca.Admin{
adm1,
adm2,
}, "nextCursorValue", nil
},
}
return test{
ctx: context.Background(),
req: req,
auth: auth,
statusCode: 200,
err: nil,
resp: GetAdminsResponse{
Admins: []*linkedca.Admin{
adm1,
adm2,
},
NextCursor: "nextCursorValue",
},
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := tc.req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.GetAdmins(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
if res.StatusCode >= 400 {
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
response := GetAdminsResponse{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &response))
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.resp, response, opts...) {
t.Errorf("GetAdmins diff =\n%s", cmp.Diff(tc.resp, response, opts...))
}
})
}
}
func TestHandler_CreateAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
body []byte
statusCode int
err *admin.Error
adm *linkedca.Admin
}
var tests = map[string]func(t *testing.T) test{
"fail/ReadJSON": func(t *testing.T) test {
body := []byte("{!?}")
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "error reading request body: error decoding json: invalid character '!' looking for beginning of object key string",
},
}
},
"fail/validate": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "",
Provisioner: "",
Type: -1,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "subject cannot be empty",
},
}
},
"fail/auth.LoadProvisionerByName": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
auth := &mockAdminAuthority{
MockLoadProvisionerByName: func(name string) (provisioner.Interface, error) {
assert.Equals(t, "prov", name)
return nil, errors.New("force")
},
}
return test{
ctx: context.Background(),
body: body,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error loading provisioner prov: force",
},
}
},
"fail/auth.StoreAdmin": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
auth := &mockAdminAuthority{
MockLoadProvisionerByName: func(name string) (provisioner.Interface, error) {
assert.Equals(t, "prov", name)
return &provisioner.ACME{
ID: "provID",
Name: "prov",
}, nil
},
MockStoreAdmin: func(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error {
assert.Equals(t, "admin", adm.Subject)
assert.Equals(t, "provID", prov.GetID())
return errors.New("force")
},
}
return test{
ctx: context.Background(),
body: body,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error storing admin: force",
},
}
},
"ok": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
auth := &mockAdminAuthority{
MockLoadProvisionerByName: func(name string) (provisioner.Interface, error) {
assert.Equals(t, "prov", name)
return &provisioner.ACME{
ID: "provID",
Name: "prov",
}, nil
},
MockStoreAdmin: func(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error {
assert.Equals(t, "admin", adm.Subject)
assert.Equals(t, "provID", prov.GetID())
return nil
},
}
return test{
ctx: context.Background(),
body: body,
auth: auth,
statusCode: 201,
err: nil,
adm: &linkedca.Admin{
ProvisionerId: "provID",
Subject: "admin",
Type: linkedca.Admin_SUPER_ADMIN,
},
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", io.NopCloser(bytes.NewBuffer(tc.body)))
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.CreateAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
adm := &linkedca.Admin{}
err := readProtoJSON(res.Body, adm)
assert.FatalError(t, err)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.adm, adm, opts...) {
t.Errorf("h.CreateAdmin diff =\n%s", cmp.Diff(tc.adm, adm, opts...))
}
})
}
}
func TestHandler_DeleteAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
statusCode int
err *admin.Error
}
var tests = map[string]func(t *testing.T) test{
"fail/auth.RemoveAdmin": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &mockAdminAuthority{
MockRemoveAdmin: func(ctx context.Context, id string) error {
assert.Equals(t, "adminID", id)
return errors.New("force")
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error deleting admin adminID: force",
},
}
},
"ok": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &mockAdminAuthority{
MockRemoveAdmin: func(ctx context.Context, id string) error {
assert.Equals(t, "adminID", id)
return nil
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 200,
err: nil,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("DELETE", "/foo", nil) // chi routing is prepared in test setup
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.DeleteAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
response := DeleteResponse{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &response))
assert.Equals(t, "ok", response.Status)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
})
}
}
func TestHandler_UpdateAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
body []byte
statusCode int
err *admin.Error
adm *linkedca.Admin
}
var tests = map[string]func(t *testing.T) test{
"fail/ReadJSON": func(t *testing.T) test {
body := []byte("{!?}")
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "error reading request body: error decoding json: invalid character '!' looking for beginning of object key string",
},
}
},
"fail/validate": func(t *testing.T) test {
req := UpdateAdminRequest{
Type: -1,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "invalid value for admin type",
},
}
},
"fail/auth.UpdateAdmin": func(t *testing.T) test {
req := UpdateAdminRequest{
Type: linkedca.Admin_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &mockAdminAuthority{
MockUpdateAdmin: func(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error) {
assert.Equals(t, "adminID", id)
assert.Equals(t, linkedca.Admin_ADMIN, nu.Type)
return nil, errors.New("force")
},
}
return test{
ctx: ctx,
body: body,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error updating admin adminID: force",
},
}
},
"ok": func(t *testing.T) test {
req := UpdateAdminRequest{
Type: linkedca.Admin_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
adm := &linkedca.Admin{
Id: "adminID",
ProvisionerId: "provID",
Subject: "admin",
Type: linkedca.Admin_SUPER_ADMIN,
}
auth := &mockAdminAuthority{
MockUpdateAdmin: func(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error) {
assert.Equals(t, "adminID", id)
assert.Equals(t, linkedca.Admin_ADMIN, nu.Type)
return adm, nil
},
}
return test{
ctx: ctx,
body: body,
auth: auth,
statusCode: 200,
err: nil,
adm: adm,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", io.NopCloser(bytes.NewBuffer(tc.body)))
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.UpdateAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
adm := &linkedca.Admin{}
err := readProtoJSON(res.Body, adm)
assert.FatalError(t, err)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.adm, adm, opts...) {
t.Errorf("h.UpdateAdmin diff =\n%s", cmp.Diff(tc.adm, adm, opts...))
}
})
}
}

@ -1,22 +1,25 @@
package api
import (
"github.com/smallstep/certificates/acme"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority"
"github.com/smallstep/certificates/authority/admin"
)
// Handler is the ACME API request handler.
// Handler is the Admin API request handler.
type Handler struct {
db admin.DB
auth *authority.Authority
db admin.DB
auth adminAuthority
acmeDB acme.DB
}
// NewHandler returns a new Authority Config Handler.
func NewHandler(auth *authority.Authority) api.RouterHandler {
h := &Handler{db: auth.GetAdminDatabase(), auth: auth}
return h
func NewHandler(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB) api.RouterHandler {
return &Handler{
db: adminDB,
auth: auth,
acmeDB: acmeDB,
}
}
// Route traffic and implement the Router interface.
@ -25,6 +28,10 @@ func (h *Handler) Route(r api.Router) {
return h.extractAuthorizeTokenAdmin(h.requireAPIEnabled(next))
}
requireEABEnabled := func(next nextHTTP) nextHTTP {
return h.requireEABEnabled(next)
}
// Provisioners
r.MethodFunc("GET", "/provisioners/{name}", authnz(h.GetProvisioner))
r.MethodFunc("GET", "/provisioners", authnz(h.GetProvisioners))
@ -38,4 +45,10 @@ func (h *Handler) Route(r api.Router) {
r.MethodFunc("POST", "/admins", authnz(h.CreateAdmin))
r.MethodFunc("PATCH", "/admins/{id}", authnz(h.UpdateAdmin))
r.MethodFunc("DELETE", "/admins/{id}", authnz(h.DeleteAdmin))
// ACME External Account Binding Keys
r.MethodFunc("GET", "/acme/eab/{provisionerName}/{reference}", authnz(requireEABEnabled(h.GetExternalAccountKeys)))
r.MethodFunc("GET", "/acme/eab/{provisionerName}", authnz(requireEABEnabled(h.GetExternalAccountKeys)))
r.MethodFunc("POST", "/acme/eab/{provisionerName}", authnz(requireEABEnabled(h.CreateExternalAccountKey)))
r.MethodFunc("DELETE", "/acme/eab/{provisionerName}/{id}", authnz(requireEABEnabled(h.DeleteExternalAccountKey)))
}

@ -0,0 +1,225 @@
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/authority/admin"
"go.step.sm/linkedca"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestHandler_requireAPIEnabled(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
next nextHTTP
err *admin.Error
statusCode int
}
var tests = map[string]func(t *testing.T) test{
"fail/auth.IsAdminAPIEnabled": func(t *testing.T) test {
return test{
ctx: context.Background(),
auth: &mockAdminAuthority{
MockIsAdminAPIEnabled: func() bool {
return false
},
},
err: &admin.Error{
Type: admin.ErrorNotImplementedType.String(),
Status: 501,
Detail: "not implemented",
Message: "administration API not enabled",
},
statusCode: 501,
}
},
"ok": func(t *testing.T) test {
auth := &mockAdminAuthority{
MockIsAdminAPIEnabled: func() bool {
return true
},
}
next := func(w http.ResponseWriter, r *http.Request) {
w.Write(nil) // mock response with status 200
}
return test{
ctx: context.Background(),
auth: auth,
next: next,
statusCode: 200,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", nil) // chi routing is prepared in test setup
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.requireAPIEnabled(tc.next)(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
if res.StatusCode >= 400 {
err := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &err))
assert.Equals(t, tc.err.Type, err.Type)
assert.Equals(t, tc.err.Message, err.Message)
assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
assert.Equals(t, tc.err.Detail, err.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
// nothing to test when the requireAPIEnabled middleware succeeds, currently
})
}
}
func TestHandler_extractAuthorizeTokenAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
req *http.Request
next nextHTTP
err *admin.Error
statusCode int
}
var tests = map[string]func(t *testing.T) test{
"fail/missing-authorization-token": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
req.Header["Authorization"] = []string{""}
return test{
ctx: context.Background(),
req: req,
statusCode: 401,
err: &admin.Error{
Type: admin.ErrorUnauthorizedType.String(),
Status: 401,
Detail: "unauthorized",
Message: "missing authorization header token",
},
}
},
"fail/auth.AuthorizeAdminToken": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
req.Header["Authorization"] = []string{"token"}
auth := &mockAdminAuthority{
MockAuthorizeAdminToken: func(r *http.Request, token string) (*linkedca.Admin, error) {
assert.Equals(t, "token", token)
return nil, admin.NewError(
admin.ErrorUnauthorizedType,
"not authorized",
)
},
}
return test{
ctx: context.Background(),
auth: auth,
req: req,
statusCode: 401,
err: &admin.Error{
Type: admin.ErrorUnauthorizedType.String(),
Status: 401,
Detail: "unauthorized",
Message: "not authorized",
},
}
},
"ok": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
req.Header["Authorization"] = []string{"token"}
createdAt := time.Now()
var deletedAt time.Time
admin := &linkedca.Admin{
Id: "adminID",
AuthorityId: "authorityID",
Subject: "admin",
ProvisionerId: "provID",
Type: linkedca.Admin_SUPER_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
auth := &mockAdminAuthority{
MockAuthorizeAdminToken: func(r *http.Request, token string) (*linkedca.Admin, error) {
assert.Equals(t, "token", token)
return admin, nil
},
}
next := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := ctx.Value(adminContextKey) // verifying that the context now has a linkedca.Admin
adm, ok := a.(*linkedca.Admin)
if !ok {
t.Errorf("expected *linkedca.Admin; got %T", a)
return
}
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(admin, adm, opts...) {
t.Errorf("linkedca.Admin diff =\n%s", cmp.Diff(admin, adm, opts...))
}
w.Write(nil) // mock response with status 200
}
return test{
ctx: context.Background(),
auth: auth,
req: req,
next: next,
statusCode: 200,
err: nil,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := tc.req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.extractAuthorizeTokenAdmin(tc.next)(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
if res.StatusCode >= 400 {
err := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &err))
assert.Equals(t, tc.err.Type, err.Type)
assert.Equals(t, tc.err.Message, err.Message)
assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
assert.Equals(t, tc.err.Detail, err.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
})
}
}

@ -54,7 +54,7 @@ func (h *Handler) GetProvisioners(w http.ResponseWriter, r *http.Request) {
cursor, limit, err := api.ParseCursor(r)
if err != nil {
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err,
"error parsing cursor & limit query params"))
"error parsing cursor and limit from query params"))
return
}

File diff suppressed because it is too large Load Diff

@ -11,7 +11,7 @@ import (
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/db"
"github.com/smallstep/nosql"
"github.com/smallstep/nosql/database"
nosqldb "github.com/smallstep/nosql/database"
"go.step.sm/linkedca"
"google.golang.org/protobuf/types/known/timestamppb"
)
@ -31,7 +31,7 @@ func TestDB_getDBAdminBytes(t *testing.T) {
assert.Equals(t, bucket, adminsTable)
assert.Equals(t, string(key), adminID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
@ -105,7 +105,7 @@ func TestDB_getDBAdmin(t *testing.T) {
assert.Equals(t, bucket, adminsTable)
assert.Equals(t, string(key), adminID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
@ -398,7 +398,7 @@ func TestDB_GetAdmin(t *testing.T) {
assert.Equals(t, bucket, adminsTable)
assert.Equals(t, string(key), adminID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
@ -551,7 +551,7 @@ func TestDB_DeleteAdmin(t *testing.T) {
assert.Equals(t, bucket, adminsTable)
assert.Equals(t, string(key), adminID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
@ -697,7 +697,7 @@ func TestDB_UpdateAdmin(t *testing.T) {
assert.Equals(t, bucket, adminsTable)
assert.Equals(t, string(key), adminID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
@ -985,7 +985,7 @@ func TestDB_GetAdmins(t *testing.T) {
"fail/db.List-error": func(t *testing.T) test {
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, adminsTable)
return nil, errors.New("force")
@ -995,14 +995,14 @@ func TestDB_GetAdmins(t *testing.T) {
}
},
"fail/unmarshal-error": func(t *testing.T) test {
ret := []*database.Entry{
ret := []*nosqldb.Entry{
{Bucket: adminsTable, Key: []byte("foo"), Value: foob},
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
{Bucket: adminsTable, Key: []byte("zap"), Value: []byte("zap")},
}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, adminsTable)
return ret, nil
@ -1012,10 +1012,10 @@ func TestDB_GetAdmins(t *testing.T) {
}
},
"ok/none": func(t *testing.T) test {
ret := []*database.Entry{}
ret := []*nosqldb.Entry{}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, adminsTable)
return ret, nil
@ -1027,13 +1027,13 @@ func TestDB_GetAdmins(t *testing.T) {
}
},
"ok/only-invalid": func(t *testing.T) test {
ret := []*database.Entry{
ret := []*nosqldb.Entry{
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
{Bucket: adminsTable, Key: []byte("baz"), Value: bazb},
}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, adminsTable)
return ret, nil
@ -1045,7 +1045,7 @@ func TestDB_GetAdmins(t *testing.T) {
}
},
"ok": func(t *testing.T) test {
ret := []*database.Entry{
ret := []*nosqldb.Entry{
{Bucket: adminsTable, Key: []byte("foo"), Value: foob},
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
{Bucket: adminsTable, Key: []byte("baz"), Value: bazb},
@ -1053,7 +1053,7 @@ func TestDB_GetAdmins(t *testing.T) {
}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, adminsTable)
return ret, nil

@ -11,7 +11,7 @@ import (
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/db"
"github.com/smallstep/nosql"
"github.com/smallstep/nosql/database"
nosqldb "github.com/smallstep/nosql/database"
"go.step.sm/linkedca"
)
@ -30,7 +30,7 @@ func TestDB_getDBProvisionerBytes(t *testing.T) {
assert.Equals(t, bucket, provisionersTable)
assert.Equals(t, string(key), provID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
@ -104,7 +104,7 @@ func TestDB_getDBProvisioner(t *testing.T) {
assert.Equals(t, bucket, provisionersTable)
assert.Equals(t, string(key), provID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
@ -444,7 +444,7 @@ func TestDB_GetProvisioner(t *testing.T) {
assert.Equals(t, bucket, provisionersTable)
assert.Equals(t, string(key), provID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
@ -581,7 +581,7 @@ func TestDB_DeleteProvisioner(t *testing.T) {
assert.Equals(t, bucket, provisionersTable)
assert.Equals(t, string(key), provID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
@ -735,7 +735,7 @@ func TestDB_GetProvisioners(t *testing.T) {
"fail/db.List-error": func(t *testing.T) test {
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, provisionersTable)
return nil, errors.New("force")
@ -745,14 +745,14 @@ func TestDB_GetProvisioners(t *testing.T) {
}
},
"fail/unmarshal-error": func(t *testing.T) test {
ret := []*database.Entry{
ret := []*nosqldb.Entry{
{Bucket: provisionersTable, Key: []byte("foo"), Value: foob},
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
{Bucket: provisionersTable, Key: []byte("zap"), Value: []byte("zap")},
}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, provisionersTable)
return ret, nil
@ -762,10 +762,10 @@ func TestDB_GetProvisioners(t *testing.T) {
}
},
"ok/none": func(t *testing.T) test {
ret := []*database.Entry{}
ret := []*nosqldb.Entry{}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, provisionersTable)
return ret, nil
@ -777,13 +777,13 @@ func TestDB_GetProvisioners(t *testing.T) {
}
},
"ok/only-invalid": func(t *testing.T) test {
ret := []*database.Entry{
ret := []*nosqldb.Entry{
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
{Bucket: provisionersTable, Key: []byte("baz"), Value: bazb},
}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, provisionersTable)
return ret, nil
@ -795,7 +795,7 @@ func TestDB_GetProvisioners(t *testing.T) {
}
},
"ok": func(t *testing.T) test {
ret := []*database.Entry{
ret := []*nosqldb.Entry{
{Bucket: provisionersTable, Key: []byte("foo"), Value: foob},
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
{Bucket: provisionersTable, Key: []byte("baz"), Value: bazb},
@ -803,7 +803,7 @@ func TestDB_GetProvisioners(t *testing.T) {
}
return test{
db: &db.MockNoSQLDB{
MList: func(bucket []byte) ([]*database.Entry, error) {
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
assert.Equals(t, bucket, provisionersTable)
return ret, nil
@ -988,7 +988,7 @@ func TestDB_UpdateProvisioner(t *testing.T) {
assert.Equals(t, bucket, provisionersTable)
assert.Equals(t, string(key), provID)
return nil, database.ErrNotFound
return nil, nosqldb.ErrNotFound
},
},
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),

@ -104,7 +104,7 @@ var (
}
)
// Error represents an Admin
// Error represents an Admin error
type Error struct {
Type string `json:"type"`
Detail string `json:"detail"`

@ -101,8 +101,8 @@ func WithSSHBastionFunc(fn func(ctx context.Context, user, host string) (*config
}
}
// WithSSHGetHosts sets a custom function to get the bastion for a
// given user-host pair.
// WithSSHGetHosts sets a custom function to return a list of step ssh enabled
// hosts.
func WithSSHGetHosts(fn func(ctx context.Context, cert *x509.Certificate) ([]config.Host, error)) Option {
return func(a *Authority) error {
a.sshGetHostsFunc = fn

@ -13,13 +13,18 @@ import (
// provisioning flow.
type ACME struct {
*base
ID string `json:"-"`
Type string `json:"type"`
Name string `json:"name"`
ForceCN bool `json:"forceCN,omitempty"`
Claims *Claims `json:"claims,omitempty"`
Options *Options `json:"options,omitempty"`
claimer *Claimer
ID string `json:"-"`
Type string `json:"type"`
Name string `json:"name"`
ForceCN bool `json:"forceCN,omitempty"`
// RequireEAB makes the provisioner require ACME EAB to be provided
// by clients when creating a new Account. If set to true, the provided
// EAB will be verified. If set to false and an EAB is provided, it is
// not verified. Defaults to false.
RequireEAB bool `json:"requireEAB,omitempty"`
Claims *Claims `json:"claims,omitempty"`
Options *Options `json:"options,omitempty"`
claimer *Claimer
}
// GetID returns the provisioner unique identifier.

@ -638,12 +638,13 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface,
case *linkedca.ProvisionerDetails_ACME:
cfg := d.ACME
return &provisioner.ACME{
ID: p.Id,
Type: p.Type.String(),
Name: p.Name,
ForceCN: cfg.ForceCn,
Claims: claims,
Options: options,
ID: p.Id,
Type: p.Type.String(),
Name: p.Name,
ForceCN: cfg.ForceCn,
RequireEAB: cfg.RequireEab,
Claims: claims,
Options: options,
}, nil
case *linkedca.ProvisionerDetails_OIDC:
cfg := d.OIDC

@ -198,6 +198,13 @@ func (a *Authority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisi
errs.WithKeyVal("signOptions", signOpts),
)
}
// explicitly check for unmarshaling errors, which are most probably caused by JSON template syntax errors
if strings.HasPrefix(err.Error(), "error unmarshaling certificate") {
return nil, errs.InternalServerErr(templatingError(err),
errs.WithKeyVal("signOptions", signOpts),
errs.WithMessage("error applying certificate template"),
)
}
return nil, errs.Wrap(http.StatusInternalServerError, err, "authority.SignSSH")
}

@ -172,6 +172,14 @@ func TestAuthority_SignSSH(t *testing.T) {
SSH: &provisioner.SSHOptions{Template: `{{ fail "an error"}}`},
}, sshutil.CreateTemplateData(sshutil.UserCert, "key-id", []string{"user"}))
assert.FatalError(t, err)
userJSONSyntaxErrorTemplateFile, err := provisioner.TemplateSSHOptions(&provisioner.Options{
SSH: &provisioner.SSHOptions{TemplateFile: "./testdata/templates/badjsonsyntax.tpl"},
}, sshutil.CreateTemplateData(sshutil.UserCert, "key-id", []string{"user"}))
assert.FatalError(t, err)
userJSONValueErrorTemplateFile, err := provisioner.TemplateSSHOptions(&provisioner.Options{
SSH: &provisioner.SSHOptions{TemplateFile: "./testdata/templates/badjsonvalue.tpl"},
}, sshutil.CreateTemplateData(sshutil.UserCert, "key-id", []string{"user"}))
assert.FatalError(t, err)
now := time.Now()
@ -222,6 +230,8 @@ func TestAuthority_SignSSH(t *testing.T) {
{"fail-no-host-key", fields{signer, nil}, args{pub, provisioner.SignSSHOptions{CertType: "host"}, []provisioner.SignOption{hostTemplate}}, want{}, true},
{"fail-bad-type", fields{signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userTemplate, sshTestModifier{CertType: 100}}}, want{}, true},
{"fail-custom-template", fields{signer, signer}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userFailTemplate, userOptions}}, want{}, true},
{"fail-custom-template-syntax-error-file", fields{signer, signer}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userJSONSyntaxErrorTemplateFile, userOptions}}, want{}, true},
{"fail-custom-template-syntax-value-file", fields{signer, signer}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userJSONValueErrorTemplateFile, userOptions}}, want{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

@ -0,0 +1,3 @@
{
"subject": "badjson.localhost,
}

@ -0,0 +1,10 @@
{
"subject": 1,
"sans": {{ toJson .SANs }},
{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
"keyUsage": ["keyEncipherment", "digitalSignature"],
{{- else }}
"keyUsage": ["digitalSignature"],
{{- end }}
"extKeyUsage": ["serverAuth", "clientAuth"]
}

@ -7,8 +7,11 @@ import (
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
@ -126,6 +129,14 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign
errs.WithKeyVal("signOptions", signOpts),
)
}
// explicitly check for unmarshaling errors, which are most probably caused by JSON template (syntax) errors
if strings.HasPrefix(err.Error(), "error unmarshaling certificate") {
return nil, errs.InternalServerErr(templatingError(err),
errs.WithKeyVal("csr", csr),
errs.WithKeyVal("signOptions", signOpts),
errs.WithMessage("error applying certificate template"),
)
}
return nil, errs.Wrap(http.StatusInternalServerError, err, "authority.Sign", opts...)
}
@ -549,3 +560,22 @@ func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) {
tlsCrt.Leaf = resp.Certificate
return &tlsCrt, nil
}
// templatingError tries to extract more information about the cause of
// an error related to (most probably) malformed template data and adds
// this to the error message.
func templatingError(err error) error {
cause := errors.Cause(err)
var (
syntaxError *json.SyntaxError
typeError *json.UnmarshalTypeError
)
if errors.As(err, &syntaxError) {
// offset is arguably not super clear to the user, but it's the best we can do here
cause = fmt.Errorf("%s at offset %d", cause.Error(), syntaxError.Offset)
} else if errors.As(err, &typeError) {
// slightly rewriting the default error message to include the offset
cause = fmt.Errorf("cannot unmarshal %s at offset %d into Go value of type %s", typeError.Value, typeError.Offset, typeError.Type)
}
return errors.Wrap(cause, "error applying certificate template")
}

@ -396,6 +396,64 @@ ZYtQ9Ot36qc=
code: http.StatusBadRequest,
}
},
"fail bad JSON syntax template file": func(t *testing.T) *signTest {
csr := getCSR(t, priv)
testAuthority := testAuthority(t)
p, ok := testAuthority.provisioners.Load("step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc")
if !ok {
t.Fatal("provisioner not found")
}
p.(*provisioner.JWK).Options = &provisioner.Options{
X509: &provisioner.X509Options{
TemplateFile: "./testdata/templates/badjsonsyntax.tpl",
},
}
testExtraOpts, err := testAuthority.Authorize(ctx, token)
assert.FatalError(t, err)
testAuthority.db = &db.MockAuthDB{
MStoreCertificate: func(crt *x509.Certificate) error {
assert.Equals(t, crt.Subject.CommonName, "smallstep test")
return nil
},
}
return &signTest{
auth: testAuthority,
csr: csr,
extraOpts: testExtraOpts,
signOpts: signOpts,
err: errors.New("error applying certificate template: invalid character"),
code: http.StatusInternalServerError,
}
},
"fail bad JSON value template file": func(t *testing.T) *signTest {
csr := getCSR(t, priv)
testAuthority := testAuthority(t)
p, ok := testAuthority.provisioners.Load("step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc")
if !ok {
t.Fatal("provisioner not found")
}
p.(*provisioner.JWK).Options = &provisioner.Options{
X509: &provisioner.X509Options{
TemplateFile: "./testdata/templates/badjsonvalue.tpl",
},
}
testExtraOpts, err := testAuthority.Authorize(ctx, token)
assert.FatalError(t, err)
testAuthority.db = &db.MockAuthDB{
MStoreCertificate: func(crt *x509.Certificate) error {
assert.Equals(t, crt.Subject.CommonName, "smallstep test")
return nil
},
}
return &signTest{
auth: testAuthority,
csr: csr,
extraOpts: testExtraOpts,
signOpts: signOpts,
err: errors.New("error applying certificate template: cannot unmarshal"),
code: http.StatusInternalServerError,
}
},
"ok": func(t *testing.T) *signTest {
csr := getCSR(t, priv)
_a := testAuthority(t)

@ -560,7 +560,115 @@ retry:
return nil
}
// GetExternalAccountKeysPaginate returns a page from the GET /admin/acme/eab request to the CA.
func (c *AdminClient) GetExternalAccountKeysPaginate(provisionerName, reference string, opts ...AdminOption) (*adminAPI.GetExternalAccountKeysResponse, error) {
var retried bool
o := new(adminOptions)
if err := o.apply(opts); err != nil {
return nil, err
}
p := path.Join(adminURLPrefix, "acme/eab", provisionerName)
if reference != "" {
p = path.Join(p, "/", reference)
}
u := c.endpoint.ResolveReference(&url.URL{
Path: p,
RawQuery: o.rawQuery(),
})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return nil, errors.Wrapf(err, "error generating admin token")
}
req, err := http.NewRequest("GET", u.String(), http.NoBody)
if err != nil {
return nil, errors.Wrapf(err, "create GET %s request failed", u)
}
req.Header.Add("Authorization", tok)
retry:
resp, err := c.client.Do(req)
if err != nil {
return nil, errors.Wrapf(err, "client GET %s failed", u)
}
if resp.StatusCode >= 400 {
if !retried && c.retryOnError(resp) {
retried = true
goto retry
}
return nil, readAdminError(resp.Body)
}
var body = new(adminAPI.GetExternalAccountKeysResponse)
if err := readJSON(resp.Body, body); err != nil {
return nil, errors.Wrapf(err, "error reading %s", u)
}
return body, nil
}
// CreateExternalAccountKey performs the POST /admin/acme/eab request to the CA.
func (c *AdminClient) CreateExternalAccountKey(provisionerName string, eakRequest *adminAPI.CreateExternalAccountKeyRequest) (*linkedca.EABKey, error) {
var retried bool
body, err := json.Marshal(eakRequest)
if err != nil {
return nil, errs.Wrap(http.StatusInternalServerError, err, "error marshaling request")
}
u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "acme/eab/", provisionerName)})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return nil, errors.Wrapf(err, "error generating admin token")
}
req, err := http.NewRequest("POST", u.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.Wrapf(err, "create POST %s request failed", u)
}
req.Header.Add("Authorization", tok)
retry:
resp, err := c.client.Do(req)
if err != nil {
return nil, errors.Wrapf(err, "client POST %s failed", u)
}
if resp.StatusCode >= 400 {
if !retried && c.retryOnError(resp) {
retried = true
goto retry
}
return nil, readAdminError(resp.Body)
}
var eabKey = new(linkedca.EABKey)
if err := readProtoJSON(resp.Body, eabKey); err != nil {
return nil, errors.Wrapf(err, "error reading %s", u)
}
return eabKey, nil
}
// RemoveExternalAccountKey performs the DELETE /admin/acme/eab/{prov}/{key_id} request to the CA.
func (c *AdminClient) RemoveExternalAccountKey(provisionerName, keyID string) error {
var retried bool
u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "acme/eab", provisionerName, "/", keyID)})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return errors.Wrapf(err, "error generating admin token")
}
req, err := http.NewRequest("DELETE", u.String(), http.NoBody)
if err != nil {
return errors.Wrapf(err, "create DELETE %s request failed", u)
}
req.Header.Add("Authorization", tok)
retry:
resp, err := c.client.Do(req)
if err != nil {
return errors.Wrapf(err, "client DELETE %s failed", u)
}
if resp.StatusCode >= 400 {
if !retried && c.retryOnError(resp) {
retried = true
goto retry
}
return readAdminError(resp.Body)
}
return nil
}
func readAdminError(r io.ReadCloser) error {
// TODO: not all errors can be read (i.e. 404); seems to be a bigger issue
defer r.Close()
adminErr := new(admin.Error)
if err := json.NewDecoder(r).Decode(adminErr); err != nil {

@ -207,7 +207,7 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) {
if cfg.AuthorityConfig.EnableAdmin {
adminDB := auth.GetAdminDatabase()
if adminDB != nil {
adminHandler := adminAPI.NewHandler(auth)
adminHandler := adminAPI.NewHandler(auth, adminDB, acmeDB)
mux.Route("/admin", func(r chi.Router) {
adminHandler.Route(r)
})

@ -346,6 +346,7 @@ Below is an example of an ACME provisioner in the `ca.json`:
"type": "ACME",
"name": "my-acme-provisioner",
"forceCN": true,
"requireEAB": false,
"claims": {
"maxTLSCertDuration": "8h",
"defaultTLSCertDuration": "2h",
@ -361,6 +362,9 @@ Below is an example of an ACME provisioner in the `ca.json`:
* `forceCN` (optional): force one of the SANs to become the Common Name, if a
common name is not provided.
* `requireEAB` (optional): require clients to provide External Account Binding
credentials when creating an ACME Account.
* `claims` (optional): overwrites the default claims set in the authority, see
the [top](#provisioners) section for all the options.

@ -34,7 +34,7 @@ require (
github.com/urfave/cli v1.22.4
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352
go.step.sm/cli-utils v0.7.0
go.step.sm/crypto v0.14.0
go.step.sm/crypto v0.15.0
go.step.sm/linkedca v0.9.2
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d

@ -610,10 +610,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
go.step.sm/cli-utils v0.7.0 h1:2GvY5Muid1yzp7YQbfCCS+gK3q7zlHjjLL5Z0DXz8ds=
go.step.sm/cli-utils v0.7.0/go.mod h1:Ur6bqA/yl636kCUJbp30J7Unv5JJ226eW2KqXPDwF/E=
go.step.sm/crypto v0.9.0/go.mod h1:+CYG05Mek1YDqi5WK0ERc6cOpKly2i/a5aZmU1sfGj0=
go.step.sm/crypto v0.14.0 h1:HzSkUDwqKhODKpsTxevJz956U2xVDZ3sDdGQVwR6Ttw=
go.step.sm/crypto v0.14.0/go.mod h1:3G0yQr5lQqfEG0CMYz8apC/qMtjLRQlzflL2AxkcN+g=
go.step.sm/linkedca v0.9.1 h1:FQ2A4vSo9a7l74dnMVJRl4DC+Hu9FNUzTGUD0kEqlSM=
go.step.sm/linkedca v0.9.1/go.mod h1:5uTRjozEGSPAZal9xJqlaD38cvJcLe3o1VAFVjqcORo=
go.step.sm/crypto v0.15.0 h1:VioBln+x3+RoejgeBhvxkLGVYdWRy6PFiAaUUN29/E0=
go.step.sm/crypto v0.15.0/go.mod h1:3G0yQr5lQqfEG0CMYz8apC/qMtjLRQlzflL2AxkcN+g=
go.step.sm/linkedca v0.9.2 h1:CpAkd174sLXFfrOZrbPEiTzik91QRj3+L0omsiwsiok=
go.step.sm/linkedca v0.9.2/go.mod h1:5uTRjozEGSPAZal9xJqlaD38cvJcLe3o1VAFVjqcORo=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=

@ -978,6 +978,7 @@ func (p *PKI) Save(opt ...ConfigOption) error {
}
if cfg.DB != nil {
os.MkdirAll(cfg.DB.DataSource, 0700)
ui.PrintSelected("Database folder", cfg.DB.DataSource)
}
if cfg.Templates != nil {

Loading…
Cancel
Save