mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 03:19:01 +00:00
6119f26b1b
Closes the #1 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. The production New() constructor previously hardcoded &stubClient{}, which returned "AWS SDK client not initialized (stub)" on every method. Tests passed green via NewWithClient mock injection — a path the production constructor never took. AWSACMPCA was wired into the factory, the seed file, the test suite, and marketing collateral but did not actually issue, retrieve, or revoke certificates. This commit: - Adds aws-sdk-go-v2/{config,service/acmpca,aws} to go.mod (with acmpca/types as a sub-package). go mod tidy could not be completed in the sandbox due to virtiofs concurrent-open-file ceiling on the module cache; the require blocks were arranged manually so the three directly-imported packages are non-indirect. Build, vet, staticcheck, and the full test suite are green; operator should run `go mod tidy` on the workstation to confirm cosmetic ordering before pushing. - Implements sdkClient wrapping *acmpca.Client with local input/output type translation. Each method translates the connector's local input type to the SDK's typed input, calls the SDK, and translates the SDK output back to the local output type. aws-sdk-go-v2 types do not leak out of the awsacmpca package. - Deletes stubClient (the four "AWS SDK client not initialized (stub)" methods). After this commit, there is no fall-back stub; production New() always wires the SDK. - Rewrites New() to load credentials via awsconfig.LoadDefaultConfig with awsconfig.WithRegion(config.Region) and construct the SDK client via acmpca.NewFromConfig. Returns (*Connector, error). When config is nil or config.Region is empty, New defers SDK loading; ValidateConfig builds the client lazily on the first successful validation. This preserves the test pattern of New(nil, logger) → ValidateConfig. - Wires acmpca.NewCertificateIssuedWaiter (5-minute default timeout) inside sdkClient.IssueCertificate so the connector's two-call pattern (IssueCertificate → GetCertificate) sees synchronous-via- waiter semantics. The waiter is hidden from the ACMPCAClient interface so mock implementations stay simple. - Maps RFC 5280 revocation reasons to acmpcatypes.RevocationReason via the existing mapRevocationReason helper plus a cast at the sdkClient.RevokeCertificate boundary. - Updates the issuerfactory.NewFromConfig call site at factory.go:L88 for the new (*Connector, error) signature; the factory's outer signature already returns (issuer.Connector, error) so the change is local. - Adds nil-client guards on the four client-using connector methods (IssueCertificate, RevokeCertificate, GetCACertPEM, plus the RenewCertificate path via IssueCertificate). When the connector is used before ValidateConfig has been called, these methods fail-fast with a "client not initialized" sentinel error instead of panicking. - Fixes the copy-paste env-var doc-comments at awsacmpca.go:L41,L45 (CERTCTL_GOOGLE_CAS_PROJECT / CERTCTL_GOOGLE_CAS_CA_ARN → CERTCTL_AWS_PCA_REGION / CERTCTL_AWS_PCA_CA_ARN). The actual config loader at internal/config/config.go:L1556-L1561 already used the correct env-var names; only the doc-comments were wrong. - Updates the package doc-comment at awsacmpca.go:L1-L36 to clarify the synchronous-via-waiter behavior (issuance is asynchronous at the API level; the waiter inside sdkClient.IssueCertificate hides the asynchrony). - Adds TestNew_ProductionPath/ValidConfigBuildsRealClient: calls production New() (NOT NewWithClient) with a valid config, asserts err is nil, then calls IssueCertificate with a bogus CSR and asserts the resulting error is the expected PEM-decode error rather than the deleted stubClient's "client not initialized" sentinel. This is the regression-marker test the audit's D11 blocker called out as missing — if anyone re-introduces a stub-style placeholder from production New() in the future, this test fails. - Adds TestNew_ProductionPath/NilConfigDefersClientInit: documents the lazy-init contract for the New(nil, logger) → ValidateConfig pattern. - Adds TestNew_ProductionPath/ValidateConfigBuildsClientLazily: verifies that ValidateConfig wires the SDK client when New was called with nil config. - Adds TestNew_ProductionPath/{Revoke,GetCAPEM}BeforeInitFailsFast: verifies the nil-client guards on the other client-using methods. - Adds TestNew_ErrorPaths covering AccessDeniedException-shaped errors, transient 5xx errors, and ctx-cancel propagation via the existing mockACMPCAClient. - Updates docs/connectors.md:L490-L555 with: the synchronous-via-waiter behavior, a complete IAM policy example scoped to the four ACM PCA actions, a worked POST /api/v1/issuers example, and a troubleshooting section with three known failure modes (AccessDeniedException, ResourceNotFoundException, waiter timeout). Live AWS integration testing is intentionally not added: ACM PCA is a Pro-tier feature in localstack and the existing interface-mock tests cover correctness end-to-end. Operators with AWS credentials can validate by following the worked example in docs/connectors.md. Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #1 (Part 3, narrative section).
600 lines
22 KiB
Go
600 lines
22 KiB
Go
// 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/shankar0123/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.
|
|
func New(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(context.Background(), 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)
|