Add initial implementation of StepCAS.
StepCAS allows to configure step-ca as an RA using another step-ca as the main CA.pull/510/head
parent
3b9eed003d
commit
a6115e29c2
@ -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
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue