From a6115e29c294cd9788f6752f7e1c9734e0113e8b Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 17 Mar 2021 19:33:35 -0700 Subject: [PATCH] Add initial implementation of StepCAS. StepCAS allows to configure step-ca as an RA using another step-ca as the main CA. --- authority/config.go | 1 - authority/tls.go | 9 +- cas/apiv1/options.go | 39 ++++++-- cas/apiv1/requests.go | 11 ++- cas/apiv1/services.go | 2 + cas/stepcas/issuer.go | 40 ++++++++ cas/stepcas/stepcas.go | 189 ++++++++++++++++++++++++++++++++++++++ cas/stepcas/x5c_issuer.go | 153 ++++++++++++++++++++++++++++++ cmd/step-ca/main.go | 1 + 9 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 cas/stepcas/issuer.go create mode 100644 cas/stepcas/stepcas.go create mode 100644 cas/stepcas/x5c_issuer.go diff --git a/authority/config.go b/authority/config.go index 9d79ce9a..86a5c80c 100644 --- a/authority/config.go +++ b/authority/config.go @@ -189,7 +189,6 @@ func (c *Config) Validate() error { // Options holds the RA/CAS configuration. ra := c.AuthorityConfig.Options - // The default RA/CAS requires root, crt and key. if ra.Is(cas.SoftCAS) { switch { diff --git a/authority/tls.go b/authority/tls.go index f22f4624..e1c72310 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -148,6 +148,7 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign lifetime := leaf.NotAfter.Sub(leaf.NotBefore.Add(signOpts.Backdate)) resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{ Template: leaf, + CSR: csr, Lifetime: lifetime, Backdate: signOpts.Backdate, }) @@ -367,9 +368,10 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error // CAS operation, note that SoftCAS (default) is a noop. // The revoke happens when this is stored in the db. _, err = a.x509CAService.RevokeCertificate(&casapi.RevokeCertificateRequest{ - Certificate: revokedCert, - Reason: rci.Reason, - ReasonCode: rci.ReasonCode, + Certificate: revokedCert, + SerialNumber: rci.Serial, + Reason: rci.Reason, + ReasonCode: rci.ReasonCode, }) if err != nil { 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{ Template: certTpl, + CSR: cr, Lifetime: 24 * time.Hour, Backdate: 1 * time.Minute, }) diff --git a/cas/apiv1/options.go b/cas/apiv1/options.go index 46efae3b..4e0b67bc 100644 --- a/cas/apiv1/options.go +++ b/cas/apiv1/options.go @@ -14,17 +14,29 @@ type Options struct { // The type of the CAS to use. Type string `json:"type"` - // Path to the credentials file used in CloudCAS - CredentialsFile string `json:"credentialsFile"` + // CertificateAuthority reference: + // In StepCAS the values 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 - // `projects/*/locations/*/certificateAuthorities/*`. - CertificateAuthority string `json:"certificateAuthority"` + // CertificateAuthorityFingerprint is the root fingerprint used to + // authenticate the connection to the CA when using StepCAS. + 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. - // They are configured in ca.json crt and key properties. - CertificateChain []*x509.Certificate - Signer crypto.Signer `json:"-"` + // CertificateIssuer contains the configuration used in StepCAS. + CertificateIssuer *CertificateIssuer `json:"certificateIssuer,omitempty"` + + // 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 // used to skip some validations when initializing a CertificateAuthority. @@ -39,6 +51,15 @@ type Options struct { 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"` +} + // Validate checks the fields in Options. func (o *Options) Validate() error { var typ Type diff --git a/cas/apiv1/requests.go b/cas/apiv1/requests.go index 68f1cb7b..bcda66a3 100644 --- a/cas/apiv1/requests.go +++ b/cas/apiv1/requests.go @@ -53,6 +53,7 @@ const ( // CreateCertificateRequest is the request used to sign a new certificate. type CreateCertificateRequest struct { Template *x509.Certificate + CSR *x509.CertificateRequest Lifetime time.Duration Backdate time.Duration RequestID string @@ -67,6 +68,7 @@ type CreateCertificateResponse struct { // RenewCertificateRequest is the request used to re-sign a certificate. type RenewCertificateRequest struct { Template *x509.Certificate + CSR *x509.CertificateRequest Lifetime time.Duration Backdate time.Duration RequestID string @@ -80,10 +82,11 @@ type RenewCertificateResponse struct { // RevokeCertificateRequest is the request used to revoke a certificate. type RevokeCertificateRequest struct { - Certificate *x509.Certificate - Reason string - ReasonCode int - RequestID string + Certificate *x509.Certificate + SerialNumber string + Reason string + ReasonCode int + RequestID string } // RevokeCertificateResponse is the response to a revoke certificate request. diff --git a/cas/apiv1/services.go b/cas/apiv1/services.go index 58a8f139..40122601 100644 --- a/cas/apiv1/services.go +++ b/cas/apiv1/services.go @@ -35,6 +35,8 @@ const ( SoftCAS = "softcas" // CloudCAS is a CertificateAuthorityService using Google Cloud CAS. 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 diff --git a/cas/stepcas/issuer.go b/cas/stepcas/issuer.go new file mode 100644 index 00000000..5b9b0ce9 --- /dev/null +++ b/cas/stepcas/issuer.go @@ -0,0 +1,40 @@ +package stepcas + +import ( + "strings" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/cas/apiv1" +) + +// 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) + 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 + } +} diff --git a/cas/stepcas/stepcas.go b/cas/stepcas/stepcas.go new file mode 100644 index 00000000..5f2acc9f --- /dev/null +++ b/cas/stepcas/stepcas.go @@ -0,0 +1,189 @@ +package stepcas + +import ( + "context" + "crypto/x509" + "net/url" + + "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 { + x5c *x5cIssuer + 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") + } + if err := validateCertificateIssuer(opts.CertificateIssuer); err != nil { + return nil, err + } + + // Create client. + client, err := ca.NewClient(opts.CertificateAuthority, ca.WithRootSHA256(opts.CertificateAuthorityFingerprint)) + if err != nil { + return nil, err + } + + // X5C is the only one supported at the moment. + x5c, err := newX5CIssuer(caURL, opts.CertificateIssuer) + if err != nil { + return nil, err + } + + return &StepCAS{ + x5c: x5c, + client: client, + fingerprint: opts.CertificateAuthorityFingerprint, + }, nil +} + +func (s *StepCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1.CreateCertificateResponse, error) { + switch { + case req.CSR == nil: + return nil, errors.New("createCertificateRequest `template` cannot be nil") + case req.Lifetime == 0: + return nil, errors.New("createCertificateRequest `lifetime` cannot be 0") + } + + token, err := s.signToken(req.CSR.Subject.CommonName, req.CSR.DNSNames) + if err != nil { + return nil, err + } + + resp, err := s.client.Sign(&api.SignRequest{ + CsrPEM: api.CertificateRequest{CertificateRequest: req.CSR}, + OTT: token, + }) + if err != nil { + return nil, err + } + + var chain []*x509.Certificate + cert := resp.CertChainPEM[0].Certificate + for _, c := range resp.CertChainPEM[1:] { + chain = append(chain, c.Certificate) + } + + return &apiv1.CreateCertificateResponse{ + Certificate: cert, + CertificateChain: chain, + }, nil +} + +func (s *StepCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.RenewCertificateResponse, error) { + switch { + case req.CSR == nil: + return nil, errors.New("createCertificateRequest `template` cannot be nil") + case req.Lifetime == 0: + return nil, errors.New("createCertificateRequest `lifetime` cannot be 0") + } + + token, err := s.signToken(req.CSR.Subject.CommonName, req.CSR.DNSNames) + if err != nil { + return nil, err + } + + resp, err := s.client.Sign(&api.SignRequest{ + CsrPEM: api.CertificateRequest{CertificateRequest: req.CSR}, + OTT: token, + }) + if err != nil { + return nil, err + } + + var chain []*x509.Certificate + cert := resp.CertChainPEM[0].Certificate + for _, c := range resp.CertChainPEM[1:] { + chain = append(chain, c.Certificate) + } + + return &apiv1.RenewCertificateResponse{ + Certificate: cert, + CertificateChain: chain, + }, nil +} + +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.revokeToken(serialNumber) + if err != nil { + return nil, err + } + + _, err = s.client.Revoke(&api.RevokeRequest{ + Serial: serialNumber, + ReasonCode: req.ReasonCode, + Reason: req.Reason, + OTT: token, + }, 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) signToken(subject string, sans []string) (string, error) { + if s.x5c != nil { + return s.x5c.SignToken(subject, sans) + } + + return "", errors.New("stepCAS does not have any provisioner configured") +} + +func (s *StepCAS) revokeToken(subject string) (string, error) { + if s.x5c != nil { + return s.x5c.RevokeToken(subject) + } + + return "", errors.New("stepCAS does not have any provisioner configured") +} diff --git a/cas/stepcas/x5c_issuer.go b/cas/stepcas/x5c_issuer.go new file mode 100644 index 00000000..f7e77a3f --- /dev/null +++ b/cas/stepcas/x5c_issuer.go @@ -0,0 +1,153 @@ +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 + +type x5cIssuer struct { + caURL *url.URL + certFile string + keyFile string + issuer 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) + if err != nil { + return nil, err + } + + return &x5cIssuer{ + caURL: caURL, + certFile: cfg.Certificate, + keyFile: cfg.Key, + issuer: cfg.Provisioner, + }, 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) createToken(aud, sub string, sans []string) (string, error) { + signer, err := newX5CSigner(i.certFile, i.keyFile) + 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 := time.Now() + 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 newX5CSigner(certFile, keyFile string) (jose.Signer, error) { + key, err := pemutil.Read(keyFile) + if err != nil { + return nil, err + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, errors.New("key is not a crypto.Signer") + } + kid, err := jose.Thumbprint(&jose.JSONWebKey{Key: signer.Public()}) + if err != nil { + return nil, err + } + certs, err := jose.ValidateX5C(certFile, key) + 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.(type) { + case *ecdsa.PrivateKey: + 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.PrivateKey: + alg = jose.EdDSA + case *rsa.PrivateKey: + 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 +} diff --git a/cmd/step-ca/main.go b/cmd/step-ca/main.go index dad9cdbe..a243022a 100644 --- a/cmd/step-ca/main.go +++ b/cmd/step-ca/main.go @@ -37,6 +37,7 @@ import ( // Enabled cas interfaces. _ "github.com/smallstep/certificates/cas/cloudcas" _ "github.com/smallstep/certificates/cas/softcas" + _ "github.com/smallstep/certificates/cas/stepcas" ) // commit and buildTime are filled in during build by the Makefile