mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 19:48:52 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
605 lines
22 KiB
Go
605 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/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)
|