diff --git a/cas/apiv1/extension.go b/cas/apiv1/extension.go new file mode 100644 index 00000000..66da15a0 --- /dev/null +++ b/cas/apiv1/extension.go @@ -0,0 +1,57 @@ +package apiv1 + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + + "github.com/pkg/errors" +) + +// CertificateAuthorityExtension is type used to encode the certificate +// authority extension. +type CertificateAuthorityExtension struct { + Type string + CertificateID string `asn1:"optional,omitempty"` + KeyValuePairs []string `asn1:"optional,omitempty"` +} + +// CreateCertificateAuthorityExtension returns a X.509 extension that shows the +// CAS type, id and a list of optional key value pairs. +func CreateCertificateAuthorityExtension(typ Type, certificateID string, keyValuePairs ...string) (pkix.Extension, error) { + b, err := asn1.Marshal(CertificateAuthorityExtension{ + Type: typ.String(), + CertificateID: certificateID, + KeyValuePairs: keyValuePairs, + }) + if err != nil { + return pkix.Extension{}, errors.Wrapf(err, "error marshaling certificate id extension") + } + return pkix.Extension{ + Id: oidStepCertificateAuthority, + Critical: false, + Value: b, + }, nil +} + +// FindCertificateAuthorityExtension returns the certificate authority extension +// from a signed certificate. +func FindCertificateAuthorityExtension(cert *x509.Certificate) (pkix.Extension, bool) { + for _, ext := range cert.Extensions { + if ext.Id.Equal(oidStepCertificateAuthority) { + return ext, true + } + } + return pkix.Extension{}, false +} + +// RemoveCertificateAuthorityExtension removes the certificate authority +// extension from a certificate template. +func RemoveCertificateAuthorityExtension(cert *x509.Certificate) { + for i, ext := range cert.ExtraExtensions { + if ext.Id.Equal(oidStepCertificateAuthority) { + cert.ExtraExtensions = append(cert.ExtraExtensions[:i], cert.ExtraExtensions[i+1:]...) + return + } + } +} diff --git a/cas/apiv1/options.go b/cas/apiv1/options.go index b3da527c..7118472e 100644 --- a/cas/apiv1/options.go +++ b/cas/apiv1/options.go @@ -30,3 +30,11 @@ func (o *Options) Validate() error { return nil } + +// HasType returns if the options have the given type. +func (o *Options) HasType(t Type) bool { + if o == nil { + return SoftCAS == t.String() + } + return Type(o.Type).String() == t.String() +} diff --git a/cas/apiv1/requests.go b/cas/apiv1/requests.go index b3d12a08..bc6770be 100644 --- a/cas/apiv1/requests.go +++ b/cas/apiv1/requests.go @@ -7,11 +7,11 @@ import ( ) type CreateCertificateRequest struct { - Template *x509.Certificate - Issuer *x509.Certificate - Signer crypto.Signer - Lifetime time.Duration - + Template *x509.Certificate + Issuer *x509.Certificate + Signer crypto.Signer + Lifetime time.Duration + Backdate time.Duration RequestID string } type CreateCertificateResponse struct { @@ -19,8 +19,24 @@ type CreateCertificateResponse struct { CertificateChain []*x509.Certificate } -type RenewCertificateRequest struct{} -type RenewCertificateResponse struct{} +type RenewCertificateRequest struct { + Template *x509.Certificate + Issuer *x509.Certificate + Signer crypto.Signer + Lifetime time.Duration + Backdate time.Duration + RequestID string +} +type RenewCertificateResponse struct { + Certificate *x509.Certificate + CertificateChain []*x509.Certificate +} -type RevokeCertificateRequest struct{} -type RevokeCertificateResponse struct{} +// RevokeCertificateRequest is the request used to revoke a certificate. +type RevokeCertificateRequest struct { + Certificate *x509.Certificate +} +type RevokeCertificateResponse struct { + Certificate *x509.Certificate + CertificateChain []*x509.Certificate +} diff --git a/cas/apiv1/services.go b/cas/apiv1/services.go index 08660d3a..f3bc6b16 100644 --- a/cas/apiv1/services.go +++ b/cas/apiv1/services.go @@ -1,5 +1,15 @@ package apiv1 +import ( + "encoding/asn1" + "strings" +) + +var ( + oidStepRoot = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 37476, 9000, 64} + oidStepCertificateAuthority = append(asn1.ObjectIdentifier(nil), append(oidStepRoot, 2)...) +) + // CertificateAuthorityService is the interface implemented to support external // certificate authorities. type CertificateAuthorityService interface { @@ -11,12 +21,24 @@ type CertificateAuthorityService interface { // Type represents the KMS type used. type Type string -// const ( // DefaultCAS is a CertificateAuthorityService using software. DefaultCAS = "" // SoftCAS is a CertificateAuthorityService using software. - SoftCAS = "softcas" + SoftCAS = "SoftCAS" // CloudCAS is a CertificateAuthorityService using Google Cloud CAS. - CloudCAS = "cloudcas" + CloudCAS = "CloudCAS" ) + +// String returns the given type as a string. All the letters will be lowercase. +func (t Type) String() string { + if t == "" { + return SoftCAS + } + for _, s := range []string{SoftCAS, CloudCAS} { + if strings.EqualFold(s, string(t)) { + return s + } + } + return string(t) +} diff --git a/cas/cloudcas/certificate.go b/cas/cloudcas/certificate.go index 5c7f5bd0..31da2c7d 100644 --- a/cas/cloudcas/certificate.go +++ b/cas/cloudcas/certificate.go @@ -63,7 +63,6 @@ func createCertificateConfig(tpl *x509.Certificate) (*pb.Certificate_Config, err } func createPublicKey(key crypto.PublicKey) (*pb.PublicKey, error) { - pk := new(pb.PublicKey) switch key := key.(type) { case *ecdsa.PublicKey: asn1Bytes, err := x509.MarshalPKIXPublicKey(key) @@ -88,8 +87,6 @@ func createPublicKey(key crypto.PublicKey) (*pb.PublicKey, error) { default: return nil, errors.Errorf("unsupported public key type: %T", key) } - - return pk, nil } func createSubject(cert *x509.Certificate) *pb.Subject { @@ -138,12 +135,43 @@ func createSubjectAlternativeNames(cert *x509.Certificate) *pb.SubjectAltNames { // Add extra SANs coming from the extensions if ext, ok := findExtraExtension(cert, oidExtensionSubjectAltName); ok { - ret.CustomSans = []*pb.X509Extension{{ - ObjectId: createObjectID(ext.Id), - Critical: ext.Critical, - Value: ext.Value, - }} + var rawValues []asn1.RawValue + if _, err := asn1.Unmarshal(ext.Value, &rawValues); err == nil { + var newValues []asn1.RawValue + for _, v := range rawValues { + switch v.Tag { + case nameTypeDNS: + if len(ret.DnsNames) == 0 { + newValues = append(newValues, v) + } + case nameTypeEmail: + if len(ret.EmailAddresses) == 0 { + newValues = append(newValues, v) + } + case nameTypeIP: + if len(ret.IpAddresses) == 0 { + newValues = append(newValues, v) + } + case nameTypeURI: + if len(ret.Uris) == 0 { + newValues = append(newValues, v) + } + default: + newValues = append(newValues, v) + } + } + if len(newValues) > 0 { + if b, err := asn1.Marshal(newValues); err == nil { + ret.CustomSans = []*pb.X509Extension{{ + ObjectId: createObjectID(ext.Id), + Critical: ext.Critical, + Value: b, + }} + } + } + } } + return ret } @@ -210,12 +238,14 @@ func createReusableConfig(cert *x509.Certificate) *pb.ReusableConfigWrapper { } } - extraExtensions := make([]*pb.X509Extension, len(cert.ExtraExtensions)) - for i, ext := range cert.ExtraExtensions { - extraExtensions[i] = &pb.X509Extension{ - ObjectId: createObjectID(ext.Id), - Critical: ext.Critical, - Value: ext.Value, + var extraExtensions []*pb.X509Extension + for _, ext := range cert.ExtraExtensions { + if !ext.Id.Equal(oidExtensionSubjectAltName) { + extraExtensions = append(extraExtensions, &pb.X509Extension{ + ObjectId: createObjectID(ext.Id), + Critical: ext.Critical, + Value: ext.Value, + }) } } diff --git a/cas/cloudcas/cloudcas.go b/cas/cloudcas/cloudcas.go index 6dcd0c2f..52608658 100644 --- a/cas/cloudcas/cloudcas.go +++ b/cas/cloudcas/cloudcas.go @@ -3,14 +3,18 @@ package cloudcas import ( "context" "crypto/x509" + "encoding/asn1" + "encoding/json" "encoding/pem" "fmt" "time" privateca "cloud.google.com/go/security/privateca/apiv1beta1" + "github.com/google/uuid" "github.com/pkg/errors" "github.com/smallstep/certificates/cas/apiv1" - privatecapb "google.golang.org/genproto/googleapis/cloud/security/privateca/v1beta1" + "google.golang.org/api/option" + pb "google.golang.org/genproto/googleapis/cloud/security/privateca/v1beta1" durationpb "google.golang.org/protobuf/types/known/durationpb" ) @@ -20,6 +24,16 @@ func init() { }) } +func debug(v interface{}) { + b, _ := json.MarshalIndent(v, "", " ") + fmt.Println(string(b)) +} + +var ( + stepOIDRoot = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 37476, 9000, 64} + stepOIDCertificateAuthority = append(asn1.ObjectIdentifier(nil), append(stepOIDRoot, 2)...) +) + // CloudCAS implements a Certificate Authority Service using Google Cloud CAS. type CloudCAS struct { client *privateca.CertificateAuthorityClient @@ -31,14 +45,19 @@ type caClient interface{} // New creates a new CertificateAuthorityService implementation using Google // Cloud CAS. func New(ctx context.Context, opts apiv1.Options) (*CloudCAS, error) { - client, err := privateca.NewCertificateAuthorityClient(ctx) + var cloudOpts []option.ClientOption + if opts.CredentialsFile != "" { + cloudOpts = append(cloudOpts, option.WithCredentialsFile(opts.CredentialsFile)) + } + + client, err := privateca.NewCertificateAuthorityClient(ctx, cloudOpts...) if err != nil { return nil, errors.Wrap(err, "error creating client") } return &CloudCAS{ client: client, - certificateAuthority: "", + certificateAuthority: "projects/smallstep-cas-test/locations/us-west1/certificateAuthorities/Smallstep-Test-Intermediate-CA", }, nil } @@ -51,52 +70,131 @@ func (c *CloudCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv return nil, errors.New("createCertificateRequest `lifetime` cannot be 0") } - certConfig, err := createCertificateConfig(req.Template) + 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("renewCertificate `template` cannot be nil") + case req.Lifetime == 0: + return nil, errors.New("renewCertificate `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) { + if req.Certificate == nil { + return nil, errors.New("revokeCertificate `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, &ext); err != nil { + return nil, errors.Wrap(err, "error unmarshaling certificate authority extension") + } + ctx, cancel := defaultContext() defer cancel() - certpb, err := c.client.CreateCertificate(ctx, &privatecapb.CreateCertificateRequest{ - Parent: c.certificateAuthority, - CertificateId: "", - Certificate: &privatecapb.Certificate{ - CertificateConfig: certConfig, - Lifetime: durationpb.New(req.Lifetime), - Labels: map[string]string{}, - }, - RequestId: req.RequestID, + certpb, err := c.client.RevokeCertificate(ctx, &pb.RevokeCertificateRequest{ + Name: c.certificateAuthority + "/certificates/" + cae.CertificateID, + Reason: pb.RevocationReason_REVOCATION_REASON_UNSPECIFIED, }) if err != nil { - return nil, errors.Wrap(err, "cloudCAS CreateCertificate failed") + return nil, errors.Wrap(err, "cloudCAS RevokeCertificate failed") } - cert, err := parseCertificate(certpb.PemCertificate) + cert, chain, err := getCertificateAndChain(certpb) if err != nil { return nil, err } - return &apiv1.CreateCertificateResponse{ - Certificate: cert, + return &apiv1.RevokeCertificateResponse{ + Certificate: cert, + CertificateChain: chain, }, nil } -func (c *CloudCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.RenewCertificateResponse, error) { - return nil, fmt.Errorf("not implemented") -} +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) -// RevokeCertificate a certificate using Google Cloud CAS. -func (c *CloudCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1.RevokeCertificateResponse, error) { + // 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 nil, fmt.Errorf("not implemented") + // 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.NewRandom() + 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 { @@ -108,3 +206,22 @@ func parseCertificate(pemCert string) (*x509.Certificate, error) { } 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 + +} diff --git a/go.mod b/go.mod index 4b7fc282..f489d6d7 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.14 require ( cloud.google.com/go v0.65.1-0.20200904011802-3c2db50b5678 + github.com/Masterminds/sprig/v3 v3.1.0 github.com/aws/aws-sdk-go v1.30.29 github.com/go-chi/chi v4.0.2+incompatible github.com/go-piv/piv-go v1.5.0 + github.com/google/uuid v1.1.2 github.com/googleapis/gax-go/v2 v2.0.5 github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect @@ -24,11 +26,16 @@ require ( golang.org/x/net v0.0.0-20200822124328-c89045814202 google.golang.org/api v0.31.0 google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d - google.golang.org/grpc v1.31.1 + google.golang.org/grpc v1.32.0 google.golang.org/protobuf v1.25.0 gopkg.in/square/go-jose.v2 v2.5.1 + // cloud.google.com/go/security/privateca/apiv1alpha1 v0.0.0 + // google.golang.org/genproto/googleapis/cloud/security/privateca/v1alpha1 v0.0.0 ) // replace github.com/smallstep/cli => ../cli // replace github.com/smallstep/nosql => ../nosql // replace go.step.sm/crypto => ../crypto + +// replace cloud.google.com/go/security/privateca/apiv1alpha1 => ./pkg/cloud.google.com/go/security/privateca/apiv1alpha1 +// replace google.golang.org/genproto/googleapis/cloud/security/privateca/v1alpha1 => ./pkg/google.golang.org/genproto/googleapis/cloud/security/privateca/v1alpha1 diff --git a/go.sum b/go.sum index d07b0546..60b99c45 100644 --- a/go.sum +++ b/go.sum @@ -298,6 +298,8 @@ github.com/google/trillian-examples v0.0.0-20190603134952-4e75ba15216c/go.mod h1 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= @@ -911,6 +913,7 @@ google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200831141814-d751682dd103/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d h1:92D1fum1bJLKSdr11OJ+54YeCMCGYIygTA7R/YZxH5M= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200910191746-8ad3c7ee2cd1 h1:Oi/dETbxPPblvoi4hgkzJun62A4dwuBsTM0UcZYpN3U= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -929,6 +932,8 @@ google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1 h1:SfXqXS5hkufcdZ/mHtYCh53P2b+92WQq/DZcKLgsFRs= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.32.0 h1:zWTV+LMdc3kaiJMSTOFz2UgSBgx8RNQoTGiZu3fR9S0= +google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=