// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // Package awsacmpca implements the issuer.Connector interface for AWS Certificate Manager Private CA (ACM PCA). // // AWS ACM Private CA provides a fully managed private certificate authority // with certificate signing, revocation, CRL, and OCSP capabilities. This // connector uses the AWS SDK v2 (aws-sdk-go-v2/service/acmpca) to drive the // ACM PCA API. // // Issuance is asynchronous at the API level — IssueCertificate returns a // certificate ARN immediately, and GetCertificate is then polled until the // cert reaches the CERTIFICATE_ISSUED state. The sdkClient wrapper hides // this asynchrony behind the connector's two-call pattern by running the // SDK's NewCertificateIssuedWaiter between the IssueCertificate and // GetCertificate calls. Callers see synchronous-via-waiter behavior with // a configurable wait deadline (default 5 minutes; see WaiterTimeout). // // Authentication: AWS credentials via the standard credential chain // (environment variables, shared config / shared credentials files, // IAM role for service accounts, EC2 instance profile, ECS task role, // SSO). awsconfig.LoadDefaultConfig handles all of these transparently; // certctl does not store AWS credentials directly. Configuration // specifies the CA ARN, region, and optional signing algorithm, // validity days, and template ARN. // // CRL and OCSP are served by AWS ACM PCA directly (the CA owns those // endpoints). certctl records revocations locally and notifies AWS // via the RevokeCertificate API with RFC 5280 reason mapping. // // AWS ACM PCA SDK calls used (abstracted via the local ACMPCAClient // interface so tests can inject a mock without depending on the SDK): // // IssueCertificate — submit a CSR for signing // GetCertificate — retrieve the issued cert // RevokeCertificate — revoke an issued cert // GetCertificateAuthorityCertificate — fetch the CA cert + chain // NewCertificateIssuedWaiter (internal) — wait for cert to be ready package awsacmpca import ( "context" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "log/slog" "regexp" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/acmpca" acmpcatypes "github.com/aws/aws-sdk-go-v2/service/acmpca/types" "github.com/certctl-io/certctl/internal/connector/issuer" ) // defaultWaiterTimeout is how long sdkClient.IssueCertificate will wait for // the issued cert to reach CERTIFICATE_ISSUED state before giving up. Five // minutes covers slow CA backends and short-lived rate-limit pauses; the // SDK waiter retries with exponential backoff inside this window. const defaultWaiterTimeout = 5 * time.Minute // Config represents the AWS ACM Private CA issuer connector configuration. type Config struct { // Region is the AWS region where the CA resides (e.g., "us-east-1"). // Required. Set via CERTCTL_AWS_PCA_REGION environment variable. Region string `json:"region"` // CAArn is the ARN of the AWS Certificate Manager Private CA. // Required. Set via CERTCTL_AWS_PCA_CA_ARN environment variable. // Example: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012 CAArn string `json:"ca_arn"` // SigningAlgorithm is the algorithm used to sign certificates. // Default: "SHA256WITHRSA". Set via CERTCTL_AWS_PCA_SIGNING_ALGORITHM. // Valid values: SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, // SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA SigningAlgorithm string `json:"signing_algorithm,omitempty"` // ValidityDays is the number of days the certificate is valid. // Default: 365. Set via CERTCTL_AWS_PCA_VALIDITY_DAYS. ValidityDays int `json:"validity_days,omitempty"` // TemplateArn is the optional certificate template ARN for subordinate CAs with restrictions. // Set via CERTCTL_AWS_PCA_TEMPLATE_ARN. TemplateArn string `json:"template_arn,omitempty"` } // ACMPCAClient defines the interface for interacting with AWS ACM Private CA. // This allows for dependency injection and testing with mock clients without // importing aws-sdk-go-v2 from test code. // // The production implementation (sdkClient) wraps *acmpca.Client and // translates between local input/output types and the SDK's typed // inputs/outputs. The sdkClient also runs the SDK's // NewCertificateIssuedWaiter inside IssueCertificate so callers see // synchronous-via-waiter semantics; the waiter is hidden from the // interface to keep mock implementations simple. type ACMPCAClient interface { // IssueCertificate submits a CSR for signing and waits for the issued // cert to be retrievable. Returns the certificate ARN. IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) // GetCertificate retrieves a previously issued certificate by ARN. GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) // RevokeCertificate revokes a certificate by serial number. RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error // GetCACertificate retrieves the CA certificate and chain. GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) } // IssueCertificateInput represents the request to issue a certificate. type IssueCertificateInput struct { CAArn string CSR []byte // DER-encoded CSR SigningAlgorithm string ValidityDays int TemplateArn string } // IssueCertificateOutput represents the response to an issue request. type IssueCertificateOutput struct { CertificateArn string } // GetCertificateInput represents the request to retrieve a certificate. type GetCertificateInput struct { CAArn string CertificateArn string } // GetCertificateOutput represents the response containing the certificate. type GetCertificateOutput struct { Certificate string // PEM-encoded certificate CertificateChain string // PEM-encoded certificate chain } // RevokeCertificateInput represents the request to revoke a certificate. type RevokeCertificateInput struct { CAArn string CertificateSerial string RevocationReason string } // GetCACertificateInput represents the request to retrieve the CA certificate. type GetCACertificateInput struct { CAArn string } // GetCACertificateOutput represents the response containing the CA certificate. type GetCACertificateOutput struct { Certificate string // PEM-encoded CA certificate CertificateChain string // PEM-encoded CA chain } // Connector implements the issuer.Connector interface for AWS ACM Private CA. type Connector struct { config *Config client ACMPCAClient logger *slog.Logger } // New creates a new AWS ACM Private CA connector with the given configuration // and logger. If config is non-nil and config.Region is set, New attempts to // load the AWS SDK default credential chain (environment variables, shared // config, IAM role, instance profile, SSO) and constructs an *acmpca.Client // pinned to the region. Returns an error if SDK config load fails. // // If config is nil or config.Region is empty, the connector is constructed // with no client; ValidateConfig will lazily build the client on first // successful validation. This keeps backward compatibility with the // "construct then validate" pattern used by tests that exercise // ValidateConfig in isolation. // // Callers wanting to inject a mock client (tests, fake CAs) should use // NewWithClient instead, which bypasses the SDK loading path entirely. // // ctx is used only for the SDK config load (LoadDefaultConfig may probe IMDS // or remote credential sources). Callers that don't have a useful deadline // should pass context.Background(); the SDK has its own internal timeouts // for credential resolution. func New(ctx context.Context, config *Config, logger *slog.Logger) (*Connector, error) { if config != nil { if config.SigningAlgorithm == "" { config.SigningAlgorithm = "SHA256WITHRSA" } if config.ValidityDays == 0 { config.ValidityDays = 365 } } c := &Connector{ config: config, logger: logger, } if config != nil && config.Region != "" { client, err := buildSDKClient(ctx, config.Region) if err != nil { return nil, fmt.Errorf("AWS ACM PCA SDK init: %w", err) } c.client = client } return c, nil } // NewWithClient creates a new AWS ACM Private CA connector with a custom // client implementation. Used primarily for testing with mock clients; // production code should use New, which wires the real SDK-backed client. func NewWithClient(config *Config, client ACMPCAClient, logger *slog.Logger) *Connector { if config != nil { if config.SigningAlgorithm == "" { config.SigningAlgorithm = "SHA256WITHRSA" } if config.ValidityDays == 0 { config.ValidityDays = 365 } } return &Connector{ config: config, client: client, logger: logger, } } // buildSDKClient loads the AWS default credential chain pinned to the given // region and returns a sdkClient ready for use. Separated from New so // ValidateConfig can also call it when the connector was constructed with // no config (the test-init path). func buildSDKClient(ctx context.Context, region string) (ACMPCAClient, error) { awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) if err != nil { return nil, fmt.Errorf("LoadDefaultConfig: %w", err) } return &sdkClient{ client: acmpca.NewFromConfig(awsCfg), waiterTimeout: defaultWaiterTimeout, }, nil } // sdkClient wraps *acmpca.Client and translates between local input/output // types and the SDK's typed inputs/outputs. The waiter for asynchronous // issuance is run inside IssueCertificate so the connector layer's two-call // pattern (IssueCertificate → GetCertificate) sees synchronous-via-waiter // semantics. type sdkClient struct { client *acmpca.Client waiterTimeout time.Duration } func (s *sdkClient) IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) { sdkInput := &acmpca.IssueCertificateInput{ CertificateAuthorityArn: aws.String(input.CAArn), Csr: input.CSR, SigningAlgorithm: acmpcatypes.SigningAlgorithm(input.SigningAlgorithm), Validity: &acmpcatypes.Validity{ Type: acmpcatypes.ValidityPeriodTypeDays, Value: aws.Int64(int64(input.ValidityDays)), }, } if input.TemplateArn != "" { sdkInput.TemplateArn = aws.String(input.TemplateArn) } output, err := s.client.IssueCertificate(ctx, sdkInput) if err != nil { return nil, fmt.Errorf("acmpca IssueCertificate: %w", err) } if output == nil || output.CertificateArn == nil { return nil, fmt.Errorf("acmpca IssueCertificate returned no CertificateArn") } // Wait for the certificate to reach CERTIFICATE_ISSUED state. The SDK's // waiter polls GetCertificate with exponential backoff until either the // cert is ready or the deadline expires. waiter := acmpca.NewCertificateIssuedWaiter(s.client) waitErr := waiter.Wait(ctx, &acmpca.GetCertificateInput{ CertificateAuthorityArn: aws.String(input.CAArn), CertificateArn: output.CertificateArn, }, s.waiterTimeout) if waitErr != nil { return nil, fmt.Errorf("acmpca waiter (waiting for issuance): %w", waitErr) } return &IssueCertificateOutput{ CertificateArn: aws.ToString(output.CertificateArn), }, nil } func (s *sdkClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) { output, err := s.client.GetCertificate(ctx, &acmpca.GetCertificateInput{ CertificateAuthorityArn: aws.String(input.CAArn), CertificateArn: aws.String(input.CertificateArn), }) if err != nil { return nil, fmt.Errorf("acmpca GetCertificate: %w", err) } if output == nil { return nil, fmt.Errorf("acmpca GetCertificate returned nil output") } return &GetCertificateOutput{ Certificate: aws.ToString(output.Certificate), CertificateChain: aws.ToString(output.CertificateChain), }, nil } func (s *sdkClient) RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error { _, err := s.client.RevokeCertificate(ctx, &acmpca.RevokeCertificateInput{ CertificateAuthorityArn: aws.String(input.CAArn), CertificateSerial: aws.String(input.CertificateSerial), RevocationReason: acmpcatypes.RevocationReason(input.RevocationReason), }) if err != nil { return fmt.Errorf("acmpca RevokeCertificate: %w", err) } return nil } func (s *sdkClient) GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) { output, err := s.client.GetCertificateAuthorityCertificate(ctx, &acmpca.GetCertificateAuthorityCertificateInput{ CertificateAuthorityArn: aws.String(input.CAArn), }) if err != nil { return nil, fmt.Errorf("acmpca GetCertificateAuthorityCertificate: %w", err) } if output == nil { return nil, fmt.Errorf("acmpca GetCertificateAuthorityCertificate returned nil output") } return &GetCACertificateOutput{ Certificate: aws.ToString(output.Certificate), CertificateChain: aws.ToString(output.CertificateChain), }, nil } // ValidateConfig checks that the AWS ACM Private CA configuration is valid. // On success, ValidateConfig also lazily builds the SDK client if the // connector was constructed with no config (the test-init path: New(nil, ...)). // In production, the factory always passes a fully-populated config to New, // so the SDK client is built at New time and ValidateConfig only re-validates. func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error { var cfg Config if err := json.Unmarshal(rawConfig, &cfg); err != nil { return fmt.Errorf("invalid AWS ACM PCA config: %w", err) } if cfg.Region == "" { return fmt.Errorf("AWS region is required") } if cfg.CAArn == "" { return fmt.Errorf("AWS CA ARN is required") } // Validate ARN format: arn:aws(-[a-z]+)?:acm-pca:[a-z0-9-]+:\d{12}:certificate-authority/[a-f0-9-]+ arnPattern := regexp.MustCompile(`^arn:aws(-[a-z]+)?:acm-pca:[a-z0-9-]+:\d{12}:certificate-authority/[a-f0-9-]+$`) if !arnPattern.MatchString(cfg.CAArn) { return fmt.Errorf("invalid CA ARN format: %s", cfg.CAArn) } // Validate signing algorithm if provided if cfg.SigningAlgorithm != "" { validAlgorithms := map[string]bool{ "SHA256WITHRSA": true, "SHA384WITHRSA": true, "SHA512WITHRSA": true, "SHA256WITHECDSA": true, "SHA384WITHECDSA": true, "SHA512WITHECDSA": true, } if !validAlgorithms[cfg.SigningAlgorithm] { return fmt.Errorf("invalid signing algorithm: %s", cfg.SigningAlgorithm) } } else { cfg.SigningAlgorithm = "SHA256WITHRSA" } // Validate validity days if provided if cfg.ValidityDays < 0 { return fmt.Errorf("validity days must be non-negative") } if cfg.ValidityDays == 0 { cfg.ValidityDays = 365 } c.config = &cfg c.logger.Info("AWS ACM Private CA configuration validated", "region", cfg.Region, "ca_arn", cfg.CAArn, "signing_algorithm", cfg.SigningAlgorithm, "validity_days", cfg.ValidityDays) // Lazily build the SDK client if the connector was constructed without one // (e.g., New(nil, logger)). NewWithClient injects a mock and we leave it // alone; production New with a populated config builds the client up // front and we leave it alone too. if c.client == nil { client, err := buildSDKClient(ctx, cfg.Region) if err != nil { return fmt.Errorf("AWS ACM PCA SDK init: %w", err) } c.client = client } return nil } // IssueCertificate issues a new certificate using AWS ACM Private CA. func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) { if c.client == nil { return nil, fmt.Errorf("AWS ACM PCA client not initialized; ValidateConfig must be called first") } c.logger.Info("processing AWS ACM PCA issuance request", "common_name", request.CommonName, "san_count", len(request.SANs)) // Decode CSR from PEM csrBlock, _ := pem.Decode([]byte(request.CSRPEM)) if csrBlock == nil { return nil, fmt.Errorf("failed to decode CSR PEM") } // Call AWS API to issue certificate. The sdkClient hides the asynchronous // IssueCertificate → waiter → GetCertificate dance behind this single call; // IssueCertificate returns only after the cert has reached // CERTIFICATE_ISSUED state (or the waiter timeout has expired). issueOutput, err := c.client.IssueCertificate(ctx, &IssueCertificateInput{ CAArn: c.config.CAArn, CSR: csrBlock.Bytes, SigningAlgorithm: c.config.SigningAlgorithm, ValidityDays: c.config.ValidityDays, TemplateArn: c.config.TemplateArn, }) if err != nil { return nil, fmt.Errorf("AWS IssueCertificate failed: %w", err) } // Retrieve the issued certificate getCertOutput, err := c.client.GetCertificate(ctx, &GetCertificateInput{ CAArn: c.config.CAArn, CertificateArn: issueOutput.CertificateArn, }) if err != nil { return nil, fmt.Errorf("AWS GetCertificate failed: %w", err) } if getCertOutput.Certificate == "" { return nil, fmt.Errorf("no certificate in AWS response") } // Parse the certificate to extract metadata block, _ := pem.Decode([]byte(getCertOutput.Certificate)) if block == nil { return nil, fmt.Errorf("failed to decode certificate PEM from AWS") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("failed to parse certificate: %w", err) } // Extract serial number (hex format, uppercase) serial := strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber)) // Use certificate ARN as OrderID for revocation lookup orderID := issueOutput.CertificateArn c.logger.Info("AWS ACM PCA certificate issued", "common_name", request.CommonName, "serial", serial, "not_after", cert.NotAfter) return &issuer.IssuanceResult{ CertPEM: getCertOutput.Certificate, ChainPEM: getCertOutput.CertificateChain, Serial: serial, NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, OrderID: orderID, }, nil } // RenewCertificate renews a certificate by creating a new signing request. // For AWS ACM PCA, renewal is functionally identical to issuance (new cert signed from CSR). func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) { c.logger.Info("processing AWS ACM PCA renewal request", "common_name", request.CommonName, "san_count", len(request.SANs)) return c.IssueCertificate(ctx, issuer.IssuanceRequest{ CommonName: request.CommonName, SANs: request.SANs, CSRPEM: request.CSRPEM, EKUs: request.EKUs, }) } // RevokeCertificate revokes a certificate at AWS ACM Private CA. func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error { if c.client == nil { return fmt.Errorf("AWS ACM PCA client not initialized; ValidateConfig must be called first") } c.logger.Info("processing AWS ACM PCA revocation request", "serial", request.Serial) // Map RFC 5280 reason string to AWS reason reason := mapRevocationReason(request.Reason) err := c.client.RevokeCertificate(ctx, &RevokeCertificateInput{ CAArn: c.config.CAArn, CertificateSerial: request.Serial, RevocationReason: reason, }) if err != nil { return fmt.Errorf("AWS RevokeCertificate failed: %w", err) } c.logger.Info("AWS ACM PCA certificate revoked", "serial", request.Serial) return nil } // GetOrderStatus returns the status of an AWS ACM PCA order. From the // connector's perspective, issuance is synchronous (the sdkClient runs the // SDK waiter inside IssueCertificate), so by the time a caller reaches // GetOrderStatus the cert is already available. func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) { return &issuer.OrderStatus{ OrderID: orderID, Status: "completed", UpdatedAt: time.Now(), }, nil } // GenerateCRL is not supported because AWS ACM PCA serves CRL directly. func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) { return nil, fmt.Errorf("CRL delegated to AWS ACM Private CA; use AWS endpoint directly") } // SignOCSPResponse is not supported because AWS ACM PCA serves OCSP directly. func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) { return nil, fmt.Errorf("OCSP delegated to AWS ACM Private CA; use AWS endpoint directly") } // GetCACertPEM retrieves the CA certificate from AWS ACM Private CA. func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { if c.client == nil { return "", fmt.Errorf("AWS ACM PCA client not initialized; ValidateConfig must be called first") } caCertOutput, err := c.client.GetCACertificate(ctx, &GetCACertificateInput{ CAArn: c.config.CAArn, }) if err != nil { return "", fmt.Errorf("AWS GetCACertificate failed: %w", err) } // Combine CA certificate and chain if caCertOutput.CertificateChain != "" { return caCertOutput.Certificate + "\n" + caCertOutput.CertificateChain, nil } return caCertOutput.Certificate, nil } // GetRenewalInfo returns nil, nil as AWS ACM PCA does not support ACME Renewal Information (ARI). func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) { return nil, nil } // mapRevocationReason converts RFC 5280 reason strings to AWS ACM PCA reason // codes. The returned string corresponds to a valid acmpcatypes.RevocationReason // value, which sdkClient.RevokeCertificate then casts back to the SDK enum. func mapRevocationReason(reason *string) string { if reason == nil { return "UNSPECIFIED" } reasonMap := map[string]string{ "unspecified": "UNSPECIFIED", "keyCompromise": "KEY_COMPROMISE", "caCompromise": "CERTIFICATE_AUTHORITY_COMPROMISE", "affiliationChanged": "AFFILIATION_CHANGED", "superseded": "SUPERSEDED", "cessationOfOperation": "CESSATION_OF_OPERATION", "certificateHold": "CERTIFICATE_HOLD", "privilegeWithdrawn": "PRIVILEGE_WITHDRAWN", } if mapped, ok := reasonMap[*reason]; ok { return mapped } return "UNSPECIFIED" } // Ensure Connector implements the issuer.Connector interface. var _ issuer.Connector = (*Connector)(nil) // Ensure sdkClient implements the ACMPCAClient interface. var _ ACMPCAClient = (*sdkClient)(nil)