diff --git a/docs/connectors.md b/docs/connectors.md index b032664..29e4f5e 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -489,7 +489,7 @@ Location: `internal/connector/issuer/googlecas/googlecas.go` ### Built-in: AWS ACM Private CA -AWS Certificate Manager Private Certificate Authority — managed private CA on AWS. Synchronous issuance via ACM PCA API with standard AWS credential chain (env vars, IAM roles, instance profiles, SSO). +AWS Certificate Manager Private Certificate Authority — managed private CA on AWS. Synchronous-via-waiter issuance: the connector calls `IssueCertificate` (which is asynchronous at the ACM PCA API level), then runs the SDK's `NewCertificateIssuedWaiter` until the cert reaches `CERTIFICATE_ISSUED` state, then `GetCertificate` to retrieve the PEM. Default waiter timeout is 5 minutes; tune by editing `defaultWaiterTimeout` in the connector. | Setting | Required | Default | Description | |---------|----------|---------|-------------| @@ -501,9 +501,57 @@ AWS Certificate Manager Private Certificate Authority — managed private CA on **Supported signing algorithms:** SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA. -**Authentication:** Standard AWS credential chain. The connector uses `aws-sdk-go-v2/config.LoadDefaultConfig()` which supports environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), IAM roles (EC2/ECS), instance profiles, and SSO credentials. +**Authentication:** Standard AWS credential chain via `aws-sdk-go-v2/config.LoadDefaultConfig()`. Resolves credentials in this order: environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`), shared config files (`~/.aws/config`, `~/.aws/credentials`, profile via `AWS_PROFILE`), IAM Roles for Service Accounts (EKS), EC2 instance profiles, ECS task roles, and SSO. certctl never stores AWS credentials directly — set them in the certctl process's environment or via the IAM role attached to the host. -**Note:** CRL and OCSP are managed by AWS ACM PCA directly. certctl records revocations locally and notifies AWS via the RevokeCertificate API with RFC 5280 reason mapping. +**Minimal IAM policy.** The IAM principal that certctl authenticates as needs the following actions against the CA's ARN: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "acm-pca:IssueCertificate", + "acm-pca:GetCertificate", + "acm-pca:RevokeCertificate", + "acm-pca:GetCertificateAuthorityCertificate" + ], + "Resource": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012" + } + ] +} +``` + +Replace the `Resource` ARN with your own CA ARN. If you use a `TemplateArn` (subordinate-CA template), the policy needs no additional permissions — `IssueCertificate` covers it. + +**Worked example.** Add an AWSACMPCA issuer via the API: + +```bash +curl -k -X POST https://localhost:8443/api/v1/issuers \ + -H 'Content-Type: application/json' \ + -d '{ + "id": "iss-aws-prod", + "name": "AWS ACM PCA (prod)", + "type": "AWSACMPCA", + "config": { + "region": "us-east-1", + "ca_arn": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", + "signing_algorithm": "SHA256WITHRSA", + "validity_days": 90 + } + }' +``` + +The certctl server process must have AWS credentials available before the issuer is created (or before any subsequent issuance call). For a local dev run with shared-config creds: `export AWS_PROFILE=my-profile` before `docker compose up`. For an EKS deployment: attach an IRSA-bound IAM role to the certctl pod's service account. + +**Troubleshooting.** + +- **`AccessDeniedException: User ... is not authorized to perform: acm-pca:IssueCertificate`** — the IAM principal certctl is using lacks the required actions. Apply the IAM policy above (scoped to your CA ARN) to the role/user. The principal can be inspected with `aws sts get-caller-identity` from the certctl host. +- **`ResourceNotFoundException: Could not find Certificate Authority`** — the `CAArn` doesn't match any CA in the configured region. Common causes: region mismatch (CA is in `us-west-2`, certctl region is set to `us-east-1`), CA was deleted, ARN typo. Verify with `aws acm-pca describe-certificate-authority --certificate-authority-arn --region `. +- **`acmpca waiter (waiting for issuance): exceeded max wait time`** — the cert was submitted but didn't reach `CERTIFICATE_ISSUED` state within 5 minutes. Check the CA's CloudWatch metrics for backlog; check the CA's audit reports for any policy violations on the request. If the wait is consistently slow, edit `defaultWaiterTimeout` in `internal/connector/issuer/awsacmpca/awsacmpca.go` and rebuild. + +**Note:** CRL and OCSP are managed by AWS ACM PCA directly. certctl records revocations locally and notifies AWS via the `RevokeCertificate` API with RFC 5280 reason mapping (e.g., `keyCompromise` → `KEY_COMPROMISE`). AWS ACM PCA's CRL distribution point and OCSP responder serve the resulting status to verifying clients; certctl is not in the OCSP path for this connector. Location: `internal/connector/issuer/awsacmpca/awsacmpca.go` diff --git a/go.mod b/go.mod index c31713a..858b093 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,9 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14 github.com/leanovate/gopter v0.2.11 github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 github.com/pkg/sftp v1.13.10 @@ -23,6 +26,18 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect diff --git a/go.sum b/go.sum index 39dd9cf..ab65a23 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,36 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14 h1:Srm+IbQm8jjQoBQJ7tf/+etEzogQhV2QaVHA0kesQoM= +github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14/go.mod h1:qFP+Zv26pVlLajTm293Ga9I82NRjnrTpXtMtkFFn5xc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE= diff --git a/internal/connector/issuer/awsacmpca/awsacmpca.go b/internal/connector/issuer/awsacmpca/awsacmpca.go index d5a1189..5821476 100644 --- a/internal/connector/issuer/awsacmpca/awsacmpca.go +++ b/internal/connector/issuer/awsacmpca/awsacmpca.go @@ -1,24 +1,38 @@ -// Package awsacmpca implements the issuer.Connector interface for AWS Certificate Authority Service (CAS). +// Package awsacmpca implements the issuer.Connector interface for AWS Certificate Manager Private CA (ACM PCA). // -// AWS ACM Private CA (ACM PCA) provides a fully managed private certificate authority -// with certificate signing, revocation, and CRL capabilities. This connector uses the -// AWS ACM PCA API to issue and manage certificates. +// 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. // -// This connector issues certificates synchronously: the IssueCertificate call returns -// the issued certificate immediately. GetOrderStatus always returns "completed" since -// issuance is synchronous. CRL and OCSP operations are delegated to AWS PCA's own -// endpoints. +// 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, -// IAM role, instance profile, or SSO). Configuration specifies the CA ARN, region, and -// optional signing algorithm and validity days. +// 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. // -// AWS ACM PCA API used (abstracted via ACMPCAClient interface): +// 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. // -// IssueCertificate - Issue a certificate from a CSR -// GetCertificate - Retrieve the issued certificate -// RevokeCertificate - Revoke a certificate -// GetCACertificate - Get the CA certificate chain +// 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 ( @@ -32,17 +46,28 @@ import ( "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_GOOGLE_CAS_PROJECT environment variable. + // Required. Set via CERTCTL_AWS_PCA_REGION environment variable. Region string `json:"region"` - // CAArn is the ARN of the AWS Certificate Authority Service CA. - // Required. Set via CERTCTL_GOOGLE_CAS_CA_ARN environment variable. + // 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"` @@ -62,18 +87,27 @@ type Config struct { } // ACMPCAClient defines the interface for interacting with AWS ACM Private CA. -// This allows for dependency injection and testing with mock clients. +// 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 issues a new certificate. + // 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 an issued certificate. + // GetCertificate retrieves a previously issued certificate by ARN. GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) - // RevokeCertificate revokes a certificate. + // RevokeCertificate revokes a certificate by serial number. RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error - // GetCACertificate retrieves the CA certificate chain. + // GetCACertificate retrieves the CA certificate and chain. GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) } @@ -128,9 +162,21 @@ type Connector struct { logger *slog.Logger } -// New creates a new AWS ACM Private CA connector with the given configuration and logger. -// The real client will use the AWS SDK via the standard credential chain. -func New(config *Config, logger *slog.Logger) *Connector { +// 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" @@ -140,15 +186,25 @@ func New(config *Config, logger *slog.Logger) *Connector { } } - return &Connector{ + c := &Connector{ config: config, - client: &stubClient{}, // Placeholder; real AWS client will be injected or implemented 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. -// Used primarily for testing with mock clients. +// 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 == "" { @@ -166,27 +222,120 @@ func NewWithClient(config *Config, client ACMPCAClient, logger *slog.Logger) *Co } } -// stubClient is a placeholder client that returns "not implemented" errors. -// In production, this would be replaced with a real AWS SDK client. -type stubClient struct{} - -func (s *stubClient) IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) { - return nil, fmt.Errorf("AWS SDK client not initialized (stub)") +// 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 } -func (s *stubClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) { - return nil, fmt.Errorf("AWS SDK client not initialized (stub)") +// 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 *stubClient) RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error { - return fmt.Errorf("AWS SDK client not initialized (stub)") +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 *stubClient) GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) { - return nil, fmt.Errorf("AWS SDK client not initialized (stub)") +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 { @@ -239,11 +388,27 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag "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)) @@ -254,7 +419,10 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc return nil, fmt.Errorf("failed to decode CSR PEM") } - // Call AWS API to issue certificate + // 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, @@ -328,6 +496,10 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal // 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 @@ -346,8 +518,10 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca return nil } -// GetOrderStatus returns the status of an AWS ACM PCA order. -// AWS ACM PCA issues synchronously, so orders are always "completed" immediately. +// 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, @@ -368,6 +542,10 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq // 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, }) @@ -388,7 +566,9 @@ func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer return nil, nil } -// mapRevocationReason converts RFC 5280 reason strings to AWS ACM PCA reason codes. +// 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" @@ -414,3 +594,6 @@ func mapRevocationReason(reason *string) string { // Ensure Connector implements the issuer.Connector interface. var _ issuer.Connector = (*Connector)(nil) + +// Ensure sdkClient implements the ACMPCAClient interface. +var _ ACMPCAClient = (*sdkClient)(nil) diff --git a/internal/connector/issuer/awsacmpca/awsacmpca_test.go b/internal/connector/issuer/awsacmpca/awsacmpca_test.go index 9b887f4..58fc531 100644 --- a/internal/connector/issuer/awsacmpca/awsacmpca_test.go +++ b/internal/connector/issuer/awsacmpca/awsacmpca_test.go @@ -8,6 +8,7 @@ import ( "crypto/x509/pkix" "encoding/json" "encoding/pem" + "errors" "fmt" "log/slog" "math/big" @@ -69,6 +70,19 @@ func (m *mockACMPCAClient) GetCACertificate(ctx context.Context, input *awsacmpc }, nil } +// mustNew is a test helper that calls awsacmpca.New and fails the test if +// New returns an error. Use this for the ValidateConfig-only test sites +// where config is nil; New(nil, ...) skips SDK loading and never errors, +// so this helper is just to keep the call sites terse. +func mustNew(t *testing.T, config *awsacmpca.Config, logger *slog.Logger) *awsacmpca.Connector { + t.Helper() + c, err := awsacmpca.New(config, logger) + if err != nil { + t.Fatalf("awsacmpca.New: %v", err) + } + return c +} + // Helper function to generate a test certificate and CSR. func generateTestCertAndCSR(t *testing.T) (certPEM string, csrPEM string) { // Generate private key @@ -141,7 +155,7 @@ func TestAWSACMPCAConnector(t *testing.T) { ValidityDays: 365, } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -158,7 +172,7 @@ func TestAWSACMPCAConnector(t *testing.T) { TemplateArn: "arn:aws:acm-pca:eu-west-1:123456789012:template/WebServer", } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -167,7 +181,7 @@ func TestAWSACMPCAConnector(t *testing.T) { }) t.Run("ValidateConfig_InvalidJSON", func(t *testing.T) { - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) err := connector.ValidateConfig(ctx, []byte(`{invalid json}`)) if err == nil { t.Fatal("Expected error for invalid JSON") @@ -182,7 +196,7 @@ func TestAWSACMPCAConnector(t *testing.T) { CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -198,7 +212,7 @@ func TestAWSACMPCAConnector(t *testing.T) { Region: "us-east-1", } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -215,7 +229,7 @@ func TestAWSACMPCAConnector(t *testing.T) { CAArn: "not-an-arn", } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -233,7 +247,7 @@ func TestAWSACMPCAConnector(t *testing.T) { SigningAlgorithm: "INVALID_ALGO", } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -251,7 +265,7 @@ func TestAWSACMPCAConnector(t *testing.T) { ValidityDays: -1, } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -581,7 +595,7 @@ func TestAWSACMPCAConnector(t *testing.T) { // SigningAlgorithm and ValidityDays not set } - connector := awsacmpca.New(nil, logger) + connector := mustNew(t, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -627,3 +641,238 @@ func TestAWSACMPCAConnector(t *testing.T) { } }) } + +// TestNew_ProductionPath exercises the production New() path (NOT +// NewWithClient). The audit's D11 blocker for AWSACMPCA was that tests +// passed green via NewWithClient mock injection while the production +// New() returned a stubClient that errored on every method. These tests +// guard against that regression by verifying the production New() path +// builds a real client end-to-end. +// +// The "client not initialized" sentinel string is the regression-marker: +// the deleted stubClient returned an error containing that phrase from +// every method. If anyone re-introduces a stub-style placeholder client +// from New(), these tests fail because the production client is +// non-stubby and doesn't return that sentinel. +func TestNew_ProductionPath(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + t.Run("ValidConfigBuildsRealClient", func(t *testing.T) { + // New with a valid config calls awsconfig.LoadDefaultConfig + + // acmpca.NewFromConfig. Should succeed even without AWS + // credentials: LoadDefaultConfig sets up the credential chain + // providers but doesn't actually fetch credentials until an + // API call is made. + cfg := &awsacmpca.Config{ + Region: "us-east-1", + CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", + } + c, err := awsacmpca.New(cfg, logger) + if err != nil { + t.Fatalf("New with valid config returned error: %v", err) + } + if c == nil { + t.Fatal("New returned nil connector") + } + + // Behavioral assertion: IssueCertificate with a bogus CSR fails + // at PEM-decode (before any network call), and the error must + // NOT be the deleted stubClient's "client not initialized" + // sentinel. If anyone re-introduces a stub from production + // New(), this assertion catches it. + _, err = c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "example.com", + CSRPEM: "", // intentionally bogus + }) + if err == nil { + t.Fatal("expected error from bogus CSR, got nil") + } + if strings.Contains(err.Error(), "not initialized") { + t.Fatalf("got 'not initialized' error after New with valid config — production client was not wired: %v", err) + } + // Expected: PEM decode error. + if !strings.Contains(err.Error(), "decode CSR PEM") { + t.Errorf("expected CSR decode error, got: %v", err) + } + }) + + t.Run("NilConfigDefersClientInit", func(t *testing.T) { + // New(nil, ...) is the test-only path that skips SDK loading; + // the connector is constructed with no client and ValidateConfig + // must be called before any operation. This documents the lazy + // initialization contract. + c, err := awsacmpca.New(nil, logger) + if err != nil { + t.Fatalf("New(nil, logger) returned error: %v", err) + } + if c == nil { + t.Fatal("New(nil, ...) returned nil connector") + } + + // Calling client-using methods before ValidateConfig should + // fail-fast with the documented sentinel. + _, err = c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "example.com", + CSRPEM: "", // bogus, but client-init check fires first + }) + if err == nil { + t.Fatal("expected error from uninitialized client, got nil") + } + if !strings.Contains(err.Error(), "client not initialized") { + t.Errorf("expected 'client not initialized' error, got: %v", err) + } + }) + + t.Run("ValidateConfigBuildsClientLazily", func(t *testing.T) { + // New(nil, ...) leaves client nil; ValidateConfig with a valid + // config should build it. After ValidateConfig succeeds, client- + // using methods should work end-to-end (modulo network errors). + c, err := awsacmpca.New(nil, logger) + if err != nil { + t.Fatalf("New(nil, logger): %v", err) + } + + cfg := awsacmpca.Config{ + Region: "us-east-1", + CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", + } + cfgJSON, _ := json.Marshal(cfg) + if err := c.ValidateConfig(ctx, cfgJSON); err != nil { + t.Fatalf("ValidateConfig: %v", err) + } + + // IssueCertificate should now reach the PEM-decode step + // (the client is wired). Bogus CSR triggers PEM error, + // not the "client not initialized" sentinel. + _, err = c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "example.com", + CSRPEM: "", + }) + if err == nil { + t.Fatal("expected error from bogus CSR") + } + if strings.Contains(err.Error(), "not initialized") { + t.Fatalf("ValidateConfig didn't wire client: %v", err) + } + }) + + t.Run("RevokeBeforeInitFailsFast", func(t *testing.T) { + // The audit also flagged RevokeCertificate as part of the stub + // blocker. Verify the nil-client guard fires for revoke too. + c, err := awsacmpca.New(nil, logger) + if err != nil { + t.Fatalf("New(nil, logger): %v", err) + } + err = c.RevokeCertificate(ctx, issuer.RevocationRequest{ + Serial: "aabbccdd", + }) + if err == nil { + t.Fatal("expected error from uninitialized client") + } + if !strings.Contains(err.Error(), "client not initialized") { + t.Errorf("expected 'client not initialized', got: %v", err) + } + }) + + t.Run("GetCAPEMBeforeInitFailsFast", func(t *testing.T) { + c, err := awsacmpca.New(nil, logger) + if err != nil { + t.Fatalf("New(nil, logger): %v", err) + } + _, err = c.GetCACertPEM(ctx) + if err == nil { + t.Fatal("expected error from uninitialized client") + } + if !strings.Contains(err.Error(), "client not initialized") { + t.Errorf("expected 'client not initialized', got: %v", err) + } + }) +} + +// TestNew_ErrorPaths covers connector-level error paths via the mock +// client. These complement the existing IssueCertificate_IssueError / +// IssueCertificate_GetCertificateError tests by adding access-denied, +// transient 5xx, and ctx-cancel coverage that the audit called out as +// missing from D11. +func TestNew_ErrorPaths(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + cfg := awsacmpca.Config{ + Region: "us-east-1", + CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", + } + + t.Run("AccessDeniedSurfacedAsError", func(t *testing.T) { + // Simulate the AWS access-denied case via the mock. The error + // message format mirrors what aws-sdk-go-v2 surfaces for IAM + // failures; the assertion is that the connector wraps the error + // without swallowing it. + mock := &mockACMPCAClient{ + issueCertificateErr: fmt.Errorf("operation error ACM-PCA: IssueCertificate, https response error StatusCode: 403, RequestID: x, AccessDeniedException: User is not authorized to perform: acm-pca:IssueCertificate"), + } + c := awsacmpca.NewWithClient(&cfg, mock, logger) + _, csrPEM := generateTestCertAndCSR(t) + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected access-denied error") + } + if !strings.Contains(err.Error(), "AccessDenied") { + t.Errorf("expected wrapped AccessDeniedException, got: %v", err) + } + if !strings.Contains(err.Error(), "IssueCertificate failed") { + t.Errorf("expected connector wrapping ('IssueCertificate failed: ...'), got: %v", err) + } + }) + + t.Run("Transient5xxSurfacedAsError", func(t *testing.T) { + // Simulate a transient 5xx from ACM PCA. The connector returns + // the error to the caller; retry logic, if any, lives upstream + // in the scheduler. + mock := &mockACMPCAClient{ + issueCertificateErr: fmt.Errorf("operation error ACM-PCA: IssueCertificate, https response error StatusCode: 503, ServiceUnavailable"), + } + c := awsacmpca.NewWithClient(&cfg, mock, logger) + _, csrPEM := generateTestCertAndCSR(t) + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected 5xx error") + } + if !strings.Contains(err.Error(), "503") && !strings.Contains(err.Error(), "ServiceUnavailable") { + t.Errorf("expected wrapped 5xx, got: %v", err) + } + }) + + t.Run("CtxCancelPropagated", func(t *testing.T) { + // Mock that respects ctx cancellation. Asserts the connector + // honors caller-supplied deadlines. + mock := &mockACMPCAClient{} + c := awsacmpca.NewWithClient(&cfg, mock, logger) + + cancelCtx, cancel := context.WithCancel(ctx) + cancel() // cancel immediately + + _, csrPEM := generateTestCertAndCSR(t) + // The mock doesn't check ctx; we test by injecting a ctx-aware + // error. Use a wrapped context.Canceled to simulate the SDK + // returning a cancellation error. + mock.issueCertificateErr = context.Canceled + _, err := c.IssueCertificate(cancelCtx, issuer.IssuanceRequest{ + CommonName: "example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected ctx-cancel error") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected errors.Is(err, context.Canceled), got: %v", err) + } + }) +} diff --git a/internal/connector/issuerfactory/factory.go b/internal/connector/issuerfactory/factory.go index 44784b1..3f959f0 100644 --- a/internal/connector/issuerfactory/factory.go +++ b/internal/connector/issuerfactory/factory.go @@ -90,7 +90,11 @@ func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.L if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid AWS ACM PCA config: %w", err) } - return awsacmpca.New(&cfg, logger), nil + conn, err := awsacmpca.New(&cfg, logger) + if err != nil { + return nil, fmt.Errorf("AWS ACM PCA init: %w", err) + } + return conn, nil case "Entrust", "entrust": var cfg entrust.Config