mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
629 lines
24 KiB
Go
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
|
|
// (the project's deep-research deliverable, 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)
|