forked from TrueCloudLab/certificates
Merge pull request #650 from hslatman/hs/acme-eab
ACME External Account Binding
This commit is contained in:
commit
c57dfeec2d
31 changed files with 8062 additions and 300 deletions
|
@ -4,6 +4,7 @@ import (
|
||||||
"crypto"
|
"crypto"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.step.sm/crypto/jose"
|
"go.step.sm/crypto/jose"
|
||||||
)
|
)
|
||||||
|
@ -16,6 +17,7 @@ type Account struct {
|
||||||
Contact []string `json:"contact,omitempty"`
|
Contact []string `json:"contact,omitempty"`
|
||||||
Status Status `json:"status"`
|
Status Status `json:"status"`
|
||||||
OrdersURL string `json:"orders"`
|
OrdersURL string `json:"orders"`
|
||||||
|
ExternalAccountBinding interface{} `json:"externalAccountBinding,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToLog enables response logging.
|
// ToLog enables response logging.
|
||||||
|
@ -40,3 +42,32 @@ func KeyToID(jwk *jose.JSONWebKey) (string, error) {
|
||||||
}
|
}
|
||||||
return base64.RawURLEncoding.EncodeToString(kid), nil
|
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"
|
"crypto"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/smallstep/assert"
|
"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ type NewAccountRequest struct {
|
||||||
Contact []string `json:"contact"`
|
Contact []string `json:"contact"`
|
||||||
OnlyReturnExisting bool `json:"onlyReturnExisting"`
|
OnlyReturnExisting bool `json:"onlyReturnExisting"`
|
||||||
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
|
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
|
||||||
|
ExternalAccountBinding *ExternalAccountBinding `json:"externalAccountBinding,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateContacts(cs []string) error {
|
func validateContacts(cs []string) error {
|
||||||
|
@ -83,8 +84,14 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prov, err := acmeProvisionerFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
api.WriteError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
httpStatus := http.StatusCreated
|
httpStatus := http.StatusCreated
|
||||||
acc, err := accountFromContext(r.Context())
|
acc, err := accountFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
acmeErr, ok := err.(*acme.Error)
|
acmeErr, ok := err.(*acme.Error)
|
||||||
if !ok || acmeErr.Status != http.StatusBadRequest {
|
if !ok || acmeErr.Status != http.StatusBadRequest {
|
||||||
|
@ -99,12 +106,19 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
"account does not exist"))
|
"account does not exist"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jwk, err := jwkFromContext(ctx)
|
jwk, err := jwkFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.WriteError(w, err)
|
api.WriteError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eak, err := h.validateExternalAccountBinding(ctx, &nar)
|
||||||
|
if err != nil {
|
||||||
|
api.WriteError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
acc = &acme.Account{
|
acc = &acme.Account{
|
||||||
Key: jwk,
|
Key: jwk,
|
||||||
Contact: nar.Contact,
|
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"))
|
api.WriteError(w, acme.WrapErrorISE(err, "error creating account"))
|
||||||
return
|
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 {
|
} else {
|
||||||
// Account exists //
|
// Account exists
|
||||||
httpStatus = http.StatusOK
|
httpStatus = http.StatusOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/smallstep/assert"
|
"github.com/smallstep/assert"
|
||||||
"github.com/smallstep/certificates/acme"
|
"github.com/smallstep/certificates/acme"
|
||||||
"github.com/smallstep/certificates/authority/provisioner"
|
"github.com/smallstep/certificates/authority/provisioner"
|
||||||
|
@ -40,6 +41,66 @@ func newProv() acme.Provisioner {
|
||||||
return p
|
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) {
|
func TestNewAccountRequest_Validate(t *testing.T) {
|
||||||
type test struct {
|
type test struct {
|
||||||
nar *NewAccountRequest
|
nar *NewAccountRequest
|
||||||
|
@ -290,6 +351,7 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
prov := newProv()
|
prov := newProv()
|
||||||
escProvName := url.PathEscape(prov.GetName())
|
escProvName := url.PathEscape(prov.GetName())
|
||||||
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
|
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
|
||||||
|
provID := prov.GetID()
|
||||||
|
|
||||||
type test struct {
|
type test struct {
|
||||||
db acme.DB
|
db acme.DB
|
||||||
|
@ -343,6 +405,7 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
b, err := json.Marshal(nar)
|
b, err := json.Marshal(nar)
|
||||||
assert.FatalError(t, err)
|
assert.FatalError(t, err)
|
||||||
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
|
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
|
||||||
|
ctx = context.WithValue(ctx, provisionerContextKey, prov)
|
||||||
return test{
|
return test{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
|
@ -355,7 +418,8 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(nar)
|
b, err := json.Marshal(nar)
|
||||||
assert.FatalError(t, err)
|
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{
|
return test{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
@ -368,7 +432,8 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(nar)
|
b, err := json.Marshal(nar)
|
||||||
assert.FatalError(t, err)
|
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)
|
ctx = context.WithValue(ctx, jwkContextKey, nil)
|
||||||
return test{
|
return test{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
@ -376,6 +441,27 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
err: acme.NewErrorISE("jwk expected in request context"),
|
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 {
|
"fail/db.CreateAccount-error": func(t *testing.T) test {
|
||||||
nar := &NewAccountRequest{
|
nar := &NewAccountRequest{
|
||||||
Contact: []string{"foo", "bar"},
|
Contact: []string{"foo", "bar"},
|
||||||
|
@ -385,6 +471,7 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
assert.FatalError(t, err)
|
assert.FatalError(t, err)
|
||||||
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
|
ctx := context.WithValue(context.Background(), payloadContextKey, &payloadInfo{value: b})
|
||||||
|
ctx = context.WithValue(ctx, provisionerContextKey, prov)
|
||||||
ctx = context.WithValue(ctx, jwkContextKey, jwk)
|
ctx = context.WithValue(ctx, jwkContextKey, jwk)
|
||||||
return test{
|
return test{
|
||||||
db: &acme.MockDB{
|
db: &acme.MockDB{
|
||||||
|
@ -399,6 +486,109 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
err: acme.NewErrorISE("force"),
|
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 {
|
"ok/new-account": func(t *testing.T) test {
|
||||||
nar := &NewAccountRequest{
|
nar := &NewAccountRequest{
|
||||||
Contact: []string{"foo", "bar"},
|
Contact: []string{"foo", "bar"},
|
||||||
|
@ -455,6 +645,116 @@ func TestHandler_NewAccount(t *testing.T) {
|
||||||
statusCode: 200,
|
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 {
|
for name, run := range tests {
|
||||||
tc := run(t)
|
tc := run(t)
|
||||||
|
|
155
acme/api/eab.go
Normal file
155
acme/api/eab.go
Normal file
|
@ -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
|
||||||
|
}
|
1068
acme/api/eab_test.go
Normal file
1068
acme/api/eab_test.go
Normal file
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.
|
// Directory represents an ACME directory for configuring clients.
|
||||||
type Directory struct {
|
type Directory struct {
|
||||||
NewNonce string `json:"newNonce"`
|
NewNonce string `json:"newNonce"`
|
||||||
|
@ -143,6 +150,7 @@ type Directory struct {
|
||||||
NewOrder string `json:"newOrder"`
|
NewOrder string `json:"newOrder"`
|
||||||
RevokeCert string `json:"revokeCert"`
|
RevokeCert string `json:"revokeCert"`
|
||||||
KeyChange string `json:"keyChange"`
|
KeyChange string `json:"keyChange"`
|
||||||
|
Meta Meta `json:"meta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToLog enables response logging for the Directory type.
|
// ToLog enables response logging for the Directory type.
|
||||||
|
@ -158,12 +166,21 @@ func (d *Directory) ToLog() (interface{}, error) {
|
||||||
// for client configuration.
|
// for client configuration.
|
||||||
func (h *Handler) GetDirectory(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetDirectory(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
acmeProv, err := acmeProvisionerFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
api.WriteError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
api.JSON(w, &Directory{
|
api.JSON(w, &Directory{
|
||||||
NewNonce: h.linker.GetLink(ctx, NewNonceLinkType),
|
NewNonce: h.linker.GetLink(ctx, NewNonceLinkType),
|
||||||
NewAccount: h.linker.GetLink(ctx, NewAccountLinkType),
|
NewAccount: h.linker.GetLink(ctx, NewAccountLinkType),
|
||||||
NewOrder: h.linker.GetLink(ctx, NewOrderLinkType),
|
NewOrder: h.linker.GetLink(ctx, NewOrderLinkType),
|
||||||
RevokeCert: h.linker.GetLink(ctx, RevokeCertLinkType),
|
RevokeCert: h.linker.GetLink(ctx, RevokeCertLinkType),
|
||||||
KeyChange: h.linker.GetLink(ctx, KeyChangeLinkType),
|
KeyChange: h.linker.GetLink(ctx, KeyChangeLinkType),
|
||||||
|
Meta: Meta{
|
||||||
|
ExternalAccountRequired: acmeProv.RequireEAB,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,9 +15,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/smallstep/assert"
|
"github.com/smallstep/assert"
|
||||||
"github.com/smallstep/certificates/acme"
|
"github.com/smallstep/certificates/acme"
|
||||||
|
"github.com/smallstep/certificates/authority/provisioner"
|
||||||
"go.step.sm/crypto/jose"
|
"go.step.sm/crypto/jose"
|
||||||
"go.step.sm/crypto/pemutil"
|
"go.step.sm/crypto/pemutil"
|
||||||
)
|
)
|
||||||
|
@ -51,13 +53,43 @@ func TestHandler_GetNonce(t *testing.T) {
|
||||||
|
|
||||||
func TestHandler_GetDirectory(t *testing.T) {
|
func TestHandler_GetDirectory(t *testing.T) {
|
||||||
linker := NewLinker("ca.smallstep.com", "acme")
|
linker := NewLinker("ca.smallstep.com", "acme")
|
||||||
|
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()
|
prov := newProv()
|
||||||
provName := url.PathEscape(prov.GetName())
|
provName := url.PathEscape(prov.GetName())
|
||||||
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
|
baseURL := &url.URL{Scheme: "https", Host: "test.ca.smallstep.com"}
|
||||||
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
ctx := context.WithValue(context.Background(), provisionerContextKey, prov)
|
||||||
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
ctx = context.WithValue(ctx, baseURLContextKey, baseURL)
|
||||||
|
|
||||||
expDir := Directory{
|
expDir := Directory{
|
||||||
NewNonce: fmt.Sprintf("%s/acme/%s/new-nonce", baseURL.String(), provName),
|
NewNonce: fmt.Sprintf("%s/acme/%s/new-nonce", baseURL.String(), provName),
|
||||||
NewAccount: fmt.Sprintf("%s/acme/%s/new-account", baseURL.String(), provName),
|
NewAccount: fmt.Sprintf("%s/acme/%s/new-account", baseURL.String(), provName),
|
||||||
|
@ -65,14 +97,32 @@ func TestHandler_GetDirectory(t *testing.T) {
|
||||||
RevokeCert: fmt.Sprintf("%s/acme/%s/revoke-cert", 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),
|
KeyChange: fmt.Sprintf("%s/acme/%s/key-change", baseURL.String(), provName),
|
||||||
}
|
}
|
||||||
|
|
||||||
type test struct {
|
|
||||||
statusCode int
|
|
||||||
err *acme.Error
|
|
||||||
}
|
|
||||||
var tests = map[string]func(t *testing.T) test{
|
|
||||||
"ok": func(t *testing.T) test {
|
|
||||||
return test{
|
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,
|
statusCode: 200,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -82,7 +132,7 @@ func TestHandler_GetDirectory(t *testing.T) {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
h := &Handler{linker: linker}
|
h := &Handler{linker: linker}
|
||||||
req := httptest.NewRequest("GET", "/foo/bar", nil)
|
req := httptest.NewRequest("GET", "/foo/bar", nil)
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(tc.ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.GetDirectory(w, req)
|
h.GetDirectory(w, req)
|
||||||
res := w.Result()
|
res := w.Result()
|
||||||
|
@ -105,7 +155,9 @@ func TestHandler_GetDirectory(t *testing.T) {
|
||||||
} else {
|
} else {
|
||||||
var dir Directory
|
var dir Directory
|
||||||
json.Unmarshal(bytes.TrimSpace(body), &dir)
|
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"})
|
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
|
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
|
// payloadFromContext searches the context for a payload. Returns the payload
|
||||||
// or an error.
|
// or an error.
|
||||||
func payloadFromContext(ctx context.Context) (*payloadInfo, error) {
|
func payloadFromContext(ctx context.Context) (*payloadInfo, error) {
|
||||||
|
|
74
acme/db.go
74
acme/db.go
|
@ -19,6 +19,13 @@ type DB interface {
|
||||||
GetAccountByKeyID(ctx context.Context, kid string) (*Account, error)
|
GetAccountByKeyID(ctx context.Context, kid string) (*Account, error)
|
||||||
UpdateAccount(ctx context.Context, acc *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)
|
CreateNonce(ctx context.Context) (Nonce, error)
|
||||||
DeleteNonce(ctx context.Context, nonce 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)
|
MockGetAccountByKeyID func(ctx context.Context, kid string) (*Account, error)
|
||||||
MockUpdateAccount func(ctx context.Context, acc *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)
|
MockCreateNonce func(ctx context.Context) (Nonce, error)
|
||||||
MockDeleteNonce func(ctx context.Context, nonce 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
|
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
|
// CreateNonce mock
|
||||||
func (m *MockDB) CreateNonce(ctx context.Context) (Nonce, error) {
|
func (m *MockDB) CreateNonce(ctx context.Context) (Nonce, error) {
|
||||||
if m.MockCreateNonce != nil {
|
if m.MockCreateNonce != nil {
|
||||||
|
|
|
@ -307,7 +307,7 @@ func TestDB_GetAccountByKeyID(t *testing.T) {
|
||||||
assert.Equals(t, string(key), accID)
|
assert.Equals(t, string(key), accID)
|
||||||
return nil, errors.New("force")
|
return nil, errors.New("force")
|
||||||
default:
|
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")
|
return nil, errors.New("force")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -340,7 +340,7 @@ func TestDB_GetAccountByKeyID(t *testing.T) {
|
||||||
assert.Equals(t, string(key), accID)
|
assert.Equals(t, string(key), accID)
|
||||||
return b, nil
|
return b, nil
|
||||||
default:
|
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")
|
return nil, errors.New("force")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -462,7 +462,7 @@ func TestDB_CreateAccount(t *testing.T) {
|
||||||
assert.True(t, dbacc.DeactivatedAt.IsZero())
|
assert.True(t, dbacc.DeactivatedAt.IsZero())
|
||||||
return nil, false, errors.New("force")
|
return nil, false, errors.New("force")
|
||||||
default:
|
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")
|
return nil, false, errors.New("force")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -506,7 +506,7 @@ func TestDB_CreateAccount(t *testing.T) {
|
||||||
assert.True(t, dbacc.DeactivatedAt.IsZero())
|
assert.True(t, dbacc.DeactivatedAt.IsZero())
|
||||||
return nu, true, nil
|
return nu, true, nil
|
||||||
default:
|
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")
|
return nil, false, errors.New("force")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
380
acme/db/nosql/eab.go
Normal file
380
acme/db/nosql/eab.go
Normal file
|
@ -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:]...)
|
||||||
|
}
|
1712
acme/db/nosql/eab_test.go
Normal file
1712
acme/db/nosql/eab_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
@ -20,6 +20,9 @@ var (
|
||||||
ordersByAccountIDTable = []byte("acme_account_orders_index")
|
ordersByAccountIDTable = []byte("acme_account_orders_index")
|
||||||
certTable = []byte("acme_certs")
|
certTable = []byte("acme_certs")
|
||||||
certBySerialTable = []byte("acme_serial_certs_index")
|
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.
|
// 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.
|
// New configures and returns a new ACME DB backend implemented using a nosql DB.
|
||||||
func New(db nosqlDB.DB) (*DB, error) {
|
func New(db nosqlDB.DB) (*DB, error) {
|
||||||
tables := [][]byte{accountTable, accountByKeyIDTable, authzTable,
|
tables := [][]byte{accountTable, accountByKeyIDTable, authzTable,
|
||||||
challengeTable, nonceTable, orderTable, ordersByAccountIDTable, certTable, certBySerialTable}
|
challengeTable, nonceTable, orderTable, ordersByAccountIDTable,
|
||||||
|
certTable, certBySerialTable, externalAccountKeyTable,
|
||||||
|
externalAccountKeyIDsByReferenceTable, externalAccountKeyIDsByProvisionerIDTable,
|
||||||
|
}
|
||||||
for _, b := range tables {
|
for _, b := range tables {
|
||||||
if err := db.CreateTable(b); err != nil {
|
if err := db.CreateTable(b); err != nil {
|
||||||
return nil, errors.Wrapf(err, "error creating table %s",
|
return nil, errors.Wrapf(err, "error creating table %s",
|
||||||
|
|
404
api/api_test.go
404
api/api_test.go
|
@ -167,6 +167,208 @@ func parseCertificateRequest(data string) *x509.CertificateRequest {
|
||||||
return csr
|
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) {
|
func TestNewCertificate(t *testing.T) {
|
||||||
cert := parseCertificate(rootPEM)
|
cert := parseCertificate(rootPEM)
|
||||||
if !reflect.DeepEqual(Certificate{Certificate: cert}, NewCertificate(cert)) {
|
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
|
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) {
|
func Test_caHandler_Route(t *testing.T) {
|
||||||
type fields struct {
|
type fields struct {
|
||||||
Authority Authority
|
Authority Authority
|
||||||
|
|
246
authority/admin/api/acme.go
Normal file
246
authority/admin/api/acme.go
Normal file
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
1222
authority/admin/api/acme_test.go
Normal file
1222
authority/admin/api/acme_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,14 +1,32 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/smallstep/certificates/api"
|
"github.com/smallstep/certificates/api"
|
||||||
"github.com/smallstep/certificates/authority/admin"
|
"github.com/smallstep/certificates/authority/admin"
|
||||||
|
"github.com/smallstep/certificates/authority/provisioner"
|
||||||
"go.step.sm/linkedca"
|
"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.
|
// CreateAdminRequest represents the body for a CreateAdmin request.
|
||||||
type CreateAdminRequest struct {
|
type CreateAdminRequest struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
|
|
919
authority/admin/api/admin_test.go
Normal file
919
authority/admin/api/admin_test.go
Normal file
|
@ -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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/smallstep/certificates/acme"
|
||||||
"github.com/smallstep/certificates/api"
|
"github.com/smallstep/certificates/api"
|
||||||
"github.com/smallstep/certificates/authority"
|
|
||||||
"github.com/smallstep/certificates/authority/admin"
|
"github.com/smallstep/certificates/authority/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is the ACME API request handler.
|
// Handler is the Admin API request handler.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db admin.DB
|
db admin.DB
|
||||||
auth *authority.Authority
|
auth adminAuthority
|
||||||
|
acmeDB acme.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a new Authority Config Handler.
|
// NewHandler returns a new Authority Config Handler.
|
||||||
func NewHandler(auth *authority.Authority) api.RouterHandler {
|
func NewHandler(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB) api.RouterHandler {
|
||||||
h := &Handler{db: auth.GetAdminDatabase(), auth: auth}
|
return &Handler{
|
||||||
|
db: adminDB,
|
||||||
return h
|
auth: auth,
|
||||||
|
acmeDB: acmeDB,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route traffic and implement the Router interface.
|
// Route traffic and implement the Router interface.
|
||||||
|
@ -25,6 +28,10 @@ func (h *Handler) Route(r api.Router) {
|
||||||
return h.extractAuthorizeTokenAdmin(h.requireAPIEnabled(next))
|
return h.extractAuthorizeTokenAdmin(h.requireAPIEnabled(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requireEABEnabled := func(next nextHTTP) nextHTTP {
|
||||||
|
return h.requireEABEnabled(next)
|
||||||
|
}
|
||||||
|
|
||||||
// Provisioners
|
// Provisioners
|
||||||
r.MethodFunc("GET", "/provisioners/{name}", authnz(h.GetProvisioner))
|
r.MethodFunc("GET", "/provisioners/{name}", authnz(h.GetProvisioner))
|
||||||
r.MethodFunc("GET", "/provisioners", authnz(h.GetProvisioners))
|
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("POST", "/admins", authnz(h.CreateAdmin))
|
||||||
r.MethodFunc("PATCH", "/admins/{id}", authnz(h.UpdateAdmin))
|
r.MethodFunc("PATCH", "/admins/{id}", authnz(h.UpdateAdmin))
|
||||||
r.MethodFunc("DELETE", "/admins/{id}", authnz(h.DeleteAdmin))
|
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)))
|
||||||
}
|
}
|
||||||
|
|
225
authority/admin/api/middleware_test.go
Normal file
225
authority/admin/api/middleware_test.go
Normal file
|
@ -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)
|
cursor, limit, err := api.ParseCursor(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err,
|
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err,
|
||||||
"error parsing cursor & limit query params"))
|
"error parsing cursor and limit from query params"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
1100
authority/admin/api/provisioner_test.go
Normal file
1100
authority/admin/api/provisioner_test.go
Normal file
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/authority/admin"
|
||||||
"github.com/smallstep/certificates/db"
|
"github.com/smallstep/certificates/db"
|
||||||
"github.com/smallstep/nosql"
|
"github.com/smallstep/nosql"
|
||||||
"github.com/smallstep/nosql/database"
|
nosqldb "github.com/smallstep/nosql/database"
|
||||||
"go.step.sm/linkedca"
|
"go.step.sm/linkedca"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"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, bucket, adminsTable)
|
||||||
assert.Equals(t, string(key), adminID)
|
assert.Equals(t, string(key), adminID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
|
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, bucket, adminsTable)
|
||||||
assert.Equals(t, string(key), adminID)
|
assert.Equals(t, string(key), adminID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
|
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, bucket, adminsTable)
|
||||||
assert.Equals(t, string(key), adminID)
|
assert.Equals(t, string(key), adminID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
|
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, bucket, adminsTable)
|
||||||
assert.Equals(t, string(key), adminID)
|
assert.Equals(t, string(key), adminID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
|
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, bucket, adminsTable)
|
||||||
assert.Equals(t, string(key), adminID)
|
assert.Equals(t, string(key), adminID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "admin adminID not found"),
|
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 {
|
"fail/db.List-error": func(t *testing.T) test {
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, adminsTable)
|
assert.Equals(t, bucket, adminsTable)
|
||||||
|
|
||||||
return nil, errors.New("force")
|
return nil, errors.New("force")
|
||||||
|
@ -995,14 +995,14 @@ func TestDB_GetAdmins(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fail/unmarshal-error": func(t *testing.T) test {
|
"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("foo"), Value: foob},
|
||||||
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
|
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
|
||||||
{Bucket: adminsTable, Key: []byte("zap"), Value: []byte("zap")},
|
{Bucket: adminsTable, Key: []byte("zap"), Value: []byte("zap")},
|
||||||
}
|
}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, adminsTable)
|
assert.Equals(t, bucket, adminsTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -1012,10 +1012,10 @@ func TestDB_GetAdmins(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok/none": func(t *testing.T) test {
|
"ok/none": func(t *testing.T) test {
|
||||||
ret := []*database.Entry{}
|
ret := []*nosqldb.Entry{}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, adminsTable)
|
assert.Equals(t, bucket, adminsTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -1027,13 +1027,13 @@ func TestDB_GetAdmins(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok/only-invalid": func(t *testing.T) test {
|
"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("bar"), Value: barb},
|
||||||
{Bucket: adminsTable, Key: []byte("baz"), Value: bazb},
|
{Bucket: adminsTable, Key: []byte("baz"), Value: bazb},
|
||||||
}
|
}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, adminsTable)
|
assert.Equals(t, bucket, adminsTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -1045,7 +1045,7 @@ func TestDB_GetAdmins(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok": func(t *testing.T) test {
|
"ok": func(t *testing.T) test {
|
||||||
ret := []*database.Entry{
|
ret := []*nosqldb.Entry{
|
||||||
{Bucket: adminsTable, Key: []byte("foo"), Value: foob},
|
{Bucket: adminsTable, Key: []byte("foo"), Value: foob},
|
||||||
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
|
{Bucket: adminsTable, Key: []byte("bar"), Value: barb},
|
||||||
{Bucket: adminsTable, Key: []byte("baz"), Value: bazb},
|
{Bucket: adminsTable, Key: []byte("baz"), Value: bazb},
|
||||||
|
@ -1053,7 +1053,7 @@ func TestDB_GetAdmins(t *testing.T) {
|
||||||
}
|
}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, adminsTable)
|
assert.Equals(t, bucket, adminsTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"github.com/smallstep/certificates/authority/admin"
|
"github.com/smallstep/certificates/authority/admin"
|
||||||
"github.com/smallstep/certificates/db"
|
"github.com/smallstep/certificates/db"
|
||||||
"github.com/smallstep/nosql"
|
"github.com/smallstep/nosql"
|
||||||
"github.com/smallstep/nosql/database"
|
nosqldb "github.com/smallstep/nosql/database"
|
||||||
"go.step.sm/linkedca"
|
"go.step.sm/linkedca"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ func TestDB_getDBProvisionerBytes(t *testing.T) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
assert.Equals(t, string(key), provID)
|
assert.Equals(t, string(key), provID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
|
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, bucket, provisionersTable)
|
||||||
assert.Equals(t, string(key), provID)
|
assert.Equals(t, string(key), provID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
|
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, bucket, provisionersTable)
|
||||||
assert.Equals(t, string(key), provID)
|
assert.Equals(t, string(key), provID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
|
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, bucket, provisionersTable)
|
||||||
assert.Equals(t, string(key), provID)
|
assert.Equals(t, string(key), provID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
|
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 {
|
"fail/db.List-error": func(t *testing.T) test {
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
|
|
||||||
return nil, errors.New("force")
|
return nil, errors.New("force")
|
||||||
|
@ -745,14 +745,14 @@ func TestDB_GetProvisioners(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fail/unmarshal-error": func(t *testing.T) test {
|
"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("foo"), Value: foob},
|
||||||
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
|
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
|
||||||
{Bucket: provisionersTable, Key: []byte("zap"), Value: []byte("zap")},
|
{Bucket: provisionersTable, Key: []byte("zap"), Value: []byte("zap")},
|
||||||
}
|
}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -762,10 +762,10 @@ func TestDB_GetProvisioners(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok/none": func(t *testing.T) test {
|
"ok/none": func(t *testing.T) test {
|
||||||
ret := []*database.Entry{}
|
ret := []*nosqldb.Entry{}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -777,13 +777,13 @@ func TestDB_GetProvisioners(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok/only-invalid": func(t *testing.T) test {
|
"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("bar"), Value: barb},
|
||||||
{Bucket: provisionersTable, Key: []byte("baz"), Value: bazb},
|
{Bucket: provisionersTable, Key: []byte("baz"), Value: bazb},
|
||||||
}
|
}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -795,7 +795,7 @@ func TestDB_GetProvisioners(t *testing.T) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok": func(t *testing.T) test {
|
"ok": func(t *testing.T) test {
|
||||||
ret := []*database.Entry{
|
ret := []*nosqldb.Entry{
|
||||||
{Bucket: provisionersTable, Key: []byte("foo"), Value: foob},
|
{Bucket: provisionersTable, Key: []byte("foo"), Value: foob},
|
||||||
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
|
{Bucket: provisionersTable, Key: []byte("bar"), Value: barb},
|
||||||
{Bucket: provisionersTable, Key: []byte("baz"), Value: bazb},
|
{Bucket: provisionersTable, Key: []byte("baz"), Value: bazb},
|
||||||
|
@ -803,7 +803,7 @@ func TestDB_GetProvisioners(t *testing.T) {
|
||||||
}
|
}
|
||||||
return test{
|
return test{
|
||||||
db: &db.MockNoSQLDB{
|
db: &db.MockNoSQLDB{
|
||||||
MList: func(bucket []byte) ([]*database.Entry, error) {
|
MList: func(bucket []byte) ([]*nosqldb.Entry, error) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -988,7 +988,7 @@ func TestDB_UpdateProvisioner(t *testing.T) {
|
||||||
assert.Equals(t, bucket, provisionersTable)
|
assert.Equals(t, bucket, provisionersTable)
|
||||||
assert.Equals(t, string(key), provID)
|
assert.Equals(t, string(key), provID)
|
||||||
|
|
||||||
return nil, database.ErrNotFound
|
return nil, nosqldb.ErrNotFound
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminErr: admin.NewError(admin.ErrorNotFoundType, "provisioner provID not found"),
|
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 Error struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
|
|
|
@ -17,6 +17,11 @@ type ACME struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ForceCN bool `json:"forceCN,omitempty"`
|
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"`
|
Claims *Claims `json:"claims,omitempty"`
|
||||||
Options *Options `json:"options,omitempty"`
|
Options *Options `json:"options,omitempty"`
|
||||||
claimer *Claimer
|
claimer *Claimer
|
||||||
|
|
|
@ -642,6 +642,7 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface,
|
||||||
Type: p.Type.String(),
|
Type: p.Type.String(),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
ForceCN: cfg.ForceCn,
|
ForceCN: cfg.ForceCn,
|
||||||
|
RequireEAB: cfg.RequireEab,
|
||||||
Claims: claims,
|
Claims: claims,
|
||||||
Options: options,
|
Options: options,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
@ -560,7 +560,115 @@ retry:
|
||||||
return nil
|
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 {
|
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()
|
defer r.Close()
|
||||||
adminErr := new(admin.Error)
|
adminErr := new(admin.Error)
|
||||||
if err := json.NewDecoder(r).Decode(adminErr); err != nil {
|
if err := json.NewDecoder(r).Decode(adminErr); err != nil {
|
||||||
|
|
2
ca/ca.go
2
ca/ca.go
|
@ -207,7 +207,7 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) {
|
||||||
if cfg.AuthorityConfig.EnableAdmin {
|
if cfg.AuthorityConfig.EnableAdmin {
|
||||||
adminDB := auth.GetAdminDatabase()
|
adminDB := auth.GetAdminDatabase()
|
||||||
if adminDB != nil {
|
if adminDB != nil {
|
||||||
adminHandler := adminAPI.NewHandler(auth)
|
adminHandler := adminAPI.NewHandler(auth, adminDB, acmeDB)
|
||||||
mux.Route("/admin", func(r chi.Router) {
|
mux.Route("/admin", func(r chi.Router) {
|
||||||
adminHandler.Route(r)
|
adminHandler.Route(r)
|
||||||
})
|
})
|
||||||
|
|
|
@ -346,6 +346,7 @@ Below is an example of an ACME provisioner in the `ca.json`:
|
||||||
"type": "ACME",
|
"type": "ACME",
|
||||||
"name": "my-acme-provisioner",
|
"name": "my-acme-provisioner",
|
||||||
"forceCN": true,
|
"forceCN": true,
|
||||||
|
"requireEAB": false,
|
||||||
"claims": {
|
"claims": {
|
||||||
"maxTLSCertDuration": "8h",
|
"maxTLSCertDuration": "8h",
|
||||||
"defaultTLSCertDuration": "2h",
|
"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
|
* `forceCN` (optional): force one of the SANs to become the Common Name, if a
|
||||||
common name is not provided.
|
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
|
* `claims` (optional): overwrites the default claims set in the authority, see
|
||||||
the [top](#provisioners) section for all the options.
|
the [top](#provisioners) section for all the options.
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue