certificates/cas/cloudcas/cloudcas.go
2021-10-08 14:59:57 -04:00

734 lines
23 KiB
Go

package cloudcas
import (
"context"
"crypto/rand"
"crypto/x509"
"encoding/asn1"
"encoding/pem"
"regexp"
"strings"
"time"
privateca "cloud.google.com/go/security/privateca/apiv1"
"github.com/google/uuid"
gax "github.com/googleapis/gax-go/v2"
"github.com/pkg/errors"
"github.com/smallstep/certificates/cas/apiv1"
"go.step.sm/crypto/x509util"
"google.golang.org/api/option"
pb "google.golang.org/genproto/googleapis/cloud/security/privateca/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
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)
})
}
var now = time.Now
// The actual regular expression that matches a certificate authority is:
// ^projects/[a-z][a-z0-9-]{4,28}[a-z0-9]/locations/[a-z0-9-]+/caPools/[a-zA-Z0-9-_]+/certificateAuthorities/[a-zA-Z0-9-_]+$
// But we will allow a more flexible one to fail if this changes.
var caRegexp = regexp.MustCompile("^projects/[^/]+/locations/[^/]+/caPools/[^/]+/certificateAuthorities/[^/]+$")
// 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)
GetCertificateAuthority(ctx context.Context, req *pb.GetCertificateAuthorityRequest, opts ...gax.CallOption) (*pb.CertificateAuthority, error)
CreateCertificateAuthority(ctx context.Context, req *pb.CreateCertificateAuthorityRequest, opts ...gax.CallOption) (*privateca.CreateCertificateAuthorityOperation, error)
FetchCertificateAuthorityCsr(ctx context.Context, req *pb.FetchCertificateAuthorityCsrRequest, opts ...gax.CallOption) (*pb.FetchCertificateAuthorityCsrResponse, error)
ActivateCertificateAuthority(ctx context.Context, req *pb.ActivateCertificateAuthorityRequest, opts ...gax.CallOption) (*privateca.ActivateCertificateAuthorityOperation, error)
EnableCertificateAuthority(ctx context.Context, req *pb.EnableCertificateAuthorityRequest, opts ...gax.CallOption) (*privateca.EnableCertificateAuthorityOperation, error)
GetCaPool(ctx context.Context, req *pb.GetCaPoolRequest, opts ...gax.CallOption) (*pb.CaPool, error)
CreateCaPool(ctx context.Context, req *pb.CreateCaPoolRequest, opts ...gax.CallOption) (*privateca.CreateCaPoolOperation, error)
}
// 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,
}
// caPoolTierMap contains the map between apiv1.Options.Tier and the pb type.
var caPoolTierMap = map[string]pb.CaPool_Tier{
"": pb.CaPool_DEVOPS,
"ENTERPRISE": pb.CaPool_ENTERPRISE,
"DEVOPS": pb.CaPool_DEVOPS,
}
// CloudCAS implements a Certificate Authority Service using Google Cloud CAS.
type CloudCAS struct {
client CertificateAuthorityClient
certificateAuthority string
project string
location string
caPool string
caPoolTier pb.CaPool_Tier
gcsBucket 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) {
var caPoolTier pb.CaPool_Tier
if opts.IsCreator && opts.CertificateAuthority == "" {
switch {
case opts.Project == "":
return nil, errors.New("cloudCAS 'project' cannot be empty")
case opts.Location == "":
return nil, errors.New("cloudCAS 'location' cannot be empty")
case opts.CaPool == "":
return nil, errors.New("cloudCAS 'caPool' cannot be empty")
}
var ok bool
if caPoolTier, ok = caPoolTierMap[strings.ToUpper(opts.CaPoolTier)]; !ok {
return nil, errors.New("cloudCAS 'caPoolTier' is not a valid tier")
}
} else {
if opts.CertificateAuthority == "" {
return nil, errors.New("cloudCAS 'certificateAuthority' cannot be empty")
}
if !caRegexp.MatchString(opts.CertificateAuthority) {
return nil, errors.New("cloudCAS 'certificateAuthority' is not valid certificate authority resource")
}
// Extract project and location from CertificateAuthority
if parts := strings.Split(opts.CertificateAuthority, "/"); len(parts) == 8 {
if opts.Project == "" {
opts.Project = parts[1]
}
if opts.Location == "" {
opts.Location = parts[3]
}
if opts.CaPool == "" {
opts.CaPool = parts[5]
}
}
}
client, err := newCertificateAuthorityClient(ctx, opts.CredentialsFile)
if err != nil {
return nil, err
}
// GCSBucket is the the bucket name or empty for a managed bucket.
return &CloudCAS{
client: client,
certificateAuthority: opts.CertificateAuthority,
project: opts.Project,
location: opts.Location,
caPool: opts.CaPool,
gcsBucket: opts.GCSBucket,
caPoolTier: caPoolTier,
}, nil
}
// GetCertificateAuthority returns the root certificate for the given
// certificate authority. It implements apiv1.CertificateAuthorityGetter
// interface.
func (c *CloudCAS) GetCertificateAuthority(req *apiv1.GetCertificateAuthorityRequest) (*apiv1.GetCertificateAuthorityResponse, error) {
name := req.Name
if name == "" {
name = c.certificateAuthority
}
ctx, cancel := defaultContext()
defer cancel()
resp, err := c.client.GetCertificateAuthority(ctx, &pb.GetCertificateAuthorityRequest{
Name: name,
})
if err != nil {
return nil, errors.Wrap(err, "cloudCAS GetCertificateAuthority failed")
}
if len(resp.PemCaCertificates) == 0 {
return nil, errors.New("cloudCAS GetCertificateAuthority: PemCACertificate should not be empty")
}
// Last certificate in the chain is the root.
root, err := parseCertificate(resp.PemCaCertificates[len(resp.PemCaCertificates)-1])
if err != nil {
return nil, err
}
return &apiv1.GetCertificateAuthorityResponse{
RootCertificate: root,
}, 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 revokes 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
}
// CreateCertificateAuthority creates a new root or intermediate certificate
// using Google Cloud CAS.
func (c *CloudCAS) CreateCertificateAuthority(req *apiv1.CreateCertificateAuthorityRequest) (*apiv1.CreateCertificateAuthorityResponse, error) {
switch {
case c.project == "":
return nil, errors.New("cloudCAS `project` cannot be empty")
case c.location == "":
return nil, errors.New("cloudCAS `location` cannot be empty")
case c.caPool == "":
return nil, errors.New("cloudCAS `caPool` cannot be empty")
case c.caPoolTier == 0:
return nil, errors.New("cloudCAS `caPoolTier` cannot be empty")
case req.Template == nil:
return nil, errors.New("createCertificateAuthorityRequest `template` cannot be nil")
case req.Lifetime == 0:
return nil, errors.New("createCertificateAuthorityRequest `lifetime` cannot be 0")
case req.Type == apiv1.IntermediateCA && req.Parent == nil:
return nil, errors.New("createCertificateAuthorityRequest `parent` cannot be nil")
case req.Type == apiv1.IntermediateCA && req.Parent.Name == "" && (req.Parent.Certificate == nil || req.Parent.Signer == nil):
return nil, errors.New("createCertificateAuthorityRequest `parent.name` cannot be empty")
}
var caType pb.CertificateAuthority_Type
switch req.Type {
case apiv1.RootCA:
caType = pb.CertificateAuthority_SELF_SIGNED
case apiv1.IntermediateCA:
caType = pb.CertificateAuthority_SUBORDINATE
default:
return nil, errors.Errorf("createCertificateAuthorityRequest `type=%d' is invalid or not supported", req.Type)
}
// Select key and signature algorithm to use
var err error
var keySpec *pb.CertificateAuthority_KeyVersionSpec
if req.CreateKey == nil {
if keySpec, err = createKeyVersionSpec(0, 0); err != nil {
return nil, errors.Wrap(err, "createCertificateAuthorityRequest `createKey` is not valid")
}
} else {
if keySpec, err = createKeyVersionSpec(req.CreateKey.SignatureAlgorithm, req.CreateKey.Bits); err != nil {
return nil, errors.Wrap(err, "createCertificateAuthorityRequest `createKey` is not valid")
}
}
// Normalize or generate id.
caID := normalizeCertificateAuthorityName(req.Name)
if caID == "" {
id, err := createCertificateID()
if err != nil {
return nil, err
}
caID = id
}
// Add CertificateAuthority extension
casExtension, err := apiv1.CreateCertificateAuthorityExtension(apiv1.CloudCAS, caID)
if err != nil {
return nil, err
}
req.Template.ExtraExtensions = append(req.Template.ExtraExtensions, casExtension)
// Create the caPool if necessary
parent, err := c.createCaPoolIfNecessary()
if err != nil {
return nil, err
}
// Prepare CreateCertificateAuthorityRequest
pbReq := &pb.CreateCertificateAuthorityRequest{
Parent: parent,
CertificateAuthorityId: caID,
RequestId: req.RequestID,
CertificateAuthority: &pb.CertificateAuthority{
Type: caType,
Config: &pb.CertificateConfig{
SubjectConfig: &pb.CertificateConfig_SubjectConfig{
Subject: createSubject(req.Template),
SubjectAltName: createSubjectAlternativeNames(req.Template),
},
X509Config: createX509Parameters(req.Template),
},
Lifetime: durationpb.New(req.Lifetime),
KeySpec: keySpec,
GcsBucket: c.gcsBucket,
Labels: map[string]string{},
},
}
// Create certificate authority.
ctx, cancel := defaultContext()
defer cancel()
resp, err := c.client.CreateCertificateAuthority(ctx, pbReq)
if err != nil {
return nil, errors.Wrap(err, "cloudCAS CreateCertificateAuthority failed")
}
// Wait for the long-running operation.
ctx, cancel = defaultInitiatorContext()
defer cancel()
ca, err := resp.Wait(ctx)
if err != nil {
return nil, errors.Wrap(err, "cloudCAS CreateCertificateAuthority failed")
}
// Sign Intermediate CAs with the parent.
if req.Type == apiv1.IntermediateCA {
ca, err = c.signIntermediateCA(parent, ca.Name, req)
if err != nil {
return nil, err
}
}
// Enable Certificate Authority.
ca, err = c.enableCertificateAuthority(ca)
if err != nil {
return nil, err
}
if len(ca.PemCaCertificates) == 0 {
return nil, errors.New("cloudCAS CreateCertificateAuthority failed: PemCaCertificates is empty")
}
cert, err := parseCertificate(ca.PemCaCertificates[0])
if err != nil {
return nil, err
}
var chain []*x509.Certificate
if pemChain := ca.PemCaCertificates[1:]; len(pemChain) > 0 {
chain = make([]*x509.Certificate, len(pemChain))
for i, s := range pemChain {
if chain[i], err = parseCertificate(s); err != nil {
return nil, err
}
}
}
return &apiv1.CreateCertificateAuthorityResponse{
Name: ca.Name,
Certificate: cert,
CertificateChain: chain,
}, nil
}
func (c *CloudCAS) createCaPoolIfNecessary() (string, error) {
ctx, cancel := defaultContext()
defer cancel()
pool, err := c.client.GetCaPool(ctx, &pb.GetCaPoolRequest{
Name: "projects/" + c.project + "/locations/" + c.location + "/caPools/" + c.caPool,
})
if err == nil {
return pool.Name, nil
}
if status.Code(err) != codes.NotFound {
return "", errors.Wrap(err, "cloudCAS GetCaPool failed")
}
// PublishCrl is only supported by the enterprise tier
var publishCrl bool
if c.caPoolTier == pb.CaPool_ENTERPRISE {
publishCrl = true
}
ctx, cancel = defaultContext()
defer cancel()
op, err := c.client.CreateCaPool(ctx, &pb.CreateCaPoolRequest{
Parent: "projects/" + c.project + "/locations/" + c.location,
CaPoolId: c.caPool,
CaPool: &pb.CaPool{
Tier: c.caPoolTier,
IssuancePolicy: nil,
PublishingOptions: &pb.CaPool_PublishingOptions{
PublishCaCert: true,
PublishCrl: publishCrl,
},
},
})
if err != nil {
return "", errors.Wrap(err, "cloudCAS CreateCaPool failed")
}
ctx, cancel = defaultInitiatorContext()
defer cancel()
pool, err = op.Wait(ctx)
if err != nil {
return "", errors.Wrap(err, "cloudCAS CreateCaPool failed")
}
return pool.Name, nil
}
func (c *CloudCAS) enableCertificateAuthority(ca *pb.CertificateAuthority) (*pb.CertificateAuthority, error) {
if ca.State == pb.CertificateAuthority_ENABLED {
return ca, nil
}
ctx, cancel := defaultContext()
defer cancel()
resp, err := c.client.EnableCertificateAuthority(ctx, &pb.EnableCertificateAuthorityRequest{
Name: ca.Name,
})
if err != nil {
return nil, errors.Wrap(err, "cloudCAS EnableCertificateAuthority failed")
}
ctx, cancel = defaultInitiatorContext()
defer cancel()
ca, err = resp.Wait(ctx)
if err != nil {
return nil, errors.Wrap(err, "cloudCAS EnableCertificateAuthority failed")
}
return ca, 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: "projects/" + c.project + "/locations/" + c.location + "/caPools/" + c.caPool,
CertificateId: id,
Certificate: &pb.Certificate{
CertificateConfig: certConfig,
Lifetime: durationpb.New(lifetime),
Labels: map[string]string{},
},
IssuingCertificateAuthorityId: getResourceName(c.certificateAuthority),
RequestId: requestID,
})
if err != nil {
return nil, nil, errors.Wrap(err, "cloudCAS CreateCertificate failed")
}
// Return certificate and certificate chain
return getCertificateAndChain(cert)
}
func (c *CloudCAS) signIntermediateCA(parent, name string, req *apiv1.CreateCertificateAuthorityRequest) (*pb.CertificateAuthority, error) {
id, err := createCertificateID()
if err != nil {
return nil, err
}
// Fetch intermediate CSR
ctx, cancel := defaultInitiatorContext()
defer cancel()
csr, err := c.client.FetchCertificateAuthorityCsr(ctx, &pb.FetchCertificateAuthorityCsrRequest{
Name: name,
})
if err != nil {
return nil, errors.Wrap(err, "cloudCAS FetchCertificateAuthorityCsr failed")
}
// Sign the CSR with the ca.
var cert *pb.Certificate
if req.Parent.Certificate != nil && req.Parent.Signer != nil {
// Using a local certificate and key.
cr, err := parseCertificateRequest(csr.PemCsr)
if err != nil {
return nil, err
}
template, err := x509util.CreateCertificateTemplate(cr)
if err != nil {
return nil, err
}
t := now()
template.NotBefore = t.Add(-1 * req.Backdate)
template.NotAfter = t.Add(req.Lifetime)
// Sign certificate
crt, err := x509util.CreateCertificate(template, req.Parent.Certificate, template.PublicKey, req.Parent.Signer)
if err != nil {
return nil, err
}
// Build pb.Certificate for activaion
chain := []string{
encodeCertificate(req.Parent.Certificate),
}
for _, c := range req.Parent.CertificateChain {
chain = append(chain, encodeCertificate(c))
}
cert = &pb.Certificate{
PemCertificate: encodeCertificate(crt),
PemCertificateChain: chain,
}
} else {
// Using the parent in CloudCAS.
ctx, cancel = defaultInitiatorContext()
defer cancel()
cert, err = c.client.CreateCertificate(ctx, &pb.CreateCertificateRequest{
Parent: parent,
CertificateId: id,
Certificate: &pb.Certificate{
CertificateConfig: &pb.Certificate_PemCsr{
PemCsr: csr.PemCsr,
},
Lifetime: durationpb.New(req.Lifetime),
Labels: map[string]string{},
},
IssuingCertificateAuthorityId: getResourceName(req.Parent.Name),
RequestId: req.RequestID,
})
if err != nil {
return nil, errors.Wrap(err, "cloudCAS CreateCertificate failed")
}
}
// Activate the intermediate certificate.
ctx, cancel = defaultInitiatorContext()
defer cancel()
resp, err := c.client.ActivateCertificateAuthority(ctx, &pb.ActivateCertificateAuthorityRequest{
Name: name,
PemCaCertificate: cert.PemCertificate,
SubordinateConfig: &pb.SubordinateConfig{
SubordinateConfig: &pb.SubordinateConfig_PemIssuerChain{
PemIssuerChain: &pb.SubordinateConfig_SubordinateConfigChain{
PemCertificates: cert.PemCertificateChain,
},
},
},
RequestId: req.RequestID,
})
if err != nil {
return nil, errors.Wrap(err, "cloudCAS ActivateCertificateAuthority1 failed")
}
// Wait for the long-running operation.
ctx, cancel = defaultInitiatorContext()
defer cancel()
ca, err := resp.Wait(ctx)
if err != nil {
return nil, errors.Wrap(err, "cloudCAS ActivateCertificateAuthority failed")
}
return ca, nil
}
func defaultContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 15*time.Second)
}
func defaultInitiatorContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 60*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 parseCertificateRequest(pemCsr string) (*x509.CertificateRequest, error) {
block, _ := pem.Decode([]byte(pemCsr))
if block == nil {
return nil, errors.New("error decoding certificate request: not a valid PEM encoded block")
}
cr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, errors.Wrap(err, "error parsing certificate request")
}
return cr, nil
}
func encodeCertificate(cert *x509.Certificate) string {
return string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}))
}
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
}
// getResourceName returns the last part of a resource.
func getResourceName(name string) string {
parts := strings.Split(name, "/")
return parts[len(parts)-1]
}
// Normalize a certificate authority name to comply with [a-zA-Z0-9-_].
func normalizeCertificateAuthorityName(name string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '-':
return r
case r == '_':
return r
default:
return '-'
}
}, name)
}