diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6da2aa27..5d0416ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: uses: golangci/golangci-lint-action@v2 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: 'latest' + version: 'v1.44.0' # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10e7360d..f36e78ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: uses: golangci/golangci-lint-action@v2 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: 'v1.43.0' + version: 'v1.44.0' # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/CHANGELOG.md b/CHANGELOG.md index e9636942..101985f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [Unreleased - 0.18.1] - DATE +## [Unreleased - 0.18.2] - DATE ### Added -- Support for ACME revocation. -- Replace hash function with an RSA SSH CA to "rsa-sha2-256". ### Changed +- IPv6 addresses are normalized as IP addresses instead of hostnames. +- More descriptive JWK decryption error message. +- Make the X5C leaf certificate available to the templates using `{{ .AuthorizationCrt }}`. ### Deprecated ### Removed ### Fixed ### Security +## [0.18.1] - 2022-02-03 +### Added +- Support for ACME revocation. +- Replace hash function with an RSA SSH CA to "rsa-sha2-256". +- Support Nebula provisioners. +- Example Ansible configurations. +- Support PKCS#11 as a decrypter, as used by SCEP. +### Changed +- Automatically create database directory on `step ca init`. +- Slightly improve errors reported when a template has invalid content. +- Error reporting in logs and to clients. +### Fixed +- SCEP renewal using HTTPS on macOS. + ## [0.18.0] - 2021-11-17 ### Added - Support for multiple certificate authority contexts. diff --git a/acme/api/linker.go b/acme/api/linker.go index d4490470..a605ffc3 100644 --- a/acme/api/linker.go +++ b/acme/api/linker.go @@ -3,13 +3,29 @@ package api import ( "context" "fmt" + "net" "net/url" + "strings" "github.com/smallstep/certificates/acme" ) // NewLinker returns a new Directory type. func NewLinker(dns, prefix string) Linker { + _, _, err := net.SplitHostPort(dns) + if err != nil && strings.Contains(err.Error(), "too many colons in address") { + // this is most probably an IPv6 without brackets, e.g. ::1, 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + // in case a port was appended to this wrong format, we try to extract the port, then check if it's + // still a valid IPv6: 2001:0db8:85a3:0000:0000:8a2e:0370:7334:8443 (8443 is the port). If none of + // these cases, then the input dns is not changed. + lastIndex := strings.LastIndex(dns, ":") + hostPart, portPart := dns[:lastIndex], dns[lastIndex+1:] + if ip := net.ParseIP(hostPart); ip != nil { + dns = "[" + hostPart + "]:" + portPart + } else if ip := net.ParseIP(dns); ip != nil { + dns = "[" + dns + "]" + } + } return &linker{prefix: prefix, dns: dns} } diff --git a/acme/api/linker_test.go b/acme/api/linker_test.go index 4790dec8..74c2c8b0 100644 --- a/acme/api/linker_test.go +++ b/acme/api/linker_test.go @@ -31,6 +31,86 @@ func TestLinker_GetUnescapedPathSuffix(t *testing.T) { assert.Equals(t, getPath(CertificateLinkType, "{provisionerID}", "{certID}"), "/{provisionerID}/certificate/{certID}") } +func TestLinker_DNS(t *testing.T) { + prov := newProv() + escProvName := url.PathEscape(prov.GetName()) + ctx := context.WithValue(context.Background(), provisionerContextKey, prov) + type test struct { + name string + dns string + prefix string + expectedDirectoryLink string + } + tests := []test{ + { + name: "domain", + dns: "ca.smallstep.com", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://ca.smallstep.com/acme/%s/directory", escProvName), + }, + { + name: "domain-port", + dns: "ca.smallstep.com:8443", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://ca.smallstep.com:8443/acme/%s/directory", escProvName), + }, + { + name: "ipv4", + dns: "127.0.0.1", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://127.0.0.1/acme/%s/directory", escProvName), + }, + { + name: "ipv4-port", + dns: "127.0.0.1:8443", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://127.0.0.1:8443/acme/%s/directory", escProvName), + }, + { + name: "ipv6", + dns: "[::1]", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://[::1]/acme/%s/directory", escProvName), + }, + { + name: "ipv6-port", + dns: "[::1]:8443", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://[::1]:8443/acme/%s/directory", escProvName), + }, + { + name: "ipv6-no-brackets", + dns: "::1", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://[::1]/acme/%s/directory", escProvName), + }, + { + name: "ipv6-port-no-brackets", + dns: "::1:8443", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://[::1]:8443/acme/%s/directory", escProvName), + }, + { + name: "ipv6-long-no-brackets", + dns: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]/acme/%s/directory", escProvName), + }, + { + name: "ipv6-long-port-no-brackets", + dns: "2001:0db8:85a3:0000:0000:8a2e:0370:7334:8443", + prefix: "acme", + expectedDirectoryLink: fmt.Sprintf("https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8443/acme/%s/directory", escProvName), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + linker := NewLinker(tt.dns, tt.prefix) + assert.Equals(t, tt.expectedDirectoryLink, linker.GetLink(ctx, DirectoryLinkType)) + }) + } +} + func TestLinker_GetLink(t *testing.T) { dns := "ca.smallstep.com" prefix := "acme" diff --git a/authority/admin/api/acme.go b/authority/admin/api/acme.go index 2cd75900..27c3ba6f 100644 --- a/authority/admin/api/acme.go +++ b/authority/admin/api/acme.go @@ -2,17 +2,14 @@ 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 ( @@ -70,7 +67,7 @@ func (h *Handler) provisionerHasEABEnabled(ctx context.Context, provisionerName return false, nil, admin.WrapErrorISE(err, "error loading provisioner %s", provisionerName) } - prov, err := h.db.GetProvisioner(ctx, p.GetID()) + prov, err := h.adminDB.GetProvisioner(ctx, p.GetID()) if err != nil { return false, nil, admin.WrapErrorISE(err, "error getting provisioner with ID: %s", p.GetID()) } @@ -88,159 +85,31 @@ func (h *Handler) provisionerHasEABEnabled(ctx context.Context, provisionerName 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 +type acmeAdminResponderInterface interface { + GetExternalAccountKeys(w http.ResponseWriter, r *http.Request) + CreateExternalAccountKey(w http.ResponseWriter, r *http.Request) + DeleteExternalAccountKey(w http.ResponseWriter, r *http.Request) } -// 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 - } +// ACMEAdminResponder is responsible for writing ACME admin responses +type ACMEAdminResponder struct{} - 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) +// NewACMEAdminResponder returns a new ACMEAdminResponder +func NewACMEAdminResponder() *ACMEAdminResponder { + return &ACMEAdminResponder{} } -// 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 writes the response for the EAB keys GET endpoint +func (h *ACMEAdminResponder) GetExternalAccountKeys(w http.ResponseWriter, r *http.Request) { + api.WriteError(w, admin.NewError(admin.ErrorNotImplementedType, "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm")) } -// 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, - }) +// CreateExternalAccountKey writes the response for the EAB key POST endpoint +func (h *ACMEAdminResponder) CreateExternalAccountKey(w http.ResponseWriter, r *http.Request) { + api.WriteError(w, admin.NewError(admin.ErrorNotImplementedType, "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm")) +} + +// DeleteExternalAccountKey writes the response for the EAB key DELETE endpoint +func (h *ACMEAdminResponder) DeleteExternalAccountKey(w http.ResponseWriter, r *http.Request) { + api.WriteError(w, admin.NewError(admin.ErrorNotImplementedType, "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm")) } diff --git a/authority/admin/api/acme_test.go b/authority/admin/api/acme_test.go index 50086955..6ffe1418 100644 --- a/authority/admin/api/acme_test.go +++ b/authority/admin/api/acme_test.go @@ -10,19 +10,14 @@ import ( "net/http/httptest" "strings" "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/acme" "github.com/smallstep/certificates/authority/admin" "github.com/smallstep/certificates/authority/provisioner" "go.step.sm/linkedca" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" ) func readProtoJSON(r io.ReadCloser, m proto.Message) error { @@ -37,7 +32,7 @@ func readProtoJSON(r io.ReadCloser, m proto.Message) error { func TestHandler_requireEABEnabled(t *testing.T) { type test struct { ctx context.Context - db admin.DB + adminDB admin.DB auth adminAuthority next nextHTTP err *admin.Error @@ -98,7 +93,7 @@ func TestHandler_requireEABEnabled(t *testing.T) { return test{ ctx: ctx, auth: auth, - db: db, + adminDB: db, err: err, statusCode: 400, } @@ -134,9 +129,9 @@ func TestHandler_requireEABEnabled(t *testing.T) { }, } return test{ - ctx: ctx, - auth: auth, - db: db, + ctx: ctx, + auth: auth, + adminDB: db, next: func(w http.ResponseWriter, r *http.Request) { w.Write(nil) // mock response with status 200 }, @@ -149,9 +144,9 @@ func TestHandler_requireEABEnabled(t *testing.T) { tc := prep(t) t.Run(name, func(t *testing.T) { h := &Handler{ - db: tc.db, - auth: tc.auth, - acmeDB: nil, + auth: tc.auth, + adminDB: tc.adminDB, + acmeDB: nil, } req := httptest.NewRequest("GET", "/foo", nil) // chi routing is prepared in test setup @@ -183,7 +178,7 @@ func TestHandler_requireEABEnabled(t *testing.T) { func TestHandler_provisionerHasEABEnabled(t *testing.T) { type test struct { - db admin.DB + adminDB admin.DB auth adminAuthority provisionerName string want bool @@ -223,7 +218,7 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { } return test{ auth: auth, - db: db, + adminDB: db, provisionerName: "provName", want: false, err: admin.WrapErrorISE(errors.New("force"), "error loading provisioner provName"), @@ -252,7 +247,7 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { } return test{ auth: auth, - db: db, + adminDB: db, provisionerName: "provName", want: false, err: admin.WrapErrorISE(errors.New("force"), "error loading provisioner provName"), @@ -285,7 +280,7 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { } return test{ auth: auth, - db: db, + adminDB: db, provisionerName: "provName", want: false, err: admin.WrapErrorISE(errors.New("force"), "error loading provisioner provName"), @@ -319,7 +314,7 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { }, } return test{ - db: db, + adminDB: db, auth: auth, provisionerName: "eab-disabled", want: false, @@ -353,7 +348,7 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { }, } return test{ - db: db, + adminDB: db, auth: auth, provisionerName: "eab-enabled", want: true, @@ -364,9 +359,9 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { tc := prep(t) t.Run(name, func(t *testing.T) { h := &Handler{ - db: tc.db, - auth: tc.auth, - acmeDB: nil, + auth: tc.auth, + adminDB: tc.adminDB, + acmeDB: nil, } got, prov, err := h.provisionerHasEABEnabled(context.TODO(), tc.provisionerName) if (err != nil) != (tc.err != nil) { @@ -391,54 +386,6 @@ func TestHandler_provisionerHasEABEnabled(t *testing.T) { } } -func Test_provisionerFromContext(t *testing.T) { - prov := &linkedca.Provisioner{ - Id: "provID", - Name: "acmeProv", - } - tests := []struct { - name string - ctx context.Context - want *linkedca.Provisioner - wantErr bool - }{ - { - name: "fail/no-provisioner", - ctx: context.Background(), - want: nil, - wantErr: true, - }, - { - name: "fail/wrong-type", - ctx: context.WithValue(context.Background(), provisionerContextKey, "prov"), - want: nil, - wantErr: true, - }, - { - name: "ok", - ctx: context.WithValue(context.Background(), provisionerContextKey, prov), - want: &linkedca.Provisioner{ - Id: "provID", - Name: "acmeProv", - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := provisionerFromContext(tt.ctx) - if (err != nil) != tt.wantErr { - t.Errorf("provisionerFromContext() error = %v, wantErr %v", err, tt.wantErr) - return - } - opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Provisioner{})} - if !cmp.Equal(tt.want, got, opts...) { - t.Errorf("provisionerFromContext() diff =\n %s", cmp.Diff(tt.want, got, opts...)) - } - }) - } -} - func TestCreateExternalAccountKeyRequest_Validate(t *testing.T) { type fields struct { Reference string @@ -483,740 +430,158 @@ func TestCreateExternalAccountKeyRequest_Validate(t *testing.T) { } func TestHandler_CreateExternalAccountKey(t *testing.T) { - prov := &linkedca.Provisioner{ - Id: "provID", - Name: "provName", - } type test struct { ctx context.Context - db acme.DB - body []byte statusCode int - eak *linkedca.EABKey err *admin.Error } var tests = map[string]func(t *testing.T) test{ - "fail/ReadJSON": func(t *testing.T) test { + "ok": func(t *testing.T) test { chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - body := []byte("{!?}") return test{ ctx: ctx, - body: body, - statusCode: 400, - eak: nil, + statusCode: 501, 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 { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - req := CreateExternalAccountKeyRequest{ - Reference: strings.Repeat("A", 257), - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - return test{ - ctx: ctx, - body: body, - statusCode: 400, - eak: nil, - err: &admin.Error{ - Type: admin.ErrorBadRequestType.String(), - Status: 400, - Detail: "bad request", - Message: "error validating request body: reference length 257 exceeds the maximum (256)", - }, - } - }, - "fail/no-provisioner-in-context": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - req := CreateExternalAccountKeyRequest{ - Reference: "aRef", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - return test{ - ctx: ctx, - body: body, - statusCode: 500, - eak: nil, - err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "error getting provisioner from context: provisioner expected in request context", - }, - } - }, - "fail/acmeDB.GetExternalAccountKeyByReference": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - req := CreateExternalAccountKeyRequest{ - Reference: "an-external-key-reference", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return nil, errors.New("force") - }, - } - return test{ - ctx: ctx, - db: db, - body: body, - statusCode: 500, - eak: nil, - err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "could not lookup external account key by reference: force", - }, - } - }, - "fail/reference-conflict-409": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - req := CreateExternalAccountKeyRequest{ - Reference: "an-external-key-reference", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - past := time.Now().Add(-24 * time.Hour) - return &acme.ExternalAccountKey{ - ID: "eakID", - ProvisionerID: "provID", - Reference: "an-external-key-reference", - KeyBytes: []byte{1, 3, 3, 7}, - CreatedAt: past, - }, nil - }, - } - return test{ - ctx: ctx, - db: db, - body: body, - statusCode: 409, - eak: nil, - err: &admin.Error{ - Type: admin.ErrorBadRequestType.String(), - Status: 409, - Detail: "bad request", - Message: "an ACME EAB key for provisioner 'provName' with reference 'an-external-key-reference' already exists", - }, - } - }, - "fail/acmeDB.CreateExternalAccountKey-no-reference": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - req := CreateExternalAccountKeyRequest{ - Reference: "", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - db := &acme.MockDB{ - MockCreateExternalAccountKey: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "", reference) - return nil, errors.New("force") - }, - } - return test{ - ctx: ctx, - db: db, - body: body, - statusCode: 500, - err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "error creating ACME EAB key for provisioner 'provName': force", - }, - } - }, - "fail/acmeDB.CreateExternalAccountKey-with-reference": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - req := CreateExternalAccountKeyRequest{ - Reference: "an-external-key-reference", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return nil, acme.ErrNotFound // simulating not found; skipping 409 conflict - }, - MockCreateExternalAccountKey: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return nil, errors.New("force") - }, - } - return test{ - ctx: ctx, - db: db, - body: body, - statusCode: 500, - err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "error creating ACME EAB key for provisioner 'provName' and reference 'an-external-key-reference': force", - }, - } - }, - "ok/no-reference": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - req := CreateExternalAccountKeyRequest{ - Reference: "", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - now := time.Now() - db := &acme.MockDB{ - MockCreateExternalAccountKey: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "", reference) - return &acme.ExternalAccountKey{ - ID: "eakID", - ProvisionerID: "provID", - Reference: "", - KeyBytes: []byte{1, 3, 3, 7}, - CreatedAt: now, - }, nil - }, - } - return test{ - ctx: ctx, - db: db, - body: body, - statusCode: 201, - eak: &linkedca.EABKey{ - Id: "eakID", - Provisioner: "provName", - Reference: "", - HmacKey: []byte{1, 3, 3, 7}, - }, - } - }, - "ok/with-reference": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - req := CreateExternalAccountKeyRequest{ - Reference: "an-external-key-reference", - } - body, err := json.Marshal(req) - assert.FatalError(t, err) - now := time.Now() - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return nil, acme.ErrNotFound // simulating not found; skipping 409 conflict - }, - MockCreateExternalAccountKey: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return &acme.ExternalAccountKey{ - ID: "eakID", - ProvisionerID: "provID", - Reference: "an-external-key-reference", - KeyBytes: []byte{1, 3, 3, 7}, - CreatedAt: now, - }, nil - }, - } - return test{ - ctx: ctx, - db: db, - body: body, - statusCode: 201, - eak: &linkedca.EABKey{ - Id: "eakID", - Provisioner: "provName", - Reference: "an-external-key-reference", - HmacKey: []byte{1, 3, 3, 7}, + Type: admin.ErrorNotImplementedType.String(), + Status: http.StatusNotImplemented, + Message: "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm", + Detail: "not implemented", }, } }, } - for name, prep := range tests { tc := prep(t) t.Run(name, func(t *testing.T) { - h := &Handler{ - acmeDB: tc.db, - } - req := httptest.NewRequest("POST", "/foo", io.NopCloser(bytes.NewBuffer(tc.body))) // chi routing is prepared in test setup + + req := httptest.NewRequest("POST", "/foo", nil) // chi routing is prepared in test setup req = req.WithContext(tc.ctx) w := httptest.NewRecorder() - h.CreateExternalAccountKey(w, req) + acmeResponder := NewACMEAdminResponder() + acmeResponder.CreateExternalAccountKey(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 - } - - eabKey := &linkedca.EABKey{} - err := readProtoJSON(res.Body, eabKey) + body, err := io.ReadAll(res.Body) + res.Body.Close() assert.FatalError(t, err) - assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"]) - opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.EABKey{})} - if !cmp.Equal(tc.eak, eabKey, opts...) { - t.Errorf("h.CreateExternalAccountKey diff =\n%s", cmp.Diff(tc.eak, eabKey, opts...)) - } + 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"]) }) } } func TestHandler_DeleteExternalAccountKey(t *testing.T) { - prov := &linkedca.Provisioner{ - Id: "provID", - Name: "provName", - } type test struct { ctx context.Context - db acme.DB statusCode int err *admin.Error } var tests = map[string]func(t *testing.T) test{ - "fail/no-provisioner-in-context": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - return test{ - ctx: ctx, - statusCode: 500, - err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "error getting provisioner from context: provisioner expected in request context", - }, - } - }, - "fail/acmeDB.DeleteExternalAccountKey": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - chiCtx.URLParams.Add("id", "keyID") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - db := &acme.MockDB{ - MockDeleteExternalAccountKey: func(ctx context.Context, provisionerID, keyID string) error { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "keyID", keyID) - return errors.New("force") - }, - } - return test{ - ctx: ctx, - db: db, - statusCode: 500, - err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "error deleting ACME EAB Key 'keyID': force", - }, - } - }, "ok": func(t *testing.T) test { chiCtx := chi.NewRouteContext() chiCtx.URLParams.Add("provisionerName", "provName") chiCtx.URLParams.Add("id", "keyID") ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - db := &acme.MockDB{ - MockDeleteExternalAccountKey: func(ctx context.Context, provisionerID, keyID string) error { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "keyID", keyID) - return nil - }, - } return test{ ctx: ctx, - db: db, - statusCode: 200, - err: nil, + statusCode: 501, + err: &admin.Error{ + Type: admin.ErrorNotImplementedType.String(), + Status: http.StatusNotImplemented, + Message: "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm", + Detail: "not implemented", + }, } }, } for name, prep := range tests { tc := prep(t) t.Run(name, func(t *testing.T) { - h := &Handler{ - acmeDB: tc.db, - } + req := httptest.NewRequest("DELETE", "/foo", nil) // chi routing is prepared in test setup req = req.WithContext(tc.ctx) w := httptest.NewRecorder() - h.DeleteExternalAccountKey(w, req) + acmeResponder := NewACMEAdminResponder() + acmeResponder.DeleteExternalAccountKey(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"]) + 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"]) }) } } func TestHandler_GetExternalAccountKeys(t *testing.T) { - prov := &linkedca.Provisioner{ - Id: "provID", - Name: "provName", - } type test struct { ctx context.Context - db acme.DB statusCode int req *http.Request - resp GetExternalAccountKeysResponse err *admin.Error } var tests = map[string]func(t *testing.T) test{ - "fail/no-provisioner-in-context": func(t *testing.T) test { + "ok": func(t *testing.T) test { chiCtx := chi.NewRouteContext() chiCtx.URLParams.Add("provisionerName", "provName") - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) req := httptest.NewRequest("GET", "/foo", nil) + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) return test{ ctx: ctx, - statusCode: 500, + statusCode: 501, req: req, err: &admin.Error{ - Type: admin.ErrorServerInternalType.String(), - Status: 500, - Detail: "the server experienced an internal error", - Message: "error getting provisioner from context: provisioner expected in request context", + Type: admin.ErrorNotImplementedType.String(), + Status: http.StatusNotImplemented, + Message: "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm", + Detail: "not implemented", }, } }, - "fail/parse-cursor": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - req := httptest.NewRequest("GET", "/foo?limit=A", nil) - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - return test{ - ctx: ctx, - statusCode: 400, - req: req, - 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/acmeDB.GetExternalAccountKeyByReference": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - chiCtx.URLParams.Add("reference", "an-external-key-reference") - req := httptest.NewRequest("GET", "/foo", nil) - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return nil, errors.New("force") - }, - } - return test{ - ctx: ctx, - statusCode: 500, - req: req, - db: db, - err: &admin.Error{ - Status: 500, - Type: admin.ErrorServerInternalType.String(), - Detail: "the server experienced an internal error", - Message: "error retrieving external account key with reference 'an-external-key-reference': force", - }, - } - }, - "fail/acmeDB.GetExternalAccountKeys": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - req := httptest.NewRequest("GET", "/foo", nil) - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - db := &acme.MockDB{ - MockGetExternalAccountKeys: func(ctx context.Context, provisionerID, cursor string, limit int) ([]*acme.ExternalAccountKey, string, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "", cursor) - assert.Equals(t, 0, limit) - return nil, "", errors.New("force") - }, - } - return test{ - ctx: ctx, - statusCode: 500, - req: req, - db: db, - err: &admin.Error{ - Status: 500, - Type: admin.ErrorServerInternalType.String(), - Detail: "the server experienced an internal error", - Message: "error retrieving external account keys: force", - }, - } - }, - "ok/reference-not-found": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - chiCtx.URLParams.Add("reference", "an-external-key-reference") - req := httptest.NewRequest("GET", "/foo", nil) - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return nil, nil // returning nil; no key found - }, - } - return test{ - ctx: ctx, - statusCode: 200, - req: req, - resp: GetExternalAccountKeysResponse{ - EAKs: []*linkedca.EABKey{}, - }, - db: db, - err: nil, - } - }, - "ok/reference-found": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - chiCtx.URLParams.Add("reference", "an-external-key-reference") - req := httptest.NewRequest("GET", "/foo", nil) - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - createdAt := time.Now().Add(-24 * time.Hour) - var boundAt time.Time - db := &acme.MockDB{ - MockGetExternalAccountKeyByReference: func(ctx context.Context, provisionerID, reference string) (*acme.ExternalAccountKey, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "an-external-key-reference", reference) - return &acme.ExternalAccountKey{ - ID: "eakID", - ProvisionerID: "provID", - Reference: "an-external-key-reference", - CreatedAt: createdAt, - }, nil - }, - } - return test{ - ctx: ctx, - statusCode: 200, - req: req, - resp: GetExternalAccountKeysResponse{ - EAKs: []*linkedca.EABKey{ - { - Id: "eakID", - Provisioner: "provName", - Reference: "an-external-key-reference", - CreatedAt: timestamppb.New(createdAt), - BoundAt: timestamppb.New(boundAt), - }, - }, - }, - db: db, - err: nil, - } - }, - "ok/multiple-keys": func(t *testing.T) test { - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("provisionerName", "provName") - req := httptest.NewRequest("GET", "/foo", nil) - ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, provisionerContextKey, prov) - createdAt := time.Now().Add(-24 * time.Hour) - var boundAt time.Time - boundAtSet := time.Now().Add(-12 * time.Hour) - db := &acme.MockDB{ - MockGetExternalAccountKeys: func(ctx context.Context, provisionerID, cursor string, limit int) ([]*acme.ExternalAccountKey, string, error) { - assert.Equals(t, "provID", provisionerID) - assert.Equals(t, "", cursor) - assert.Equals(t, 0, limit) - return []*acme.ExternalAccountKey{ - { - ID: "eakID1", - ProvisionerID: "provID", - Reference: "some-external-key-reference", - KeyBytes: []byte{1, 3, 3, 7}, - CreatedAt: createdAt, - }, - { - ID: "eakID2", - ProvisionerID: "provID", - Reference: "some-other-external-key-reference", - KeyBytes: []byte{1, 3, 3, 7}, - CreatedAt: createdAt.Add(1 * time.Hour), - }, - { - ID: "eakID3", - ProvisionerID: "provID", - Reference: "another-external-key-reference", - KeyBytes: []byte{1, 3, 3, 7}, - CreatedAt: createdAt, - BoundAt: boundAtSet, - AccountID: "accountID", - }, - }, "", nil - }, - } - return test{ - ctx: ctx, - statusCode: 200, - req: req, - resp: GetExternalAccountKeysResponse{ - EAKs: []*linkedca.EABKey{ - { - Id: "eakID1", - Provisioner: "provName", - Reference: "some-external-key-reference", - CreatedAt: timestamppb.New(createdAt), - BoundAt: timestamppb.New(boundAt), - }, - { - Id: "eakID2", - Provisioner: "provName", - Reference: "some-other-external-key-reference", - CreatedAt: timestamppb.New(createdAt.Add(1 * time.Hour)), - BoundAt: timestamppb.New(boundAt), - }, - { - Id: "eakID3", - Provisioner: "provName", - Reference: "another-external-key-reference", - CreatedAt: timestamppb.New(createdAt), - BoundAt: timestamppb.New(boundAtSet), - Account: "accountID", - }, - }, - }, - db: db, - err: nil, - } - }, } for name, prep := range tests { tc := prep(t) t.Run(name, func(t *testing.T) { - h := &Handler{ - acmeDB: tc.db, - } + req := tc.req.WithContext(tc.ctx) w := httptest.NewRecorder() - h.GetExternalAccountKeys(w, req) + acmeResponder := NewACMEAdminResponder() + acmeResponder.GetExternalAccountKeys(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 := GetExternalAccountKeysResponse{} - assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &response)) + 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"]) - - opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.EABKey{}, timestamppb.Timestamp{})} - if !cmp.Equal(tc.resp, response, opts...) { - t.Errorf("h.GetExternalAccountKeys diff =\n%s", cmp.Diff(tc.resp, response, opts...)) - } }) } } diff --git a/authority/admin/api/handler.go b/authority/admin/api/handler.go index 51751057..99e74c88 100644 --- a/authority/admin/api/handler.go +++ b/authority/admin/api/handler.go @@ -8,17 +8,19 @@ import ( // Handler is the Admin API request handler. type Handler struct { - db admin.DB - auth adminAuthority - acmeDB acme.DB + adminDB admin.DB + auth adminAuthority + acmeDB acme.DB + acmeResponder acmeAdminResponderInterface } // NewHandler returns a new Authority Config Handler. -func NewHandler(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB) api.RouterHandler { +func NewHandler(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB, acmeResponder acmeAdminResponderInterface) api.RouterHandler { return &Handler{ - db: adminDB, - auth: auth, - acmeDB: acmeDB, + auth: auth, + adminDB: adminDB, + acmeDB: acmeDB, + acmeResponder: acmeResponder, } } @@ -47,8 +49,8 @@ func (h *Handler) Route(r api.Router) { 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))) + r.MethodFunc("GET", "/acme/eab/{provisionerName}/{reference}", authnz(requireEABEnabled(h.acmeResponder.GetExternalAccountKeys))) + r.MethodFunc("GET", "/acme/eab/{provisionerName}", authnz(requireEABEnabled(h.acmeResponder.GetExternalAccountKeys))) + r.MethodFunc("POST", "/acme/eab/{provisionerName}", authnz(requireEABEnabled(h.acmeResponder.CreateExternalAccountKey))) + r.MethodFunc("DELETE", "/acme/eab/{provisionerName}/{id}", authnz(requireEABEnabled(h.acmeResponder.DeleteExternalAccountKey))) } diff --git a/authority/admin/api/provisioner.go b/authority/admin/api/provisioner.go index d111f1e6..b8cc0f4c 100644 --- a/authority/admin/api/provisioner.go +++ b/authority/admin/api/provisioner.go @@ -41,7 +41,7 @@ func (h *Handler) GetProvisioner(w http.ResponseWriter, r *http.Request) { } } - prov, err := h.db.GetProvisioner(ctx, p.GetID()) + prov, err := h.adminDB.GetProvisioner(ctx, p.GetID()) if err != nil { api.WriteError(w, err) return @@ -134,7 +134,7 @@ func (h *Handler) UpdateProvisioner(w http.ResponseWriter, r *http.Request) { return } - old, err := h.db.GetProvisioner(r.Context(), _old.GetID()) + old, err := h.adminDB.GetProvisioner(r.Context(), _old.GetID()) if err != nil { api.WriteError(w, admin.WrapErrorISE(err, "error loading provisioner from db '%s'", _old.GetID())) return diff --git a/authority/admin/api/provisioner_test.go b/authority/admin/api/provisioner_test.go index 6c463590..6d5024f2 100644 --- a/authority/admin/api/provisioner_test.go +++ b/authority/admin/api/provisioner_test.go @@ -26,7 +26,7 @@ func TestHandler_GetProvisioner(t *testing.T) { type test struct { ctx context.Context auth adminAuthority - db admin.DB + adminDB admin.DB req *http.Request statusCode int err *admin.Error @@ -104,7 +104,7 @@ func TestHandler_GetProvisioner(t *testing.T) { ctx: ctx, req: req, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -143,7 +143,7 @@ func TestHandler_GetProvisioner(t *testing.T) { ctx: ctx, req: req, auth: auth, - db: db, + adminDB: db, statusCode: 200, err: nil, prov: prov, @@ -154,8 +154,8 @@ func TestHandler_GetProvisioner(t *testing.T) { tc := prep(t) t.Run(name, func(t *testing.T) { h := &Handler{ - auth: tc.auth, - db: tc.db, + auth: tc.auth, + adminDB: tc.adminDB, } req := tc.req.WithContext(tc.ctx) w := httptest.NewRecorder() @@ -605,7 +605,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx context.Context auth adminAuthority body []byte - db admin.DB + adminDB admin.DB statusCode int err *admin.Error prov *linkedca.Provisioner @@ -685,7 +685,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -728,7 +728,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -772,7 +772,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -818,7 +818,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -867,7 +867,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -919,7 +919,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: admin.ErrorServerInternalType.String(), @@ -978,7 +978,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 500, err: &admin.Error{ Type: "", // TODO(hs): this error can be improved @@ -1043,7 +1043,7 @@ func TestHandler_UpdateProvisioner(t *testing.T) { ctx: ctx, body: body, auth: auth, - db: db, + adminDB: db, statusCode: 200, prov: prov, } @@ -1053,8 +1053,8 @@ func TestHandler_UpdateProvisioner(t *testing.T) { tc := prep(t) t.Run(name, func(t *testing.T) { h := &Handler{ - auth: tc.auth, - db: tc.db, + auth: tc.auth, + adminDB: tc.adminDB, } req := httptest.NewRequest("POST", "/foo", io.NopCloser(bytes.NewBuffer(tc.body))) req = req.WithContext(tc.ctx) diff --git a/authority/authority.go b/authority/authority.go index 6624f11d..b10c3c33 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -50,6 +50,7 @@ type Authority struct { rootX509CertPool *x509.CertPool federatedX509Certs []*x509.Certificate certificates *sync.Map + x509Enforcers []provisioner.CertificateEnforcer // SCEP CA scepService *scep.Service diff --git a/authority/config/config.go b/authority/config/config.go index 75c32994..589b5bbf 100644 --- a/authority/config/config.go +++ b/authority/config/config.go @@ -270,28 +270,36 @@ func (c *Config) GetAudiences() provisioner.Audiences { for _, name := range c.DNSNames { audiences.Sign = append(audiences.Sign, - fmt.Sprintf("https://%s/1.0/sign", name), - fmt.Sprintf("https://%s/sign", name), - fmt.Sprintf("https://%s/1.0/ssh/sign", name), - fmt.Sprintf("https://%s/ssh/sign", name)) + fmt.Sprintf("https://%s/1.0/sign", toHostname(name)), + fmt.Sprintf("https://%s/sign", toHostname(name)), + fmt.Sprintf("https://%s/1.0/ssh/sign", toHostname(name)), + fmt.Sprintf("https://%s/ssh/sign", toHostname(name))) audiences.Revoke = append(audiences.Revoke, - fmt.Sprintf("https://%s/1.0/revoke", name), - fmt.Sprintf("https://%s/revoke", name)) + fmt.Sprintf("https://%s/1.0/revoke", toHostname(name)), + fmt.Sprintf("https://%s/revoke", toHostname(name))) audiences.SSHSign = append(audiences.SSHSign, - fmt.Sprintf("https://%s/1.0/ssh/sign", name), - fmt.Sprintf("https://%s/ssh/sign", name), - fmt.Sprintf("https://%s/1.0/sign", name), - fmt.Sprintf("https://%s/sign", name)) + fmt.Sprintf("https://%s/1.0/ssh/sign", toHostname(name)), + fmt.Sprintf("https://%s/ssh/sign", toHostname(name)), + fmt.Sprintf("https://%s/1.0/sign", toHostname(name)), + fmt.Sprintf("https://%s/sign", toHostname(name))) audiences.SSHRevoke = append(audiences.SSHRevoke, - fmt.Sprintf("https://%s/1.0/ssh/revoke", name), - fmt.Sprintf("https://%s/ssh/revoke", name)) + fmt.Sprintf("https://%s/1.0/ssh/revoke", toHostname(name)), + fmt.Sprintf("https://%s/ssh/revoke", toHostname(name))) audiences.SSHRenew = append(audiences.SSHRenew, - fmt.Sprintf("https://%s/1.0/ssh/renew", name), - fmt.Sprintf("https://%s/ssh/renew", name)) + fmt.Sprintf("https://%s/1.0/ssh/renew", toHostname(name)), + fmt.Sprintf("https://%s/ssh/renew", toHostname(name))) audiences.SSHRekey = append(audiences.SSHRekey, - fmt.Sprintf("https://%s/1.0/ssh/rekey", name), - fmt.Sprintf("https://%s/ssh/rekey", name)) + fmt.Sprintf("https://%s/1.0/ssh/rekey", toHostname(name)), + fmt.Sprintf("https://%s/ssh/rekey", toHostname(name))) } return audiences } + +func toHostname(name string) string { + // ensure an IPv6 address is represented with square brackets when used as hostname + if ip := net.ParseIP(name); ip != nil && ip.To4() == nil { + name = "[" + name + "]" + } + return name +} diff --git a/authority/config/config_test.go b/authority/config/config_test.go index a5b60513..b921be13 100644 --- a/authority/config/config_test.go +++ b/authority/config/config_test.go @@ -7,9 +7,8 @@ import ( "github.com/pkg/errors" "github.com/smallstep/assert" "github.com/smallstep/certificates/authority/provisioner" - "go.step.sm/crypto/jose" - _ "github.com/smallstep/certificates/cas" + "go.step.sm/crypto/jose" ) func TestConfigValidate(t *testing.T) { @@ -298,3 +297,23 @@ func TestAuthConfigValidate(t *testing.T) { }) } } + +func Test_toHostname(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "localhost", want: "localhost"}, + {name: "ca.smallstep.com", want: "ca.smallstep.com"}, + {name: "127.0.0.1", want: "127.0.0.1"}, + {name: "::1", want: "[::1]"}, + {name: "[::1]", want: "[::1]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := toHostname(tt.name); got != tt.want { + t.Errorf("toHostname() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/authority/options.go b/authority/options.go index a18d40e2..f92db99b 100644 --- a/authority/options.go +++ b/authority/options.go @@ -241,6 +241,15 @@ func WithLinkedCAToken(token string) Option { } } +// WithX509Enforcers is an option that allows to define custom certificate +// modifiers that will be processed just before the signing of the certificate. +func WithX509Enforcers(ces ...provisioner.CertificateEnforcer) Option { + return func(a *Authority) error { + a.x509Enforcers = ces + return nil + } +} + func readCertificateBundle(pemCerts []byte) ([]*x509.Certificate, error) { var block *pem.Block var certs []*x509.Certificate diff --git a/authority/provisioner/nebula.go b/authority/provisioner/nebula.go index 39980cc8..f8027de9 100644 --- a/authority/provisioner/nebula.go +++ b/authority/provisioner/nebula.go @@ -157,8 +157,9 @@ func (p *Nebula) AuthorizeSign(ctx context.Context, token string) ([]SignOption, data.SetToken(v) } - // The Nebula certificate will be available using the template variable Crt. - // For example {{ .Crt.Details.Groups }} can be used to get all the groups. + // The Nebula certificate will be available using the template variable + // AuthorizationCrt. For example {{ .AuthorizationCrt.Details.Groups }} can + // be used to get all the groups. data.SetAuthorizationCertificate(crt) templateOptions, err := TemplateOptions(p.Options, data) diff --git a/authority/provisioner/x5c.go b/authority/provisioner/x5c.go index 850fc752..12112cc6 100644 --- a/authority/provisioner/x5c.go +++ b/authority/provisioner/x5c.go @@ -231,6 +231,11 @@ func (p *X5C) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er data.SetToken(v) } + // The X509 certificate will be available using the template variable + // AuthorizationCrt. For example {{ .AuthorizationCrt.DNSNames }} can be + // used to get all the domains. + data.SetAuthorizationCertificate(claims.chains[0][0]) + templateOptions, err := TemplateOptions(p.Options, data) if err != nil { return nil, errs.Wrap(http.StatusInternalServerError, err, "jwk.AuthorizeSign") @@ -306,6 +311,11 @@ func (p *X5C) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, data.SetToken(v) } + // The X509 certificate will be available using the template variable + // AuthorizationCrt. For example {{ .AuthorizationCrt.DNSNames }} can be + // used to get all the domains. + data.SetAuthorizationCertificate(claims.chains[0][0]) + templateOptions, err := TemplateSSHOptions(p.Options, data) if err != nil { return nil, errs.Wrap(http.StatusInternalServerError, err, "x5c.AuthorizeSSHSign") diff --git a/authority/ssh_test.go b/authority/ssh_test.go index 2ea65352..c299b347 100644 --- a/authority/ssh_test.go +++ b/authority/ssh_test.go @@ -57,7 +57,7 @@ func (m sshTestCertModifier) Modify(cert *ssh.Certificate, opts provisioner.Sign if m == "" { return nil } - return fmt.Errorf(string(m)) + return errors.New(string(m)) } type sshTestCertValidator string @@ -66,7 +66,7 @@ func (v sshTestCertValidator) Valid(crt *ssh.Certificate, opts provisioner.SignS if v == "" { return nil } - return fmt.Errorf(string(v)) + return errors.New(string(v)) } type sshTestOptionsValidator string @@ -75,7 +75,7 @@ func (v sshTestOptionsValidator) Valid(opts provisioner.SignSSHOptions) error { if v == "" { return nil } - return fmt.Errorf(string(v)) + return errors.New(string(v)) } type sshTestOptionsModifier string @@ -84,7 +84,7 @@ func (m sshTestOptionsModifier) Modify(cert *ssh.Certificate, opts provisioner.S if m == "" { return nil } - return fmt.Errorf(string(m)) + return errors.New(string(m)) } func TestAuthority_initHostOnly(t *testing.T) { diff --git a/authority/tls.go b/authority/tls.go index cc049655..58a1247c 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -10,6 +10,7 @@ import ( "encoding/json" "encoding/pem" "fmt" + "net" "net/http" "strings" "time" @@ -180,6 +181,17 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign } } + // Process injected modifiers after validation + for _, m := range a.x509Enforcers { + if err := m.Enforce(leaf); err != nil { + return nil, errs.ApplyOptions( + errs.ForbiddenErr(err, "error creating certificate"), + opts..., + ) + } + } + + // Sign certificate lifetime := leaf.NotAfter.Sub(leaf.NotBefore.Add(signOpts.Backdate)) resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{ Template: leaf, @@ -508,8 +520,19 @@ func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) { return fatal(errors.New("private key is not a crypto.Signer")) } + // prepare the sans: IPv6 DNS hostname representations are converted to their IP representation + sans := make([]string, len(a.config.DNSNames)) + for i, san := range a.config.DNSNames { + if strings.HasPrefix(san, "[") && strings.HasSuffix(san, "]") { + if ip := net.ParseIP(san[1 : len(san)-1]); ip != nil { + san = ip.String() + } + } + sans[i] = san + } + // Create initial certificate request. - cr, err := x509util.CreateCertificateRequest("Step Online CA", a.config.DNSNames, signer) + cr, err := x509util.CreateCertificateRequest("Step Online CA", sans, signer) if err != nil { return fatal(err) } diff --git a/authority/tls_test.go b/authority/tls_test.go index 3a0c999e..aeadaf0f 100644 --- a/authority/tls_test.go +++ b/authority/tls_test.go @@ -205,6 +205,17 @@ type basicConstraints struct { MaxPathLen int `asn1:"optional,default:-1"` } +type testEnforcer struct { + enforcer func(*x509.Certificate) error +} + +func (e *testEnforcer) Enforce(cert *x509.Certificate) error { + if e.enforcer != nil { + return e.enforcer(cert) + } + return nil +} + func TestAuthority_Sign(t *testing.T) { pub, priv, err := keyutil.GenerateDefaultKeyPair() assert.FatalError(t, err) @@ -238,14 +249,15 @@ func TestAuthority_Sign(t *testing.T) { assert.FatalError(t, err) type signTest struct { - auth *Authority - csr *x509.CertificateRequest - signOpts provisioner.SignOptions - extraOpts []provisioner.SignOption - notBefore time.Time - notAfter time.Time - err error - code int + auth *Authority + csr *x509.CertificateRequest + signOpts provisioner.SignOptions + extraOpts []provisioner.SignOption + notBefore time.Time + notAfter time.Time + extensionsCount int + err error + code int } tests := map[string]func(*testing.T) *signTest{ "fail invalid signature": func(t *testing.T) *signTest { @@ -454,6 +466,49 @@ ZYtQ9Ot36qc= code: http.StatusInternalServerError, } }, + "fail with provisioner enforcer": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + aa := testAuthority(t) + aa.db = &db.MockAuthDB{ + MStoreCertificate: func(crt *x509.Certificate) error { + assert.Equals(t, crt.Subject.CommonName, "smallstep test") + return nil + }, + } + + return &signTest{ + auth: aa, + csr: csr, + extraOpts: append(extraOpts, &testEnforcer{ + enforcer: func(crt *x509.Certificate) error { return fmt.Errorf("an error") }, + }), + signOpts: signOpts, + err: errors.New("error creating certificate"), + code: http.StatusForbidden, + } + }, + "fail with custom enforcer": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + aa := testAuthority(t, WithX509Enforcers(&testEnforcer{ + enforcer: func(cert *x509.Certificate) error { + return fmt.Errorf("an error") + }, + })) + aa.db = &db.MockAuthDB{ + MStoreCertificate: func(crt *x509.Certificate) error { + assert.Equals(t, crt.Subject.CommonName, "smallstep test") + return nil + }, + } + return &signTest{ + auth: aa, + csr: csr, + extraOpts: extraOpts, + signOpts: signOpts, + err: errors.New("error creating certificate"), + code: http.StatusForbidden, + } + }, "ok": func(t *testing.T) *signTest { csr := getCSR(t, priv) _a := testAuthority(t) @@ -464,12 +519,13 @@ ZYtQ9Ot36qc= }, } return &signTest{ - auth: a, - csr: csr, - extraOpts: extraOpts, - signOpts: signOpts, - notBefore: signOpts.NotBefore.Time().Truncate(time.Second), - notAfter: signOpts.NotAfter.Time().Truncate(time.Second), + auth: a, + csr: csr, + extraOpts: extraOpts, + signOpts: signOpts, + notBefore: signOpts.NotBefore.Time().Truncate(time.Second), + notAfter: signOpts.NotAfter.Time().Truncate(time.Second), + extensionsCount: 6, } }, "ok with enforced modifier": func(t *testing.T) *signTest { @@ -497,12 +553,13 @@ ZYtQ9Ot36qc= }, } return &signTest{ - auth: a, - csr: csr, - extraOpts: enforcedExtraOptions, - signOpts: signOpts, - notBefore: now.Truncate(time.Second), - notAfter: now.Add(365 * 24 * time.Hour).Truncate(time.Second), + auth: a, + csr: csr, + extraOpts: enforcedExtraOptions, + signOpts: signOpts, + notBefore: now.Truncate(time.Second), + notAfter: now.Add(365 * 24 * time.Hour).Truncate(time.Second), + extensionsCount: 6, } }, "ok with custom template": func(t *testing.T) *signTest { @@ -530,12 +587,13 @@ ZYtQ9Ot36qc= }, } return &signTest{ - auth: testAuthority, - csr: csr, - extraOpts: testExtraOpts, - signOpts: signOpts, - notBefore: signOpts.NotBefore.Time().Truncate(time.Second), - notAfter: signOpts.NotAfter.Time().Truncate(time.Second), + auth: testAuthority, + csr: csr, + extraOpts: testExtraOpts, + signOpts: signOpts, + notBefore: signOpts.NotBefore.Time().Truncate(time.Second), + notAfter: signOpts.NotAfter.Time().Truncate(time.Second), + extensionsCount: 6, } }, "ok/csr with no template critical SAN extension": func(t *testing.T) *signTest { @@ -558,12 +616,39 @@ ZYtQ9Ot36qc= }, } return &signTest{ - auth: _a, - csr: csr, - extraOpts: enforcedExtraOptions, - signOpts: provisioner.SignOptions{}, - notBefore: now.Truncate(time.Second), - notAfter: now.Add(365 * 24 * time.Hour).Truncate(time.Second), + auth: _a, + csr: csr, + extraOpts: enforcedExtraOptions, + signOpts: provisioner.SignOptions{}, + notBefore: now.Truncate(time.Second), + notAfter: now.Add(365 * 24 * time.Hour).Truncate(time.Second), + extensionsCount: 5, + } + }, + "ok with custom enforcer": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + aa := testAuthority(t, WithX509Enforcers(&testEnforcer{ + enforcer: func(cert *x509.Certificate) error { + cert.CRLDistributionPoints = []string{"http://ca.example.org/leaf.crl"} + return nil + }, + })) + aa.config.AuthorityConfig.Template = a.config.AuthorityConfig.Template + aa.db = &db.MockAuthDB{ + MStoreCertificate: func(crt *x509.Certificate) error { + assert.Equals(t, crt.Subject.CommonName, "smallstep test") + assert.Equals(t, crt.CRLDistributionPoints, []string{"http://ca.example.org/leaf.crl"}) + return nil + }, + } + return &signTest{ + auth: aa, + csr: csr, + extraOpts: extraOpts, + signOpts: signOpts, + notBefore: signOpts.NotBefore.Time().Truncate(time.Second), + notAfter: signOpts.NotAfter.Time().Truncate(time.Second), + extensionsCount: 7, } }, } @@ -645,9 +730,6 @@ ZYtQ9Ot36qc= // Empty CSR subject test does not use any provisioner extensions. // So provisioner ID ext will be missing. found = 1 - assert.Len(t, 5, leaf.Extensions) - } else { - assert.Len(t, 6, leaf.Extensions) } } } @@ -655,6 +737,7 @@ ZYtQ9Ot36qc= realIntermediate, err := x509.ParseCertificate(issuer.Raw) assert.FatalError(t, err) assert.Equals(t, intermediate, realIntermediate) + assert.Len(t, tc.extensionsCount, leaf.Extensions) } } }) diff --git a/ca/adminClient.go b/ca/adminClient.go index cfbf595a..5f3993b1 100644 --- a/ca/adminClient.go +++ b/ca/adminClient.go @@ -12,7 +12,6 @@ import ( "time" "github.com/pkg/errors" - "github.com/smallstep/certificates/authority/admin" adminAPI "github.com/smallstep/certificates/authority/admin/api" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/errs" @@ -40,6 +39,19 @@ type AdminClient struct { x5cSubject string } +// AdminClientError is the client side representation of an +// AdminError returned by the CA. +type AdminClientError struct { + Type string `json:"type"` + Detail string `json:"detail"` + Message string `json:"message"` +} + +// Error returns the AdminClientError message as the error message +func (e *AdminClientError) Error() string { + return e.Message +} + // NewAdminClient creates a new AdminClient with the given endpoint and options. func NewAdminClient(endpoint string, opts ...ClientOption) (*AdminClient, error) { u, err := parseEndpoint(endpoint) @@ -670,9 +682,9 @@ retry: func readAdminError(r io.ReadCloser) error { // TODO: not all errors can be read (i.e. 404); seems to be a bigger issue defer r.Close() - adminErr := new(admin.Error) + adminErr := new(AdminClientError) if err := json.NewDecoder(r).Decode(adminErr); err != nil { return err } - return errors.New(adminErr.Message) + return adminErr } diff --git a/ca/ca.go b/ca/ca.go index a57dfd6f..c95ba22f 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -207,7 +207,8 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { if cfg.AuthorityConfig.EnableAdmin { adminDB := auth.GetAdminDatabase() if adminDB != nil { - adminHandler := adminAPI.NewHandler(auth, adminDB, acmeDB) + acmeAdminResponder := adminAPI.NewACMEAdminResponder() + adminHandler := adminAPI.NewHandler(auth, adminDB, acmeDB, acmeAdminResponder) mux.Route("/admin", func(r chi.Router) { adminHandler.Route(r) }) diff --git a/ca/provisioner.go b/ca/provisioner.go index 25e5e8ae..c1879c86 100644 --- a/ca/provisioner.go +++ b/ca/provisioner.go @@ -155,11 +155,11 @@ func (p *Provisioner) SSHToken(certType, keyID string, principals []string) (str func decryptProvisionerJWK(encryptedKey string, password []byte) (*jose.JSONWebKey, error) { enc, err := jose.ParseEncrypted(encryptedKey) if err != nil { - return nil, err + return nil, errors.Wrap(err, "error parsing provisioner encrypted key") } data, err := enc.Decrypt(password) if err != nil { - return nil, err + return nil, errors.Wrap(err, "error decrypting provisioner key with provided password") } jwk := new(jose.JSONWebKey) if err := json.Unmarshal(data, jwk); err != nil { diff --git a/ca/provisioner_test.go b/ca/provisioner_test.go index 01b54d17..39193f3f 100644 --- a/ca/provisioner_test.go +++ b/ca/provisioner_test.go @@ -200,6 +200,102 @@ func TestProvisioner_Token(t *testing.T) { } } +func TestProvisioner_IPv6Token(t *testing.T) { + p := getTestProvisioner(t, "https://[::1]:9000") + sha := "ef742f95dc0d8aa82d3cca4017af6dac3fce84290344159891952d18c53eefe7" + + type fields struct { + name string + kid string + fingerprint string + jwk *jose.JSONWebKey + tokenLifetime time.Duration + } + type args struct { + subject string + sans []string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"ok", fields{p.name, p.kid, sha, p.jwk, p.tokenLifetime}, args{"subject", nil}, false}, + {"ok-with-san", fields{p.name, p.kid, sha, p.jwk, p.tokenLifetime}, args{"subject", []string{"foo.smallstep.com"}}, false}, + {"ok-with-sans", fields{p.name, p.kid, sha, p.jwk, p.tokenLifetime}, args{"subject", []string{"foo.smallstep.com", "127.0.0.1"}}, false}, + {"fail-no-subject", fields{p.name, p.kid, sha, p.jwk, p.tokenLifetime}, args{"", []string{"foo.smallstep.com"}}, true}, + {"fail-no-key", fields{p.name, p.kid, sha, &jose.JSONWebKey{}, p.tokenLifetime}, args{"subject", nil}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Provisioner{ + name: tt.fields.name, + kid: tt.fields.kid, + audience: "https://[::1]:9000/1.0/sign", + fingerprint: tt.fields.fingerprint, + jwk: tt.fields.jwk, + tokenLifetime: tt.fields.tokenLifetime, + } + got, err := p.Token(tt.args.subject, tt.args.sans...) + if (err != nil) != tt.wantErr { + t.Errorf("Provisioner.Token() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr == false { + jwt, err := jose.ParseSigned(got) + if err != nil { + t.Error(err) + return + } + var claims jose.Claims + if err := jwt.Claims(tt.fields.jwk.Public(), &claims); err != nil { + t.Error(err) + return + } + if err := claims.ValidateWithLeeway(jose.Expected{ + Audience: []string{"https://[::1]:9000/1.0/sign"}, + Issuer: tt.fields.name, + Subject: tt.args.subject, + Time: time.Now().UTC(), + }, time.Minute); err != nil { + t.Error(err) + return + } + lifetime := claims.Expiry.Time().Sub(claims.NotBefore.Time()) + if lifetime != tt.fields.tokenLifetime { + t.Errorf("Claims token life time = %s, want %s", lifetime, tt.fields.tokenLifetime) + } + allClaims := make(map[string]interface{}) + if err := jwt.Claims(tt.fields.jwk.Public(), &allClaims); err != nil { + t.Error(err) + return + } + if v, ok := allClaims["sha"].(string); !ok || v != sha { + t.Errorf("Claim sha = %s, want %s", v, sha) + } + if len(tt.args.sans) == 0 { + if v, ok := allClaims["sans"].([]interface{}); !ok || !reflect.DeepEqual(v, []interface{}{tt.args.subject}) { + t.Errorf("Claim sans = %s, want %s", v, []interface{}{tt.args.subject}) + } + } else { + want := []interface{}{} + for _, s := range tt.args.sans { + want = append(want, s) + } + if v, ok := allClaims["sans"].([]interface{}); !ok || !reflect.DeepEqual(v, want) { + t.Errorf("Claim sans = %s, want %s", v, want) + } + } + if v, ok := allClaims["jti"].(string); !ok || v == "" { + t.Errorf("Claim jti = %s, want not blank", v) + } + } + }) + } +} + func TestProvisioner_SSHToken(t *testing.T) { p := getTestProvisioner(t, "https://127.0.0.1:9000") sha := "ef742f95dc0d8aa82d3cca4017af6dac3fce84290344159891952d18c53eefe7" diff --git a/cmd/step-awskms-init/main.go b/cmd/step-awskms-init/main.go index 8e30745f..5e56f045 100644 --- a/cmd/step-awskms-init/main.go +++ b/cmd/step-awskms-init/main.go @@ -75,10 +75,11 @@ This tool is experimental and in the future it will be integrated in step cli. OPTIONS`) fmt.Fprintln(os.Stderr) flag.PrintDefaults() - fmt.Fprintln(os.Stderr, ` + fmt.Fprintf(os.Stderr, ` COPYRIGHT - (c) 2018-2020 Smallstep Labs, Inc.`) + (c) 2018-%d Smallstep Labs, Inc. +`, time.Now().Year()) os.Exit(1) } diff --git a/cmd/step-ca/main.go b/cmd/step-ca/main.go index f40ddf5f..fba5b792 100644 --- a/cmd/step-ca/main.go +++ b/cmd/step-ca/main.go @@ -144,7 +144,7 @@ $ step-ca $STEPPATH/config/ca.json --password-file ./password.txt '''` app.Flags = append(app.Flags, commands.AppCommand.Flags...) app.Flags = append(app.Flags, cli.HelpFlag) - app.Copyright = "(c) 2018-2020 Smallstep Labs, Inc." + app.Copyright = fmt.Sprintf("(c) 2018-%d Smallstep Labs, Inc.", time.Now().Year()) // All non-successful output should be written to stderr app.Writer = os.Stdout diff --git a/cmd/step-cloudkms-init/main.go b/cmd/step-cloudkms-init/main.go index 27dc82ad..78c1fcc5 100644 --- a/cmd/step-cloudkms-init/main.go +++ b/cmd/step-cloudkms-init/main.go @@ -105,10 +105,11 @@ This tool is experimental and in the future it will be integrated in step cli. OPTIONS`) fmt.Fprintln(os.Stderr) flag.PrintDefaults() - fmt.Fprintln(os.Stderr, ` + fmt.Fprintf(os.Stderr, ` COPYRIGHT - (c) 2018-2020 Smallstep Labs, Inc.`) + (c) 2018-%d Smallstep Labs, Inc. +`, time.Now().Year()) os.Exit(1) } diff --git a/cmd/step-pkcs11-init/main.go b/cmd/step-pkcs11-init/main.go index 5fadf10d..4dc15799 100644 --- a/cmd/step-pkcs11-init/main.go +++ b/cmd/step-pkcs11-init/main.go @@ -250,10 +250,11 @@ This tool is experimental and in the future it will be integrated in step cli. OPTIONS`) fmt.Fprintln(os.Stderr) flag.PrintDefaults() - fmt.Fprintln(os.Stderr, ` + fmt.Fprintf(os.Stderr, ` COPYRIGHT - (c) 2018-2021 Smallstep Labs, Inc.`) + (c) 2018-%d Smallstep Labs, Inc. +`, time.Now().Year()) os.Exit(1) } diff --git a/cmd/step-yubikey-init/main.go b/cmd/step-yubikey-init/main.go index 8b0ffab5..9f755388 100644 --- a/cmd/step-yubikey-init/main.go +++ b/cmd/step-yubikey-init/main.go @@ -148,10 +148,11 @@ This tool is experimental and in the future it will be integrated in step cli. OPTIONS`) fmt.Fprintln(os.Stderr) flag.PrintDefaults() - fmt.Fprintln(os.Stderr, ` + fmt.Fprintf(os.Stderr, ` COPYRIGHT - (c) 2018-2020 Smallstep Labs, Inc.`) + (c) 2018-%d Smallstep Labs, Inc. +`, time.Now().Year()) os.Exit(1) } diff --git a/errs/error.go b/errs/error.go index 2c1fe6a9..60da9e1f 100644 --- a/errs/error.go +++ b/errs/error.go @@ -142,7 +142,7 @@ func (e *Error) UnmarshalJSON(data []byte) error { return err } e.Status = er.Status - e.Err = fmt.Errorf(er.Message) + e.Err = fmt.Errorf("%s", er.Message) return nil } diff --git a/errs/errors_test.go b/errs/errors_test.go index 58b95437..a2accebb 100644 --- a/errs/errors_test.go +++ b/errs/errors_test.go @@ -58,7 +58,7 @@ func TestError_UnmarshalJSON(t *testing.T) { t.Errorf("Error.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) } if !reflect.DeepEqual(tt.expected, e) { - t.Errorf("Error.UnmarshalJSON() wants = %v, got %v", tt.expected, e) + t.Errorf("Error.UnmarshalJSON() wants = %+v, got %+v", tt.expected, e) } }) } diff --git a/examples/ansible/smallstep-certs/defaults/main.yml b/examples/ansible/smallstep-certs/defaults/main.yml new file mode 100644 index 00000000..b4de90c5 --- /dev/null +++ b/examples/ansible/smallstep-certs/defaults/main.yml @@ -0,0 +1,18 @@ + + + +# Root cert for each will be saved in /etc/ssl/smallstep/ca/{{ ca_name }}/certs/root_ca.crt +smallstep_root_certs: [] +# - +# ca_name: your_ca +# ca_url: "https://certs.your_ca.ca.smallstep.com" +# ca_fingerprint: "56092...2200" + +# Each leaf cert will be saved in /etc/ssl/smallstep/leaf/{{ cert_subject }}/{{ cert_subject }}.crt|key +smallstep_leaf_certs: [] +# - +# ca_name: your_ca +# cert_subject: "{{ inventory_hostname }}" +# provisioner_name: "admin" +# provisioner_password: "{{ smallstep_ssh_provisioner_password }}" + diff --git a/examples/ansible/smallstep-certs/tasks/main.yml b/examples/ansible/smallstep-certs/tasks/main.yml new file mode 100644 index 00000000..a80a72a1 --- /dev/null +++ b/examples/ansible/smallstep-certs/tasks/main.yml @@ -0,0 +1,44 @@ + +- name: "Ensure provisioners directories exist" + file: + path: "/etc/ssl/smallstep/provisioners/{{ item.context }}/{{ item.provisioner_name }}" + state: directory + mode: 0600 + owner: root + group: root + with_items: "{{ smallstep_leaf_certs }}" + no_log: true + +- name: "Ensure provisioner passwords are up to date" + copy: + dest: "/etc/ssl/smallstep/provisioners/{{ item.context }}/{{ item.provisioner_name }}/provisioner-pass.txt" + content: "{{ item.provisioner_password }}" + mode: 0700 + owner: root + group: root + with_items: "{{ smallstep_leaf_certs }}" + no_log: true + +- name: "Get root certs for CAs" + command: + cmd: "step ca bootstrap --context {{ item.context }} --ca-url {{ item.ca_url }} --fingerprint {{ item.ca_fingerprint }}" + with_items: "{{ smallstep_root_certs }}" + no_log: true + +- name: "Get leaf certs" + command: + cmd: "step ca certificate --context {{ item.context }} {{ item.cert_subject }} {{ item.cert_path }} {{ item.key_path }} --force --console --provisioner {{ item.provisioner_name }} --provisioner-password-file /etc/ssl/smallstep/provisioners/{{ item.context }}/{{ item.provisioner_name }}/provisioner-pass.txt" + with_items: "{{ smallstep_leaf_certs }}" + no_log: true + +- name: Ensure cron to renew leaf certs is up to date + cron: + user: "root" + name: "renew leaf cert {{ item.cert_subject }}" + cron_file: smallstep + job: "step ca renew --context {{ item.context }} {{ item.cert_path }} {{ item.key_path }} --expires-in 6h --force >> /var/log/smallstep-{{ item.cert_subject }}.log 2>&1" + state: present + minute: "*/30" + with_items: "{{ smallstep_leaf_certs }}" + when: "{{ item.cron_renew }}" + no_log: true diff --git a/examples/ansible/smallstep-install/defaults/main.yml b/examples/ansible/smallstep-install/defaults/main.yml new file mode 100644 index 00000000..b3b5f067 --- /dev/null +++ b/examples/ansible/smallstep-install/defaults/main.yml @@ -0,0 +1,2 @@ +smallstep_install_step_version: 0.15.3 +smallstep_install_step_ssh_version: 0.19.1-1 diff --git a/examples/ansible/smallstep-install/tasks/main.yml b/examples/ansible/smallstep-install/tasks/main.yml new file mode 100644 index 00000000..083a7333 --- /dev/null +++ b/examples/ansible/smallstep-install/tasks/main.yml @@ -0,0 +1,29 @@ + +# These steps automate the installation guide here: +# https://smallstep.com/docs/sso-ssh/hosts/ + +- name: Download step binary + get_url: + url: "https://files.smallstep.com/step-linux-{{ smallstep_install_step_version }}" + dest: "/usr/local/bin/step-{{ smallstep_install_step_version }}" + mode: '0755' + +- name: Link binaries to correct version + file: + src: "/usr/local/bin/step-{{ smallstep_install_step_version }}" + dest: "{{ item }}" + state: link + with_items: + - /usr/bin/step + - /usr/local/bin/step + +- name: Link /usr/local/bin/step to correct binary version + file: + src: "/usr/local/bin/step-{{ smallstep_install_step_version }}" + dest: /usr/local/bin/step + state: link + +- name: Ensure step-ssh is installed + apt: + deb: "https://files.smallstep.com/step-ssh_{{ smallstep_install_step_ssh_version }}_amd64.deb" + state: present diff --git a/examples/ansible/smallstep-ssh/defaults/main.yml b/examples/ansible/smallstep-ssh/defaults/main.yml new file mode 100644 index 00000000..ae358948 --- /dev/null +++ b/examples/ansible/smallstep-ssh/defaults/main.yml @@ -0,0 +1,8 @@ +# If this host is behind a bastion this variable should contain the hostname of the bastion +smallstep_ssh_host_behind_bastion_name: "" +smallstep_ssh_host_is_bastion: false +smallstep_ssh_ca_url: "https://ssh.mycompany.ca.smallstep.com" +smallstep_ssh_ca_fingerprint: "XXXXXXXXXXXXXXX" + +# Whether or not to reinitialize the host even if it's already been installed +smallstep_ssh_force_reinit: true diff --git a/examples/ansible/smallstep-ssh/tasks/main.yml b/examples/ansible/smallstep-ssh/tasks/main.yml new file mode 100644 index 00000000..e3389663 --- /dev/null +++ b/examples/ansible/smallstep-ssh/tasks/main.yml @@ -0,0 +1,41 @@ + +# These steps automate the installation guide here: +# https://smallstep.com/docs/sso-ssh/hosts/ + +# TODO: Figure out how to make this idempotent instead of reinstalling on each run + +- name: Bootstrap node to connect to CA + command: "step ca bootstrap --context ssh --ca-url {{ smallstep_ssh_ca_url }} --fingerprint {{ smallstep_ssh_ca_fingerprint }} --force" +# when: smallstep_ssh_installed.changed or smallstep_ssh_force_reinit + +- name: Get a host SSH certificate + command: "step ssh certificate --context ssh {{ inventory_hostname }} /etc/ssh/ssh_host_ecdsa_key.pub --host --sign --provisioner=\"Service Account\" --token=\"{{ smallstep_ssh_enrollment_token }}\" --force" +# when: smallstep_ssh_installed.changed or smallstep_ssh_force_reinit + +- name: Configure SSHD (will be overwriten by the sshd template in Ansible later) + command: "step ssh config --context ssh --host --set Certificate=ssh_host_ecdsa_key-cert.pub --set Key=ssh_host_ecdsa_key" +# when: smallstep_ssh_installed.changed or smallstep_ssh_force_reinit + +- name: Activate SmallStep PAM/NSS modules and nohup sshd + command: "step-ssh activate {{ inventory_hostname }}" +# when: smallstep_ssh_installed.changed or smallstep_ssh_force_reinit + +- name: Generate host tags list + set_fact: + smallstep_ssh_host_tags_string: "{{ smallstep_ssh_host_tags | to_json | regex_replace('\\:\\ ','=') | regex_replace('\\{\\\"|,\\ \\\"', ' --tag \"') | regex_replace('[\\[\\]{}]') }}" + +- name: Generate command to register + set_fact: + smallstep_ssh_register_string: | + step-ssh-ctl register + --hostname {{ inventory_hostname }} + {% if not smallstep_ssh_host_is_bastion %}--bastion '{{ smallstep_ssh_host_behind_bastion_name|default("") }}'{% endif %} + {% if smallstep_ssh_host_is_bastion %}--is-bastion{% endif %} + {{ smallstep_ssh_host_tags_string }} + +- debug: var=smallstep_ssh_register_string + +- name: Register host with smallstep + command: "{{ smallstep_ssh_register_string }}" +# when: smallstep_ssh_installed.changed or smallstep_ssh_force_reinit +