Merge branch 'master' into hs/scep

This commit is contained in:
Herman Slatman 2021-03-26 15:22:41 +01:00
commit c5e4ea08b3
No known key found for this signature in database
GPG key ID: F4D8A44EA0A75A4F
23 changed files with 2300 additions and 39 deletions

View file

@ -190,7 +190,6 @@ func (c *Config) Validate() error {
// Options holds the RA/CAS configuration. // Options holds the RA/CAS configuration.
ra := c.AuthorityConfig.Options ra := c.AuthorityConfig.Options
// The default RA/CAS requires root, crt and key. // The default RA/CAS requires root, crt and key.
if ra.Is(cas.SoftCAS) { if ra.Is(cas.SoftCAS) {
switch { switch {

View file

@ -148,6 +148,7 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign
lifetime := leaf.NotAfter.Sub(leaf.NotBefore.Add(signOpts.Backdate)) lifetime := leaf.NotAfter.Sub(leaf.NotBefore.Add(signOpts.Backdate))
resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{ resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{
Template: leaf, Template: leaf,
CSR: csr,
Lifetime: lifetime, Lifetime: lifetime,
Backdate: signOpts.Backdate, Backdate: signOpts.Backdate,
}) })
@ -333,22 +334,21 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error
if !ok { if !ok {
return errs.InternalServer("authority.Revoke; provisioner not found", opts...) return errs.InternalServer("authority.Revoke; provisioner not found", opts...)
} }
rci.ProvisionerID = p.GetID()
rci.TokenID, err = p.GetTokenID(revokeOpts.OTT) rci.TokenID, err = p.GetTokenID(revokeOpts.OTT)
if err != nil { if err != nil {
return errs.Wrap(http.StatusInternalServerError, err, return errs.Wrap(http.StatusInternalServerError, err,
"authority.Revoke; could not get ID for token") "authority.Revoke; could not get ID for token")
} }
opts = append(opts, errs.WithKeyVal("provisionerID", rci.ProvisionerID))
opts = append(opts, errs.WithKeyVal("tokenID", rci.TokenID)) opts = append(opts, errs.WithKeyVal("tokenID", rci.TokenID))
} else { } else {
// Load the Certificate provisioner if one exists. // Load the Certificate provisioner if one exists.
p, err = a.LoadProvisionerByCertificate(revokeOpts.Crt) if p, err = a.LoadProvisionerByCertificate(revokeOpts.Crt); err == nil {
if err != nil { rci.ProvisionerID = p.GetID()
return errs.Wrap(http.StatusUnauthorized, err, opts = append(opts, errs.WithKeyVal("provisionerID", rci.ProvisionerID))
"authority.Revoke: unable to load certificate provisioner", opts...)
} }
} }
rci.ProvisionerID = p.GetID()
opts = append(opts, errs.WithKeyVal("provisionerID", rci.ProvisionerID))
if provisioner.MethodFromContext(ctx) == provisioner.SSHRevokeMethod { if provisioner.MethodFromContext(ctx) == provisioner.SSHRevokeMethod {
err = a.db.RevokeSSH(rci) err = a.db.RevokeSSH(rci)
@ -367,9 +367,11 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error
// CAS operation, note that SoftCAS (default) is a noop. // CAS operation, note that SoftCAS (default) is a noop.
// The revoke happens when this is stored in the db. // The revoke happens when this is stored in the db.
_, err = a.x509CAService.RevokeCertificate(&casapi.RevokeCertificateRequest{ _, err = a.x509CAService.RevokeCertificate(&casapi.RevokeCertificateRequest{
Certificate: revokedCert, Certificate: revokedCert,
Reason: rci.Reason, SerialNumber: rci.Serial,
ReasonCode: rci.ReasonCode, Reason: rci.Reason,
ReasonCode: rci.ReasonCode,
PassiveOnly: revokeOpts.PassiveOnly,
}) })
if err != nil { if err != nil {
return errs.Wrap(http.StatusInternalServerError, err, "authority.Revoke", opts...) return errs.Wrap(http.StatusInternalServerError, err, "authority.Revoke", opts...)
@ -427,6 +429,7 @@ func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) {
resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{ resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{
Template: certTpl, Template: certTpl,
CSR: cr,
Lifetime: 24 * time.Hour, Lifetime: 24 * time.Hour,
Backdate: 1 * time.Minute, Backdate: 1 * time.Minute,
}) })

View file

@ -1231,6 +1231,30 @@ func TestAuthority_Revoke(t *testing.T) {
crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt") crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt")
assert.FatalError(t, err) assert.FatalError(t, err)
return test{
auth: _a,
opts: &RevokeOptions{
Crt: crt,
Serial: "102012593071130646873265215610956555026",
ReasonCode: reasonCode,
Reason: reason,
MTLS: true,
},
}
},
"ok/mTLS-no-provisioner": func() test {
_a := testAuthority(t, WithDatabase(&db.MockAuthDB{}))
crt, err := pemutil.ReadCertificate("./testdata/certs/foo.crt")
assert.FatalError(t, err)
// Filter out provisioner extension.
for i, ext := range crt.Extensions {
if ext.Id.Equal(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 37476, 9000, 64, 1}) {
crt.Extensions = append(crt.Extensions[:i], crt.Extensions[i+1:]...)
break
}
}
return test{ return test{
auth: _a, auth: _a,
opts: &RevokeOptions{ opts: &RevokeOptions{

View file

@ -25,9 +25,10 @@ import (
) )
type options struct { type options struct {
configFile string configFile string
password []byte password []byte
database db.AuthDB issuerPassword []byte
database db.AuthDB
} }
func (o *options) apply(opts []Option) { func (o *options) apply(opts []Option) {
@ -55,6 +56,14 @@ func WithPassword(password []byte) Option {
} }
} }
// WithIssuerPassword sets the given password as the configured certificate
// issuer password in the CA options.
func WithIssuerPassword(password []byte) Option {
return func(o *options) {
o.issuerPassword = password
}
}
// WithDatabase sets the given authority database to the CA options. // WithDatabase sets the given authority database to the CA options.
func WithDatabase(db db.AuthDB) Option { func WithDatabase(db db.AuthDB) Option {
return func(o *options) { return func(o *options) {
@ -85,10 +94,18 @@ func New(config *authority.Config, opts ...Option) (*CA, error) {
// Init initializes the CA with the given configuration. // Init initializes the CA with the given configuration.
func (ca *CA) Init(config *authority.Config) (*CA, error) { func (ca *CA) Init(config *authority.Config) (*CA, error) {
if l := len(ca.opts.password); l > 0 { // Intermediate Password.
if len(ca.opts.password) > 0 {
ca.config.Password = string(ca.opts.password) ca.config.Password = string(ca.opts.password)
} }
// Certificate issuer password for RA mode.
if len(ca.opts.issuerPassword) > 0 {
if ca.config.AuthorityConfig != nil && ca.config.AuthorityConfig.CertificateIssuer != nil {
ca.config.AuthorityConfig.CertificateIssuer.Password = string(ca.opts.issuerPassword)
}
}
var opts []authority.Option var opts []authority.Option
if ca.opts.database != nil { if ca.opts.database != nil {
opts = append(opts, authority.WithDatabase(ca.opts.database)) opts = append(opts, authority.WithDatabase(ca.opts.database))
@ -269,6 +286,7 @@ func (ca *CA) Reload() error {
newCA, err := New(config, newCA, err := New(config,
WithPassword(ca.opts.password), WithPassword(ca.opts.password),
WithIssuerPassword(ca.opts.issuerPassword),
WithConfigFile(ca.opts.configFile), WithConfigFile(ca.opts.configFile),
WithDatabase(ca.auth.GetDatabase()), WithDatabase(ca.auth.GetDatabase()),
) )

View file

@ -14,17 +14,29 @@ type Options struct {
// The type of the CAS to use. // The type of the CAS to use.
Type string `json:"type"` Type string `json:"type"`
// Path to the credentials file used in CloudCAS // CertificateAuthority reference:
CredentialsFile string `json:"credentialsFile"` // In StepCAS the value is the CA url, e.g. "https://ca.smallstep.com:9000".
// In CloudCAS the format is "projects/*/locations/*/certificateAuthorities/*".
CertificateAuthority string `json:"certificateAuthority,omitempty"`
// CertificateAuthority reference. In CloudCAS the format is // CertificateAuthorityFingerprint is the root fingerprint used to
// `projects/*/locations/*/certificateAuthorities/*`. // authenticate the connection to the CA when using StepCAS.
CertificateAuthority string `json:"certificateAuthority"` CertificateAuthorityFingerprint string `json:"certificateAuthorityFingerprint,omitempty"`
// Certificate and signer are the issuer certificate,along with any other bundled certificates to be returned in the chain for consumers, and signer used in SoftCAS. // CertificateIssuer contains the configuration used in StepCAS.
// They are configured in ca.json crt and key properties. CertificateIssuer *CertificateIssuer `json:"certificateIssuer,omitempty"`
CertificateChain []*x509.Certificate
Signer crypto.Signer `json:"-"` // Path to the credentials file used in CloudCAS. If not defined the default
// authentication mechanism provided by Google SDK will be used. See
// https://cloud.google.com/docs/authentication.
CredentialsFile string `json:"credentialsFile,omitempty"`
// Certificate and signer are the issuer certificate, along with any other
// bundled certificates to be returned in the chain for consumers, and
// signer used in SoftCAS. They are configured in ca.json crt and key
// properties.
CertificateChain []*x509.Certificate `json:"-"`
Signer crypto.Signer `json:"-"`
// IsCreator is set to true when we're creating a certificate authority. Is // IsCreator is set to true when we're creating a certificate authority. Is
// used to skip some validations when initializing a CertificateAuthority. // used to skip some validations when initializing a CertificateAuthority.
@ -39,6 +51,16 @@ type Options struct {
Location string `json:"-"` Location string `json:"-"`
} }
// CertificateIssuer contains the properties used to use the StepCAS certificate
// authority service.
type CertificateIssuer struct {
Type string `json:"type"`
Provisioner string `json:"provisioner,omitempty"`
Certificate string `json:"crt,omitempty"`
Key string `json:"key,omitempty"`
Password string `json:"password,omitempty"`
}
// Validate checks the fields in Options. // Validate checks the fields in Options.
func (o *Options) Validate() error { func (o *Options) Validate() error {
var typ Type var typ Type

View file

@ -53,6 +53,7 @@ const (
// CreateCertificateRequest is the request used to sign a new certificate. // CreateCertificateRequest is the request used to sign a new certificate.
type CreateCertificateRequest struct { type CreateCertificateRequest struct {
Template *x509.Certificate Template *x509.Certificate
CSR *x509.CertificateRequest
Lifetime time.Duration Lifetime time.Duration
Backdate time.Duration Backdate time.Duration
RequestID string RequestID string
@ -67,6 +68,7 @@ type CreateCertificateResponse struct {
// RenewCertificateRequest is the request used to re-sign a certificate. // RenewCertificateRequest is the request used to re-sign a certificate.
type RenewCertificateRequest struct { type RenewCertificateRequest struct {
Template *x509.Certificate Template *x509.Certificate
CSR *x509.CertificateRequest
Lifetime time.Duration Lifetime time.Duration
Backdate time.Duration Backdate time.Duration
RequestID string RequestID string
@ -80,10 +82,12 @@ type RenewCertificateResponse struct {
// RevokeCertificateRequest is the request used to revoke a certificate. // RevokeCertificateRequest is the request used to revoke a certificate.
type RevokeCertificateRequest struct { type RevokeCertificateRequest struct {
Certificate *x509.Certificate Certificate *x509.Certificate
Reason string SerialNumber string
ReasonCode int Reason string
RequestID string ReasonCode int
PassiveOnly bool
RequestID string
} }
// RevokeCertificateResponse is the response to a revoke certificate request. // RevokeCertificateResponse is the response to a revoke certificate request.

View file

@ -1,6 +1,7 @@
package apiv1 package apiv1
import ( import (
"net/http"
"strings" "strings"
) )
@ -35,6 +36,8 @@ const (
SoftCAS = "softcas" SoftCAS = "softcas"
// CloudCAS is a CertificateAuthorityService using Google Cloud CAS. // CloudCAS is a CertificateAuthorityService using Google Cloud CAS.
CloudCAS = "cloudcas" CloudCAS = "cloudcas"
// StepCAS is a CertificateAuthorityService using another step-ca instance.
StepCAS = "stepcas"
) )
// String returns a string from the type. It will always return the lower case // String returns a string from the type. It will always return the lower case
@ -46,3 +49,23 @@ func (t Type) String() string {
} }
return strings.ToLower(string(t)) return strings.ToLower(string(t))
} }
// ErrNotImplemented is the type of error returned if an operation is not
// implemented.
type ErrNotImplemented struct {
Message string
}
// ErrNotImplemented implements the error interface.
func (e ErrNotImplemented) Error() string {
if e.Message != "" {
return e.Message
}
return "not implemented"
}
// StatusCode implements the StatusCoder interface and returns the HTTP 501
// error.
func (e ErrNotImplemented) StatusCode() int {
return http.StatusNotImplemented
}

View file

@ -1,6 +1,8 @@
package apiv1 package apiv1
import "testing" import (
"testing"
)
func TestType_String(t *testing.T) { func TestType_String(t *testing.T) {
tests := []struct { tests := []struct {
@ -21,3 +23,51 @@ func TestType_String(t *testing.T) {
}) })
} }
} }
func TestErrNotImplemented_Error(t *testing.T) {
type fields struct {
Message string
}
tests := []struct {
name string
fields fields
want string
}{
{"default", fields{""}, "not implemented"},
{"with message", fields{"method not supported"}, "method not supported"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := ErrNotImplemented{
Message: tt.fields.Message,
}
if got := e.Error(); got != tt.want {
t.Errorf("ErrNotImplemented.Error() = %v, want %v", got, tt.want)
}
})
}
}
func TestErrNotImplemented_StatusCode(t *testing.T) {
type fields struct {
Message string
}
tests := []struct {
name string
fields fields
want int
}{
{"default", fields{""}, 501},
{"with message", fields{"method not supported"}, 501},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := ErrNotImplemented{
Message: tt.fields.Message,
}
if got := s.StatusCode(); got != tt.want {
t.Errorf("ErrNotImplemented.StatusCode() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -141,7 +141,6 @@ func (c *CloudCAS) GetCertificateAuthority(req *apiv1.GetCertificateAuthorityReq
Name: name, Name: name,
}) })
if err != nil { if err != nil {
println(name)
return nil, errors.Wrap(err, "cloudCAS GetCertificateAuthority failed") return nil, errors.Wrap(err, "cloudCAS GetCertificateAuthority failed")
} }
if len(resp.PemCaCertificates) == 0 { if len(resp.PemCaCertificates) == 0 {

79
cas/stepcas/issuer.go Normal file
View file

@ -0,0 +1,79 @@
package stepcas
import (
"net/url"
"strings"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
)
type stepIssuer interface {
SignToken(subject string, sans []string) (string, error)
RevokeToken(subject string) (string, error)
Lifetime(d time.Duration) time.Duration
}
// newStepIssuer returns the configured step issuer.
func newStepIssuer(caURL *url.URL, client *ca.Client, iss *apiv1.CertificateIssuer) (stepIssuer, error) {
if err := validateCertificateIssuer(iss); err != nil {
return nil, err
}
switch strings.ToLower(iss.Type) {
case "x5c":
return newX5CIssuer(caURL, iss)
case "jwk":
return newJWKIssuer(caURL, client, iss)
default:
return nil, errors.Errorf("stepCAS `certificateIssuer.type` %s is not supported", iss.Type)
}
}
// validateCertificateIssuer validates the configuration of the certificate
// issuer.
func validateCertificateIssuer(iss *apiv1.CertificateIssuer) error {
switch {
case iss == nil:
return errors.New("stepCAS 'certificateIssuer' cannot be nil")
case iss.Type == "":
return errors.New("stepCAS `certificateIssuer.type` cannot be empty")
}
switch strings.ToLower(iss.Type) {
case "x5c":
return validateX5CIssuer(iss)
case "jwk":
return validateJWKIssuer(iss)
default:
return errors.Errorf("stepCAS `certificateIssuer.type` %s is not supported", iss.Type)
}
}
// validateX5CIssuer validates the configuration of x5c issuer.
func validateX5CIssuer(iss *apiv1.CertificateIssuer) error {
switch {
case iss.Certificate == "":
return errors.New("stepCAS `certificateIssuer.crt` cannot be empty")
case iss.Key == "":
return errors.New("stepCAS `certificateIssuer.key` cannot be empty")
case iss.Provisioner == "":
return errors.New("stepCAS `certificateIssuer.provisioner` cannot be empty")
default:
return nil
}
}
// validateJWKIssuer validates the configuration of jwk issuer. If the key is
// not given, then it will download it from the CA. If the password is not set
// it will be prompted.
func validateJWKIssuer(iss *apiv1.CertificateIssuer) error {
switch {
case iss.Provisioner == "":
return errors.New("stepCAS `certificateIssuer.provisioner` cannot be empty")
default:
return nil
}
}

View file

@ -0,0 +1,97 @@
package stepcas
import (
"net/url"
"reflect"
"testing"
"time"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/crypto/jose"
)
type mockErrIssuer struct{}
func (m mockErrIssuer) SignToken(subject string, sans []string) (string, error) {
return "", apiv1.ErrNotImplemented{}
}
func (m mockErrIssuer) RevokeToken(subject string) (string, error) {
return "", apiv1.ErrNotImplemented{}
}
func (m mockErrIssuer) Lifetime(d time.Duration) time.Duration {
return d
}
type mockErrSigner struct{}
func (s *mockErrSigner) Sign(payload []byte) (*jose.JSONWebSignature, error) {
return nil, apiv1.ErrNotImplemented{}
}
func (s *mockErrSigner) Options() jose.SignerOptions {
return jose.SignerOptions{}
}
func Test_newStepIssuer(t *testing.T) {
caURL, client := testCAHelper(t)
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type args struct {
caURL *url.URL
client *ca.Client
iss *apiv1.CertificateIssuer
}
tests := []struct {
name string
args args
want stepIssuer
wantErr bool
}{
{"x5c", args{caURL, client, &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
}}, &x5cIssuer{
caURL: caURL,
certFile: testX5CPath,
keyFile: testX5CKeyPath,
issuer: "X5C",
}, false},
{"jwk", args{caURL, client, &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Key: testX5CKeyPath,
}}, &jwkIssuer{
caURL: caURL,
issuer: "ra@doe.org",
signer: signer,
}, false},
{"fail", args{caURL, client, &apiv1.CertificateIssuer{
Type: "unknown",
Provisioner: "ra@doe.org",
Key: testX5CKeyPath,
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := newStepIssuer(tt.args.caURL, tt.args.client, tt.args.iss)
if (err != nil) != tt.wantErr {
t.Errorf("newStepIssuer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.args.iss.Type == "jwk" && got != nil && tt.want != nil {
got.(*jwkIssuer).signer = tt.want.(*jwkIssuer).signer
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("newStepIssuer() = %v, want %v", got, tt.want)
}
})
}
}

157
cas/stepcas/jwk_issuer.go Normal file
View file

@ -0,0 +1,157 @@
package stepcas
import (
"crypto"
"encoding/json"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/cli-utils/ui"
"go.step.sm/crypto/jose"
"go.step.sm/crypto/randutil"
)
type jwkIssuer struct {
caURL *url.URL
issuer string
signer jose.Signer
}
func newJWKIssuer(caURL *url.URL, client *ca.Client, cfg *apiv1.CertificateIssuer) (*jwkIssuer, error) {
var err error
var signer jose.Signer
// Read the key from the CA if not provided.
// Or read it from a PEM file.
if cfg.Key == "" {
p, err := findProvisioner(client, provisioner.TypeJWK, cfg.Provisioner)
if err != nil {
return nil, err
}
kid, key, ok := p.GetEncryptedKey()
if !ok {
return nil, errors.Errorf("provisioner with name %s does not have an encrypted key", cfg.Provisioner)
}
signer, err = newJWKSignerFromEncryptedKey(kid, key, cfg.Password)
if err != nil {
return nil, err
}
} else {
signer, err = newJWKSigner(cfg.Key, cfg.Password)
if err != nil {
return nil, err
}
}
return &jwkIssuer{
caURL: caURL,
issuer: cfg.Provisioner,
signer: signer,
}, nil
}
func (i *jwkIssuer) SignToken(subject string, sans []string) (string, error) {
aud := i.caURL.ResolveReference(&url.URL{
Path: "/1.0/sign",
}).String()
return i.createToken(aud, subject, sans)
}
func (i *jwkIssuer) RevokeToken(subject string) (string, error) {
aud := i.caURL.ResolveReference(&url.URL{
Path: "/1.0/revoke",
}).String()
return i.createToken(aud, subject, nil)
}
func (i *jwkIssuer) Lifetime(d time.Duration) time.Duration {
return d
}
func (i *jwkIssuer) createToken(aud, sub string, sans []string) (string, error) {
id, err := randutil.Hex(64) // 256 bits
if err != nil {
return "", err
}
claims := defaultClaims(i.issuer, sub, aud, id)
builder := jose.Signed(i.signer).Claims(claims)
if len(sans) > 0 {
builder = builder.Claims(map[string]interface{}{
"sans": sans,
})
}
tok, err := builder.CompactSerialize()
if err != nil {
return "", errors.Wrap(err, "error signing token")
}
return tok, nil
}
func newJWKSigner(keyFile, password string) (jose.Signer, error) {
signer, err := readKey(keyFile, password)
if err != nil {
return nil, err
}
kid, err := jose.Thumbprint(&jose.JSONWebKey{Key: signer.Public()})
if err != nil {
return nil, err
}
so := new(jose.SignerOptions)
so.WithType("JWT")
so.WithHeader("kid", kid)
return newJoseSigner(signer, so)
}
func newJWKSignerFromEncryptedKey(kid, key, password string) (jose.Signer, error) {
var jwk jose.JSONWebKey
// If the password is empty it will use the password prompter.
b, err := jose.Decrypt([]byte(key),
jose.WithPassword([]byte(password)),
jose.WithPasswordPrompter("Please enter the password to decrypt the provisioner key", func(msg string) ([]byte, error) {
return ui.PromptPassword(msg)
}))
if err != nil {
return nil, err
}
// Decrypt returns the JSON representation of the JWK.
if err := json.Unmarshal(b, &jwk); err != nil {
return nil, errors.Wrap(err, "error parsing provisioner key")
}
signer, ok := jwk.Key.(crypto.Signer)
if !ok {
return nil, errors.New("error parsing provisioner key: key is not a crypto.Signer")
}
so := new(jose.SignerOptions)
so.WithType("JWT")
so.WithHeader("kid", kid)
return newJoseSigner(signer, so)
}
func findProvisioner(client *ca.Client, typ provisioner.Type, name string) (provisioner.Interface, error) {
cursor := ""
for {
ps, err := client.Provisioners(ca.WithProvisionerCursor(cursor))
if err != nil {
return nil, err
}
for _, p := range ps.Provisioners {
if p.GetType() == typ && p.GetName() == name {
return p, nil
}
}
if ps.NextCursor == "" {
return nil, errors.Errorf("provisioner with name %s was not found", name)
}
cursor = ps.NextCursor
}
}

View file

@ -0,0 +1,235 @@
package stepcas
import (
"net/url"
"reflect"
"testing"
"time"
"go.step.sm/crypto/jose"
)
func Test_jwkIssuer_SignToken(t *testing.T) {
caURL, err := url.Parse("https://ca.smallstep.com")
if err != nil {
t.Fatal(err)
}
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
issuer string
signer jose.Signer
}
type args struct {
subject string
sans []string
}
type claims struct {
Aud []string `json:"aud"`
Sub string `json:"sub"`
Sans []string `json:"sans"`
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{"ok", fields{caURL, "ra@doe.org", signer}, args{"doe", []string{"doe.org"}}, false},
{"fail", fields{caURL, "ra@doe.org", &mockErrSigner{}}, args{"doe", []string{"doe.org"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &jwkIssuer{
caURL: tt.fields.caURL,
issuer: tt.fields.issuer,
signer: tt.fields.signer,
}
got, err := i.SignToken(tt.args.subject, tt.args.sans)
if (err != nil) != tt.wantErr {
t.Errorf("jwkIssuer.SignToken() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
jwt, err := jose.ParseSigned(got)
if err != nil {
t.Errorf("jose.ParseSigned() error = %v", err)
}
var c claims
want := claims{
Aud: []string{tt.fields.caURL.String() + "/1.0/sign"},
Sub: tt.args.subject,
Sans: tt.args.sans,
}
if err := jwt.Claims(testX5CKey.Public(), &c); err != nil {
t.Errorf("jwt.Claims() error = %v", err)
}
if !reflect.DeepEqual(c, want) {
t.Errorf("jwt.Claims() claims = %#v, want %#v", c, want)
}
}
})
}
}
func Test_jwkIssuer_RevokeToken(t *testing.T) {
caURL, err := url.Parse("https://ca.smallstep.com")
if err != nil {
t.Fatal(err)
}
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
issuer string
signer jose.Signer
}
type args struct {
subject string
}
type claims struct {
Aud []string `json:"aud"`
Sub string `json:"sub"`
Sans []string `json:"sans"`
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{"ok", fields{caURL, "ra@doe.org", signer}, args{"doe"}, false},
{"ok", fields{caURL, "ra@doe.org", &mockErrSigner{}}, args{"doe"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &jwkIssuer{
caURL: tt.fields.caURL,
issuer: tt.fields.issuer,
signer: tt.fields.signer,
}
got, err := i.RevokeToken(tt.args.subject)
if (err != nil) != tt.wantErr {
t.Errorf("jwkIssuer.RevokeToken() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
jwt, err := jose.ParseSigned(got)
if err != nil {
t.Errorf("jose.ParseSigned() error = %v", err)
}
var c claims
want := claims{
Aud: []string{tt.fields.caURL.String() + "/1.0/revoke"},
Sub: tt.args.subject,
}
if err := jwt.Claims(testX5CKey.Public(), &c); err != nil {
t.Errorf("jwt.Claims() error = %v", err)
}
if !reflect.DeepEqual(c, want) {
t.Errorf("jwt.Claims() claims = %#v, want %#v", c, want)
}
}
})
}
}
func Test_jwkIssuer_Lifetime(t *testing.T) {
caURL, err := url.Parse("https://ca.smallstep.com")
if err != nil {
t.Fatal(err)
}
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
issuer string
signer jose.Signer
}
type args struct {
d time.Duration
}
tests := []struct {
name string
fields fields
args args
want time.Duration
}{
{"ok", fields{caURL, "ra@smallstep.com", signer}, args{time.Second}, time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &jwkIssuer{
caURL: tt.fields.caURL,
issuer: tt.fields.issuer,
signer: tt.fields.signer,
}
if got := i.Lifetime(tt.args.d); got != tt.want {
t.Errorf("jwkIssuer.Lifetime() = %v, want %v", got, tt.want)
}
})
}
}
func Test_newJWKSignerFromEncryptedKey(t *testing.T) {
encrypt := func(plaintext string) string {
recipient := jose.Recipient{
Algorithm: jose.PBES2_HS256_A128KW,
Key: testPassword,
PBES2Count: jose.PBKDF2Iterations,
PBES2Salt: []byte{0x01, 0x02},
}
opts := new(jose.EncrypterOptions)
opts.WithContentType(jose.ContentType("jwk+json"))
encrypter, err := jose.NewEncrypter(jose.DefaultEncAlgorithm, recipient, opts)
if err != nil {
t.Fatal(err)
}
jwe, err := encrypter.Encrypt([]byte(plaintext))
if err != nil {
t.Fatal(err)
}
ret, err := jwe.CompactSerialize()
if err != nil {
t.Fatal(err)
}
return ret
}
type args struct {
kid string
key string
password string
}
tests := []struct {
name string
args args
wantErr bool
}{
{"ok", args{testKeyID, testEncryptedJWKKey, testPassword}, false},
{"fail decrypt", args{testKeyID, testEncryptedJWKKey, "bad-password"}, true},
{"fail unmarshal", args{testKeyID, encrypt(`{not a json}`), testPassword}, true},
{"fail not signer", args{testKeyID, encrypt(`{"kty":"oct","k":"password"}`), testPassword}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := newJWKSignerFromEncryptedKey(tt.args.kid, tt.args.key, tt.args.password)
if (err != nil) != tt.wantErr {
t.Errorf("newJWKSignerFromEncryptedKey() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

178
cas/stepcas/stepcas.go Normal file
View file

@ -0,0 +1,178 @@
package stepcas
import (
"context"
"crypto/x509"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
)
func init() {
apiv1.Register(apiv1.StepCAS, func(ctx context.Context, opts apiv1.Options) (apiv1.CertificateAuthorityService, error) {
return New(ctx, opts)
})
}
// StepCAS implements the cas.CertificateAuthorityService interface using
// another step-ca instance.
type StepCAS struct {
iss stepIssuer
client *ca.Client
fingerprint string
}
// New creates a new CertificateAuthorityService implementation using another
// step-ca instance.
func New(ctx context.Context, opts apiv1.Options) (*StepCAS, error) {
switch {
case opts.CertificateAuthority == "":
return nil, errors.New("stepCAS 'certificateAuthority' cannot be empty")
case opts.CertificateAuthorityFingerprint == "":
return nil, errors.New("stepCAS 'certificateAuthorityFingerprint' cannot be empty")
}
caURL, err := url.Parse(opts.CertificateAuthority)
if err != nil {
return nil, errors.Wrap(err, "stepCAS `certificateAuthority` is not valid")
}
// Create client.
client, err := ca.NewClient(opts.CertificateAuthority, ca.WithRootSHA256(opts.CertificateAuthorityFingerprint))
if err != nil {
return nil, err
}
// Create configured issuer
iss, err := newStepIssuer(caURL, client, opts.CertificateIssuer)
if err != nil {
return nil, err
}
return &StepCAS{
iss: iss,
client: client,
fingerprint: opts.CertificateAuthorityFingerprint,
}, nil
}
// CreateCertificate uses the step-ca sign request with the configured
// provisioner to get a new certificate from the certificate authority.
func (s *StepCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1.CreateCertificateResponse, error) {
switch {
case req.CSR == nil:
return nil, errors.New("createCertificateRequest `csr` cannot be nil")
case req.Lifetime == 0:
return nil, errors.New("createCertificateRequest `lifetime` cannot be 0")
}
cert, chain, err := s.createCertificate(req.CSR, req.Lifetime)
if err != nil {
return nil, err
}
return &apiv1.CreateCertificateResponse{
Certificate: cert,
CertificateChain: chain,
}, nil
}
// RenewCertificate will always return a non-implemented error as mTLS renewals
// are not supported yet.
func (s *StepCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.RenewCertificateResponse, error) {
return nil, apiv1.ErrNotImplemented{Message: "stepCAS does not support mTLS renewals"}
}
func (s *StepCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1.RevokeCertificateResponse, error) {
switch {
case req.SerialNumber == "" && req.Certificate == nil:
return nil, errors.New("revokeCertificateRequest `serialNumber` or `certificate` are required")
}
serialNumber := req.SerialNumber
if req.Certificate != nil {
serialNumber = req.Certificate.SerialNumber.String()
}
token, err := s.iss.RevokeToken(serialNumber)
if err != nil {
return nil, err
}
_, err = s.client.Revoke(&api.RevokeRequest{
Serial: serialNumber,
ReasonCode: req.ReasonCode,
Reason: req.Reason,
OTT: token,
Passive: req.PassiveOnly,
}, nil)
if err != nil {
return nil, err
}
return &apiv1.RevokeCertificateResponse{
Certificate: req.Certificate,
CertificateChain: nil,
}, nil
}
// GetCertificateAuthority returns the root certificate of the certificate
// authority using the configured fingerprint.
func (s *StepCAS) GetCertificateAuthority(req *apiv1.GetCertificateAuthorityRequest) (*apiv1.GetCertificateAuthorityResponse, error) {
resp, err := s.client.Root(s.fingerprint)
if err != nil {
return nil, err
}
return &apiv1.GetCertificateAuthorityResponse{
RootCertificate: resp.RootPEM.Certificate,
}, nil
}
func (s *StepCAS) createCertificate(cr *x509.CertificateRequest, lifetime time.Duration) (*x509.Certificate, []*x509.Certificate, error) {
sans := make([]string, 0, len(cr.DNSNames)+len(cr.EmailAddresses)+len(cr.IPAddresses)+len(cr.URIs))
sans = append(sans, cr.DNSNames...)
sans = append(sans, cr.EmailAddresses...)
for _, ip := range cr.IPAddresses {
sans = append(sans, ip.String())
}
for _, u := range cr.URIs {
sans = append(sans, u.String())
}
commonName := cr.Subject.CommonName
if commonName == "" && len(sans) > 0 {
commonName = sans[0]
}
token, err := s.iss.SignToken(commonName, sans)
if err != nil {
return nil, nil, err
}
resp, err := s.client.Sign(&api.SignRequest{
CsrPEM: api.CertificateRequest{CertificateRequest: cr},
OTT: token,
NotAfter: s.lifetime(lifetime),
})
if err != nil {
return nil, nil, err
}
var chain []*x509.Certificate
cert := resp.CertChainPEM[0].Certificate
for _, c := range resp.CertChainPEM[1:] {
chain = append(chain, c.Certificate)
}
return cert, chain, nil
}
func (s *StepCAS) lifetime(d time.Duration) api.TimeDuration {
var td api.TimeDuration
td.SetDuration(s.iss.Lifetime(d))
return td
}

891
cas/stepcas/stepcas_test.go Normal file
View file

@ -0,0 +1,891 @@
package stepcas
import (
"bytes"
"context"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/ca"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/crypto/jose"
"go.step.sm/crypto/pemutil"
"go.step.sm/crypto/randutil"
"go.step.sm/crypto/x509util"
)
var (
testRootCrt *x509.Certificate
testRootKey crypto.Signer
testRootPath, testRootKeyPath string
testRootFingerprint string
testIssCrt *x509.Certificate
testIssKey crypto.Signer
testIssPath, testIssKeyPath string
testX5CCrt *x509.Certificate
testX5CKey crypto.Signer
testX5CPath, testX5CKeyPath string
testPassword, testEncryptedKeyPath string
testKeyID, testEncryptedJWKKey string
testCR *x509.CertificateRequest
testCrt *x509.Certificate
testKey crypto.Signer
testFailCR *x509.CertificateRequest
)
func mustSignCertificate(subject string, sans []string, template string, parent *x509.Certificate, signer crypto.Signer) (*x509.Certificate, crypto.Signer) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
cr, err := x509util.CreateCertificateRequest(subject, sans, priv)
if err != nil {
panic(err)
}
cert, err := x509util.NewCertificate(cr, x509util.WithTemplate(template, x509util.CreateTemplateData(subject, sans)))
if err != nil {
panic(err)
}
crt := cert.GetCertificate()
crt.NotBefore = time.Now()
crt.NotAfter = crt.NotBefore.Add(time.Hour)
if parent == nil {
parent = crt
}
if signer == nil {
signer = priv
}
if crt, err = x509util.CreateCertificate(crt, parent, pub, signer); err != nil {
panic(err)
}
return crt, priv
}
func mustSerializeCrt(filename string, certs ...*x509.Certificate) {
buf := new(bytes.Buffer)
for _, c := range certs {
if err := pem.Encode(buf, &pem.Block{
Type: "CERTIFICATE",
Bytes: c.Raw,
}); err != nil {
panic(err)
}
}
if err := ioutil.WriteFile(filename, buf.Bytes(), 0600); err != nil {
panic(err)
}
}
func mustSerializeKey(filename string, key crypto.Signer) {
b, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
panic(err)
}
b = pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: b,
})
if err := ioutil.WriteFile(filename, b, 0600); err != nil {
panic(err)
}
}
func mustEncryptKey(filename string, key crypto.Signer) {
_, err := pemutil.Serialize(key,
pemutil.ToFile(filename, 0600),
pemutil.WithPKCS8(true),
pemutil.WithPassword([]byte(testPassword)))
if err != nil {
panic(err)
}
}
func testCAHelper(t *testing.T) (*url.URL, *ca.Client) {
t.Helper()
writeJSON := func(w http.ResponseWriter, v interface{}) {
_ = json.NewEncoder(w).Encode(v)
}
parseJSON := func(r *http.Request, v interface{}) {
_ = json.NewDecoder(r.Body).Decode(v)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.RequestURI == "/root/"+testRootFingerprint:
w.WriteHeader(http.StatusOK)
writeJSON(w, api.RootResponse{
RootPEM: api.NewCertificate(testRootCrt),
})
case r.RequestURI == "/sign":
var msg api.SignRequest
parseJSON(r, &msg)
if msg.CsrPEM.DNSNames[0] == "fail.doe.org" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"fail","message":"fail"}`)
return
}
w.WriteHeader(http.StatusOK)
writeJSON(w, api.SignResponse{
CertChainPEM: []api.Certificate{api.NewCertificate(testCrt), api.NewCertificate(testIssCrt)},
})
case r.RequestURI == "/revoke":
var msg api.RevokeRequest
parseJSON(r, &msg)
if msg.Serial == "fail" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"fail","message":"fail"}`)
return
}
w.WriteHeader(http.StatusOK)
writeJSON(w, api.RevokeResponse{
Status: "ok",
})
case r.RequestURI == "/provisioners":
w.WriteHeader(http.StatusOK)
writeJSON(w, api.ProvisionersResponse{
NextCursor: "cursor",
Provisioners: []provisioner.Interface{
&provisioner.JWK{
Type: "JWK",
Name: "ra@doe.org",
Key: &jose.JSONWebKey{KeyID: testKeyID, Key: testX5CKey.Public()},
EncryptedKey: testEncryptedJWKKey,
},
&provisioner.JWK{
Type: "JWK",
Name: "empty@doe.org",
Key: &jose.JSONWebKey{KeyID: testKeyID, Key: testX5CKey.Public()},
},
},
})
case r.RequestURI == "/provisioners?cursor=cursor":
w.WriteHeader(http.StatusOK)
writeJSON(w, api.ProvisionersResponse{})
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"not found"}`)
}
}))
t.Cleanup(func() {
srv.Close()
})
u, err := url.Parse(srv.URL)
if err != nil {
srv.Close()
t.Fatal(err)
}
client, err := ca.NewClient(srv.URL, ca.WithTransport(http.DefaultTransport))
if err != nil {
srv.Close()
t.Fatal(err)
}
return u, client
}
func testX5CIssuer(t *testing.T, caURL *url.URL, password string) *x5cIssuer {
t.Helper()
key, givenPassword := testX5CKeyPath, password
if password != "" {
key = testEncryptedKeyPath
password = testPassword
}
x5c, err := newX5CIssuer(caURL, &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: key,
Password: password,
})
if err != nil {
t.Fatal(err)
}
x5c.password = givenPassword
return x5c
}
func testJWKIssuer(t *testing.T, caURL *url.URL, password string) *jwkIssuer {
t.Helper()
client, err := ca.NewClient(caURL.String(), ca.WithTransport(http.DefaultTransport))
if err != nil {
t.Fatal(err)
}
key := testX5CKeyPath
if password != "" {
key = testEncryptedKeyPath
password = testPassword
}
jwk, err := newJWKIssuer(caURL, client, &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Key: key,
Password: password,
})
if err != nil {
t.Fatal(err)
}
return jwk
}
func TestMain(m *testing.M) {
testRootCrt, testRootKey = mustSignCertificate("Test Root Certificate", nil, x509util.DefaultRootTemplate, nil, nil)
testIssCrt, testIssKey = mustSignCertificate("Test Intermediate Certificate", nil, x509util.DefaultIntermediateTemplate, testRootCrt, testRootKey)
testX5CCrt, testX5CKey = mustSignCertificate("Test X5C Certificate", nil, x509util.DefaultLeafTemplate, testIssCrt, testIssKey)
testRootFingerprint = x509util.Fingerprint(testRootCrt)
// Final certificate.
var err error
sans := []string{"doe.org", "jane@doe.org", "127.0.0.1", "::1", "localhost", "uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6;name=value"}
testCrt, testKey = mustSignCertificate("Test Certificate", sans, x509util.DefaultLeafTemplate, testIssCrt, testIssKey)
testCR, err = x509util.CreateCertificateRequest("Test Certificate", sans, testKey)
if err != nil {
panic(err)
}
// CR used in errors.
testFailCR, err = x509util.CreateCertificateRequest("", []string{"fail.doe.org"}, testKey)
if err != nil {
panic(err)
}
// Password used to encrypt the key.
testPassword, err = randutil.Hex(32)
if err != nil {
panic(err)
}
// Encrypted JWK key used when the key is downloaded from the CA.
jwe, err := jose.EncryptJWK(&jose.JSONWebKey{Key: testX5CKey}, []byte(testPassword))
if err != nil {
panic(err)
}
testEncryptedJWKKey, err = jwe.CompactSerialize()
if err != nil {
panic(err)
}
testKeyID, err = jose.Thumbprint(&jose.JSONWebKey{Key: testX5CKey})
if err != nil {
panic(err)
}
// Create test files.
path, err := ioutil.TempDir(os.TempDir(), "stepcas")
if err != nil {
panic(err)
}
testRootPath = filepath.Join(path, "root_ca.crt")
testRootKeyPath = filepath.Join(path, "root_ca.key")
mustSerializeCrt(testRootPath, testRootCrt)
mustSerializeKey(testRootKeyPath, testRootKey)
testIssPath = filepath.Join(path, "intermediate_ca.crt")
testIssKeyPath = filepath.Join(path, "intermediate_ca.key")
mustSerializeCrt(testIssPath, testIssCrt)
mustSerializeKey(testIssKeyPath, testIssKey)
testX5CPath = filepath.Join(path, "x5c.crt")
testX5CKeyPath = filepath.Join(path, "x5c.key")
mustSerializeCrt(testX5CPath, testX5CCrt, testIssCrt)
mustSerializeKey(testX5CKeyPath, testX5CKey)
testEncryptedKeyPath = filepath.Join(path, "x5c.enc.key")
mustEncryptKey(testEncryptedKeyPath, testX5CKey)
code := m.Run()
if err := os.RemoveAll(path); err != nil {
panic(err)
}
os.Exit(code)
}
func Test_init(t *testing.T) {
caURL, _ := testCAHelper(t)
fn, ok := apiv1.LoadCertificateAuthorityServiceNewFunc(apiv1.StepCAS)
if !ok {
t.Errorf("apiv1.Register() ok = %v, want true", ok)
return
}
fn(context.Background(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
})
}
func TestNew(t *testing.T) {
caURL, client := testCAHelper(t)
signer, err := newJWKSignerFromEncryptedKey(testKeyID, testEncryptedJWKKey, testPassword)
if err != nil {
t.Fatal(err)
}
type args struct {
ctx context.Context
opts apiv1.Options
}
tests := []struct {
name string
args args
want *StepCAS
wantErr bool
}{
{"ok", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, &StepCAS{
iss: &x5cIssuer{
caURL: caURL,
certFile: testX5CPath,
keyFile: testX5CKeyPath,
issuer: "X5C",
},
client: client,
fingerprint: testRootFingerprint,
}, false},
{"ok jwk", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Key: testX5CKeyPath,
},
}}, &StepCAS{
iss: &jwkIssuer{
caURL: caURL,
issuer: "ra@doe.org",
signer: signer,
},
client: client,
fingerprint: testRootFingerprint,
}, false},
{"ok jwk provisioners", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Password: testPassword,
},
}}, &StepCAS{
iss: &jwkIssuer{
caURL: caURL,
issuer: "ra@doe.org",
signer: signer,
},
client: client,
fingerprint: testRootFingerprint,
}, false},
{"fail authority", args{context.TODO(), apiv1.Options{
CertificateAuthority: "",
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail fingerprint", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: "",
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail type", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail provisioner", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail provisioner jwk", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "",
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail provisioner not found", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "notfound@doe.org",
Password: testPassword,
},
}}, nil, true},
{"fail invalid password", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Password: "bad-password",
},
}}, nil, true},
{"fail no key", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "empty@doe.org",
Password: testPassword,
},
}}, nil, true},
{"fail certificate", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: "",
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail key", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: "",
},
}}, nil, true},
{"fail key jwk", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@smallstep.com",
Key: "",
},
}}, nil, true},
{"bad authority", args{context.TODO(), apiv1.Options{
CertificateAuthority: "https://foobar",
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail parse url", args{context.TODO(), apiv1.Options{
CertificateAuthority: "::failparse",
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail new client", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: "foobar",
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail new x5c issuer", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "x5c",
Provisioner: "X5C",
Certificate: testX5CPath + ".missing",
Key: testX5CKeyPath,
},
}}, nil, true},
{"fail new jwk issuer", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "jwk",
Provisioner: "ra@doe.org",
Key: testX5CKeyPath + ".missing",
},
}}, nil, true},
{"bad issuer", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: nil}}, nil, true},
{"bad issuer type", args{context.TODO(), apiv1.Options{
CertificateAuthority: caURL.String(),
CertificateAuthorityFingerprint: testRootFingerprint,
CertificateIssuer: &apiv1.CertificateIssuer{
Type: "fail",
Provisioner: "X5C",
Certificate: testX5CPath,
Key: testX5CKeyPath,
},
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := New(tt.args.ctx, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
// We cannot compare neither the client nor the signer.
if got != nil && tt.want != nil {
got.client = tt.want.client
if jwk, ok := got.iss.(*jwkIssuer); ok {
jwk.signer = signer
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("New() = %v, want %v", got, tt.want)
}
})
}
}
func TestStepCAS_CreateCertificate(t *testing.T) {
caURL, client := testCAHelper(t)
x5c := testX5CIssuer(t, caURL, "")
jwk := testJWKIssuer(t, caURL, "")
x5cEnc := testX5CIssuer(t, caURL, testPassword)
jwkEnc := testJWKIssuer(t, caURL, testPassword)
x5cBad := testX5CIssuer(t, caURL, "bad-password")
type fields struct {
iss stepIssuer
client *ca.Client
fingerprint string
}
type args struct {
req *apiv1.CreateCertificateRequest
}
tests := []struct {
name string
fields fields
args args
want *apiv1.CreateCertificateResponse
wantErr bool
}{
{"ok", fields{x5c, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, &apiv1.CreateCertificateResponse{
Certificate: testCrt,
CertificateChain: []*x509.Certificate{testIssCrt},
}, false},
{"ok with password", fields{x5cEnc, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, &apiv1.CreateCertificateResponse{
Certificate: testCrt,
CertificateChain: []*x509.Certificate{testIssCrt},
}, false},
{"ok jwk", fields{jwk, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, &apiv1.CreateCertificateResponse{
Certificate: testCrt,
CertificateChain: []*x509.Certificate{testIssCrt},
}, false},
{"ok jwk with password", fields{jwkEnc, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, &apiv1.CreateCertificateResponse{
Certificate: testCrt,
CertificateChain: []*x509.Certificate{testIssCrt},
}, false},
{"fail CSR", fields{x5c, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: nil,
Lifetime: time.Hour,
}}, nil, true},
{"fail lifetime", fields{x5c, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: 0,
}}, nil, true},
{"fail sign token", fields{mockErrIssuer{}, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, nil, true},
{"fail client sign", fields{x5c, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testFailCR,
Lifetime: time.Hour,
}}, nil, true},
{"fail password", fields{x5cBad, client, testRootFingerprint}, args{&apiv1.CreateCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StepCAS{
iss: tt.fields.iss,
client: tt.fields.client,
fingerprint: tt.fields.fingerprint,
}
got, err := s.CreateCertificate(tt.args.req)
if (err != nil) != tt.wantErr {
t.Errorf("StepCAS.CreateCertificate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("StepCAS.CreateCertificate() = %v, want %v", got, tt.want)
}
})
}
}
func TestStepCAS_RenewCertificate(t *testing.T) {
caURL, client := testCAHelper(t)
x5c := testX5CIssuer(t, caURL, "")
jwk := testJWKIssuer(t, caURL, "")
type fields struct {
iss stepIssuer
client *ca.Client
fingerprint string
}
type args struct {
req *apiv1.RenewCertificateRequest
}
tests := []struct {
name string
fields fields
args args
want *apiv1.RenewCertificateResponse
wantErr bool
}{
{"not implemented", fields{x5c, client, testRootFingerprint}, args{&apiv1.RenewCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, nil, true},
{"not implemented jwk", fields{jwk, client, testRootFingerprint}, args{&apiv1.RenewCertificateRequest{
CSR: testCR,
Lifetime: time.Hour,
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StepCAS{
iss: tt.fields.iss,
client: tt.fields.client,
fingerprint: tt.fields.fingerprint,
}
got, err := s.RenewCertificate(tt.args.req)
if (err != nil) != tt.wantErr {
t.Errorf("StepCAS.RenewCertificate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("StepCAS.RenewCertificate() = %v, want %v", got, tt.want)
}
})
}
}
func TestStepCAS_RevokeCertificate(t *testing.T) {
caURL, client := testCAHelper(t)
x5c := testX5CIssuer(t, caURL, "")
jwk := testJWKIssuer(t, caURL, "")
x5cEnc := testX5CIssuer(t, caURL, testPassword)
jwkEnc := testJWKIssuer(t, caURL, testPassword)
x5cBad := testX5CIssuer(t, caURL, "bad-password")
type fields struct {
iss stepIssuer
client *ca.Client
fingerprint string
}
type args struct {
req *apiv1.RevokeCertificateRequest
}
tests := []struct {
name string
fields fields
args args
want *apiv1.RevokeCertificateResponse
wantErr bool
}{
{"ok serial number", fields{x5c, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: nil,
}}, &apiv1.RevokeCertificateResponse{}, false},
{"ok certificate", fields{x5c, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "",
Certificate: testCrt,
}}, &apiv1.RevokeCertificateResponse{
Certificate: testCrt,
}, false},
{"ok both", fields{x5c, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: testCrt,
}}, &apiv1.RevokeCertificateResponse{
Certificate: testCrt,
}, false},
{"ok with password", fields{x5cEnc, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: nil,
}}, &apiv1.RevokeCertificateResponse{}, false},
{"ok serial number jwk", fields{jwk, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: nil,
}}, &apiv1.RevokeCertificateResponse{}, false},
{"ok certificate jwk", fields{jwk, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "",
Certificate: testCrt,
}}, &apiv1.RevokeCertificateResponse{
Certificate: testCrt,
}, false},
{"ok both jwk", fields{jwk, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: testCrt,
}}, &apiv1.RevokeCertificateResponse{
Certificate: testCrt,
}, false},
{"ok jwk with password", fields{jwkEnc, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: nil,
}}, &apiv1.RevokeCertificateResponse{}, false},
{"fail request", fields{x5c, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "",
Certificate: nil,
}}, nil, true},
{"fail revoke token", fields{mockErrIssuer{}, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
}}, nil, true},
{"fail client revoke", fields{x5c, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "fail",
}}, nil, true},
{"fail password", fields{x5cBad, client, testRootFingerprint}, args{&apiv1.RevokeCertificateRequest{
SerialNumber: "ok",
Certificate: nil,
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StepCAS{
iss: tt.fields.iss,
client: tt.fields.client,
fingerprint: tt.fields.fingerprint,
}
got, err := s.RevokeCertificate(tt.args.req)
if (err != nil) != tt.wantErr {
t.Errorf("StepCAS.RevokeCertificate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("StepCAS.RevokeCertificate() = %v, want %v", got, tt.want)
}
})
}
}
func TestStepCAS_GetCertificateAuthority(t *testing.T) {
caURL, client := testCAHelper(t)
x5c := testX5CIssuer(t, caURL, "")
jwk := testJWKIssuer(t, caURL, "")
type fields struct {
iss stepIssuer
client *ca.Client
fingerprint string
}
type args struct {
req *apiv1.GetCertificateAuthorityRequest
}
tests := []struct {
name string
fields fields
args args
want *apiv1.GetCertificateAuthorityResponse
wantErr bool
}{
{"ok", fields{x5c, client, testRootFingerprint}, args{&apiv1.GetCertificateAuthorityRequest{
Name: caURL.String(),
}}, &apiv1.GetCertificateAuthorityResponse{
RootCertificate: testRootCrt,
}, false},
{"ok jwk", fields{jwk, client, testRootFingerprint}, args{&apiv1.GetCertificateAuthorityRequest{
Name: caURL.String(),
}}, &apiv1.GetCertificateAuthorityResponse{
RootCertificate: testRootCrt,
}, false},
{"fail fingerprint", fields{x5c, client, "fail"}, args{&apiv1.GetCertificateAuthorityRequest{
Name: caURL.String(),
}}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StepCAS{
iss: tt.fields.iss,
client: tt.fields.client,
fingerprint: tt.fields.fingerprint,
}
got, err := s.GetCertificateAuthority(tt.args.req)
if (err != nil) != tt.wantErr {
t.Errorf("StepCAS.GetCertificateAuthority() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("StepCAS.GetCertificateAuthority() = %v, want %v", got, tt.want)
}
})
}
}

185
cas/stepcas/x5c_issuer.go Normal file
View file

@ -0,0 +1,185 @@
package stepcas
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/crypto/jose"
"go.step.sm/crypto/pemutil"
"go.step.sm/crypto/randutil"
)
const defaultValidity = 5 * time.Minute
// timeNow returns the current time.
// This method is used for unit testing purposes.
var timeNow = func() time.Time {
return time.Now()
}
type x5cIssuer struct {
caURL *url.URL
issuer string
certFile string
keyFile string
password string
}
// newX5CIssuer create a new x5c token issuer. The given configuration should be
// already validate.
func newX5CIssuer(caURL *url.URL, cfg *apiv1.CertificateIssuer) (*x5cIssuer, error) {
_, err := newX5CSigner(cfg.Certificate, cfg.Key, cfg.Password)
if err != nil {
return nil, err
}
return &x5cIssuer{
caURL: caURL,
issuer: cfg.Provisioner,
certFile: cfg.Certificate,
keyFile: cfg.Key,
password: cfg.Password,
}, nil
}
func (i *x5cIssuer) SignToken(subject string, sans []string) (string, error) {
aud := i.caURL.ResolveReference(&url.URL{
Path: "/1.0/sign",
Fragment: "x5c/" + i.issuer,
}).String()
return i.createToken(aud, subject, sans)
}
func (i *x5cIssuer) RevokeToken(subject string) (string, error) {
aud := i.caURL.ResolveReference(&url.URL{
Path: "/1.0/revoke",
Fragment: "x5c/" + i.issuer,
}).String()
return i.createToken(aud, subject, nil)
}
func (i *x5cIssuer) Lifetime(d time.Duration) time.Duration {
cert, err := pemutil.ReadCertificate(i.certFile, pemutil.WithFirstBlock())
if err != nil {
return d
}
now := timeNow()
if now.Add(d + time.Minute).After(cert.NotAfter) {
return cert.NotAfter.Sub(now) - time.Minute
}
return d
}
func (i *x5cIssuer) createToken(aud, sub string, sans []string) (string, error) {
signer, err := newX5CSigner(i.certFile, i.keyFile, i.password)
if err != nil {
return "", err
}
id, err := randutil.Hex(64) // 256 bits
if err != nil {
return "", err
}
claims := defaultClaims(i.issuer, sub, aud, id)
builder := jose.Signed(signer).Claims(claims)
if len(sans) > 0 {
builder = builder.Claims(map[string]interface{}{
"sans": sans,
})
}
tok, err := builder.CompactSerialize()
if err != nil {
return "", errors.Wrap(err, "error signing token")
}
return tok, nil
}
func defaultClaims(iss, sub, aud, id string) jose.Claims {
now := timeNow()
return jose.Claims{
ID: id,
Issuer: iss,
Subject: sub,
Audience: jose.Audience{aud},
Expiry: jose.NewNumericDate(now.Add(defaultValidity)),
NotBefore: jose.NewNumericDate(now),
IssuedAt: jose.NewNumericDate(now),
}
}
func readKey(keyFile, password string) (crypto.Signer, error) {
var opts []pemutil.Options
if password != "" {
opts = append(opts, pemutil.WithPassword([]byte(password)))
}
key, err := pemutil.Read(keyFile, opts...)
if err != nil {
return nil, err
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("key is not a crypto.Signer")
}
return signer, nil
}
func newX5CSigner(certFile, keyFile, password string) (jose.Signer, error) {
signer, err := readKey(keyFile, password)
if err != nil {
return nil, err
}
kid, err := jose.Thumbprint(&jose.JSONWebKey{Key: signer.Public()})
if err != nil {
return nil, err
}
certs, err := jose.ValidateX5C(certFile, signer)
if err != nil {
return nil, errors.Wrap(err, "error validating x5c certificate chain and key")
}
so := new(jose.SignerOptions)
so.WithType("JWT")
so.WithHeader("kid", kid)
so.WithHeader("x5c", certs)
return newJoseSigner(signer, so)
}
func newJoseSigner(key crypto.Signer, so *jose.SignerOptions) (jose.Signer, error) {
var alg jose.SignatureAlgorithm
switch k := key.Public().(type) {
case *ecdsa.PublicKey:
switch k.Curve.Params().Name {
case "P-256":
alg = jose.ES256
case "P-384":
alg = jose.ES384
case "P-521":
alg = jose.ES512
default:
return nil, errors.Errorf("unsupported elliptic curve %s", k.Curve.Params().Name)
}
case ed25519.PublicKey:
alg = jose.EdDSA
case *rsa.PublicKey:
alg = jose.DefaultRSASigAlgorithm
default:
return nil, errors.Errorf("unsupported key type %T", k)
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: key}, so)
if err != nil {
return nil, errors.Wrap(err, "error creating jose.Signer")
}
return signer, nil
}

View file

@ -0,0 +1,277 @@
package stepcas
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"io"
"net/url"
"reflect"
"testing"
"time"
"go.step.sm/crypto/jose"
)
type noneSigner []byte
func (b noneSigner) Public() crypto.PublicKey {
return []byte(b)
}
func (b noneSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
return digest, nil
}
func fakeTime(t *testing.T) {
t.Helper()
tmp := timeNow
t.Cleanup(func() {
timeNow = tmp
})
timeNow = func() time.Time {
return testX5CCrt.NotBefore
}
}
func Test_x5cIssuer_SignToken(t *testing.T) {
caURL, err := url.Parse("https://ca.smallstep.com")
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
certFile string
keyFile string
issuer string
}
type args struct {
subject string
sans []string
}
type claims struct {
Aud []string `json:"aud"`
Sub string `json:"sub"`
Sans []string `json:"sans"`
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{"ok", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{"doe", []string{"doe.org"}}, false},
{"fail crt", fields{caURL, "", testX5CKeyPath, "X5C"}, args{"doe", []string{"doe.org"}}, true},
{"fail key", fields{caURL, testX5CPath, "", "X5C"}, args{"doe", []string{"doe.org"}}, true},
{"fail no signer", fields{caURL, testIssKeyPath, testIssPath, "X5C"}, args{"doe", []string{"doe.org"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &x5cIssuer{
caURL: tt.fields.caURL,
certFile: tt.fields.certFile,
keyFile: tt.fields.keyFile,
issuer: tt.fields.issuer,
}
got, err := i.SignToken(tt.args.subject, tt.args.sans)
if (err != nil) != tt.wantErr {
t.Errorf("x5cIssuer.SignToken() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
jwt, err := jose.ParseSigned(got)
if err != nil {
t.Errorf("jose.ParseSigned() error = %v", err)
}
var c claims
want := claims{
Aud: []string{tt.fields.caURL.String() + "/1.0/sign#x5c/X5C"},
Sub: tt.args.subject,
Sans: tt.args.sans,
}
if err := jwt.Claims(testX5CKey.Public(), &c); err != nil {
t.Errorf("jwt.Claims() error = %v", err)
}
if !reflect.DeepEqual(c, want) {
t.Errorf("jwt.Claims() claims = %#v, want %#v", c, want)
}
}
})
}
}
func Test_x5cIssuer_RevokeToken(t *testing.T) {
caURL, err := url.Parse("https://ca.smallstep.com")
if err != nil {
t.Fatal(err)
}
type fields struct {
caURL *url.URL
certFile string
keyFile string
issuer string
}
type args struct {
subject string
}
type claims struct {
Aud []string `json:"aud"`
Sub string `json:"sub"`
Sans []string `json:"sans"`
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{"ok", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{"doe"}, false},
{"fail crt", fields{caURL, "", testX5CKeyPath, "X5C"}, args{"doe"}, true},
{"fail key", fields{caURL, testX5CPath, "", "X5C"}, args{"doe"}, true},
{"fail no signer", fields{caURL, testIssKeyPath, testIssPath, "X5C"}, args{"doe"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &x5cIssuer{
caURL: tt.fields.caURL,
certFile: tt.fields.certFile,
keyFile: tt.fields.keyFile,
issuer: tt.fields.issuer,
}
got, err := i.RevokeToken(tt.args.subject)
if (err != nil) != tt.wantErr {
t.Errorf("x5cIssuer.RevokeToken() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
jwt, err := jose.ParseSigned(got)
if err != nil {
t.Errorf("jose.ParseSigned() error = %v", err)
}
var c claims
want := claims{
Aud: []string{tt.fields.caURL.String() + "/1.0/revoke#x5c/X5C"},
Sub: tt.args.subject,
}
if err := jwt.Claims(testX5CKey.Public(), &c); err != nil {
t.Errorf("jwt.Claims() error = %v", err)
}
if !reflect.DeepEqual(c, want) {
t.Errorf("jwt.Claims() claims = %#v, want %#v", c, want)
}
}
})
}
}
func Test_x5cIssuer_Lifetime(t *testing.T) {
fakeTime(t)
caURL, err := url.Parse("https://ca.smallstep.com")
if err != nil {
t.Fatal(err)
}
// With a leeway of 1m the max duration will be 59m.
maxDuration := testX5CCrt.NotAfter.Sub(timeNow()) - time.Minute
type fields struct {
caURL *url.URL
certFile string
keyFile string
issuer string
}
type args struct {
d time.Duration
}
tests := []struct {
name string
fields fields
args args
want time.Duration
}{
{"ok 0s", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{0}, 0},
{"ok 1m", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{time.Minute}, time.Minute},
{"ok max-1m", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{maxDuration - time.Minute}, maxDuration - time.Minute},
{"ok max", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{maxDuration}, maxDuration},
{"ok max+1m", fields{caURL, testX5CPath, testX5CKeyPath, "X5C"}, args{maxDuration + time.Minute}, maxDuration},
{"ok fail", fields{caURL, testX5CPath + ".missing", testX5CKeyPath, "X5C"}, args{maxDuration + time.Minute}, maxDuration + time.Minute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &x5cIssuer{
caURL: tt.fields.caURL,
certFile: tt.fields.certFile,
keyFile: tt.fields.keyFile,
issuer: tt.fields.issuer,
}
if got := i.Lifetime(tt.args.d); got != tt.want {
t.Errorf("x5cIssuer.Lifetime() = %v, want %v", got, tt.want)
}
})
}
}
func Test_newJoseSigner(t *testing.T) {
mustSigner := func(args ...interface{}) crypto.Signer {
if err := args[len(args)-1]; err != nil {
t.Fatal(err)
}
for _, a := range args {
if s, ok := a.(crypto.Signer); ok {
return s
}
}
t.Fatal("signer not found")
return nil
}
p224 := mustSigner(ecdsa.GenerateKey(elliptic.P224(), rand.Reader))
p256 := mustSigner(ecdsa.GenerateKey(elliptic.P256(), rand.Reader))
p384 := mustSigner(ecdsa.GenerateKey(elliptic.P384(), rand.Reader))
p521 := mustSigner(ecdsa.GenerateKey(elliptic.P521(), rand.Reader))
edKey := mustSigner(ed25519.GenerateKey(rand.Reader))
rsaKey := mustSigner(rsa.GenerateKey(rand.Reader, 2048))
type args struct {
key crypto.Signer
so *jose.SignerOptions
}
tests := []struct {
name string
args args
want []jose.Header
wantErr bool
}{
{"p256", args{p256, nil}, []jose.Header{{Algorithm: "ES256"}}, false},
{"p384", args{p384, new(jose.SignerOptions).WithType("JWT")}, []jose.Header{{Algorithm: "ES384", ExtraHeaders: map[jose.HeaderKey]interface{}{"typ": "JWT"}}}, false},
{"p521", args{p521, new(jose.SignerOptions).WithHeader("kid", "the-kid")}, []jose.Header{{Algorithm: "ES512", KeyID: "the-kid"}}, false},
{"ed25519", args{edKey, nil}, []jose.Header{{Algorithm: "EdDSA"}}, false},
{"rsa", args{rsaKey, nil}, []jose.Header{{Algorithm: "RS256"}}, false},
{"fail p224", args{p224, nil}, nil, true},
{"fail signer", args{noneSigner{1, 2, 3}, nil}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := newJoseSigner(tt.args.key, tt.args.so)
if (err != nil) != tt.wantErr {
t.Errorf("newJoseSigner() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
jws, err := got.Sign([]byte("{}"))
if err != nil {
t.Errorf("jose.Signer.Sign() err = %v", err)
}
jwt, err := jose.ParseSigned(jws.FullSerialize())
if err != nil {
t.Errorf("jose.ParseSigned() err = %v", err)
}
if !reflect.DeepEqual(jwt.Headers, tt.want) {
t.Errorf("jose.Header got = %v, want = %v", jwt.Headers, tt.want)
}
}
})
}
}

View file

@ -37,6 +37,7 @@ import (
// Enabled cas interfaces. // Enabled cas interfaces.
_ "github.com/smallstep/certificates/cas/cloudcas" _ "github.com/smallstep/certificates/cas/cloudcas"
_ "github.com/smallstep/certificates/cas/softcas" _ "github.com/smallstep/certificates/cas/softcas"
_ "github.com/smallstep/certificates/cas/stepcas"
) )
// commit and buildTime are filled in during build by the Makefile // commit and buildTime are filled in during build by the Makefile
@ -65,6 +66,7 @@ var appHelpTemplate = `## NAME
| **{{join .Names ", "}}** | {{.Usage}} |{{end}} | **{{join .Names ", "}}** | {{.Usage}} |{{end}}
{{end}}{{if .VisibleFlags}}{{end}} {{end}}{{if .VisibleFlags}}{{end}}
## OPTIONS ## OPTIONS
{{range $index, $option := .VisibleFlags}}{{if $index}} {{range $index, $option := .VisibleFlags}}{{if $index}}
{{end}}{{$option}} {{end}}{{$option}}
{{end}}{{end}}{{if .Copyright}}{{if len .Authors}} {{end}}{{end}}{{if .Copyright}}{{if len .Authors}}
@ -105,7 +107,7 @@ func main() {
app.HelpName = "step-ca" app.HelpName = "step-ca"
app.Version = config.Version() app.Version = config.Version()
app.Usage = "an online certificate authority for secure automated certificate management" app.Usage = "an online certificate authority for secure automated certificate management"
app.UsageText = `**step-ca** <config> [**--password-file**=<file>] [**--resolver**=<addr>] [**--help**] [**--version**]` app.UsageText = `**step-ca** <config> [**--password-file**=<file>] [**--issuer-password-file**=<file>] [**--resolver**=<addr>] [**--help**] [**--version**]`
app.Description = `**step-ca** runs the Step Online Certificate Authority app.Description = `**step-ca** runs the Step Online Certificate Authority
(Step CA) using the given configuration. (Step CA) using the given configuration.
See the README.md for more detailed configuration documentation. See the README.md for more detailed configuration documentation.

View file

@ -94,6 +94,7 @@ func main() {
var c Config var c Config
flag.StringVar(&c.KMS, "kms", kmsuri, "PKCS #11 URI with the module-path and token to connect to the module.") flag.StringVar(&c.KMS, "kms", kmsuri, "PKCS #11 URI with the module-path and token to connect to the module.")
flag.StringVar(&c.Pin, "pin", "", "PKCS #11 PIN")
flag.StringVar(&c.RootObject, "root-cert", "pkcs11:id=7330;object=root-cert", "PKCS #11 URI with object id and label to store the root certificate.") flag.StringVar(&c.RootObject, "root-cert", "pkcs11:id=7330;object=root-cert", "PKCS #11 URI with object id and label to store the root certificate.")
flag.StringVar(&c.RootKeyObject, "root-key", "pkcs11:id=7330;object=root-key", "PKCS #11 URI with object id and label to store the root key.") flag.StringVar(&c.RootKeyObject, "root-key", "pkcs11:id=7330;object=root-key", "PKCS #11 URI with object id and label to store the root key.")
flag.StringVar(&c.CrtObject, "crt-cert", "pkcs11:id=7331;object=intermediate-cert", "PKCS #11 URI with object id and label to store the intermediate certificate.") flag.StringVar(&c.CrtObject, "crt-cert", "pkcs11:id=7331;object=intermediate-cert", "PKCS #11 URI with object id and label to store the intermediate certificate.")
@ -118,7 +119,7 @@ func main() {
fatal(err) fatal(err)
} }
if u.Pin() == "" { if u.Pin() == "" && c.Pin == "" {
pin, err := ui.PromptPassword("What is the PKCS#11 PIN?") pin, err := ui.PromptPassword("What is the PKCS#11 PIN?")
if err != nil { if err != nil {
fatal(err) fatal(err)

View file

@ -22,13 +22,17 @@ var AppCommand = cli.Command{
Name: "start", Name: "start",
Action: appAction, Action: appAction,
UsageText: `**step-ca** <config> UsageText: `**step-ca** <config>
[**--password-file**=<file>] [**--password-file**=<file>] [**--issuer-password-file**=<file>] [**--resolver**=<addr>]`,
[**--resolver**=<addr>]`,
Flags: []cli.Flag{ Flags: []cli.Flag{
cli.StringFlag{ cli.StringFlag{
Name: "password-file", Name: "password-file",
Usage: `path to the <file> containing the password to decrypt the Usage: `path to the <file> containing the password to decrypt the
intermediate private key.`, intermediate private key.`,
},
cli.StringFlag{
Name: "issuer-password-file",
Usage: `path to the <file> containing the password to decrypt the
certificate issuer private key used in the RA mode.`,
}, },
cli.StringFlag{ cli.StringFlag{
Name: "resolver", Name: "resolver",
@ -40,6 +44,7 @@ intermediate private key.`,
// AppAction is the action used when the top command runs. // AppAction is the action used when the top command runs.
func appAction(ctx *cli.Context) error { func appAction(ctx *cli.Context) error {
passFile := ctx.String("password-file") passFile := ctx.String("password-file")
issuerPassFile := ctx.String("issuer-password-file")
resolver := ctx.String("resolver") resolver := ctx.String("resolver")
// If zero cmd line args show help, if >1 cmd line args show error. // If zero cmd line args show help, if >1 cmd line args show error.
@ -64,6 +69,14 @@ func appAction(ctx *cli.Context) error {
password = bytes.TrimRightFunc(password, unicode.IsSpace) password = bytes.TrimRightFunc(password, unicode.IsSpace)
} }
var issuerPassword []byte
if issuerPassFile != "" {
if issuerPassword, err = ioutil.ReadFile(issuerPassFile); err != nil {
fatal(errors.Wrapf(err, "error reading %s", issuerPassFile))
}
issuerPassword = bytes.TrimRightFunc(issuerPassword, unicode.IsSpace)
}
// replace resolver if requested // replace resolver if requested
if resolver != "" { if resolver != "" {
net.DefaultResolver.PreferGo = true net.DefaultResolver.PreferGo = true
@ -72,7 +85,10 @@ func appAction(ctx *cli.Context) error {
} }
} }
srv, err := ca.New(config, ca.WithConfigFile(configFile), ca.WithPassword(password)) srv, err := ca.New(config,
ca.WithConfigFile(configFile),
ca.WithPassword(password),
ca.WithIssuerPassword(issuerPassword))
if err != nil { if err != nil {
fatal(err) fatal(err)
} }

2
go.mod
View file

@ -23,7 +23,7 @@ require (
github.com/urfave/cli v1.22.4 github.com/urfave/cli v1.22.4
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1
go.step.sm/cli-utils v0.2.0 go.step.sm/cli-utils v0.2.0
go.step.sm/crypto v0.7.3 go.step.sm/crypto v0.8.0
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 golang.org/x/net v0.0.0-20210119194325-5f4716e94777
google.golang.org/api v0.33.0 google.golang.org/api v0.33.0

4
go.sum
View file

@ -340,8 +340,8 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.step.sm/cli-utils v0.2.0 h1:hpVu9+6dpv/7/Bd8nGJFc3V+gQ+TciSJRTu9TavDUQ4= go.step.sm/cli-utils v0.2.0 h1:hpVu9+6dpv/7/Bd8nGJFc3V+gQ+TciSJRTu9TavDUQ4=
go.step.sm/cli-utils v0.2.0/go.mod h1:+t4qCp5NO+080DdGkJxEh3xL5S4TcYC2JTPLMM72b6Y= go.step.sm/cli-utils v0.2.0/go.mod h1:+t4qCp5NO+080DdGkJxEh3xL5S4TcYC2JTPLMM72b6Y=
go.step.sm/crypto v0.6.1/go.mod h1:AKS4yMZVZD4EGjpSkY4eibuMenrvKCscb+BpWMet8c0= go.step.sm/crypto v0.6.1/go.mod h1:AKS4yMZVZD4EGjpSkY4eibuMenrvKCscb+BpWMet8c0=
go.step.sm/crypto v0.7.3 h1:uWkT0vsaZVixgn5x6Ojqittry9PiyVn2ihEYG/qOxV8= go.step.sm/crypto v0.8.0 h1:S4qBPyy3hR7KWLybSkHB0H14pwFfYkom4RZ96JzmXig=
go.step.sm/crypto v0.7.3/go.mod h1:AKS4yMZVZD4EGjpSkY4eibuMenrvKCscb+BpWMet8c0= go.step.sm/crypto v0.8.0/go.mod h1:+CYG05Mek1YDqi5WK0ERc6cOpKly2i/a5aZmU1sfGj0=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View file

@ -15,7 +15,7 @@ Environment=STEPPATH=/etc/step-ca \
; ExecStartPre checks if the certificate is ready for renewal, ; ExecStartPre checks if the certificate is ready for renewal,
; based on the exit status of the command. ; based on the exit status of the command.
; (In systemd 243 and above, you can use ExecCondition= here.) ; (In systemd 243 and above, you can use ExecCondition= here.)
ExecStartPre=/usr/bin/bash -c \ ExecStartPre=/usr/bin/env bash -c \
'step certificate inspect $CERT_LOCATION --format json --roots "$STEPPATH/certs/root_ca.crt" | \ 'step certificate inspect $CERT_LOCATION --format json --roots "$STEPPATH/certs/root_ca.crt" | \
jq -e "(((.validity.start | fromdate) + \ jq -e "(((.validity.start | fromdate) + \
((.validity.end | fromdate) - (.validity.start | fromdate)) * 0.66) \ ((.validity.end | fromdate) - (.validity.start | fromdate)) * 0.66) \
@ -25,7 +25,8 @@ ExecStartPre=/usr/bin/bash -c \
ExecStart=/usr/bin/step ca renew --force $CERT_LOCATION $KEY_LOCATION ExecStart=/usr/bin/step ca renew --force $CERT_LOCATION $KEY_LOCATION
; Try to reload or restart the systemd service that relies on this cert-renewer ; Try to reload or restart the systemd service that relies on this cert-renewer
ExecStartPost=/usr/bin/bash -c 'systemctl --quiet is-enabled %i && systemctl try-reload-or-restart %i' ; If the relying service doesn't exist, forge ahead.
ExecStartPost=/usr/bin/env bash -c "if ! systemctl --quiet is-enabled %i.service ; then exit 0; fi; systemctl try-reload-or-restart %i"
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target