Add /revoke API with interface db backend

This commit is contained in:
max furman 2019-03-05 00:07:13 -08:00
parent 07ff7d9807
commit ab4d569f36
43 changed files with 2437 additions and 212 deletions

27
Gopkg.lock generated
View file

@ -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"
@ -300,6 +300,14 @@
pruneopts = "UT"
revision = "f851b6b63d8d5e78b8a986057034d69fe904c477"
[[projects]]
branch = "master"
digest = "1:fd8d9eb07509d8ef47fc82c99646f0b2203b2ba3c240ba77d8c457bb6109836d"
name = "github.com/smallstep/nosql"
packages = ["."]
pruneopts = "UT"
revision = "d8f68d14f9ae04e0991dce06b44768f2d38dccf8"
[[projects]]
branch = "master"
digest = "1:ba52e5a5fb800ce55108b7a5f181bb809aab71c16736051312b0aa969f82ad39"
@ -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",

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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)

105
api/revoke.go Normal file
View file

@ -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,
})
}
}

234
api/revoke_test.go Normal file
View file

@ -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)
}
}
})
}
}

View file

@ -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()
}

View file

@ -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",

View file

@ -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,9 +42,10 @@ 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 {
@ -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,
}

View file

@ -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)
}
})
}
}

View file

@ -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"
)
@ -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
}

View file

@ -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())

55
authority/db_test.go Normal file
View file

@ -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
}

View file

@ -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 <issuer>:<kid>.
return c.Load(claims.Issuer + ":" + token.Headers[0].KeyID)

View file

@ -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

View file

@ -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")
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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{} {

View file

@ -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
}

View file

@ -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)
}

14
authority/testdata/certs/foo.crt vendored Normal file
View file

@ -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-----

View file

@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICTDCCAfGgAwIBAgIQH1JRmbStwdCkiuqf7SM8dzAKBggqhkjOPQQDAjAnMSUw
IwYDVQQDExxFeGFtcGxlIEluYy4gSW50ZXJtZWRpYXRlIENBMB4XDTE5MDMyMjIz
MDI0OVoXDTE5MDMyMzIzMDI0OVowLjEsMCoGA1UEAxMjcHJvdmlzaW9uZXItbm90
LWZvdW5kLnNtYWxsc3RlcC5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARw
DOZEqgkXXY0PqnEvl5ADX4xXMDNgX4lraK8SP48Ljo3vUn5FqARjKaBgPLfowFkQ
gnjsAbBPwzt4SUWZW0ybo4H3MIH0MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAU
BggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFDLOyjWD26FV5lfIwPqegYIt
PdmSMB8GA1UdIwQYMBaAFKEe9IdMyaHdURMjoJce7FN9HC9wMC4GA1UdEQQnMCWC
I3Byb3Zpc2lvbmVyLW5vdC1mb3VuZC5zbWFsbHN0ZXAuY29tMFMGDCsGAQQBgqRk
xihAAQRDMEECAQEED2dpZkBleGFtcGxlLmNvbQQrRVdDQThsdFJCdEwxN2VFQS1I
dW4zQWtCN0sxTERhUXItNkdvdXc3RXBoVTAKBggqhkjOPQQDAgNJADBGAiEAkaHR
dE706JI8eLio/AqPbH8A/qK1INlbKbrkZ03K5wECIQCqTGY4TYopJqLYt3HkQeTy
cJfHpuPfIzvpT8X0h3zlwQ==
-----END CERTIFICATE-----

View file

@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICJjCCAcygAwIBAgIQWhtLLuWC1foM7eq1jefkGDAKBggqhkjOPQQDAjAnMSUw
IwYDVQQDExxFeGFtcGxlIEluYy4gSW50ZXJtZWRpYXRlIENBMB4XDTE5MDMyNzIz
Mzk0M1oXDTE5MDMyODIzMzk0M1owHDEaMBgGA1UEAxMRYmF6LnNtYWxsc3RlcC5j
b20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATxC77uJiCHgxIoctoHZbEauQwV
1FStMSKnEQwNkm88GD0HVUcz3g9OEHJbdMuY7VJjefD2NfdMil2N1jOw8VzMo4Hk
MIHhMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH
AwIwHQYDVR0OBBYEFCEoFgFtPV3v3YsJt7uYoz7GgChEMB8GA1UdIwQYMBaAFKEe
9IdMyaHdURMjoJce7FN9HC9wMBwGA1UdEQQVMBOCEWJhei5zbWFsbHN0ZXAuY29t
MFIGDCsGAQQBgqRkxihAAQRCMEACAQEEDnJlbmV3X2Rpc2FibGVkBCtJTWk5NFdC
Tkk2Z1A1Y05IWGxaWU5VenZNakdkSHlCUm1Gb28tbENFYXFrMAoGCCqGSM49BAMC
A0gAMEUCIQD1uGcIQYdEEtVtOFWZGhDk+QJTznH5C182k74Kj/Ns3QIgeNtqYeto
Ur1bgN1pwEwjTyr4aNz+pUWHZhyodduVaCE=
-----END CERTIFICATE-----

5
authority/testdata/secrets/foo.key vendored Normal file
View file

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJmnxm3N/ahRA2PWeZhRGJUKPU1lI44WcE4P1bynIim6oAoGCCqGSM49
AwEHoUQDQgAEG6bXw6JxWnlD4k6+dsJfa93XM4K3qJcA0ZrIMhYJ69RLih+elkXI
B9YI3y9KcmxZTtz3YAbpEeLvrL3qt+NzgA==
-----END EC PRIVATE KEY-----

View file

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEILWLnE+pkh9QQ0CcM89sCBAWMEK7EtoJOmHvvFpugj2joAoGCCqGSM49
AwEHoUQDQgAEcAzmRKoJF12ND6pxL5eQA1+MVzAzYF+Ja2ivEj+PC46N71J+RagE
YymgYDy36MBZEIJ47AGwT8M7eElFmVtMmw==
-----END EC PRIVATE KEY-----

View file

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKmDvbNqeIZA9zssZxixJzAQBEUEBSyVnjCKvTWGMAd2oAoGCCqGSM49
AwEHoUQDQgAE8Qu+7iYgh4MSKHLaB2WxGrkMFdRUrTEipxEMDZJvPBg9B1VHM94P
ThByW3TLmO1SY3nw9jX3TIpdjdYzsPFczA==
-----END EC PRIVATE KEY-----

View file

@ -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",

View file

@ -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)
}
})
}
}

View file

@ -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()
}

View file

@ -346,6 +346,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.
//

View file

@ -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)},

138
db/db.go Normal file
View file

@ -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
}

190
db/db_test.go Normal file
View file

@ -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)
}
})
}
}

38
db/noop.go Normal file
View file

@ -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
}

21
db/noop_test.go Normal file
View file

@ -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)
}