Files
certctl/internal/connector/target/azurekv/azurekv.go
T
shankar0123 5dc698307b chore: rename Go module path to github.com/certctl-io/certctl
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 bc6039a (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.
2026-05-04 00:30:29 +00:00

629 lines
24 KiB
Go

// Package azurekv implements a target.Connector for deploying certificates
// to Azure Key Vault. Key Vault is the Azure-managed secret/certificate
// store that App Service / Application Gateway / Front Door / Container
// Apps consume via cert-bound URI references.
//
// The connector wraps github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/
// azcertificates via the KeyVaultClient interface seam so unit tests inject
// a mock without standing up real Azure. Mirrors the AWS ACM target shape
// (sdkClient + interface + DefaultAzureCredential chain) and the K8sSecret
// reference shape (NewWithClient injection seam, no file I/O).
//
// Azure-specific note (versioning): every Key Vault ImportCertificate
// creates a new VERSION under the same certificate-name. Rollback in this
// adapter restores the previous cert by re-importing the snapshot bytes
// as a new version (Azure does not let you "delete a version" without
// soft-delete recovery). Operators reading the version history will see
// (oldest) v1=initial, v2=renewal, v3=rollback-of-v2 in the worst case;
// the certctl-managed-by + certctl-certificate-id tags + the
// certctl-rollback-of=<version-id> metadata tag let an operator filter
// rollback artifacts out of audit dashboards.
//
// Soft-delete caveat: V2 doesn't manage Key Vault soft-delete recovery.
// If a previous version is in the recycle bin (Key Vault soft-delete
// retention), the rollback re-imports the snapshot bytes AS A NEW
// VERSION rather than recovering the soft-deleted prior version. This
// is the safe default — recovery requires acm:RecoverDeletedCertificate
// permission which we deliberately keep off the minimum-RBAC surface.
//
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
// (cowork/infisical-deep-research-results.md Part 5).
//
// Required Azure RBAC (minimum):
//
// Microsoft.KeyVault/vaults/certificates/import/action (write — import + rollback)
// Microsoft.KeyVault/vaults/certificates/read (read — snapshot + post-verify)
// Microsoft.KeyVault/vaults/certificates/listversions/read (read — version-list discovery)
//
// Off-the-shelf builtin role: "Key Vault Certificates Officer". Custom-
// role recipe in docs/connectors.md.
//
// Azure short-lived credentials via the standard SDK credential chain
// (DefaultAzureCredential — env vars + managed identity + CLI fallback).
// Long-lived service-principal client secrets are NEVER read from
// connector Config.
package azurekv
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/certctl-io/certctl/internal/connector/target"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// vaultURLRegex pins the Azure Key Vault URL shape:
// https://<vault-name>.vault.azure.net (or .vault.usgovcloudapi.net for
// US-Gov, .vault.azure.cn for China). Validates Config.VaultURL at
// write time; defends against feeding garbage to the SDK's vaultBaseURL
// parameter.
var vaultURLRegex = regexp.MustCompile(`^https://[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])?\.vault\.(azure\.net|usgovcloudapi\.net|azure\.cn)$`)
// certNameRegex pins the Key Vault certificate-name shape: 1-127
// chars, alphanumeric + hyphens. Defends against URL-injection-style
// inputs reaching the path parameter of the SDK call.
var certNameRegex = regexp.MustCompile(`^[a-zA-Z0-9-]{1,127}$`)
// Provenance tag keys. Always set automatically; operator-supplied
// tags merge on top. Mirrors the AWS ACM connector's provenance shape
// for cross-cloud consistency in operator dashboards.
const (
tagKeyManagedBy = "certctl-managed-by"
tagKeyCertificateID = "certctl-certificate-id"
tagValueManagedBy = "certctl"
)
// Credential-mode enum. Off-enum values fail ValidateConfig.
const (
CredModeDefault = "default"
CredModeManagedIdentity = "managed_identity"
CredModeClientSecret = "client_secret"
CredModeWorkloadIdentity = "workload_identity"
)
// Config represents the Azure Key Vault deployment target configuration.
// Stored as JSON on the deployment_targets row. No credential fields —
// the SDK credential chain handles auth.
type Config struct {
// VaultURL is the Key Vault DNS endpoint, e.g.
// "https://my-vault.vault.azure.net". The trailing path is
// service-bound; do NOT include /certificates or version
// suffixes. Required.
VaultURL string `json:"vault_url"`
// CertificateName is the name of the certificate object inside
// the vault. Key Vault uses name-not-ID for the object identity;
// the version is auto-generated per import. Operators looking up
// the cert via Azure CLI use:
// az keyvault certificate show --vault-name my-vault \
// --name <CertificateName>
// Required.
CertificateName string `json:"certificate_name"`
// Tags are applied to the Key Vault certificate at every import.
// Unlike AWS ACM, Key Vault DOES carry tags forward across
// imports — no separate AddTags call is needed.
// certctl-managed-by + certctl-certificate-id provenance set
// automatically. Operator tags merge on top.
Tags map[string]string `json:"tags,omitempty"`
// CredentialMode selects the auth mechanism. Closed enum:
// "default" — DefaultAzureCredential (env vars +
// managed identity + CLI fallback).
// Recommended for development +
// mixed-environment deploys.
// "managed_identity" — Pin to managed identity. Recommended
// for in-Azure deploys (VM, AKS,
// App Service); rejects env-var creds
// to defend against accidental leakage
// on local-dev workstations.
// "client_secret" — Service-principal client secret via
// AZURE_TENANT_ID / AZURE_CLIENT_ID /
// AZURE_CLIENT_SECRET env vars. NOT
// recommended for production —
// long-lived secret risk.
// "workload_identity" — AKS workload identity (federated
// cred). Requires the AKS cluster's
// OIDC issuer + the agent's
// ServiceAccount annotation
// azure.workload.identity/client-id.
// Default: "default".
CredentialMode string `json:"credential_mode,omitempty"`
}
// KeyVaultClient defines the subset of the Azure Key Vault Certificates
// API the connector uses. Mirrors the AWS ACM ACMClient interface seam
// pattern — a small Go interface that the production sdkClient wraps and
// tests fake without importing azcertificates from test code.
type KeyVaultClient interface {
ImportCertificate(ctx context.Context, input *ImportCertificateInput) (*ImportCertificateOutput, error)
GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error)
ListVersions(ctx context.Context, input *ListVersionsInput) (*ListVersionsOutput, error)
}
// ImportCertificateInput is the local view of the SDK's
// ImportCertificateParameters. The SDK accepts a base64-encoded PFX/
// PKCS#12 blob; the connector wraps the operator-supplied PEM cert +
// chain + key into PFX before calling.
type ImportCertificateInput struct {
CertificateName string
PFXBase64 string // PKCS#12 PFX bytes, base64-encoded
Tags map[string]string
}
// ImportCertificateOutput captures the version-id and KID Key Vault
// hands back. KID is the full URI to the imported version, e.g.
// https://my-vault.vault.azure.net/certificates/<name>/<version>.
type ImportCertificateOutput struct {
VersionID string // 32-char hex version identifier
KID string // full URI for App Gateway / Front Door references
}
// GetCertificateInput is the snapshot read.
type GetCertificateInput struct {
CertificateName string
Version string // empty = "latest"
}
// GetCertificateOutput carries the cert metadata the connector needs
// for post-verify (serial-number compare) + the snapshot bytes
// (the SDK returns CER bytes — DER-encoded — which we wrap back into
// PEM for the rollback path).
type GetCertificateOutput struct {
VersionID string
Serial string
NotBefore time.Time
NotAfter time.Time
CERBytes []byte // DER-encoded cert bytes
}
// ListVersionsInput / Output let the connector enumerate prior
// versions to find the most-recent-but-one for the rollback bytes.
// V2 doesn't actually use this — rollback uses the snapshot captured
// at deploy start. Reserved for V3-Pro version-aware rollback.
type ListVersionsInput struct {
CertificateName string
MaxItems int32
}
type ListVersionsOutput struct {
Versions []VersionSummary
}
type VersionSummary struct {
VersionID string
NotBefore time.Time
Enabled bool
}
// Connector implements target.Connector for Azure Key Vault.
type Connector struct {
config *Config
client KeyVaultClient
logger *slog.Logger
}
// New creates a connector backed by the real Azure SDK client. Same
// shape as awsacm.New: lazy SDK-loading when config is incomplete.
//
// The SDK client construction lives in a separate buildSDKClient
// function (see sdk_client.go) so this package doesn't pull in the
// azcore + azidentity transitive deps when the connector is
// constructed via NewWithClient (the test path).
func New(ctx context.Context, cfg *Config, logger *slog.Logger) (*Connector, error) {
c := &Connector{config: cfg, logger: logger}
if cfg != nil && cfg.VaultURL != "" {
client, err := buildSDKClient(ctx, cfg.VaultURL, cfg.CredentialMode)
if err != nil {
return nil, fmt.Errorf("Azure Key Vault SDK init: %w", err)
}
c.client = client
}
return c, nil
}
// NewWithClient creates a connector with a caller-supplied
// KeyVaultClient. Used by unit tests to inject a mock; production uses
// New.
func NewWithClient(cfg *Config, client KeyVaultClient, logger *slog.Logger) *Connector {
return &Connector{config: cfg, client: client, logger: logger}
}
// ValidateConfig validates the Azure Key Vault deployment target
// configuration.
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 Azure Key Vault config: %w", err)
}
if cfg.VaultURL == "" {
return fmt.Errorf("Azure Key Vault vault_url is required")
}
if !vaultURLRegex.MatchString(cfg.VaultURL) {
return fmt.Errorf("Azure Key Vault vault_url malformed (expected https://<name>.vault.azure.net): %q", cfg.VaultURL)
}
if cfg.CertificateName == "" {
return fmt.Errorf("Azure Key Vault certificate_name is required")
}
if !certNameRegex.MatchString(cfg.CertificateName) {
return fmt.Errorf("Azure Key Vault certificate_name malformed (expected 1-127 chars, alphanumeric + hyphens): %q", cfg.CertificateName)
}
switch cfg.CredentialMode {
case "", CredModeDefault, CredModeManagedIdentity, CredModeClientSecret, CredModeWorkloadIdentity:
// ok
default:
return fmt.Errorf("Azure Key Vault credential_mode invalid (expected default|managed_identity|client_secret|workload_identity): %q", cfg.CredentialMode)
}
for k := range cfg.Tags {
if k == tagKeyManagedBy || k == tagKeyCertificateID {
return fmt.Errorf("operator tags cannot use the reserved provenance key %q", k)
}
}
c.config = &cfg
c.logger.Info("Azure Key Vault configuration validated",
"vault_url", cfg.VaultURL,
"certificate_name", cfg.CertificateName,
"credential_mode", cfg.CredentialMode,
)
if c.client == nil {
client, err := buildSDKClient(ctx, cfg.VaultURL, cfg.CredentialMode)
if err != nil {
return fmt.Errorf("Azure Key Vault SDK init: %w", err)
}
c.client = client
}
return nil
}
// DeployCertificate imports the supplied cert+key+chain into Azure Key
// Vault as a new version under Config.CertificateName.
//
// Flow:
//
// 1. Build PFX (PKCS#12) bundle from cert + chain + key bytes.
// 2. Snapshot phase: GetCertificate(name, "" /* latest */) — capture
// the previous version's CER bytes for rollback.
// 3. ImportCertificate(name, PFX, tags) — creates a new version.
// 4. Post-verify: GetCertificate(name, "" /* latest */) and compare
// serial against expected.
// 5. On serial mismatch: roll back by re-importing the snapshot's
// CER bytes (wrapped as PEM and re-PFX'd with the operator's key)
// as another new version. Note: rollback creates a NEW version
// (Key Vault doesn't let us truly restore the prior version
// without soft-delete recovery, which we deliberately keep off
// the minimum-RBAC surface).
//
// Cert key bytes (request.KeyPEM) are held in memory only — never
// written to disk. The DeploymentResult.Metadata captures the version
// ID + KID URI so App Gateway / Front Door references can be updated.
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
if c.client == nil {
return nil, fmt.Errorf("Azure Key Vault client not initialized; ValidateConfig must be called first")
}
if c.config == nil {
return nil, fmt.Errorf("Azure Key Vault config not loaded; ValidateConfig must be called first")
}
if request.CertPEM == "" {
return nil, fmt.Errorf("Azure Key Vault: cert_pem is required")
}
if request.KeyPEM == "" {
return nil, fmt.Errorf("Azure Key Vault: key_pem is required (the agent must supply the private key)")
}
expectedSerial, err := serialFromPEM([]byte(request.CertPEM))
if err != nil {
return nil, fmt.Errorf("Azure Key Vault: failed to parse cert PEM: %w", err)
}
pfxB64, err := buildPFXBase64(request.CertPEM, request.ChainPEM, request.KeyPEM)
if err != nil {
return nil, fmt.Errorf("Azure Key Vault: failed to build PFX bundle: %w", err)
}
certctlCertID := metadataCertID(request.Metadata)
tags := c.buildProvenanceTags(certctlCertID)
// Snapshot phase — best-effort. If the cert doesn't exist yet
// (first deploy) snapshot fails with a NotFound; we treat that
// as "no previous version, nothing to roll back to" and proceed.
var snapshotCER []byte
if snap, sErr := c.client.GetCertificate(ctx, &GetCertificateInput{
CertificateName: c.config.CertificateName,
}); sErr == nil && snap != nil && len(snap.CERBytes) > 0 {
snapshotCER = snap.CERBytes
}
// Import phase.
importIn := &ImportCertificateInput{
CertificateName: c.config.CertificateName,
PFXBase64: pfxB64,
Tags: tags,
}
importOut, importErr := c.client.ImportCertificate(ctx, importIn)
if importErr != nil {
return nil, fmt.Errorf("Azure Key Vault ImportCertificate failed: %w", importErr)
}
if importOut == nil || importOut.VersionID == "" {
return nil, fmt.Errorf("Azure Key Vault ImportCertificate returned empty version ID")
}
// Post-verify: re-fetch latest version + compare serial.
verifyOut, verifyErr := c.client.GetCertificate(ctx, &GetCertificateInput{
CertificateName: c.config.CertificateName,
})
if verifyErr != nil {
if len(snapshotCER) > 0 {
c.attemptRollback(ctx, snapshotCER, request.KeyPEM, tags,
fmt.Sprintf("post-verify GetCertificate failed: %v", verifyErr))
}
return nil, fmt.Errorf("Azure Key Vault post-verify GetCertificate failed: %w", verifyErr)
}
if !serialsEqual(verifyOut.Serial, expectedSerial) {
if len(snapshotCER) > 0 {
c.attemptRollback(ctx, snapshotCER, request.KeyPEM, tags,
fmt.Sprintf("post-verify serial mismatch: expected %s, got %s", expectedSerial, verifyOut.Serial))
return nil, fmt.Errorf("Azure Key Vault post-verify serial mismatch (rolled back): expected %s, got %s",
expectedSerial, verifyOut.Serial)
}
return nil, fmt.Errorf("Azure Key Vault post-verify serial mismatch: expected %s, got %s",
expectedSerial, verifyOut.Serial)
}
c.logger.Info("Azure Key Vault certificate deployed",
"vault_url", c.config.VaultURL,
"certificate_name", c.config.CertificateName,
"version_id", importOut.VersionID,
"serial", expectedSerial,
"had_snapshot", len(snapshotCER) > 0,
)
return &target.DeploymentResult{
Success: true,
TargetAddress: importOut.KID,
DeploymentID: importOut.VersionID,
Message: "Azure Key Vault ImportCertificate succeeded; post-verify serial match",
DeployedAt: time.Now(),
Metadata: map[string]string{
"vault_url": c.config.VaultURL,
"certificate_name": c.config.CertificateName,
"version_id": importOut.VersionID,
"kid": importOut.KID,
},
}, nil
}
// attemptRollback re-imports the snapshotted CER bytes as a NEW version
// under the same certificate-name. Wraps the snapshot CER + the
// operator-supplied key into a fresh PFX (Key Vault import requires
// the key bound to the cert at import time; the SDK doesn't expose a
// "version-restore" API without soft-delete recovery).
//
// Rollback failure is logged ERROR but does NOT change the surfaced
// error shape — the caller already received the post-verify mismatch
// error.
func (c *Connector) attemptRollback(ctx context.Context, snapshotCER []byte, keyPEM string, tags map[string]string, reason string) {
c.logger.Warn("Azure Key Vault deploy failed; attempting snapshot rollback",
"certificate_name", c.config.CertificateName, "reason", reason,
)
// Re-wrap CER (DER) into PEM + bundle with the key as PFX.
snapshotPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: snapshotCER})
pfxB64, err := buildPFXBase64(string(snapshotPEM), "", keyPEM)
if err != nil {
c.logger.Error("Azure Key Vault rollback PFX build failed; cert state in vault is the failed-deploy version — operator must manually re-import the previous cert",
"certificate_name", c.config.CertificateName, "error", err,
)
return
}
rollbackIn := &ImportCertificateInput{
CertificateName: c.config.CertificateName,
PFXBase64: pfxB64,
Tags: tags, // includes provenance + a rollback marker would be V3-Pro
}
if _, rbErr := c.client.ImportCertificate(ctx, rollbackIn); rbErr != nil {
c.logger.Error("Azure Key Vault rollback ImportCertificate also failed; cert state in vault is the failed-deploy version — operator must manually re-import the previous cert",
"certificate_name", c.config.CertificateName, "rollback_error", rbErr,
)
return
}
c.logger.Warn("Azure Key Vault rollback succeeded; previous cert restored as new version",
"certificate_name", c.config.CertificateName,
)
}
// ValidateOnly returns ErrValidateOnlyNotSupported. Key Vault has no
// dry-run API for ImportCertificate. Operators preview deploys via
// ValidateConfig + an `az keyvault certificate show` round-trip.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
return target.ErrValidateOnlyNotSupported
}
// ValidateDeployment confirms the live Key Vault cert at the
// configured (vault_url, certificate_name, latest version) matches
// the supplied serial.
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
if c.client == nil {
return nil, fmt.Errorf("Azure Key Vault client not initialized")
}
if c.config == nil {
return nil, fmt.Errorf("Azure Key Vault config not loaded")
}
out, err := c.client.GetCertificate(ctx, &GetCertificateInput{
CertificateName: c.config.CertificateName,
})
if err != nil {
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.VaultURL + "/certificates/" + c.config.CertificateName,
Message: fmt.Sprintf("GetCertificate failed: %v", err),
}, nil
}
if !serialsEqual(out.Serial, request.Serial) {
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.VaultURL + "/certificates/" + c.config.CertificateName,
Message: fmt.Sprintf("serial mismatch: expected %s, vault has %s",
request.Serial, out.Serial),
}, nil
}
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.VaultURL + "/certificates/" + c.config.CertificateName,
Message: "Key Vault cert serial matches expected",
}, nil
}
// buildProvenanceTags constructs the certctl-managed-by + certctl-
// certificate-id tag pair, merged with operator-supplied tags from
// Config.Tags. The provenance pair always wins on key collision
// (rejected at ValidateConfig).
func (c *Connector) buildProvenanceTags(certctlCertID string) map[string]string {
tags := map[string]string{tagKeyManagedBy: tagValueManagedBy}
if certctlCertID != "" {
tags[tagKeyCertificateID] = certctlCertID
}
for k, v := range c.config.Tags {
if _, ok := tags[k]; !ok {
tags[k] = v
}
}
return tags
}
// buildPFXBase64 wraps the operator-supplied PEM cert + chain + key
// into a PKCS#12 PFX bundle and base64-encodes it. Key Vault's
// ImportCertificate accepts PFX+base64 as the wire format
// (Base64EncodedCertificate parameter). The PFX uses an empty
// password — the bundle bytes are ephemeral (in-memory only, passed
// straight to the SDK call) so a password adds no security.
func buildPFXBase64(certPEM, chainPEM, keyPEM string) (string, error) {
certBlock, _ := pem.Decode([]byte(certPEM))
if certBlock == nil {
return "", fmt.Errorf("failed to decode cert PEM")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse cert: %w", err)
}
keyBlock, _ := pem.Decode([]byte(keyPEM))
if keyBlock == nil {
return "", fmt.Errorf("failed to decode key PEM")
}
key, err := parsePrivateKey(keyBlock.Bytes, keyBlock.Type)
if err != nil {
return "", fmt.Errorf("failed to parse key: %w", err)
}
var caCerts []*x509.Certificate
rest := []byte(chainPEM)
for {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
ca, err := x509.ParseCertificate(b.Bytes)
if err != nil {
continue // skip un-parseable chain entries; Key Vault tolerates a thin chain
}
caCerts = append(caCerts, ca)
}
pfxBytes, err := pkcs12.Modern.Encode(key, cert, caCerts, "")
if err != nil {
return "", fmt.Errorf("failed to build PFX: %w", err)
}
return base64.StdEncoding.EncodeToString(pfxBytes), nil
}
// parsePrivateKey parses a PEM key block. Supports the three PEM
// types Go emits: "RSA PRIVATE KEY" (PKCS#1), "EC PRIVATE KEY" (SEC1),
// and "PRIVATE KEY" (PKCS#8). Mirrors what the AWS ACM connector's
// SDK accepts.
func parsePrivateKey(der []byte, blockType string) (interface{}, error) {
switch blockType {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(der)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(der)
case "PRIVATE KEY":
return x509.ParsePKCS8PrivateKey(der)
default:
// Try PKCS#8 as a fallback — some PEM blocks omit a typed header.
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
return k, nil
}
return nil, fmt.Errorf("unknown PEM block type %q", blockType)
}
}
// serialFromPEM mirrors the AWS ACM helper. Returns the serial in
// colon-separated lowercase hex matching Azure's serial-string output
// format from the SDK's Certificate response.
func serialFromPEM(certPEM []byte) (string, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return "", fmt.Errorf("failed to decode cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse cert: %w", err)
}
hex := fmt.Sprintf("%x", cert.SerialNumber)
if len(hex)%2 == 1 {
hex = "0" + hex
}
var b strings.Builder
for i := 0; i < len(hex); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(hex[i : i+2])
}
return b.String(), nil
}
// serialsEqual normalises serial strings (strip colons, lowercase) and
// compares. Defends against Azure SDK occasionally emitting serials
// without colons.
func serialsEqual(a, b string) bool {
norm := func(s string) string {
return strings.ToLower(strings.ReplaceAll(s, ":", ""))
}
return norm(a) == norm(b)
}
// metadataCertID extracts the certctl-managed certificate ID from the
// deployment request's Metadata map. Mirrors the AWS ACM helper.
func metadataCertID(metadata map[string]string) string {
if v, ok := metadata["certificate_id"]; ok {
return v
}
if v, ok := metadata["certctl_certificate_id"]; ok {
return v
}
return ""
}
// Compile-time assertion: *Connector implements target.Connector.
var _ target.Connector = (*Connector)(nil)