diff --git a/Gopkg.lock b/Gopkg.lock index 7db1f06f..04f71988 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -228,12 +228,12 @@ version = "v1.2.0" [[projects]] - digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" + digest = "1:cf31692c14422fa27c83a05292eb5cbe0fb2775972e8f1f8446a71549bd8980b" name = "github.com/pkg/errors" packages = ["."] pruneopts = "UT" - revision = "645ef00459ed84a119197bfb8d8205042c6df63d" - version = "v0.8.0" + revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4" + version = "v0.8.1" [[projects]] digest = "1:2e76a73cb51f42d63a2a1a85b3dc5731fd4faf6821b434bd0ef2c099186031d6" @@ -277,7 +277,7 @@ [[projects]] branch = "master" - digest = "1:253eec7c89c6fe08ae020877b0b44343e86da68dac99207280e7ddcacd441f1f" + digest = "1:0f02ebdfb2860d118922fa6abb2fe3b4d9984217b7f12761ac2d787f84fca67a" name = "github.com/smallstep/cli" packages = [ "command", @@ -298,7 +298,15 @@ "utils", ] pruneopts = "UT" - revision = "f851b6b63d8d5e78b8a986057034d69fe904c477" + revision = "25e732fe7e712d5b3009666fb968f9b9c188666b" + +[[projects]] + branch = "master" + digest = "1:fd8d9eb07509d8ef47fc82c99646f0b2203b2ba3c240ba77d8c457bb6109836d" + name = "github.com/smallstep/nosql" + packages = ["."] + pruneopts = "UT" + revision = "d8f68d14f9ae04e0991dce06b44768f2d38dccf8" [[projects]] branch = "master" @@ -316,15 +324,24 @@ pruneopts = "UT" revision = "b67dcf995b6a7b7f14fad5fcb7cc5441b05e814b" +[[projects]] + digest = "1:5f7414cf41466d4b4dd7ec52b2cd3e481e08cfd11e7e24fef730c0e483e88bb1" + name = "go.etcd.io/bbolt" + packages = ["."] + pruneopts = "UT" + revision = "63597a96ec0ad9e6d43c3fc81e809909e0237461" + version = "v1.3.2" + [[projects]] branch = "master" - digest = "1:a068d4e48e0f2e172903d25b6e066815fa8efd4b01102aec4c741f02a9650c03" + digest = "1:5dd7da6df07f42194cb25d162b4b89664ed7b08d7d4334f6a288393d54b095ce" name = "golang.org/x/crypto" packages = [ "cryptobyte", "cryptobyte/asn1", "ed25519", "ed25519/internal/edwards25519", + "ocsp", "pbkdf2", "ssh/terminal", ] @@ -574,8 +591,10 @@ "github.com/smallstep/cli/token", "github.com/smallstep/cli/token/provision", "github.com/smallstep/cli/usage", + "github.com/smallstep/nosql", "github.com/tsenart/deadcode", "github.com/urfave/cli", + "golang.org/x/crypto/ocsp", "golang.org/x/net/context", "golang.org/x/net/http2", "google.golang.org/grpc", diff --git a/Gopkg.toml b/Gopkg.toml index b2501368..64e1e38e 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -48,6 +48,10 @@ required = [ branch = "master" name = "github.com/smallstep/cli" +[[constraint]] + branch = "master" + name = "github.com/smallstep/nosql" + [prune] go-tests = true unused-packages = true diff --git a/api/api.go b/api/api.go index 3f5b54ab..f1013c0a 100644 --- a/api/api.go +++ b/api/api.go @@ -18,6 +18,7 @@ import ( "github.com/go-chi/chi" "github.com/pkg/errors" + "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/logging" "github.com/smallstep/cli/crypto/tlsutil" @@ -25,12 +26,17 @@ import ( // Authority is the interface implemented by a CA authority. type Authority interface { + // NOTE: Authorize will be deprecated in future releases. Please use the + // context specific Authoirize[Sign|Revoke|etc.] methods. Authorize(ott string) ([]provisioner.SignOption, error) + AuthorizeSign(ott string) ([]provisioner.SignOption, error) GetTLSOptions() *tlsutil.TLSOptions Root(shasum string) (*x509.Certificate, error) Sign(cr *x509.CertificateRequest, opts provisioner.Options, signOpts ...provisioner.SignOption) (*x509.Certificate, *x509.Certificate, error) Renew(peer *x509.Certificate) (*x509.Certificate, *x509.Certificate, error) + LoadProvisionerByCertificate(*x509.Certificate) (provisioner.Interface, error) GetProvisioners(cursor string, limit int) (provisioner.List, string, error) + Revoke(*authority.RevokeOptions) error GetEncryptedKey(kid string) (string, error) GetRoots() (federation []*x509.Certificate, err error) GetFederation() ([]*x509.Certificate, error) @@ -236,6 +242,7 @@ func (h *caHandler) Route(r Router) { r.MethodFunc("GET", "/root/{sha}", h.Root) r.MethodFunc("POST", "/sign", h.Sign) r.MethodFunc("POST", "/renew", h.Renew) + r.MethodFunc("POST", "/revoke", h.Revoke) r.MethodFunc("GET", "/provisioners", h.Provisioners) r.MethodFunc("GET", "/provisioners/{kid}/encrypted-key", h.ProvisionerKey) r.MethodFunc("GET", "/roots", h.Roots) @@ -285,7 +292,7 @@ func (h *caHandler) Sign(w http.ResponseWriter, r *http.Request) { NotAfter: body.NotAfter, } - signOpts, err := h.Authority.Authorize(body.OTT) + signOpts, err := h.Authority.AuthorizeSign(body.OTT) if err != nil { WriteError(w, Unauthorized(err)) return diff --git a/api/api_test.go b/api/api_test.go index e78b370e..3f99dd68 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -24,6 +24,7 @@ import ( "time" "github.com/go-chi/chi" + "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/logging" "github.com/smallstep/cli/crypto/tlsutil" @@ -407,23 +408,110 @@ func TestSignRequest_Validate(t *testing.T) { } } -type mockAuthority struct { - ret1, ret2 interface{} - err error - authorize func(ott string) ([]provisioner.SignOption, error) - getTLSOptions func() *tlsutil.TLSOptions - root func(shasum string) (*x509.Certificate, error) - sign func(cr *x509.CertificateRequest, opts provisioner.Options, signOpts ...provisioner.SignOption) (*x509.Certificate, *x509.Certificate, error) - renew func(cert *x509.Certificate) (*x509.Certificate, *x509.Certificate, error) - getProvisioners func(nextCursor string, limit int) (provisioner.List, string, error) - getEncryptedKey func(kid string) (string, error) - getRoots func() ([]*x509.Certificate, error) - getFederation func() ([]*x509.Certificate, error) +type mockProvisioner struct { + ret1, ret2, ret3 interface{} + err error + getID func() string + getTokenID func(string) (string, error) + getName func() string + getType func() provisioner.Type + getEncryptedKey func() (string, string, bool) + init func(provisioner.Config) error + authorizeRevoke func(ott string) error + authorizeSign func(ott string) ([]provisioner.SignOption, error) + authorizeRenewal func(*x509.Certificate) error } +func (m *mockProvisioner) GetID() string { + if m.getID != nil { + return m.getID() + } + return m.ret1.(string) +} + +func (m *mockProvisioner) GetTokenID(token string) (string, error) { + if m.getTokenID != nil { + return m.getTokenID(token) + } + if m.ret1 == nil { + return "", m.err + } + return m.ret1.(string), m.err +} + +func (m *mockProvisioner) GetName() string { + if m.getName != nil { + return m.getName() + } + return m.ret1.(string) +} + +func (m *mockProvisioner) GetType() provisioner.Type { + if m.getType != nil { + return m.getType() + } + return m.ret1.(provisioner.Type) +} + +func (m *mockProvisioner) GetEncryptedKey() (string, string, bool) { + if m.getEncryptedKey != nil { + return m.getEncryptedKey() + } + return m.ret1.(string), m.ret2.(string), m.ret3.(bool) +} + +func (m *mockProvisioner) Init(c provisioner.Config) error { + if m.init != nil { + return m.init(c) + } + return m.err +} + +func (m *mockProvisioner) AuthorizeRevoke(ott string) error { + if m.authorizeRevoke != nil { + return m.authorizeRevoke(ott) + } + return m.err +} + +func (m *mockProvisioner) AuthorizeSign(ott string) ([]provisioner.SignOption, error) { + if m.authorizeSign != nil { + return m.authorizeSign(ott) + } + return m.ret1.([]provisioner.SignOption), m.err +} + +func (m *mockProvisioner) AuthorizeRenewal(c *x509.Certificate) error { + if m.authorizeRenewal != nil { + return m.authorizeRenewal(c) + } + return m.err +} + +type mockAuthority struct { + ret1, ret2 interface{} + err error + authorizeSign func(ott string) ([]provisioner.SignOption, error) + getTLSOptions func() *tlsutil.TLSOptions + root func(shasum string) (*x509.Certificate, error) + sign func(cr *x509.CertificateRequest, opts provisioner.Options, signOpts ...provisioner.SignOption) (*x509.Certificate, *x509.Certificate, error) + renew func(cert *x509.Certificate) (*x509.Certificate, *x509.Certificate, error) + loadProvisionerByCertificate func(cert *x509.Certificate) (provisioner.Interface, error) + getProvisioners func(nextCursor string, limit int) (provisioner.List, string, error) + revoke func(*authority.RevokeOptions) error + getEncryptedKey func(kid string) (string, error) + getRoots func() ([]*x509.Certificate, error) + getFederation func() ([]*x509.Certificate, error) +} + +// TODO: remove once Authorize is deprecated. func (m *mockAuthority) Authorize(ott string) ([]provisioner.SignOption, error) { - if m.authorize != nil { - return m.authorize(ott) + 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 } @@ -463,6 +551,20 @@ func (m *mockAuthority) GetProvisioners(nextCursor string, limit int) (provision 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) Revoke(opts *authority.RevokeOptions) error { + if m.revoke != nil { + return m.revoke(opts) + } + return m.err +} + func (m *mockAuthority) GetEncryptedKey(kid string) (string, error) { if m.getEncryptedKey != nil { return m.getEncryptedKey(kid) @@ -617,7 +719,7 @@ func Test_caHandler_Sign(t *testing.T) { t.Run(tt.name, func(t *testing.T) { h := New(&mockAuthority{ ret1: tt.cert, ret2: tt.root, err: tt.signErr, - authorize: func(ott string) ([]provisioner.SignOption, error) { + authorizeSign: func(ott string) ([]provisioner.SignOption, error) { return tt.certAttrOpts, tt.autherr }, getTLSOptions: func() *tlsutil.TLSOptions { diff --git a/api/errors.go b/api/errors.go index c0103082..0e6cb939 100644 --- a/api/errors.go +++ b/api/errors.go @@ -82,6 +82,11 @@ func InternalServerError(err error) error { return NewError(http.StatusInternalServerError, err) } +// NotImplemented returns a 500 error with the given error. +func NotImplemented(err error) error { + return NewError(http.StatusNotImplemented, err) +} + // BadRequest returns an 400 error with the given error. func BadRequest(err error) error { return NewError(http.StatusBadRequest, err) diff --git a/api/revoke.go b/api/revoke.go new file mode 100644 index 00000000..9280c980 --- /dev/null +++ b/api/revoke.go @@ -0,0 +1,105 @@ +package api + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/logging" + "golang.org/x/crypto/ocsp" +) + +// RevokeResponse is the response object that returns the health of the server. +type RevokeResponse struct { + Status string `json:"status"` +} + +// RevokeRequest is the request body for a revocation request. +type RevokeRequest struct { + Serial string `json:"serial"` + OTT string `json:"ott"` + ReasonCode int `json:"reasonCode"` + Reason string `json:"reason"` + Passive bool `json:"passive"` +} + +// Validate checks the fields of the RevokeRequest and returns nil if they are ok +// or an error if something is wrong. +func (r *RevokeRequest) Validate() (err error) { + if r.Serial == "" { + return BadRequest(errors.New("missing serial")) + } + if r.ReasonCode < ocsp.Unspecified || r.ReasonCode > ocsp.AACompromise { + return BadRequest(errors.New("reasonCode out of bounds")) + } + if !r.Passive { + return NotImplemented(errors.New("non-passive revocation not implemented")) + } + + return +} + +// Revoke supports handful of different methods that revoke a Certificate. +// +// NOTE: currently only Passive revocation is supported. +// +// TODO: Add CRL and OCSP support. +func (h *caHandler) Revoke(w http.ResponseWriter, r *http.Request) { + var body RevokeRequest + if err := ReadJSON(r.Body, &body); err != nil { + WriteError(w, BadRequest(errors.Wrap(err, "error reading request body"))) + return + } + + if err := body.Validate(); err != nil { + WriteError(w, err) + return + } + + opts := &authority.RevokeOptions{ + Serial: body.Serial, + Reason: body.Reason, + ReasonCode: body.ReasonCode, + PassiveOnly: body.Passive, + } + + // A token indicates that we are using the api via a provisioner token, + // otherwise it is assumed that the certificate is revoking itself over mTLS. + if len(body.OTT) > 0 { + logOtt(w, body.OTT) + opts.OTT = body.OTT + } else { + // If no token is present, then the request must be made over mTLS and + // the client certificate Serial Number must match the serial number + // being revoked. + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + WriteError(w, BadRequest(errors.New("missing ott or peer certificate"))) + return + } + opts.Crt = r.TLS.PeerCertificates[0] + logCertificate(w, opts.Crt) + opts.MTLS = true + } + + if err := h.Authority.Revoke(opts); err != nil { + WriteError(w, Forbidden(err)) + return + } + + logRevoke(w, opts) + + w.WriteHeader(http.StatusOK) + JSON(w, &RevokeResponse{Status: "ok"}) +} + +func logRevoke(w http.ResponseWriter, ri *authority.RevokeOptions) { + if rl, ok := w.(logging.ResponseLogger); ok { + rl.WithFields(map[string]interface{}{ + "serial": ri.Serial, + "reasonCode": ri.ReasonCode, + "reason": ri.Reason, + "passiveOnly": ri.PassiveOnly, + "mTLS": ri.MTLS, + }) + } +} diff --git a/api/revoke_test.go b/api/revoke_test.go new file mode 100644 index 00000000..fed4a178 --- /dev/null +++ b/api/revoke_test.go @@ -0,0 +1,234 @@ +package api + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/pkg/errors" + "github.com/smallstep/assert" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/logging" +) + +func TestRevokeRequestValidate(t *testing.T) { + type test struct { + rr *RevokeRequest + err *Error + } + tests := map[string]test{ + "error/missing serial": { + rr: &RevokeRequest{}, + err: &Error{Err: errors.New("missing serial"), Status: http.StatusBadRequest}, + }, + "error/bad reasonCode": { + rr: &RevokeRequest{ + Serial: "sn", + ReasonCode: 15, + Passive: true, + }, + err: &Error{Err: errors.New("reasonCode out of bounds"), Status: http.StatusBadRequest}, + }, + "error/non-passive not implemented": { + rr: &RevokeRequest{ + Serial: "sn", + ReasonCode: 8, + Passive: false, + }, + err: &Error{Err: errors.New("non-passive revocation not implemented"), Status: http.StatusNotImplemented}, + }, + "ok": { + rr: &RevokeRequest{ + Serial: "sn", + ReasonCode: 9, + Passive: true, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if err := tc.rr.Validate(); err != nil { + switch v := err.(type) { + case *Error: + assert.HasPrefix(t, v.Error(), tc.err.Error()) + assert.Equals(t, v.StatusCode(), tc.err.Status) + default: + t.Errorf("unexpected error type: %T", v) + } + } else { + assert.Nil(t, tc.err) + } + }) + } +} + +func Test_caHandler_Revoke(t *testing.T) { + type test struct { + input string + auth Authority + tls *tls.ConnectionState + err error + statusCode int + expected []byte + } + tests := map[string]func(*testing.T) test{ + "400/json read error": func(t *testing.T) test { + return test{ + input: "{", + statusCode: http.StatusBadRequest, + } + }, + "400/invalid request body": func(t *testing.T) test { + input, err := json.Marshal(RevokeRequest{}) + assert.FatalError(t, err) + return test{ + input: string(input), + statusCode: http.StatusBadRequest, + } + }, + "200/ott": func(t *testing.T) test { + input, err := json.Marshal(RevokeRequest{ + Serial: "sn", + ReasonCode: 4, + Reason: "foo", + OTT: "valid", + Passive: true, + }) + assert.FatalError(t, err) + return test{ + input: string(input), + statusCode: http.StatusOK, + auth: &mockAuthority{ + revoke: func(opts *authority.RevokeOptions) error { + assert.True(t, opts.PassiveOnly) + assert.False(t, opts.MTLS) + assert.Equals(t, opts.Serial, "sn") + assert.Equals(t, opts.ReasonCode, 4) + assert.Equals(t, opts.Reason, "foo") + return nil + }, + }, + expected: []byte(`{"status":"ok"}`), + } + }, + "400/no OTT and no peer certificate": func(t *testing.T) test { + input, err := json.Marshal(RevokeRequest{ + Serial: "sn", + ReasonCode: 4, + Passive: true, + }) + assert.FatalError(t, err) + return test{ + input: string(input), + statusCode: http.StatusBadRequest, + } + }, + "200/no ott": func(t *testing.T) test { + cs := &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{parseCertificate(certPEM)}, + } + input, err := json.Marshal(RevokeRequest{ + Serial: "1404354960355712309", + ReasonCode: 4, + Reason: "foo", + Passive: true, + }) + assert.FatalError(t, err) + + return test{ + input: string(input), + statusCode: http.StatusOK, + tls: cs, + auth: &mockAuthority{ + revoke: func(ri *authority.RevokeOptions) error { + assert.True(t, ri.PassiveOnly) + assert.True(t, ri.MTLS) + assert.Equals(t, ri.Serial, "1404354960355712309") + assert.Equals(t, ri.ReasonCode, 4) + assert.Equals(t, ri.Reason, "foo") + return nil + }, + loadProvisionerByCertificate: func(crt *x509.Certificate) (provisioner.Interface, error) { + return &mockProvisioner{ + getID: func() string { + return "mock-provisioner-id" + }, + }, err + }, + }, + expected: []byte(`{"status":"ok"}`), + } + }, + "500/ott authority.Revoke": func(t *testing.T) test { + input, err := json.Marshal(RevokeRequest{ + Serial: "sn", + ReasonCode: 4, + Reason: "foo", + OTT: "valid", + Passive: true, + }) + assert.FatalError(t, err) + return test{ + input: string(input), + statusCode: http.StatusInternalServerError, + auth: &mockAuthority{ + revoke: func(opts *authority.RevokeOptions) error { + return InternalServerError(errors.New("force")) + }, + }, + } + }, + "403/ott authority.Revoke": func(t *testing.T) test { + input, err := json.Marshal(RevokeRequest{ + Serial: "sn", + ReasonCode: 4, + Reason: "foo", + OTT: "valid", + Passive: true, + }) + assert.FatalError(t, err) + return test{ + input: string(input), + statusCode: http.StatusForbidden, + auth: &mockAuthority{ + revoke: func(opts *authority.RevokeOptions) error { + return errors.New("force") + }, + }, + } + }, + } + + for name, _tc := range tests { + tc := _tc(t) + t.Run(name, func(t *testing.T) { + h := New(tc.auth).(*caHandler) + req := httptest.NewRequest("POST", "http://example.com/revoke", strings.NewReader(tc.input)) + if tc.tls != nil { + req.TLS = tc.tls + } + w := httptest.NewRecorder() + h.Revoke(logging.NewResponseLogger(w), req) + res := w.Result() + + assert.Equals(t, tc.statusCode, res.StatusCode) + + body, err := ioutil.ReadAll(res.Body) + res.Body.Close() + assert.FatalError(t, err) + + if tc.statusCode < http.StatusBadRequest { + if !bytes.Equal(bytes.TrimSpace(body), tc.expected) { + t.Errorf("caHandler.Root Body = %s, wants %s", body, tc.expected) + } + } + }) + } +} diff --git a/authority/authority.go b/authority/authority.go index 950f6e4a..aad8c3e2 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -8,6 +8,7 @@ import ( "time" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/db" "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/x509util" ) @@ -24,6 +25,7 @@ type Authority struct { ottMap *sync.Map startTime time.Time provisioners *provisioner.Collection + db db.AuthDB // Do not re-initialize initOnce bool } @@ -56,6 +58,12 @@ func (a *Authority) init() error { var err error + // Initialize step-ca Database if defined in configuration. + // If a.config.DB is nil then a noopDB will be returned. + if a.db, err = db.New(a.config.DB); err != nil { + return err + } + // Load the root certificates and add them to the certificate store a.rootX509Certs = make([]*x509.Certificate, len(a.config.Root)) for i, path := range a.config.Root { @@ -111,3 +119,8 @@ func (a *Authority) init() error { return nil } + +// Shutdown safely shuts down any clients, databases, etc. held by the Authority. +func (a *Authority) Shutdown() error { + return a.db.Shutdown() +} diff --git a/authority/authority_test.go b/authority/authority_test.go index e008b22d..8f180457 100644 --- a/authority/authority_test.go +++ b/authority/authority_test.go @@ -36,11 +36,19 @@ func testAuthority(t *testing.T) *Authority { DisableRenewal: &disableRenewal, }, }, + &provisioner.JWK{ + Name: "renew_disabled", + Type: "JWK", + Key: maxjwk, + Claims: &provisioner.Claims{ + DisableRenewal: &disableRenewal, + }, + }, } c := &Config{ Address: "127.0.0.1:443", - Root: []string{"testdata/secrets/root_ca.crt"}, - IntermediateCert: "testdata/secrets/intermediate_ca.crt", + Root: []string{"testdata/certs/root_ca.crt"}, + IntermediateCert: "testdata/certs/intermediate_ca.crt", IntermediateKey: "testdata/secrets/intermediate_ca_key", DNSNames: []string{"test.ca.smallstep.com"}, Password: "pass", diff --git a/authority/authorize.go b/authority/authorize.go index de272086..62152d09 100644 --- a/authority/authorize.go +++ b/authority/authorize.go @@ -24,15 +24,16 @@ type Claims struct { Nonce string `json:"nonce,omitempty"` } -// Authorize authorizes a signature request by validating and authenticating -// a OTT that must be sent w/ the request. -func (a *Authority) Authorize(ott string) ([]provisioner.SignOption, error) { +// authorizeToken parses the token and returns the provisioner used to generate +// the token. This method enforces the One-Time use policy (tokens can only be +// used once). +func (a *Authority) authorizeToken(ott string) (provisioner.Interface, error) { var errContext = map[string]interface{}{"ott": ott} // Validate payload token, err := jose.ParseSigned(ott) if err != nil { - return nil, &apiError{errors.Wrapf(err, "authorize: error parsing token"), + return nil, &apiError{errors.Wrapf(err, "authorizeToken: error parsing token"), http.StatusUnauthorized, errContext} } @@ -41,14 +42,15 @@ func (a *Authority) Authorize(ott string) ([]provisioner.SignOption, error) { // before we can look up the provisioner. var claims Claims if err = token.UnsafeClaimsWithoutVerification(&claims); err != nil { - return nil, &apiError{err, http.StatusUnauthorized, errContext} + return nil, &apiError{errors.Wrap(err, "authorizeToken"), http.StatusUnauthorized, errContext} } + // TODO: use new persistence layer abstraction. // Do not accept tokens issued before the start of the ca. // This check is meant as a stopgap solution to the current lack of a persistence layer. if a.config.AuthorityConfig != nil && !a.config.AuthorityConfig.DisableIssuedAtCheck { if claims.IssuedAt != nil && claims.IssuedAt.Time().Before(a.startTime) { - return nil, &apiError{errors.New("authorize: token issued before the bootstrap of certificate authority"), + return nil, &apiError{errors.New("authorizeToken: token issued before the bootstrap of certificate authority"), http.StatusUnauthorized, errContext} } } @@ -57,7 +59,7 @@ func (a *Authority) Authorize(ott string) ([]provisioner.SignOption, error) { p, ok := a.provisioners.LoadByToken(token, &claims.Claims) if !ok { return nil, &apiError{ - errors.Errorf("authorize: provisioner not found or invalid audience (%s)", strings.Join(claims.Audience, ", ")), + errors.Errorf("authorizeToken: provisioner not found or invalid audience (%s)", strings.Join(claims.Audience, ", ")), http.StatusUnauthorized, errContext} } @@ -74,19 +76,69 @@ func (a *Authority) Authorize(ott string) ([]provisioner.SignOption, error) { UsedAt: time.Now().Unix(), Subject: claims.Subject, }); ok { - return nil, &apiError{errors.Errorf("authorize: token already used"), http.StatusUnauthorized, errContext} + return nil, &apiError{errors.Errorf("authorizeToken: token already used"), http.StatusUnauthorized, errContext} } } - // Call the provisioner Authorize method to get the signing options - opts, err := p.Authorize(ott) + return p, nil +} + +// Authorize is a passthrough to AuthorizeSign. +// NOTE: Authorize will be deprecated in a future release. Please use the +// context specific Authorize[Sign|Revoke|etc.] going forwards. +func (a *Authority) Authorize(ott string) ([]provisioner.SignOption, error) { + return a.AuthorizeSign(ott) +} + +// AuthorizeSign authorizes a signature request by validating and authenticating +// a OTT that must be sent w/ the request. +func (a *Authority) AuthorizeSign(ott string) ([]provisioner.SignOption, error) { + var errContext = context{"ott": ott} + + p, err := a.authorizeToken(ott) if err != nil { - return nil, &apiError{errors.Wrap(err, "authorize"), http.StatusUnauthorized, errContext} + return nil, &apiError{errors.Wrap(err, "authorizeSign"), http.StatusUnauthorized, errContext} + } + + // Call the provisioner AuthorizeSign method to apply provisioner specific + // auth claims and get the signing options. + opts, err := p.AuthorizeSign(ott) + if err != nil { + return nil, &apiError{errors.Wrap(err, "authorizeSign"), http.StatusUnauthorized, errContext} } return opts, nil } +// authorizeRevoke authorizes a revocation request by validating and authenticating +// the RevokeOptions POSTed with the request. +// Returns a tuple of the provisioner ID and error, if one occurred. +func (a *Authority) authorizeRevoke(opts *RevokeOptions) (p provisioner.Interface, err error) { + if opts.MTLS { + if opts.Crt.SerialNumber.String() != opts.Serial { + return nil, errors.New("authorizeRevoke: serial number in certificate different than body") + } + // Load the Certificate provisioner if one exists. + p, err = a.LoadProvisionerByCertificate(opts.Crt) + if err != nil { + return nil, errors.Wrap(err, "authorizeRevoke") + } + } else { + // Gets the token provisioner and validates common token fields. + p, err = a.authorizeToken(opts.OTT) + if err != nil { + return nil, errors.Wrap(err, "authorizeRevoke") + } + + // Call the provisioner AuthorizeRevoke to apply provisioner specific auth claims. + err = p.AuthorizeRevoke(opts.OTT) + if err != nil { + return nil, errors.Wrap(err, "authorizeRevoke") + } + } + return +} + // authorizeRenewal tries to locate the step provisioner extension, and checks // if for the configured provisioner, the renewal is enabled or not. If the // extra extension cannot be found, authorize the renewal by default. @@ -94,17 +146,35 @@ func (a *Authority) Authorize(ott string) ([]provisioner.SignOption, error) { // TODO(mariano): should we authorize by default? func (a *Authority) authorizeRenewal(crt *x509.Certificate) error { errContext := map[string]interface{}{"serialNumber": crt.SerialNumber.String()} + + // Check the passive revocation table. + isRevoked, err := a.db.IsRevoked(crt.SerialNumber.String()) + if err != nil { + return &apiError{ + err: errors.Wrap(err, "renew"), + code: http.StatusInternalServerError, + context: errContext, + } + } + if isRevoked { + return &apiError{ + err: errors.New("renew: certificate has been revoked"), + code: http.StatusUnauthorized, + context: errContext, + } + } + p, ok := a.provisioners.LoadByCertificate(crt) if !ok { return &apiError{ - err: errors.New("provisioner not found"), + err: errors.New("renew: provisioner not found"), code: http.StatusUnauthorized, context: errContext, } } if err := p.AuthorizeRenewal(crt); err != nil { return &apiError{ - err: err, + err: errors.Wrap(err, "renew"), code: http.StatusUnauthorized, context: errContext, } diff --git a/authority/authorize_test.go b/authority/authorize_test.go index 64a9dc63..90365655 100644 --- a/authority/authorize_test.go +++ b/authority/authorize_test.go @@ -1,14 +1,17 @@ package authority import ( + "crypto/x509" "net/http" "testing" "time" "github.com/pkg/errors" "github.com/smallstep/assert" + "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/randutil" "github.com/smallstep/cli/jose" + "gopkg.in/square/go-jose.v2/jwt" ) func generateToken(sub, iss, aud string, sans []string, iat time.Time, jwk *jose.JSONWebKey) (string, error) { @@ -43,18 +46,20 @@ func generateToken(sub, iss, aud string, sans []string, iat time.Time, jwk *jose return jose.Signed(sig).Claims(claims).CompactSerialize() } -func TestAuthorize(t *testing.T) { +func TestAuthority_authorizeToken(t *testing.T) { a := testAuthority(t) - key, err := jose.ParseKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + jwk, err := jose.ParseKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) assert.FatalError(t, err) - // Invalid keys - keyNoKid := &jose.JSONWebKey{Key: key.Key, KeyID: ""} - keyBadKid := &jose.JSONWebKey{Key: key.Key, KeyID: "foo"} - now := time.Now() + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID)) + assert.FatalError(t, err) + + now := time.Now().UTC() + validIssuer := "step-cli" - validAudience := []string{"https://test.ca.smallstep.com/sign"} + validAudience := []string{"https://test.ca.smallstep.com/revoke"} type authorizeTest struct { auth *Authority @@ -63,83 +68,91 @@ func TestAuthorize(t *testing.T) { res []interface{} } tests := map[string]func(t *testing.T) *authorizeTest{ - "fail invalid ott": func(t *testing.T) *authorizeTest { + "fail/invalid-ott": func(t *testing.T) *authorizeTest { return &authorizeTest{ auth: a, ott: "foo", - err: &apiError{errors.New("authorize: error parsing token"), + err: &apiError{errors.New("authorizeToken: error parsing token"), http.StatusUnauthorized, context{"ott": "foo"}}, } }, - "fail empty key id": func(t *testing.T) *authorizeTest { - raw, err := generateToken("test.smallstep.com", validIssuer, validAudience[0], nil, now, keyNoKid) + "fail/prehistoric-token": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now.Add(-time.Hour)), + Audience: validAudience, + ID: "43", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() assert.FatalError(t, err) return &authorizeTest{ auth: a, ott: raw, - err: &apiError{errors.New("authorize: provisioner not found or invalid audience"), + err: &apiError{errors.New("authorizeToken: token issued before the bootstrap of certificate authority"), http.StatusUnauthorized, context{"ott": raw}}, } }, - "fail provisioner not found": func(t *testing.T) *authorizeTest { - raw, err := generateToken("test.smallstep.com", validIssuer, validAudience[0], nil, now, keyBadKid) + "fail/provisioner-not-found": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + _sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "foo")) + assert.FatalError(t, err) + + raw, err := jwt.Signed(_sig).Claims(cl).CompactSerialize() assert.FatalError(t, err) return &authorizeTest{ auth: a, ott: raw, - err: &apiError{errors.New("authorize: provisioner not found or invalid audience"), - http.StatusUnauthorized, context{"ott": raw}}, - } - }, - "fail invalid issuer": func(t *testing.T) *authorizeTest { - raw, err := generateToken("test.smallstep.com", "invalid-issuer", validAudience[0], nil, now, key) - assert.FatalError(t, err) - return &authorizeTest{ - auth: a, - ott: raw, - err: &apiError{errors.New("authorize: provisioner not found or invalid audience"), - http.StatusUnauthorized, context{"ott": raw}}, - } - }, - "fail empty subject": func(t *testing.T) *authorizeTest { - raw, err := generateToken("", validIssuer, validAudience[0], nil, now, key) - assert.FatalError(t, err) - return &authorizeTest{ - auth: a, - ott: raw, - err: &apiError{errors.New("authorize: token subject cannot be empty"), - http.StatusUnauthorized, context{"ott": raw}}, - } - }, - "fail verify-sig-failure": func(t *testing.T) *authorizeTest { - raw, err := generateToken("test.smallstep.com", validIssuer, validAudience[0], nil, now, key) - assert.FatalError(t, err) - return &authorizeTest{ - auth: a, - ott: raw + "00", - err: &apiError{errors.New("authorize: error parsing claims: square/go-jose: error in cryptographic primitive"), - http.StatusUnauthorized, context{"ott": raw + "00"}}, - } - }, - "fail token-already-used": func(t *testing.T) *authorizeTest { - raw, err := generateToken("test.smallstep.com", validIssuer, validAudience[0], nil, now, key) - assert.FatalError(t, err) - _, err = a.Authorize(raw) - assert.FatalError(t, err) - return &authorizeTest{ - auth: a, - ott: raw, - err: &apiError{errors.New("authorize: token already used"), + err: &apiError{errors.New("authorizeToken: provisioner not found or invalid audience (https://test.ca.smallstep.com/revoke)"), http.StatusUnauthorized, context{"ott": raw}}, } }, "ok": func(t *testing.T) *authorizeTest { - raw, err := generateToken("test.smallstep.com", validIssuer, validAudience[0], nil, now, key) + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "43", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() assert.FatalError(t, err) return &authorizeTest{ auth: a, ott: raw, - res: []interface{}{"1", "2", "3", "4", "5", "6"}, + } + }, + "fail/token-already-used": func(t *testing.T) *authorizeTest { + _a := testAuthority(t) + + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "43", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + _, err = _a.authorizeToken(raw) + assert.FatalError(t, err) + return &authorizeTest{ + auth: _a, + ott: raw, + err: &apiError{errors.New("authorizeToken: token already used"), + http.StatusUnauthorized, context{"ott": raw}}, } }, } @@ -147,9 +160,8 @@ func TestAuthorize(t *testing.T) { for name, genTestCase := range tests { t.Run(name, func(t *testing.T) { tc := genTestCase(t) - assert.FatalError(t, err) - crtOpts, err := tc.auth.Authorize(tc.ott) + p, err := tc.auth.authorizeToken(tc.ott) if err != nil { if assert.NotNil(t, tc.err) { switch v := err.(type) { @@ -163,9 +175,415 @@ func TestAuthorize(t *testing.T) { } } else { if assert.Nil(t, tc.err) { - assert.Equals(t, len(crtOpts), len(tc.res)) + assert.Equals(t, p.GetID(), "step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc") } } }) } } + +func TestAuthority_authorizeRevoke(t *testing.T) { + a := testAuthority(t) + + jwk, err := jose.ParseKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID)) + assert.FatalError(t, err) + + now := time.Now().UTC() + + validIssuer := "step-cli" + validAudience := []string{"https://test.ca.smallstep.com/revoke"} + + type authorizeTest struct { + auth *Authority + opts *RevokeOptions + err error + res []interface{} + } + tests := map[string]func(t *testing.T) *authorizeTest{ + "fail/token/invalid-ott": func(t *testing.T) *authorizeTest { + return &authorizeTest{ + auth: a, + opts: &RevokeOptions{OTT: "foo"}, + err: errors.New("authorizeRevoke: authorizeToken: error parsing token"), + } + }, + "fail/token/invalid-subject": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "43", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + opts: &RevokeOptions{OTT: raw}, + err: errors.New("authorizeRevoke: token subject cannot be empty"), + } + }, + "ok/token": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + opts: &RevokeOptions{OTT: raw}, + } + }, + "fail/mTLS/invalid-serial": func(t *testing.T) *authorizeTest { + crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt") + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + opts: &RevokeOptions{MTLS: true, Crt: crt, Serial: "foo"}, + err: errors.New("authorizeRevoke: serial number in certificate different than body"), + } + }, + "fail/mTLS/load-provisioner": func(t *testing.T) *authorizeTest { + crt, err := pemutil.ReadCertificate("./testdata/certs/provisioner-not-found.crt") + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + opts: &RevokeOptions{MTLS: true, Crt: crt, Serial: "41633491264736369593451462439668497527"}, + err: errors.New("authorizeRevoke: provisioner not found"), + } + }, + "ok/mTLS": func(t *testing.T) *authorizeTest { + crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt") + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + opts: &RevokeOptions{MTLS: true, Crt: crt, Serial: "102012593071130646873265215610956555026"}, + } + }, + } + + for name, genTestCase := range tests { + t.Run(name, func(t *testing.T) { + tc := genTestCase(t) + + p, err := tc.auth.authorizeRevoke(tc.opts) + if err != nil { + if assert.NotNil(t, tc.err) { + assert.HasPrefix(t, err.Error(), tc.err.Error()) + } + } else { + if assert.Nil(t, tc.err) { + if assert.NotNil(t, p) { + assert.Equals(t, p.GetID(), "step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc") + } + } + } + }) + } +} + +func TestAuthority_AuthorizeSign(t *testing.T) { + a := testAuthority(t) + + jwk, err := jose.ParseKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID)) + assert.FatalError(t, err) + + now := time.Now().UTC() + + validIssuer := "step-cli" + validAudience := []string{"https://test.ca.smallstep.com/sign"} + + type authorizeTest struct { + auth *Authority + ott string + err *apiError + res []interface{} + } + tests := map[string]func(t *testing.T) *authorizeTest{ + "fail/invalid-ott": func(t *testing.T) *authorizeTest { + return &authorizeTest{ + auth: a, + ott: "foo", + err: &apiError{errors.New("authorizeSign: authorizeToken: error parsing token"), + http.StatusUnauthorized, context{"ott": "foo"}}, + } + }, + "fail/invalid-subject": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "43", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + ott: raw, + err: &apiError{errors.New("authorizeSign: token subject cannot be empty"), + http.StatusUnauthorized, context{"ott": raw}}, + } + }, + "ok": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + ott: raw, + } + }, + } + + for name, genTestCase := range tests { + t.Run(name, func(t *testing.T) { + tc := genTestCase(t) + + got, err := tc.auth.AuthorizeSign(tc.ott) + if err != nil { + if assert.NotNil(t, tc.err) { + assert.Nil(t, got) + switch v := err.(type) { + case *apiError: + assert.HasPrefix(t, v.err.Error(), tc.err.Error()) + assert.Equals(t, v.code, tc.err.code) + assert.Equals(t, v.context, tc.err.context) + default: + t.Errorf("unexpected error type: %T", v) + } + } + } else { + if assert.Nil(t, tc.err) { + assert.Len(t, 6, got) + } + } + }) + } +} + +// TODO: remove once Authorize deprecated. +func TestAuthority_Authorize(t *testing.T) { + a := testAuthority(t) + + jwk, err := jose.ParseKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID)) + assert.FatalError(t, err) + + now := time.Now().UTC() + + validIssuer := "step-cli" + validAudience := []string{"https://test.ca.smallstep.com/sign"} + + type authorizeTest struct { + auth *Authority + ott string + err *apiError + res []interface{} + } + tests := map[string]func(t *testing.T) *authorizeTest{ + "fail/invalid-ott": func(t *testing.T) *authorizeTest { + return &authorizeTest{ + auth: a, + ott: "foo", + err: &apiError{errors.New("authorizeSign: authorizeToken: error parsing token"), + http.StatusUnauthorized, context{"ott": "foo"}}, + } + }, + "fail/invalid-subject": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "43", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + ott: raw, + err: &apiError{errors.New("authorizeSign: token subject cannot be empty"), + http.StatusUnauthorized, context{"ott": raw}}, + } + }, + "ok": func(t *testing.T) *authorizeTest { + cl := jwt.Claims{ + Subject: "test.smallstep.com", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return &authorizeTest{ + auth: a, + ott: raw, + } + }, + } + + for name, genTestCase := range tests { + t.Run(name, func(t *testing.T) { + tc := genTestCase(t) + + got, err := tc.auth.Authorize(tc.ott) + if err != nil { + if assert.NotNil(t, tc.err) { + assert.Nil(t, got) + switch v := err.(type) { + case *apiError: + assert.HasPrefix(t, v.err.Error(), tc.err.Error()) + assert.Equals(t, v.code, tc.err.code) + assert.Equals(t, v.context, tc.err.context) + default: + t.Errorf("unexpected error type: %T", v) + } + } + } else { + if assert.Nil(t, tc.err) { + assert.Len(t, 6, got) + } + } + }) + } +} + +func TestAuthority_authorizeRenewal(t *testing.T) { + fooCrt, err := pemutil.ReadCertificate("testdata/certs/foo.crt") + assert.FatalError(t, err) + + renewDisabledCrt, err := pemutil.ReadCertificate("testdata/certs/renew-disabled.crt") + assert.FatalError(t, err) + + otherCrt, err := pemutil.ReadCertificate("testdata/certs/provisioner-not-found.crt") + assert.FatalError(t, err) + + type authorizeTest struct { + auth *Authority + crt *x509.Certificate + err *apiError + } + tests := map[string]func(t *testing.T) *authorizeTest{ + "fail/db.IsRevoked-error": func(t *testing.T) *authorizeTest { + a := testAuthority(t) + a.db = &MockAuthDB{ + isRevoked: func(key string) (bool, error) { + return false, errors.New("force") + }, + } + + return &authorizeTest{ + auth: a, + crt: fooCrt, + err: &apiError{errors.New("renew: force"), + http.StatusInternalServerError, context{"serialNumber": "102012593071130646873265215610956555026"}}, + } + }, + "fail/revoked": func(t *testing.T) *authorizeTest { + a := testAuthority(t) + a.db = &MockAuthDB{ + isRevoked: func(key string) (bool, error) { + return true, nil + }, + } + return &authorizeTest{ + auth: a, + crt: fooCrt, + err: &apiError{errors.New("renew: certificate has been revoked"), + http.StatusUnauthorized, context{"serialNumber": "102012593071130646873265215610956555026"}}, + } + }, + "fail/load-provisioner": func(t *testing.T) *authorizeTest { + a := testAuthority(t) + a.db = &MockAuthDB{ + isRevoked: func(key string) (bool, error) { + return false, nil + }, + } + return &authorizeTest{ + auth: a, + crt: otherCrt, + err: &apiError{errors.New("renew: provisioner not found"), + http.StatusUnauthorized, context{"serialNumber": "41633491264736369593451462439668497527"}}, + } + }, + "fail/provisioner-authorize-renewal-fail": func(t *testing.T) *authorizeTest { + a := testAuthority(t) + a.db = &MockAuthDB{ + isRevoked: func(key string) (bool, error) { + return false, nil + }, + } + + return &authorizeTest{ + auth: a, + crt: renewDisabledCrt, + err: &apiError{errors.New("renew: renew is disabled for provisioner renew_disabled:IMi94WBNI6gP5cNHXlZYNUzvMjGdHyBRmFoo-lCEaqk"), + http.StatusUnauthorized, context{"serialNumber": "119772236532068856521070735128919532568"}}, + } + }, + "ok": func(t *testing.T) *authorizeTest { + a := testAuthority(t) + a.db = &MockAuthDB{ + isRevoked: func(key string) (bool, error) { + return false, nil + }, + } + return &authorizeTest{ + auth: a, + crt: fooCrt, + } + }, + } + + for name, genTestCase := range tests { + t.Run(name, func(t *testing.T) { + tc := genTestCase(t) + + err := tc.auth.authorizeRenewal(tc.crt) + if err != nil { + if assert.NotNil(t, tc.err) { + switch v := err.(type) { + case *apiError: + assert.HasPrefix(t, v.err.Error(), tc.err.Error()) + assert.Equals(t, v.code, tc.err.code) + assert.Equals(t, v.context, tc.err.context) + default: + t.Errorf("unexpected error type: %T", v) + } + } + } else { + assert.Nil(t, tc.err) + } + }) + } +} diff --git a/authority/config.go b/authority/config.go index d05ec519..77854812 100644 --- a/authority/config.go +++ b/authority/config.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/db" "github.com/smallstep/cli/crypto/tlsutil" "github.com/smallstep/cli/crypto/x509util" ) @@ -28,9 +29,9 @@ var ( } defaultDisableRenewal = false globalProvisionerClaims = provisioner.Claims{ - MinTLSDur: &provisioner.Duration{5 * time.Minute}, - MaxTLSDur: &provisioner.Duration{24 * time.Hour}, - DefaultTLSDur: &provisioner.Duration{24 * time.Hour}, + MinTLSDur: &provisioner.Duration{Duration: 5 * time.Minute}, + MaxTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, + DefaultTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, DisableRenewal: &defaultDisableRenewal, } ) @@ -44,6 +45,7 @@ type Config struct { Address string `json:"address"` DNSNames []string `json:"dnsNames"` Logger json.RawMessage `json:"logger,omitempty"` + DB *db.Config `json:"db,omitempty"` Monitoring json.RawMessage `json:"monitoring,omitempty"` AuthorityConfig *AuthConfig `json:"authority,omitempty"` TLS *tlsutil.TLSOptions `json:"tls,omitempty"` @@ -59,7 +61,7 @@ type AuthConfig struct { } // Validate validates the authority configuration. -func (c *AuthConfig) Validate(audiences []string) error { +func (c *AuthConfig) Validate(audiences provisioner.Audiences) error { if c == nil { return errors.New("authority cannot be undefined") } @@ -168,10 +170,18 @@ func (c *Config) Validate() error { // getAudiences returns the legacy and possible urls without the ports that will // be used as the default provisioner audiences. The CA might have proxies in // front so we cannot rely on the port. -func (c *Config) getAudiences() []string { - audiences := []string{legacyAuthority} - for _, name := range c.DNSNames { - audiences = append(audiences, fmt.Sprintf("https://%s/sign", name), fmt.Sprintf("https://%s/1.0/sign", name)) +func (c *Config) getAudiences() provisioner.Audiences { + audiences := provisioner.Audiences{ + Sign: []string{legacyAuthority}, + Revoke: []string{legacyAuthority}, } + + for _, name := range c.DNSNames { + audiences.Sign = append(audiences.Sign, + fmt.Sprintf("https://%s/sign", name), fmt.Sprintf("https://%s/1.0/sign", name)) + audiences.Revoke = append(audiences.Revoke, + fmt.Sprintf("https://%s/revoke", name), fmt.Sprintf("https://%s/1.0/revoke", name)) + } + return audiences } diff --git a/authority/config_test.go b/authority/config_test.go index b5a57998..eb5e7a5a 100644 --- a/authority/config_test.go +++ b/authority/config_test.go @@ -277,7 +277,7 @@ func TestAuthConfigValidate(t *testing.T) { ac: &AuthConfig{ Provisioners: p, Claims: &provisioner.Claims{ - MinTLSDur: &provisioner.Duration{-1}, + MinTLSDur: &provisioner.Duration{Duration: -1}, }, }, err: errors.New("claims: MinTLSCertDuration must be greater than 0"), @@ -305,7 +305,7 @@ func TestAuthConfigValidate(t *testing.T) { for name, get := range tests { t.Run(name, func(t *testing.T) { tc := get(t) - err := tc.ac.Validate([]string{}) + err := tc.ac.Validate(provisioner.Audiences{}) if err != nil { if assert.NotNil(t, tc.err) { assert.Equals(t, tc.err.Error(), err.Error()) diff --git a/authority/db_test.go b/authority/db_test.go new file mode 100644 index 00000000..b4e073f1 --- /dev/null +++ b/authority/db_test.go @@ -0,0 +1,55 @@ +package authority + +import ( + "crypto/x509" + + "github.com/smallstep/certificates/db" +) + +type MockAuthDB struct { + err error + ret1, ret2 interface{} + init func(*db.Config) (db.AuthDB, error) + isRevoked func(string) (bool, error) + revoke func(rci *db.RevokedCertificateInfo) error + storeCertificate func(crt *x509.Certificate) error + shutdown func() error +} + +func (m *MockAuthDB) Init(c *db.Config) (db.AuthDB, error) { + if m.init != nil { + return m.init(c) + } + if m.ret1 == nil { + return nil, m.err + } + return m.ret1.(*db.DB), m.err +} + +func (m *MockAuthDB) IsRevoked(sn string) (bool, error) { + if m.isRevoked != nil { + return m.isRevoked(sn) + } + return m.ret1.(bool), m.err +} + +func (m *MockAuthDB) Revoke(rci *db.RevokedCertificateInfo) error { + if m.revoke != nil { + return m.revoke(rci) + } + return m.err +} + +func (m *MockAuthDB) StoreCertificate(crt *x509.Certificate) error { + if m.storeCertificate != nil { + return m.storeCertificate(crt) + } + return m.err +} + +func (m *MockAuthDB) Shutdown() error { + if m.shutdown != nil { + return m.shutdown() + } + return m.err +} diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index 8edd1965..2c1e5ee0 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -38,12 +38,12 @@ type Collection struct { byID *sync.Map byKey *sync.Map sorted provisionerSlice - audiences []string + audiences Audiences } // NewCollection initializes a collection of provisioners. The given list of // audiences are the audiences used by the JWT provisioner. -func NewCollection(audiences []string) *Collection { +func NewCollection(audiences Audiences) *Collection { return &Collection{ byID: new(sync.Map), byKey: new(sync.Map), @@ -59,7 +59,7 @@ func (c *Collection) Load(id string) (Interface, bool) { // LoadByToken parses the token claims and loads the provisioner associated. func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) (Interface, bool) { // match with server audiences - if matchesAudience(claims.Audience, c.audiences) { + if matchesAudience(claims.Audience, c.audiences.All()) { // If matches with stored audiences it will be a JWT token (default), and // the id would be :. return c.Load(claims.Issuer + ":" + token.Headers[0].KeyID) diff --git a/authority/provisioner/collection_test.go b/authority/provisioner/collection_test.go index d065d5f3..55e0f056 100644 --- a/authority/provisioner/collection_test.go +++ b/authority/provisioner/collection_test.go @@ -68,14 +68,14 @@ func TestCollection_LoadByToken(t *testing.T) { jwk, err := decryptJSONWebKey(p1.EncryptedKey) assert.FatalError(t, err) - token, err := generateSimpleToken(p1.Name, testAudiences[0], jwk) + token, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], jwk) assert.FatalError(t, err) t1, c1, err := parseToken(token) assert.FatalError(t, err) jwk, err = decryptJSONWebKey(p2.EncryptedKey) assert.FatalError(t, err) - token, err = generateSimpleToken(p2.Name, testAudiences[1], jwk) + token, err = generateSimpleToken(p2.Name, testAudiences.Sign[1], jwk) assert.FatalError(t, err) t2, c2, err := parseToken(token) assert.FatalError(t, err) @@ -92,7 +92,7 @@ func TestCollection_LoadByToken(t *testing.T) { type fields struct { byID *sync.Map - audiences []string + audiences Audiences } type args struct { token *jose.JSONWebToken @@ -109,7 +109,7 @@ func TestCollection_LoadByToken(t *testing.T) { {"ok2", fields{byID, testAudiences}, args{t2, c2}, p2, true}, {"ok3", fields{byID, testAudiences}, args{t3, c3}, p3, true}, {"bad", fields{byID, testAudiences}, args{t4, c4}, nil, false}, - {"fail", fields{byID, []string{"https://foo"}}, args{t1, c1}, nil, false}, + {"fail", fields{byID, Audiences{Sign: []string{"https://foo"}}}, args{t1, c1}, nil, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -162,7 +162,7 @@ func TestCollection_LoadByCertificate(t *testing.T) { type fields struct { byID *sync.Map - audiences []string + audiences Audiences } type args struct { cert *x509.Certificate diff --git a/authority/provisioner/jwk.go b/authority/provisioner/jwk.go index 6c1813c5..a7e009df 100644 --- a/authority/provisioner/jwk.go +++ b/authority/provisioner/jwk.go @@ -24,7 +24,7 @@ type JWK struct { EncryptedKey string `json:"encryptedKey,omitempty"` Claims *Claims `json:"claims,omitempty"` claimer *Claimer - audiences []string + audiences Audiences } // GetID returns the provisioner unique identifier. The name and credential id @@ -33,6 +33,25 @@ func (p *JWK) GetID() string { return p.Name + ":" + p.Key.KeyID } +// +// GetTokenID returns the identifier of the token. +func (p *JWK) GetTokenID(ott string) (string, error) { + // Validate payload + token, err := jose.ParseSigned(ott) + if err != nil { + return "", errors.Wrap(err, "error parsing token") + } + + // Get claims w/out verification. We need to look up the provisioner + // key in order to verify the claims and we need the issuer from the claims + // before we can look up the provisioner. + var claims jose.Claims + if err = token.UnsafeClaimsWithoutVerification(&claims); err != nil { + return "", errors.Wrap(err, "error verifying claims") + } + return claims.ID, nil +} + // GetName returns the name of the provisioner. func (p *JWK) GetName() string { return p.Name @@ -68,8 +87,10 @@ func (p *JWK) Init(config Config) (err error) { return err } -// Authorize validates the given token. -func (p *JWK) Authorize(token string) ([]SignOption, error) { +// authorizeToken performs common jwt authorization actions and returns the +// claims for case specific downstream parsing. +// e.g. a Sign request will auth/validate different fields than a Revoke request. +func (p *JWK) authorizeToken(token string, audiences []string) (*jwtPayload, error) { jwt, err := jose.ParseSigned(token) if err != nil { return nil, errors.Wrapf(err, "error parsing token") @@ -90,7 +111,7 @@ func (p *JWK) Authorize(token string) ([]SignOption, error) { } // validate audiences with the defaults - if !matchesAudience(claims.Audience, p.audiences) { + if !matchesAudience(claims.Audience, audiences) { return nil, errors.New("invalid token: invalid audience claim (aud)") } @@ -98,6 +119,22 @@ func (p *JWK) Authorize(token string) ([]SignOption, error) { return nil, errors.New("token subject cannot be empty") } + return &claims, nil +} + +// AuthorizeRevoke returns an error if the provisioner does not have rights to +// revoke the certificate with serial number in the `sub` property. +func (p *JWK) AuthorizeRevoke(token string) error { + _, err := p.authorizeToken(token, p.audiences.Revoke) + return err +} + +// AuthorizeSign validates the given token. +func (p *JWK) AuthorizeSign(token string) ([]SignOption, error) { + claims, err := p.authorizeToken(token, p.audiences.Sign) + if err != nil { + return nil, err + } // NOTE: This is for backwards compatibility with older versions of cli // and certificates. Older versions added the token subject as the only SAN // in a CSR by default. @@ -123,9 +160,3 @@ func (p *JWK) AuthorizeRenewal(cert *x509.Certificate) error { } return nil } - -// AuthorizeRevoke returns an error if the provisioner does not have rights to -// revoke the certificate with serial number in the `sub` property. -func (p *JWK) AuthorizeRevoke(token string) error { - return errors.New("not implemented") -} diff --git a/authority/provisioner/jwk_test.go b/authority/provisioner/jwk_test.go index 7a2dc50c..21789ca8 100644 --- a/authority/provisioner/jwk_test.go +++ b/authority/provisioner/jwk_test.go @@ -110,7 +110,7 @@ func TestJWK_Init(t *testing.T) { } } -func TestJWK_Authorize(t *testing.T) { +func TestJWK_authorizeToken(t *testing.T) { p1, err := generateJWK() assert.FatalError(t, err) p2, err := generateJWK() @@ -121,11 +121,11 @@ func TestJWK_Authorize(t *testing.T) { key2, err := decryptJSONWebKey(p2.EncryptedKey) assert.FatalError(t, err) - t1, err := generateSimpleToken(p1.Name, testAudiences[0], key1) + t1, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], key1) assert.FatalError(t, err) - t2, err := generateSimpleToken(p2.Name, testAudiences[1], key2) + t2, err := generateSimpleToken(p2.Name, testAudiences.Sign[1], key2) assert.FatalError(t, err) - t3, err := generateToken("test.smallstep.com", p1.Name, testAudiences[0], "", []string{}, time.Now(), key1) + t3, err := generateToken("test.smallstep.com", p1.Name, testAudiences.Sign[0], "", []string{}, time.Now(), key1) assert.FatalError(t, err) // Invalid tokens @@ -133,14 +133,14 @@ func TestJWK_Authorize(t *testing.T) { key3, err := generateJSONWebKey() assert.FatalError(t, err) // missing key - failKey, err := generateSimpleToken(p1.Name, testAudiences[0], key3) + failKey, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], key3) assert.FatalError(t, err) // invalid token failTok := "foo." + parts[1] + "." + parts[2] // invalid claims failClaims := parts[0] + ".foo." + parts[1] // invalid issuer - failIss, err := generateSimpleToken("foobar", testAudiences[0], key1) + failIss, err := generateSimpleToken("foobar", testAudiences.Sign[0], key1) assert.FatalError(t, err) // invalid audience failAud, err := generateSimpleToken(p1.Name, "foobar", key1) @@ -148,13 +148,13 @@ func TestJWK_Authorize(t *testing.T) { // invalid signature failSig := t1[0 : len(t1)-2] // no subject - failSub, err := generateToken("", p1.Name, testAudiences[0], "", []string{"test.smallstep.com"}, time.Now(), key1) + failSub, err := generateToken("", p1.Name, testAudiences.Sign[0], "", []string{"test.smallstep.com"}, time.Now(), key1) assert.FatalError(t, err) // expired - failExp, err := generateToken("subject", p1.Name, testAudiences[0], "", []string{"test.smallstep.com"}, time.Now().Add(-360*time.Second), key1) + failExp, err := generateToken("subject", p1.Name, testAudiences.Sign[0], "", []string{"test.smallstep.com"}, time.Now().Add(-360*time.Second), key1) assert.FatalError(t, err) // not before - failNbf, err := generateToken("subject", p1.Name, testAudiences[0], "", []string{"test.smallstep.com"}, time.Now().Add(360*time.Second), key1) + failNbf, err := generateToken("subject", p1.Name, testAudiences.Sign[0], "", []string{"test.smallstep.com"}, time.Now().Add(360*time.Second), key1) assert.FatalError(t, err) // Remove encrypted key for p2 @@ -164,36 +164,123 @@ func TestJWK_Authorize(t *testing.T) { token string } tests := []struct { - name string - prov *JWK - args args - wantErr bool + name string + prov *JWK + args args + err error }{ - {"ok", p1, args{t1}, false}, - {"ok-no-encrypted-key", p2, args{t2}, false}, - {"ok-no-sans", p1, args{t3}, false}, - {"fail-key", p1, args{failKey}, true}, - {"fail-token", p1, args{failTok}, true}, - {"fail-claims", p1, args{failClaims}, true}, - {"fail-issuer", p1, args{failIss}, true}, - {"fail-audience", p1, args{failAud}, true}, - {"fail-signature", p1, args{failSig}, true}, - {"fail-subject", p1, args{failSub}, true}, - {"fail-expired", p1, args{failExp}, true}, - {"fail-not-before", p1, args{failNbf}, true}, + {"fail-token", p1, args{failTok}, errors.New("error parsing token")}, + {"fail-key", p1, args{failKey}, errors.New("error parsing claims")}, + {"fail-claims", p1, args{failClaims}, errors.New("error parsing claims")}, + {"fail-signature", p1, args{failSig}, errors.New("error parsing claims: square/go-jose: error in cryptographic primitive")}, + {"fail-issuer", p1, args{failIss}, errors.New("invalid token: square/go-jose/jwt: validation failed, invalid issuer claim (iss)")}, + {"fail-expired", p1, args{failExp}, errors.New("invalid token: square/go-jose/jwt: validation failed, token is expired (exp)")}, + {"fail-not-before", p1, args{failNbf}, errors.New("invalid token: square/go-jose/jwt: validation failed, token not valid yet (nbf)")}, + {"fail-audience", p1, args{failAud}, errors.New("invalid token: invalid audience claim (aud)")}, + {"fail-subject", p1, args{failSub}, errors.New("token subject cannot be empty")}, + {"ok", p1, args{t1}, nil}, + {"ok-no-encrypted-key", p2, args{t2}, nil}, + {"ok-no-sans", p1, args{t3}, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.prov.Authorize(tt.args.token) - if (err != nil) != tt.wantErr { - t.Errorf("JWK.Authorize() error = %v, wantErr %v", err, tt.wantErr) - return - } - if err != nil { - assert.Nil(t, got) + if got, err := tt.prov.authorizeToken(tt.args.token, testAudiences.Sign); err != nil { + if assert.NotNil(t, tt.err) { + assert.HasPrefix(t, err.Error(), tt.err.Error()) + } } else { + assert.Nil(t, tt.err) assert.NotNil(t, got) - assert.Len(t, 6, got) + } + }) + } +} + +func TestJWK_AuthorizeRevoke(t *testing.T) { + p1, err := generateJWK() + assert.FatalError(t, err) + key1, err := decryptJSONWebKey(p1.EncryptedKey) + assert.FatalError(t, err) + t1, err := generateSimpleToken(p1.Name, testAudiences.Revoke[0], key1) + assert.FatalError(t, err) + // invalid signature + failSig := t1[0 : len(t1)-2] + + type args struct { + token string + } + tests := []struct { + name string + prov *JWK + args args + err error + }{ + {"fail-signature", p1, args{failSig}, errors.New("error parsing claims: square/go-jose: error in cryptographic primitive")}, + {"ok", p1, args{t1}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.prov.AuthorizeRevoke(tt.args.token); err != nil { + if assert.NotNil(t, tt.err) { + assert.HasPrefix(t, err.Error(), tt.err.Error()) + } + } + }) + } +} + +func TestJWK_AuthorizeSign(t *testing.T) { + p1, err := generateJWK() + assert.FatalError(t, err) + key1, err := decryptJSONWebKey(p1.EncryptedKey) + assert.FatalError(t, err) + + t1, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], key1) + assert.FatalError(t, err) + + t2, err := generateToken("subject", p1.Name, testAudiences.Sign[0], "name@smallstep.com", []string{}, time.Now(), key1) + assert.FatalError(t, err) + + // invalid signature + failSig := t1[0 : len(t1)-2] + + type args struct { + token string + } + tests := []struct { + name string + prov *JWK + args args + err error + }{ + {"fail-signature", p1, args{failSig}, errors.New("error parsing claims: square/go-jose: error in cryptographic primitive")}, + {"ok-sans", p1, args{t1}, nil}, + {"ok-no-sans", p1, args{t2}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, err := tt.prov.AuthorizeSign(tt.args.token); err != nil { + if assert.NotNil(t, tt.err) { + assert.HasPrefix(t, err.Error(), tt.err.Error()) + } + } else { + if assert.NotNil(t, got) { + assert.Len(t, 6, got) + + _cnv := got[0] + cnv, ok := _cnv.(commonNameValidator) + assert.True(t, ok) + assert.Equals(t, string(cnv), "subject") + + _dnv := got[1] + dnv, ok := _dnv.(dnsNamesValidator) + assert.True(t, ok) + if tt.name == "ok-sans" { + assert.Equals(t, []string(dnv), []string{"test.smallstep.com"}) + } else { + assert.Equals(t, []string(dnv), []string{"subject"}) + } + } } }) } @@ -231,31 +318,3 @@ func TestJWK_AuthorizeRenewal(t *testing.T) { }) } } - -func TestJWK_AuthorizeRevoke(t *testing.T) { - p1, err := generateJWK() - assert.FatalError(t, err) - key1, err := decryptJSONWebKey(p1.EncryptedKey) - assert.FatalError(t, err) - t1, err := generateSimpleToken(p1.Name, testAudiences[0], key1) - assert.FatalError(t, err) - - type args struct { - token string - } - tests := []struct { - name string - prov *JWK - args args - wantErr bool - }{ - {"disabled", p1, args{t1}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := tt.prov.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { - t.Errorf("JWK.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/authority/provisioner/noop.go b/authority/provisioner/noop.go index c00ba61f..44fd4600 100644 --- a/authority/provisioner/noop.go +++ b/authority/provisioner/noop.go @@ -9,6 +9,10 @@ func (p *noop) GetID() string { return "noop" } +func (p *noop) GetTokenID(token string) (string, error) { + return "", nil +} + func (p *noop) GetName() string { return "noop" } @@ -24,7 +28,7 @@ func (p *noop) Init(config Config) error { return nil } -func (p *noop) Authorize(token string) ([]SignOption, error) { +func (p *noop) AuthorizeSign(token string) ([]SignOption, error) { return []SignOption{}, nil } diff --git a/authority/provisioner/noop_test.go b/authority/provisioner/noop_test.go index a548430e..8f25eb8c 100644 --- a/authority/provisioner/noop_test.go +++ b/authority/provisioner/noop_test.go @@ -21,7 +21,7 @@ func Test_noop(t *testing.T) { assert.Equals(t, "", key) assert.Equals(t, false, ok) - sigOptions, err := p.Authorize("foo") + sigOptions, err := p.AuthorizeSign("foo") assert.Equals(t, []SignOption{}, sigOptions) assert.Equals(t, nil, err) } diff --git a/authority/provisioner/oidc.go b/authority/provisioner/oidc.go index 76d154a3..378c4be1 100644 --- a/authority/provisioner/oidc.go +++ b/authority/provisioner/oidc.go @@ -83,6 +83,25 @@ func (o *OIDC) GetID() string { return o.ClientID } +// GetTokenID returns the provisioner unique identifier, the OIDC provisioner the +// uses the clientID for this. +func (o *OIDC) GetTokenID(ott string) (string, error) { + // Validate payload + token, err := jose.ParseSigned(ott) + if err != nil { + return "", errors.Wrap(err, "error parsing token") + } + + // Get claims w/out verification. We need to look up the provisioner + // key in order to verify the claims and we need the issuer from the claims + // before we can look up the provisioner. + var claims openIDPayload + if err = token.UnsafeClaimsWithoutVerification(&claims); err != nil { + return "", errors.Wrap(err, "error verifying claims") + } + return claims.Nonce, nil +} + // GetName returns the name of the provisioner. func (o *OIDC) GetName() string { return o.Name @@ -171,8 +190,9 @@ func (o *OIDC) ValidatePayload(p openIDPayload) error { return nil } -// Authorize validates the given token. -func (o *OIDC) Authorize(token string) ([]SignOption, error) { +// authorizeToken applies the most common provisioner authorization claims, +// leaving the rest to context specific methods. +func (o *OIDC) authorizeToken(token string) (*openIDPayload, error) { jwt, err := jose.ParseSigned(token) if err != nil { return nil, errors.Wrapf(err, "error parsing token") @@ -201,6 +221,31 @@ func (o *OIDC) Authorize(token string) ([]SignOption, error) { return nil, err } + return &claims, nil +} + +// AuthorizeRevoke returns an error if the provisioner does not have rights to +// revoke the certificate with serial number in the `sub` property. +// Only tokens generated by an admin have the right to revoke a certificate. +func (o *OIDC) AuthorizeRevoke(token string) error { + claims, err := o.authorizeToken(token) + if err != nil { + return err + } + + // Only admins can revoke certificates. + if o.IsAdmin(claims.Email) { + return nil + } + return errors.New("cannot revoke with non-admin token") +} + +// AuthorizeSign validates the given token. +func (o *OIDC) AuthorizeSign(token string) ([]SignOption, error) { + claims, err := o.authorizeToken(token) + if err != nil { + return nil, err + } // Admins should be able to authorize any SAN if o.IsAdmin(claims.Email) { return []SignOption{ @@ -226,12 +271,6 @@ func (o *OIDC) AuthorizeRenewal(cert *x509.Certificate) error { return nil } -// AuthorizeRevoke returns an error if the provisioner does not have rights to -// revoke the certificate with serial number in the `sub` property. -func (o *OIDC) AuthorizeRevoke(token string) error { - return errors.New("not implemented") -} - func getAndDecode(uri string, v interface{}) error { resp, err := http.Get(uri) if err != nil { diff --git a/authority/provisioner/oidc_test.go b/authority/provisioner/oidc_test.go index b70f44e4..89965cbd 100644 --- a/authority/provisioner/oidc_test.go +++ b/authority/provisioner/oidc_test.go @@ -122,7 +122,7 @@ func TestOIDC_Init(t *testing.T) { } } -func TestOIDC_Authorize(t *testing.T) { +func TestOIDC_authorizeToken(t *testing.T) { srv := generateJWKServer(2) defer srv.Close() @@ -153,12 +153,6 @@ func TestOIDC_Authorize(t *testing.T) { assert.FatalError(t, err) t2, err := generateSimpleToken("the-issuer", p2.ClientID, &keys.Keys[1]) assert.FatalError(t, err) - t3, err := generateSimpleToken("the-issuer", p3.ClientID, &keys.Keys[0]) - assert.FatalError(t, err) - - // Admin email not in domains - okAdmin, err := generateToken("subject", "the-issuer", p3.ClientID, "root@example.com", []string{"test.smallstep.com"}, time.Now(), &keys.Keys[0]) - assert.FatalError(t, err) // Invalid email failEmail, err := generateToken("subject", "the-issuer", p3.ClientID, "", []string{}, time.Now(), &keys.Keys[0]) assert.FatalError(t, err) @@ -202,8 +196,6 @@ func TestOIDC_Authorize(t *testing.T) { }{ {"ok1", p1, args{t1}, false}, {"ok2", p2, args{t2}, false}, - {"admin", p3, args{t3}, false}, - {"admin", p3, args{okAdmin}, false}, {"fail-email", p3, args{failEmail}, true}, {"fail-domain", p3, args{failDomain}, true}, {"fail-key", p1, args{failKey}, true}, @@ -217,7 +209,74 @@ func TestOIDC_Authorize(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.prov.Authorize(tt.args.token) + got, err := tt.prov.authorizeToken(tt.args.token) + if (err != nil) != tt.wantErr { + fmt.Println(tt) + t.Errorf("OIDC.Authorize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + assert.Nil(t, got) + } else { + assert.NotNil(t, got) + assert.Equals(t, got.Issuer, "the-issuer") + } + }) + } +} + +func TestOIDC_AuthorizeSign(t *testing.T) { + srv := generateJWKServer(2) + defer srv.Close() + + var keys jose.JSONWebKeySet + assert.FatalError(t, getAndDecode(srv.URL+"/private", &keys)) + + // Create test provisioners + p1, err := generateOIDC() + assert.FatalError(t, err) + p2, err := generateOIDC() + assert.FatalError(t, err) + p3, err := generateOIDC() + assert.FatalError(t, err) + // Admin + Domains + p3.Admins = []string{"name@smallstep.com", "root@example.com"} + p3.Domains = []string{"smallstep.com"} + + // Update configuration endpoints and initialize + config := Config{Claims: globalProvisionerClaims} + p1.ConfigurationEndpoint = srv.URL + "/.well-known/openid-configuration" + p2.ConfigurationEndpoint = srv.URL + "/.well-known/openid-configuration" + p3.ConfigurationEndpoint = srv.URL + "/.well-known/openid-configuration" + assert.FatalError(t, p1.Init(config)) + assert.FatalError(t, p2.Init(config)) + assert.FatalError(t, p3.Init(config)) + + t1, err := generateSimpleToken("the-issuer", p1.ClientID, &keys.Keys[0]) + assert.FatalError(t, err) + // Admin email not in domains + okAdmin, err := generateToken("subject", "the-issuer", p3.ClientID, "root@example.com", []string{"test.smallstep.com"}, time.Now(), &keys.Keys[0]) + assert.FatalError(t, err) + // Invalid email + failEmail, err := generateToken("subject", "the-issuer", p3.ClientID, "", []string{}, time.Now(), &keys.Keys[0]) + assert.FatalError(t, err) + + type args struct { + token string + } + tests := []struct { + name string + prov *OIDC + args args + wantErr bool + }{ + {"ok1", p1, args{t1}, false}, + {"admin", p3, args{okAdmin}, false}, + {"fail-email", p3, args{failEmail}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.prov.AuthorizeSign(tt.args.token) if (err != nil) != tt.wantErr { fmt.Println(tt) t.Errorf("OIDC.Authorize() error = %v, wantErr %v", err, tt.wantErr) @@ -237,6 +296,63 @@ func TestOIDC_Authorize(t *testing.T) { } } +func TestOIDC_AuthorizeRevoke(t *testing.T) { + srv := generateJWKServer(2) + defer srv.Close() + + var keys jose.JSONWebKeySet + assert.FatalError(t, getAndDecode(srv.URL+"/private", &keys)) + + // Create test provisioners + p1, err := generateOIDC() + assert.FatalError(t, err) + p3, err := generateOIDC() + assert.FatalError(t, err) + // Admin + Domains + p3.Admins = []string{"name@smallstep.com", "root@example.com"} + p3.Domains = []string{"smallstep.com"} + + // Update configuration endpoints and initialize + config := Config{Claims: globalProvisionerClaims} + p1.ConfigurationEndpoint = srv.URL + "/.well-known/openid-configuration" + p3.ConfigurationEndpoint = srv.URL + "/.well-known/openid-configuration" + assert.FatalError(t, p1.Init(config)) + assert.FatalError(t, p3.Init(config)) + + t1, err := generateSimpleToken("the-issuer", p1.ClientID, &keys.Keys[0]) + assert.FatalError(t, err) + // Admin email not in domains + okAdmin, err := generateToken("subject", "the-issuer", p3.ClientID, "root@example.com", []string{"test.smallstep.com"}, time.Now(), &keys.Keys[0]) + assert.FatalError(t, err) + // Invalid email + failEmail, err := generateToken("subject", "the-issuer", p3.ClientID, "", []string{}, time.Now(), &keys.Keys[0]) + assert.FatalError(t, err) + + type args struct { + token string + } + tests := []struct { + name string + prov *OIDC + args args + wantErr bool + }{ + {"ok1", p1, args{t1}, true}, + {"admin", p3, args{okAdmin}, false}, + {"fail-email", p3, args{failEmail}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.prov.AuthorizeRevoke(tt.args.token) + if (err != nil) != tt.wantErr { + fmt.Println(tt) + t.Errorf("OIDC.Authorize() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + func TestOIDC_AuthorizeRenewal(t *testing.T) { p1, err := generateOIDC() assert.FatalError(t, err) @@ -270,6 +386,7 @@ func TestOIDC_AuthorizeRenewal(t *testing.T) { } } +/* func TestOIDC_AuthorizeRevoke(t *testing.T) { srv := generateJWKServer(2) defer srv.Close() @@ -308,6 +425,7 @@ func TestOIDC_AuthorizeRevoke(t *testing.T) { }) } } +*/ func Test_sanitizeEmail(t *testing.T) { tests := []struct { diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index aff1c54c..68e34884 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -11,15 +11,27 @@ import ( // Interface is the interface that all provisioner types must implement. type Interface interface { GetID() string + GetTokenID(token string) (string, error) GetName() string GetType() Type GetEncryptedKey() (kid string, key string, ok bool) Init(config Config) error - Authorize(token string) ([]SignOption, error) + AuthorizeSign(token string) ([]SignOption, error) AuthorizeRenewal(cert *x509.Certificate) error AuthorizeRevoke(token string) error } +// Audiences stores all supported audiences by request type. +type Audiences struct { + Sign []string + Revoke []string +} + +// All returns all supported audiences across all request types in one list. +func (a *Audiences) All() []string { + return append(a.Sign, a.Revoke...) +} + // Type indicates the provisioner Type. type Type int @@ -31,6 +43,11 @@ const ( // TypeOIDC is used to indicate the OIDC provisioners. TypeOIDC Type = 2 + + // RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map. + RevokeAudienceKey = "revoke" + // SignAudienceKey is the key for the 'sign' audiences in the audiences map. + SignAudienceKey = "sign" ) // Config defines the default parameters used in the initialization of @@ -39,7 +56,7 @@ type Config struct { // Claims are the default claims. Claims Claims // Audiences are the audiences used in the default provisioner, (JWK). - Audiences []string + Audiences Audiences } type provisioner struct { diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 04d06f10..ab68f163 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -12,9 +12,9 @@ import ( "github.com/smallstep/cli/jose" ) -var testAudiences = []string{ - "https://ca.smallstep.com/sign", - "https://ca.smallsteomcom/1.0/sign", +var testAudiences = Audiences{ + Sign: []string{"https://ca.smallstep.com/sign", "https://ca.smallstep.com/1.0/sign"}, + Revoke: []string{"https://ca.smallstep.com/revoke", "https://ca.smallstep.com/1.0/revoke"}, } func must(args ...interface{}) []interface{} { diff --git a/authority/provisioners.go b/authority/provisioners.go index d3072d12..289b52a4 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1,6 +1,7 @@ package authority import ( + "crypto/x509" "net/http" "github.com/pkg/errors" @@ -23,3 +24,14 @@ func (a *Authority) GetProvisioners(cursor string, limit int) (provisioner.List, provisioners, nextCursor := a.provisioners.Find(cursor, limit) return provisioners, nextCursor, nil } + +// LoadProvisionerByCertificate returns an interface to the provisioner that +// provisioned the certificate. +func (a *Authority) LoadProvisionerByCertificate(crt *x509.Certificate) (provisioner.Interface, error) { + p, ok := a.provisioners.LoadByCertificate(crt) + if !ok { + return nil, &apiError{errors.Errorf("provisioner not found"), + http.StatusNotFound, context{}} + } + return p, nil +} diff --git a/authority/root_test.go b/authority/root_test.go index 17f25755..d4caf71a 100644 --- a/authority/root_test.go +++ b/authority/root_test.go @@ -48,7 +48,7 @@ func TestRoot(t *testing.T) { } func TestAuthority_GetRootCertificate(t *testing.T) { - cert, err := pemutil.ReadCertificate("testdata/secrets/root_ca.crt") + cert, err := pemutil.ReadCertificate("testdata/certs/root_ca.crt") if err != nil { t.Fatal(err) } @@ -70,7 +70,7 @@ func TestAuthority_GetRootCertificate(t *testing.T) { } func TestAuthority_GetRootCertificates(t *testing.T) { - cert, err := pemutil.ReadCertificate("testdata/secrets/root_ca.crt") + cert, err := pemutil.ReadCertificate("testdata/certs/root_ca.crt") if err != nil { t.Fatal(err) } @@ -92,7 +92,7 @@ func TestAuthority_GetRootCertificates(t *testing.T) { } func TestAuthority_GetRoots(t *testing.T) { - cert, err := pemutil.ReadCertificate("testdata/secrets/root_ca.crt") + cert, err := pemutil.ReadCertificate("testdata/certs/root_ca.crt") if err != nil { t.Fatal(err) } @@ -120,7 +120,7 @@ func TestAuthority_GetRoots(t *testing.T) { } func TestAuthority_GetFederation(t *testing.T) { - cert, err := pemutil.ReadCertificate("testdata/secrets/root_ca.crt") + cert, err := pemutil.ReadCertificate("testdata/certs/root_ca.crt") if err != nil { t.Fatal(err) } diff --git a/authority/testdata/certs/foo.crt b/authority/testdata/certs/foo.crt new file mode 100644 index 00000000..eb06f218 --- /dev/null +++ b/authority/testdata/certs/foo.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICIDCCAcagAwIBAgIQTL7pKDl8mFzRziotXbgjEjAKBggqhkjOPQQDAjAnMSUw +IwYDVQQDExxFeGFtcGxlIEluYy4gSW50ZXJtZWRpYXRlIENBMB4XDTE5MDMyMjIy +MjkyOVoXDTE5MDMyMzIyMjkyOVowHDEaMBgGA1UEAxMRZm9vLnNtYWxsc3RlcC5j +b20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQbptfDonFaeUPiTr52wl9r3dcz +greolwDRmsgyFgnr1EuKH56WRcgH1gjfL0pybFlO3PdgBukR4u+sveq343OAo4He +MIHbMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH +AwIwHQYDVR0OBBYEFP9pHiVlsx5mr4L2QirOb1G9Mo4jMB8GA1UdIwQYMBaAFKEe +9IdMyaHdURMjoJce7FN9HC9wMBwGA1UdEQQVMBOCEWZvby5zbWFsbHN0ZXAuY29t +MEwGDCsGAQQBgqRkxihAAQQ8MDoCAQEECHN0ZXAtY2xpBCs0VUVMSng4ZTBhUzlt +MENIM2ZaMEVCN0Q1YVVQSUNiNzU5ekFMSEZlanZjMAoGCCqGSM49BAMCA0gAMEUC +IDxtNo1BX/4Sbf/+k1n+v//kh8ETr3clPvhjcyfvBIGTAiEAiT0kvbkPdCCnmHIw +lhpgBwT5YReZzBwIYXyKyJXc07M= +-----END CERTIFICATE----- diff --git a/authority/testdata/secrets/intermediate_ca.crt b/authority/testdata/certs/intermediate_ca.crt similarity index 100% rename from authority/testdata/secrets/intermediate_ca.crt rename to authority/testdata/certs/intermediate_ca.crt diff --git a/authority/testdata/certs/provisioner-not-found.crt b/authority/testdata/certs/provisioner-not-found.crt new file mode 100644 index 00000000..17986a4f --- /dev/null +++ b/authority/testdata/certs/provisioner-not-found.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTDCCAfGgAwIBAgIQH1JRmbStwdCkiuqf7SM8dzAKBggqhkjOPQQDAjAnMSUw +IwYDVQQDExxFeGFtcGxlIEluYy4gSW50ZXJtZWRpYXRlIENBMB4XDTE5MDMyMjIz +MDI0OVoXDTE5MDMyMzIzMDI0OVowLjEsMCoGA1UEAxMjcHJvdmlzaW9uZXItbm90 +LWZvdW5kLnNtYWxsc3RlcC5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARw +DOZEqgkXXY0PqnEvl5ADX4xXMDNgX4lraK8SP48Ljo3vUn5FqARjKaBgPLfowFkQ +gnjsAbBPwzt4SUWZW0ybo4H3MIH0MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAU +BggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFDLOyjWD26FV5lfIwPqegYIt +PdmSMB8GA1UdIwQYMBaAFKEe9IdMyaHdURMjoJce7FN9HC9wMC4GA1UdEQQnMCWC +I3Byb3Zpc2lvbmVyLW5vdC1mb3VuZC5zbWFsbHN0ZXAuY29tMFMGDCsGAQQBgqRk +xihAAQRDMEECAQEED2dpZkBleGFtcGxlLmNvbQQrRVdDQThsdFJCdEwxN2VFQS1I +dW4zQWtCN0sxTERhUXItNkdvdXc3RXBoVTAKBggqhkjOPQQDAgNJADBGAiEAkaHR +dE706JI8eLio/AqPbH8A/qK1INlbKbrkZ03K5wECIQCqTGY4TYopJqLYt3HkQeTy +cJfHpuPfIzvpT8X0h3zlwQ== +-----END CERTIFICATE----- diff --git a/authority/testdata/certs/renew-disabled.crt b/authority/testdata/certs/renew-disabled.crt new file mode 100644 index 00000000..674f3c81 --- /dev/null +++ b/authority/testdata/certs/renew-disabled.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICJjCCAcygAwIBAgIQWhtLLuWC1foM7eq1jefkGDAKBggqhkjOPQQDAjAnMSUw +IwYDVQQDExxFeGFtcGxlIEluYy4gSW50ZXJtZWRpYXRlIENBMB4XDTE5MDMyNzIz +Mzk0M1oXDTE5MDMyODIzMzk0M1owHDEaMBgGA1UEAxMRYmF6LnNtYWxsc3RlcC5j +b20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATxC77uJiCHgxIoctoHZbEauQwV +1FStMSKnEQwNkm88GD0HVUcz3g9OEHJbdMuY7VJjefD2NfdMil2N1jOw8VzMo4Hk +MIHhMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH +AwIwHQYDVR0OBBYEFCEoFgFtPV3v3YsJt7uYoz7GgChEMB8GA1UdIwQYMBaAFKEe +9IdMyaHdURMjoJce7FN9HC9wMBwGA1UdEQQVMBOCEWJhei5zbWFsbHN0ZXAuY29t +MFIGDCsGAQQBgqRkxihAAQRCMEACAQEEDnJlbmV3X2Rpc2FibGVkBCtJTWk5NFdC +Tkk2Z1A1Y05IWGxaWU5VenZNakdkSHlCUm1Gb28tbENFYXFrMAoGCCqGSM49BAMC +A0gAMEUCIQD1uGcIQYdEEtVtOFWZGhDk+QJTznH5C182k74Kj/Ns3QIgeNtqYeto +Ur1bgN1pwEwjTyr4aNz+pUWHZhyodduVaCE= +-----END CERTIFICATE----- diff --git a/authority/testdata/secrets/root_ca.crt b/authority/testdata/certs/root_ca.crt similarity index 100% rename from authority/testdata/secrets/root_ca.crt rename to authority/testdata/certs/root_ca.crt diff --git a/authority/testdata/secrets/foo.key b/authority/testdata/secrets/foo.key new file mode 100644 index 00000000..b1b63324 --- /dev/null +++ b/authority/testdata/secrets/foo.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJmnxm3N/ahRA2PWeZhRGJUKPU1lI44WcE4P1bynIim6oAoGCCqGSM49 +AwEHoUQDQgAEG6bXw6JxWnlD4k6+dsJfa93XM4K3qJcA0ZrIMhYJ69RLih+elkXI +B9YI3y9KcmxZTtz3YAbpEeLvrL3qt+NzgA== +-----END EC PRIVATE KEY----- diff --git a/authority/testdata/secrets/provisioner-not-found.key b/authority/testdata/secrets/provisioner-not-found.key new file mode 100644 index 00000000..33467a96 --- /dev/null +++ b/authority/testdata/secrets/provisioner-not-found.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILWLnE+pkh9QQ0CcM89sCBAWMEK7EtoJOmHvvFpugj2joAoGCCqGSM49 +AwEHoUQDQgAEcAzmRKoJF12ND6pxL5eQA1+MVzAzYF+Ja2ivEj+PC46N71J+RagE +YymgYDy36MBZEIJ47AGwT8M7eElFmVtMmw== +-----END EC PRIVATE KEY----- diff --git a/authority/testdata/secrets/renew-disabled.key b/authority/testdata/secrets/renew-disabled.key new file mode 100644 index 00000000..0bf713ff --- /dev/null +++ b/authority/testdata/secrets/renew-disabled.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKmDvbNqeIZA9zssZxixJzAQBEUEBSyVnjCKvTWGMAd2oAoGCCqGSM49 +AwEHoUQDQgAE8Qu+7iYgh4MSKHLaB2WxGrkMFdRUrTEipxEMDZJvPBg9B1VHM94P +ThByW3TLmO1SY3nw9jX3TIpdjdYzsPFczA== +-----END EC PRIVATE KEY----- diff --git a/authority/tls.go b/authority/tls.go index 0b089194..fdaba130 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/asn1" + "encoding/base64" "encoding/pem" "net/http" "strings" @@ -11,6 +12,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/db" "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/tlsutil" "github.com/smallstep/cli/crypto/x509util" @@ -111,6 +113,13 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Opti http.StatusInternalServerError, errContext} } + if err = a.db.StoreCertificate(serverCert); err != nil { + if err != db.ErrNotImplemented { + return nil, nil, &apiError{errors.Wrap(err, "sign: error storing certificate in db"), + http.StatusInternalServerError, errContext} + } + } + return serverCert, caCert, nil } @@ -194,6 +203,80 @@ func (a *Authority) Renew(oldCert *x509.Certificate) (*x509.Certificate, *x509.C return serverCert, caCert, nil } +// RevokeOptions are the options for the Revoke API. +type RevokeOptions struct { + Serial string + Reason string + ReasonCode int + PassiveOnly bool + MTLS bool + Crt *x509.Certificate + OTT string + errCtxt map[string]interface{} +} + +// Revoke revokes a certificate. +// +// NOTE: Only supports passive revocation - prevent existing certificates from +// being renewed. +// +// TODO: Add OCSP and CRL support. +func (a *Authority) Revoke(opts *RevokeOptions) error { + errContext := context{ + "serialNumber": opts.Serial, + "reasonCode": opts.ReasonCode, + "reason": opts.Reason, + "passiveOnly": opts.PassiveOnly, + "mTLS": opts.MTLS, + } + if opts.MTLS { + errContext["certificate"] = base64.StdEncoding.EncodeToString(opts.Crt.Raw) + } else { + errContext["ott"] = opts.OTT + } + + rci := &db.RevokedCertificateInfo{ + Serial: opts.Serial, + ReasonCode: opts.ReasonCode, + Reason: opts.Reason, + MTLS: opts.MTLS, + RevokedAt: time.Now().UTC(), + } + + // Authorize mTLS or token request and get back a provisioner interface. + p, err := a.authorizeRevoke(opts) + if err != nil { + return &apiError{errors.Wrap(err, "revoke"), + http.StatusUnauthorized, errContext} + } + + // If not mTLS then get the TokenID of the token. + if !opts.MTLS { + rci.TokenID, err = p.GetTokenID(opts.OTT) + if err != nil { + return &apiError{errors.Wrap(err, "revoke: could not get ID for token"), + http.StatusInternalServerError, errContext} + } + errContext["tokenID"] = rci.TokenID + } + rci.ProvisionerID = p.GetID() + errContext["provisionerID"] = rci.ProvisionerID + + err = a.db.Revoke(rci) + switch err { + case nil: + return nil + case db.ErrNotImplemented: + return &apiError{errors.New("revoke: no persistence layer configured"), + http.StatusNotImplemented, errContext} + case db.ErrAlreadyExists: + return &apiError{errors.Errorf("revoke: certificate with serial number %s has already been revoked", rci.Serial), + http.StatusBadRequest, errContext} + default: + return &apiError{err, http.StatusInternalServerError, errContext} + } +} + // GetTLSCertificate creates a new leaf certificate to be used by the CA HTTPS server. func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) { profile, err := x509util.NewLeafProfile("Step Online CA", diff --git a/authority/tls_test.go b/authority/tls_test.go index e64881d9..ea2c4063 100644 --- a/authority/tls_test.go +++ b/authority/tls_test.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "encoding/base64" "fmt" "net/http" "reflect" @@ -15,10 +16,13 @@ import ( "github.com/pkg/errors" "github.com/smallstep/assert" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/db" "github.com/smallstep/cli/crypto/keys" + "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/tlsutil" "github.com/smallstep/cli/crypto/x509util" "github.com/smallstep/cli/jose" + "gopkg.in/square/go-jose.v2/jwt" ) var ( @@ -199,8 +203,36 @@ func TestSign(t *testing.T) { }, } }, + "fail store cert in db": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + _a := testAuthority(t) + _a.db = &MockAuthDB{ + storeCertificate: func(crt *x509.Certificate) error { + return &apiError{errors.New("force"), + http.StatusInternalServerError, + context{"csr": csr, "signOptions": signOpts}} + }, + } + return &signTest{ + auth: _a, + csr: csr, + extraOpts: extraOpts, + signOpts: signOpts, + err: &apiError{errors.New("sign: error storing certificate in db: force"), + http.StatusInternalServerError, + context{"csr": csr, "signOptions": signOpts}, + }, + } + }, "ok": func(t *testing.T) *signTest { csr := getCSR(t, priv) + _a := testAuthority(t) + _a.db = &MockAuthDB{ + storeCertificate: func(crt *x509.Certificate) error { + assert.Equals(t, crt.Subject.CommonName, "smallstep test") + return nil + }, + } return &signTest{ auth: a, csr: csr, @@ -350,7 +382,7 @@ func TestRenew(t *testing.T) { } return &renewTest{ crt: crtNoRenew, - err: &apiError{errors.New("renew is disabled for provisioner dev:IMi94WBNI6gP5cNHXlZYNUzvMjGdHyBRmFoo-lCEaqk"), + err: &apiError{errors.New("renew: renew is disabled for provisioner dev:IMi94WBNI6gP5cNHXlZYNUzvMjGdHyBRmFoo-lCEaqk"), http.StatusUnauthorized, ctx}, }, nil }, @@ -528,3 +560,230 @@ func TestGetTLSOptions(t *testing.T) { }) } } + +func TestRevoke(t *testing.T) { + reasonCode := 2 + reason := "bob was let go" + validIssuer := "step-cli" + validAudience := []string{"https://test.ca.smallstep.com/revoke"} + now := time.Now().UTC() + getCtx := func() map[string]interface{} { + return context{ + "serialNumber": "sn", + "reasonCode": reasonCode, + "reason": reason, + "mTLS": false, + "passiveOnly": false, + } + } + + jwk, err := jose.ParseKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID)) + assert.FatalError(t, err) + + type test struct { + a *Authority + opts *RevokeOptions + err *apiError + } + tests := map[string]func() test{ + "error/token/authorizeRevoke error": func() test { + a := testAuthority(t) + a.db = new(db.NoopDB) + ctx := getCtx() + ctx["ott"] = "foo" + return test{ + a: a, + opts: &RevokeOptions{ + OTT: "foo", + Serial: "sn", + ReasonCode: reasonCode, + Reason: reason, + }, + err: &apiError{errors.New("revoke: authorizeRevoke: authorizeToken: error parsing token"), + http.StatusUnauthorized, ctx}, + } + }, + "error/nil-db": func() test { + a := testAuthority(t) + a.db = new(db.NoopDB) + + cl := jwt.Claims{ + Subject: "sn", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + + ctx := getCtx() + ctx["ott"] = raw + ctx["tokenID"] = "44" + ctx["provisionerID"] = "step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc" + return test{ + a: a, + opts: &RevokeOptions{ + Serial: "sn", + ReasonCode: reasonCode, + Reason: reason, + OTT: raw, + }, + err: &apiError{errors.New("revoke: no persistence layer configured"), + http.StatusNotImplemented, ctx}, + } + }, + "error/db-revoke": func() test { + a := testAuthority(t) + a.db = &MockAuthDB{err: errors.New("force")} + + cl := jwt.Claims{ + Subject: "sn", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + + ctx := getCtx() + ctx["ott"] = raw + ctx["tokenID"] = "44" + ctx["provisionerID"] = "step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc" + return test{ + a: a, + opts: &RevokeOptions{ + Serial: "sn", + ReasonCode: reasonCode, + Reason: reason, + OTT: raw, + }, + err: &apiError{errors.New("force"), + http.StatusInternalServerError, ctx}, + } + }, + "error/already-revoked": func() test { + a := testAuthority(t) + a.db = &MockAuthDB{err: db.ErrAlreadyExists} + + cl := jwt.Claims{ + Subject: "sn", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + + ctx := getCtx() + ctx["ott"] = raw + ctx["tokenID"] = "44" + ctx["provisionerID"] = "step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc" + return test{ + a: a, + opts: &RevokeOptions{ + Serial: "sn", + ReasonCode: reasonCode, + Reason: reason, + OTT: raw, + }, + err: &apiError{errors.New("revoke: certificate with serial number sn has already been revoked"), + http.StatusBadRequest, ctx}, + } + }, + "ok/token": func() test { + a := testAuthority(t) + a.db = &MockAuthDB{} + + cl := jwt.Claims{ + Subject: "sn", + Issuer: validIssuer, + NotBefore: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: "44", + } + raw, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + return test{ + a: a, + opts: &RevokeOptions{ + Serial: "sn", + ReasonCode: reasonCode, + Reason: reason, + OTT: raw, + }, + } + }, + "error/mTLS/authorizeRevoke": func() test { + a := testAuthority(t) + a.db = &MockAuthDB{} + + crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt") + assert.FatalError(t, err) + + ctx := getCtx() + ctx["certificate"] = base64.StdEncoding.EncodeToString(crt.Raw) + ctx["mTLS"] = true + + return test{ + a: a, + opts: &RevokeOptions{ + Crt: crt, + Serial: "sn", + ReasonCode: reasonCode, + Reason: reason, + MTLS: true, + }, + err: &apiError{errors.New("revoke: authorizeRevoke: serial number in certificate different than body"), + http.StatusUnauthorized, ctx}, + } + }, + "ok/mTLS": func() test { + a := testAuthority(t) + a.db = &MockAuthDB{} + + crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt") + assert.FatalError(t, err) + + return test{ + a: a, + opts: &RevokeOptions{ + Crt: crt, + Serial: "102012593071130646873265215610956555026", + ReasonCode: reasonCode, + Reason: reason, + MTLS: true, + }, + } + }, + } + for name, f := range tests { + tc := f() + t.Run(name, func(t *testing.T) { + if err := tc.a.Revoke(tc.opts); err != nil { + if assert.NotNil(t, tc.err) { + switch v := err.(type) { + case *apiError: + assert.HasPrefix(t, v.err.Error(), tc.err.Error()) + assert.Equals(t, v.code, tc.err.code) + assert.Equals(t, v.context, tc.err.context) + default: + t.Errorf("unexpected error type: %T", v) + } + } + } else { + assert.Nil(t, tc.err) + } + }) + } +} diff --git a/ca/ca.go b/ca/ca.go index 07ee3311..b8dfc39b 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -122,6 +122,9 @@ func (ca *CA) Run() error { // Stop stops the CA calling to the server Shutdown method. func (ca *CA) Stop() error { ca.renewer.Stop() + if err := ca.auth.Shutdown(); err != nil { + return err + } return ca.srv.Shutdown() } diff --git a/ca/client.go b/ca/client.go index 8f4eb151..ddf6ab86 100644 --- a/ca/client.go +++ b/ca/client.go @@ -351,6 +351,36 @@ func (c *Client) Renew(tr http.RoundTripper) (*api.SignResponse, error) { return &sign, nil } +// Revoke performs the revoke request to the CA and returns the api.RevokeResponse +// struct. +func (c *Client) Revoke(req *api.RevokeRequest, tr http.RoundTripper) (*api.RevokeResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "error marshaling request") + } + + var client *http.Client + if tr != nil { + client = &http.Client{Transport: tr} + } else { + client = c.client + } + + u := c.endpoint.ResolveReference(&url.URL{Path: "/revoke"}) + resp, err := client.Post(u.String(), "application/json", bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrapf(err, "client POST %s failed", u) + } + if resp.StatusCode >= 400 { + return nil, readError(resp.Body) + } + var revoke api.RevokeResponse + if err := readJSON(resp.Body, &revoke); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return &revoke, nil +} + // Provisioners performs the provisioners request to the CA and returns the // api.ProvisionersResponse struct with a map of provisioners. // diff --git a/ca/client_test.go b/ca/client_test.go index bfac97a4..c8fada9b 100644 --- a/ca/client_test.go +++ b/ca/client_test.go @@ -329,6 +329,81 @@ func TestClient_Sign(t *testing.T) { } } +func TestClient_Revoke(t *testing.T) { + ok := &api.RevokeResponse{Status: "ok"} + request := &api.RevokeRequest{ + Serial: "sn", + OTT: "the-ott", + ReasonCode: 4, + } + unauthorized := api.Unauthorized(fmt.Errorf("Unauthorized")) + badRequest := api.BadRequest(fmt.Errorf("Bad Request")) + + tests := []struct { + name string + request *api.RevokeRequest + response interface{} + responseCode int + wantErr bool + }{ + {"ok", request, ok, 200, false}, + {"unauthorized", request, unauthorized, 401, true}, + {"nil request", nil, badRequest, 403, true}, + } + + srv := httptest.NewServer(nil) + defer srv.Close() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewClient(srv.URL, WithTransport(http.DefaultTransport)) + if err != nil { + t.Errorf("NewClient() error = %v", err) + return + } + + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body := new(api.RevokeRequest) + if err := api.ReadJSON(req.Body, body); err != nil { + api.WriteError(w, badRequest) + return + } else if !equalJSON(t, body, tt.request) { + if tt.request == nil { + if !reflect.DeepEqual(body, &api.RevokeRequest{}) { + t.Errorf("Client.Revoke() request = %v, wants %v", body, tt.request) + } + } else { + t.Errorf("Client.Revoke() request = %v, wants %v", body, tt.request) + } + } + w.WriteHeader(tt.responseCode) + api.JSON(w, tt.response) + }) + + got, err := c.Revoke(tt.request, nil) + if (err != nil) != tt.wantErr { + fmt.Printf("%+v", err) + t.Errorf("Client.Revoke() error = %v, wantErr %v", err, tt.wantErr) + return + } + + switch { + case err != nil: + if got != nil { + t.Errorf("Client.Revoke() = %v, want nil", got) + } + if !reflect.DeepEqual(err, tt.response) { + t.Errorf("Client.Revoke() error = %v, want %v", err, tt.response) + } + default: + if !reflect.DeepEqual(got, tt.response) { + t.Errorf("Client.Revoke() = %v, want %v", got, tt.response) + } + } + }) + } +} + func TestClient_Renew(t *testing.T) { ok := &api.SignResponse{ ServerPEM: api.Certificate{Certificate: parseCertificate(certPEM)}, diff --git a/db/db.go b/db/db.go new file mode 100644 index 00000000..bbc43ce5 --- /dev/null +++ b/db/db.go @@ -0,0 +1,138 @@ +package db + +import ( + "crypto/x509" + "encoding/json" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/nosql" +) + +var ( + revokedCertsTable = []byte("revoked_x509_certs") + certsTable = []byte("x509_certs") +) + +// ErrAlreadyExists can be returned if the DB attempts to set a key that has +// been previously set. +var ErrAlreadyExists = errors.New("already exists") + +// Config represents the JSON attributes used for configuring a step-ca DB. +type Config struct { + Type string `json:"type"` + Path string `json:"path"` +} + +// AuthDB is an interface over an Authority DB client that implements a nosql.DB interface. +type AuthDB interface { + IsRevoked(sn string) (bool, error) + Revoke(rci *RevokedCertificateInfo) error + StoreCertificate(crt *x509.Certificate) error + Shutdown() error +} + +// DB is a wrapper over the nosql.DB interface. +type DB struct { + nosql.DB +} + +// New returns a new database client that implements the AuthDB interface. +func New(c *Config) (AuthDB, error) { + if c == nil { + return new(NoopDB), nil + } + + var db nosql.DB + switch strings.ToLower(c.Type) { + case "bbolt": + db = &nosql.BoltDB{} + if err := db.Open(c.Path); err != nil { + return nil, err + } + default: + return nil, errors.Errorf("unsupported db.type '%s'", c.Type) + } + + tables := [][]byte{revokedCertsTable, certsTable} + for _, b := range tables { + if err := db.CreateTable(b); err != nil { + return nil, errors.Wrapf(err, "error creating table %s", + string(b)) + } + } + + return &DB{db}, nil +} + +// RevokedCertificateInfo contains information regarding the certificate +// revocation action. +type RevokedCertificateInfo struct { + Serial string + ProvisionerID string + ReasonCode int + Reason string + RevokedAt time.Time + TokenID string + MTLS bool +} + +// IsRevoked returns whether or not a certificate with the given identifier +// has been revoked. +// In the case of an X509 Certificate the `id` should be the Serial Number of +// the Certificate. +func (db *DB) IsRevoked(sn string) (bool, error) { + // If the DB is nil then act as pass through. + if db == nil { + return false, nil + } + + // If the error is `Not Found` then the certificate has not been revoked. + // Any other error should be propagated to the caller. + if _, err := db.Get(revokedCertsTable, []byte(sn)); err != nil { + if nosql.IsErrNotFound(err) { + return false, nil + } + return false, errors.Wrap(err, "error checking revocation bucket") + } + + // This certificate has been revoked. + return true, nil +} + +// Revoke adds a certificate to the revocation table. +func (db *DB) Revoke(rci *RevokedCertificateInfo) error { + isRvkd, err := db.IsRevoked(rci.Serial) + if err != nil { + return err + } + if isRvkd { + return ErrAlreadyExists + } + rcib, err := json.Marshal(rci) + if err != nil { + return errors.Wrap(err, "error marshaling revoked certificate info") + } + + if err = db.Set(revokedCertsTable, []byte(rci.Serial), rcib); err != nil { + return errors.Wrap(err, "database Set error") + } + return nil +} + +// StoreCertificate stores a certificate PEM. +func (db *DB) StoreCertificate(crt *x509.Certificate) error { + if err := db.Set(certsTable, []byte(crt.SerialNumber.String()), crt.Raw); err != nil { + return errors.Wrap(err, "database Set error") + } + return nil +} + +// Shutdown sends a shutdown message to the database. +func (db *DB) Shutdown() error { + if err := db.Close(); err != nil { + return errors.Wrap(err, "database shutdown error") + } + return nil +} diff --git a/db/db_test.go b/db/db_test.go new file mode 100644 index 00000000..c05dbd78 --- /dev/null +++ b/db/db_test.go @@ -0,0 +1,190 @@ +package db + +import ( + "errors" + "testing" + + "github.com/smallstep/assert" + "github.com/smallstep/nosql" +) + +type MockNoSQLDB struct { + err error + ret1, ret2 interface{} + get func(bucket, key []byte) ([]byte, error) + set func(bucket, key, value []byte) error + open func(path string) error + close func() error + createTable func(bucket []byte) error + deleteTable func(bucket []byte) error + del func(bucket, key []byte) error + list func(bucket []byte) ([]*nosql.Entry, error) + update func(tx *nosql.Tx) error +} + +func (m *MockNoSQLDB) Get(bucket, key []byte) ([]byte, error) { + if m.get != nil { + return m.get(bucket, key) + } + if m.ret1 == nil { + return nil, m.err + } + return m.ret1.([]byte), m.err +} + +func (m *MockNoSQLDB) Set(bucket, key, value []byte) error { + if m.set != nil { + return m.set(bucket, key, value) + } + return m.err +} + +func (m *MockNoSQLDB) Open(path string) error { + if m.open != nil { + return m.open(path) + } + return m.err +} + +func (m *MockNoSQLDB) Close() error { + if m.close != nil { + return m.close() + } + return m.err +} + +func (m *MockNoSQLDB) CreateTable(bucket []byte) error { + if m.createTable != nil { + return m.createTable(bucket) + } + return m.err +} + +func (m *MockNoSQLDB) DeleteTable(bucket []byte) error { + if m.deleteTable != nil { + return m.deleteTable(bucket) + } + return m.err +} + +func (m *MockNoSQLDB) Del(bucket, key []byte) error { + if m.del != nil { + return m.del(bucket, key) + } + return m.err +} + +func (m *MockNoSQLDB) List(bucket []byte) ([]*nosql.Entry, error) { + if m.list != nil { + return m.list(bucket) + } + return m.ret1.([]*nosql.Entry), m.err +} + +func (m *MockNoSQLDB) Update(tx *nosql.Tx) error { + if m.update != nil { + return m.update(tx) + } + return m.err +} + +func TestIsRevoked(t *testing.T) { + tests := map[string]struct { + key string + db *DB + isRevoked bool + err error + }{ + "false/nil db": { + key: "sn", + }, + "false/ErrNotFound": { + key: "sn", + db: &DB{&MockNoSQLDB{err: nosql.ErrNotFound, ret1: nil}}, + }, + "error/checking bucket": { + key: "sn", + db: &DB{&MockNoSQLDB{err: errors.New("force"), ret1: nil}}, + err: errors.New("error checking revocation bucket: force"), + }, + "true": { + key: "sn", + db: &DB{&MockNoSQLDB{ret1: []byte("value")}}, + isRevoked: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + isRevoked, err := tc.db.IsRevoked(tc.key) + if err != nil { + if assert.NotNil(t, tc.err) { + assert.HasPrefix(t, tc.err.Error(), err.Error()) + } + } else { + assert.Nil(t, tc.err) + assert.Fatal(t, isRevoked == tc.isRevoked) + } + }) + } +} + +func TestRevoke(t *testing.T) { + tests := map[string]struct { + rci *RevokedCertificateInfo + db *DB + err error + }{ + "error/force isRevoked": { + rci: &RevokedCertificateInfo{Serial: "sn"}, + db: &DB{&MockNoSQLDB{ + get: func(bucket []byte, sn []byte) ([]byte, error) { + return nil, errors.New("force IsRevoked") + }, + }}, + err: errors.New("error checking revocation bucket: force IsRevoked"), + }, + "error/was already revoked": { + rci: &RevokedCertificateInfo{Serial: "sn"}, + db: &DB{&MockNoSQLDB{ + get: func(bucket []byte, sn []byte) ([]byte, error) { + return nil, nil + }, + }}, + err: ErrAlreadyExists, + }, + "error/database set": { + rci: &RevokedCertificateInfo{Serial: "sn"}, + db: &DB{&MockNoSQLDB{ + get: func(bucket []byte, sn []byte) ([]byte, error) { + return nil, nosql.ErrNotFound + }, + set: func(bucket []byte, key []byte, value []byte) error { + return errors.New("force") + }, + }}, + err: errors.New("database Set error: force"), + }, + "ok": { + rci: &RevokedCertificateInfo{Serial: "sn"}, + db: &DB{&MockNoSQLDB{ + get: func(bucket []byte, sn []byte) ([]byte, error) { + return nil, nosql.ErrNotFound + }, + set: func(bucket []byte, key []byte, value []byte) error { + return nil + }, + }}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if err := tc.db.Revoke(tc.rci); err != nil { + if assert.NotNil(t, tc.err) { + assert.HasPrefix(t, tc.err.Error(), err.Error()) + } + } else { + assert.Nil(t, tc.err) + } + }) + } +} diff --git a/db/noop.go b/db/noop.go new file mode 100644 index 00000000..356b7f42 --- /dev/null +++ b/db/noop.go @@ -0,0 +1,38 @@ +package db + +import ( + "crypto/x509" + + "github.com/pkg/errors" +) + +// ErrNotImplemented is an error returned when an operation is Not Implemented. +var ErrNotImplemented = errors.Errorf("not implemented") + +// NoopDB implements the DB interface with Noops +type NoopDB int + +// Init noop +func (n *NoopDB) Init(c *Config) (AuthDB, error) { + return n, nil +} + +// IsRevoked noop +func (n *NoopDB) IsRevoked(sn string) (bool, error) { + return false, nil +} + +// Revoke returns a "NotImplemented" error. +func (n *NoopDB) Revoke(rci *RevokedCertificateInfo) error { + return ErrNotImplemented +} + +// StoreCertificate returns a "NotImplemented" error. +func (n *NoopDB) StoreCertificate(crt *x509.Certificate) error { + return ErrNotImplemented +} + +// Shutdown returns nil +func (n *NoopDB) Shutdown() error { + return nil +} diff --git a/db/noop_test.go b/db/noop_test.go new file mode 100644 index 00000000..564a586c --- /dev/null +++ b/db/noop_test.go @@ -0,0 +1,21 @@ +package db + +import ( + "testing" + + "github.com/smallstep/assert" +) + +func Test_noop(t *testing.T) { + db := new(NoopDB) + + _db, err := db.Init(&Config{}) + assert.FatalError(t, err) + assert.Equals(t, db, _db) + + isRevoked, err := db.IsRevoked("foo") + assert.False(t, isRevoked) + assert.Nil(t, err) + + assert.Equals(t, db.Revoke(&RevokedCertificateInfo{}), ErrNotImplemented) +}