forked from TrueCloudLab/certificates
260 lines
8.2 KiB
Go
260 lines
8.2 KiB
Go
package cloudcas
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/pem"
|
|
"time"
|
|
|
|
privateca "cloud.google.com/go/security/privateca/apiv1beta1"
|
|
"github.com/google/uuid"
|
|
gax "github.com/googleapis/gax-go/v2"
|
|
"github.com/pkg/errors"
|
|
"github.com/smallstep/certificates/cas/apiv1"
|
|
"google.golang.org/api/option"
|
|
pb "google.golang.org/genproto/googleapis/cloud/security/privateca/v1beta1"
|
|
durationpb "google.golang.org/protobuf/types/known/durationpb"
|
|
)
|
|
|
|
func init() {
|
|
apiv1.Register(apiv1.CloudCAS, func(ctx context.Context, opts apiv1.Options) (apiv1.CertificateAuthorityService, error) {
|
|
return New(ctx, opts)
|
|
})
|
|
}
|
|
|
|
// CertificateAuthorityClient is the interface implemented by the Google CAS
|
|
// client.
|
|
type CertificateAuthorityClient interface {
|
|
CreateCertificate(ctx context.Context, req *pb.CreateCertificateRequest, opts ...gax.CallOption) (*pb.Certificate, error)
|
|
RevokeCertificate(ctx context.Context, req *pb.RevokeCertificateRequest, opts ...gax.CallOption) (*pb.Certificate, error)
|
|
}
|
|
|
|
var (
|
|
stepOIDRoot = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 37476, 9000, 64}
|
|
stepOIDCertificateAuthority = append(asn1.ObjectIdentifier(nil), append(stepOIDRoot, 2)...)
|
|
)
|
|
|
|
// recocationCodeMap maps revocation reason codes from RFC 5280, to Google CAS
|
|
// revocation reasons. Revocation reason 7 is not used, and revocation reason 8
|
|
// (removeFromCRL) is not supported by Google CAS.
|
|
var revocationCodeMap = map[int]pb.RevocationReason{
|
|
0: pb.RevocationReason_REVOCATION_REASON_UNSPECIFIED,
|
|
1: pb.RevocationReason_KEY_COMPROMISE,
|
|
2: pb.RevocationReason_CERTIFICATE_AUTHORITY_COMPROMISE,
|
|
3: pb.RevocationReason_AFFILIATION_CHANGED,
|
|
4: pb.RevocationReason_SUPERSEDED,
|
|
5: pb.RevocationReason_CESSATION_OF_OPERATION,
|
|
6: pb.RevocationReason_CERTIFICATE_HOLD,
|
|
9: pb.RevocationReason_PRIVILEGE_WITHDRAWN,
|
|
10: pb.RevocationReason_ATTRIBUTE_AUTHORITY_COMPROMISE,
|
|
}
|
|
|
|
// CloudCAS implements a Certificate Authority Service using Google Cloud CAS.
|
|
type CloudCAS struct {
|
|
client CertificateAuthorityClient
|
|
certificateAuthority string
|
|
}
|
|
|
|
// newCertificateAuthorityClient creates the certificate authority client. This
|
|
// function is used for testing purposes.
|
|
var newCertificateAuthorityClient = func(ctx context.Context, credentialsFile string) (CertificateAuthorityClient, error) {
|
|
var cloudOpts []option.ClientOption
|
|
if credentialsFile != "" {
|
|
cloudOpts = append(cloudOpts, option.WithCredentialsFile(credentialsFile))
|
|
}
|
|
client, err := privateca.NewCertificateAuthorityClient(ctx, cloudOpts...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "error creating client")
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// New creates a new CertificateAuthorityService implementation using Google
|
|
// Cloud CAS.
|
|
func New(ctx context.Context, opts apiv1.Options) (*CloudCAS, error) {
|
|
if opts.Certificateauthority == "" {
|
|
return nil, errors.New("cloudCAS 'certificateAuthority' cannot be empty")
|
|
}
|
|
|
|
client, err := newCertificateAuthorityClient(ctx, opts.CredentialsFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &CloudCAS{
|
|
client: client,
|
|
certificateAuthority: opts.Certificateauthority,
|
|
}, nil
|
|
}
|
|
|
|
// CreateCertificate signs a new certificate using Google Cloud CAS.
|
|
func (c *CloudCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1.CreateCertificateResponse, error) {
|
|
switch {
|
|
case req.Template == nil:
|
|
return nil, errors.New("createCertificateRequest `template` cannot be nil")
|
|
case req.Lifetime == 0:
|
|
return nil, errors.New("createCertificateRequest `lifetime` cannot be 0")
|
|
}
|
|
|
|
cert, chain, err := c.createCertificate(req.Template, req.Lifetime, req.RequestID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &apiv1.CreateCertificateResponse{
|
|
Certificate: cert,
|
|
CertificateChain: chain,
|
|
}, nil
|
|
}
|
|
|
|
// RenewCertificate renews the given certificate using Google Cloud CAS.
|
|
// Google's CAS does not support the renew operation, so this method uses
|
|
// CreateCertificate.
|
|
func (c *CloudCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.RenewCertificateResponse, error) {
|
|
switch {
|
|
case req.Template == nil:
|
|
return nil, errors.New("renewCertificateRequest `template` cannot be nil")
|
|
case req.Lifetime == 0:
|
|
return nil, errors.New("renewCertificateRequest `lifetime` cannot be 0")
|
|
}
|
|
|
|
cert, chain, err := c.createCertificate(req.Template, req.Lifetime, req.RequestID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &apiv1.RenewCertificateResponse{
|
|
Certificate: cert,
|
|
CertificateChain: chain,
|
|
}, nil
|
|
}
|
|
|
|
// RevokeCertificate a certificate using Google Cloud CAS.
|
|
func (c *CloudCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1.RevokeCertificateResponse, error) {
|
|
reason, ok := revocationCodeMap[req.ReasonCode]
|
|
switch {
|
|
case !ok:
|
|
return nil, errors.Errorf("revokeCertificate 'reasonCode=%d' is invalid or not supported", req.ReasonCode)
|
|
case req.Certificate == nil:
|
|
return nil, errors.New("revokeCertificateRequest `certificate` cannot be nil")
|
|
}
|
|
|
|
ext, ok := apiv1.FindCertificateAuthorityExtension(req.Certificate)
|
|
if !ok {
|
|
return nil, errors.New("error revoking certificate: certificate authority extension was not found")
|
|
}
|
|
|
|
var cae apiv1.CertificateAuthorityExtension
|
|
if _, err := asn1.Unmarshal(ext.Value, &cae); err != nil {
|
|
return nil, errors.Wrap(err, "error unmarshaling certificate authority extension")
|
|
}
|
|
|
|
ctx, cancel := defaultContext()
|
|
defer cancel()
|
|
|
|
certpb, err := c.client.RevokeCertificate(ctx, &pb.RevokeCertificateRequest{
|
|
Name: c.certificateAuthority + "/certificates/" + cae.CertificateID,
|
|
Reason: reason,
|
|
RequestId: req.RequestID,
|
|
})
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "cloudCAS RevokeCertificate failed")
|
|
}
|
|
|
|
cert, chain, err := getCertificateAndChain(certpb)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &apiv1.RevokeCertificateResponse{
|
|
Certificate: cert,
|
|
CertificateChain: chain,
|
|
}, nil
|
|
}
|
|
|
|
func (c *CloudCAS) createCertificate(tpl *x509.Certificate, lifetime time.Duration, requestID string) (*x509.Certificate, []*x509.Certificate, error) {
|
|
// Removes the CAS extension if it exists.
|
|
apiv1.RemoveCertificateAuthorityExtension(tpl)
|
|
|
|
// Create new CAS extension with the certificate id.
|
|
id, err := createCertificateID()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
casExtension, err := apiv1.CreateCertificateAuthorityExtension(apiv1.CloudCAS, id)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
tpl.ExtraExtensions = append(tpl.ExtraExtensions, casExtension)
|
|
|
|
// Create and submit certificate
|
|
certConfig, err := createCertificateConfig(tpl)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
ctx, cancel := defaultContext()
|
|
defer cancel()
|
|
|
|
cert, err := c.client.CreateCertificate(ctx, &pb.CreateCertificateRequest{
|
|
Parent: c.certificateAuthority,
|
|
CertificateId: id,
|
|
Certificate: &pb.Certificate{
|
|
CertificateConfig: certConfig,
|
|
Lifetime: durationpb.New(lifetime),
|
|
Labels: map[string]string{},
|
|
},
|
|
RequestId: requestID,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "cloudCAS CreateCertificate failed")
|
|
}
|
|
|
|
// Return certificate and certificate chain
|
|
return getCertificateAndChain(cert)
|
|
}
|
|
|
|
func defaultContext() (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), 15*time.Second)
|
|
}
|
|
|
|
func createCertificateID() (string, error) {
|
|
id, err := uuid.NewRandomFromReader(rand.Reader)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "error creating certificate id")
|
|
}
|
|
return id.String(), nil
|
|
}
|
|
|
|
func parseCertificate(pemCert string) (*x509.Certificate, error) {
|
|
block, _ := pem.Decode([]byte(pemCert))
|
|
if block == nil {
|
|
return nil, errors.New("error decoding certificate: not a valid PEM encoded block")
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "error parsing certificate")
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
func getCertificateAndChain(certpb *pb.Certificate) (*x509.Certificate, []*x509.Certificate, error) {
|
|
cert, err := parseCertificate(certpb.PemCertificate)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
pemChain := certpb.PemCertificateChain[:len(certpb.PemCertificateChain)-1]
|
|
chain := make([]*x509.Certificate, len(pemChain))
|
|
for i := range pemChain {
|
|
chain[i], err = parseCertificate(pemChain[i])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
return cert, chain, nil
|
|
|
|
}
|