mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 15:08:53 +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.
1163 lines
42 KiB
Go
1163 lines
42 KiB
Go
package acme
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/acme"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/issuer"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
)
|
|
|
|
// Config represents the ACME issuer connector configuration.
|
|
type Config struct {
|
|
DirectoryURL string `json:"directory_url"` // ACME directory URL (e.g., https://acme-staging-v02.api.letsencrypt.org/directory)
|
|
Email string `json:"email"` // Contact email for the ACME account
|
|
EABKid string `json:"eab_kid,omitempty"` // External Account Binding Key ID (for some CAs)
|
|
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
|
|
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
|
|
|
|
// ChallengeType selects the ACME challenge method: "http-01" (default), "dns-01", or "dns-persist-01".
|
|
// DNS-01 is required for wildcard certificates (*.example.com).
|
|
// DNS-PERSIST-01 uses a standing TXT record (set once, reused forever) — no per-renewal DNS updates.
|
|
ChallengeType string `json:"challenge_type,omitempty"`
|
|
|
|
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 and dns-persist-01).
|
|
// The script receives CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE, CERTCTL_DNS_TOKEN.
|
|
DNSPresentScript string `json:"dns_present_script,omitempty"`
|
|
|
|
// DNSCleanUpScript is the path to a script that removes DNS TXT records (dns-01 only).
|
|
// Optional — if not set, records are not cleaned up automatically.
|
|
// Not used by dns-persist-01 (records are permanent).
|
|
DNSCleanUpScript string `json:"dns_cleanup_script,omitempty"`
|
|
|
|
// DNSPropagationWait is how long to wait (in seconds) after creating the TXT record
|
|
// before telling the CA to validate. Defaults to 30 seconds.
|
|
DNSPropagationWait int `json:"dns_propagation_wait,omitempty"`
|
|
|
|
// DNSPersistIssuerDomain is the CA's issuer domain name for dns-persist-01 records.
|
|
// Used to construct the TXT record value: "<issuer-domain>; accounturi=<account-uri>".
|
|
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
|
|
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
|
|
|
|
// Profile selects the ACME certificate profile for the newOrder request.
|
|
// Let's Encrypt supports "tlsserver" (standard TLS, default) and "shortlived" (6-day certs).
|
|
// Leave empty for the CA's default profile (backward-compatible).
|
|
// See: https://letsencrypt.org/2025/01/09/acme-profiles.html
|
|
Profile string `json:"profile,omitempty"`
|
|
|
|
// ARIEnabled enables ACME Renewal Information (RFC 9773) support per CERTCTL_ACME_ARI_ENABLED.
|
|
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
|
|
ARIEnabled bool `json:"ari_enabled,omitempty"`
|
|
|
|
// ARIHTTPTimeoutSeconds bounds the per-request timeout on ARI HTTP calls.
|
|
// Bundle C / Audit M-019: a CA whose ARI endpoint is unreachable or
|
|
// stalls indefinitely must not stall the renewal scheduler — the
|
|
// fallback path is threshold-based renewal, which only kicks in once
|
|
// the ARI request errors out. The audit's "no fallback timeout" claim
|
|
// was wrong (a 15s default has been in place since the ARI feature
|
|
// shipped), but the previous timeout was hardcoded; this knob makes
|
|
// it configurable per-issuer for operators on flaky-CA networks.
|
|
// Defaults to 15 when zero. CERTCTL_ACME_ARI_HTTP_TIMEOUT_SECONDS in
|
|
// the env-driven build path.
|
|
ARIHTTPTimeoutSeconds int `json:"ari_http_timeout_seconds,omitempty"`
|
|
|
|
// Insecure skips TLS certificate verification when connecting to the ACME directory.
|
|
// Only use for testing with self-signed ACME servers like Pebble.
|
|
Insecure bool `json:"insecure,omitempty"`
|
|
}
|
|
|
|
// CertificateLookupRepo lets the ACME connector recover a previously-issued
|
|
// certificate's PEM chain from the local cert store given only the serial.
|
|
// RFC 8555 §7.6 requires the certificate DER bytes (not just the serial) on
|
|
// the revoke wire — this interface bridges the gap so an operator who calls
|
|
// RevokeCertificate with just a serial in hand (lost PEM, rotated key, GUI
|
|
// revoke action) gets the same outcome as one who supplies the full DER.
|
|
//
|
|
// Defined at the connector boundary on purpose: the connector doesn't import
|
|
// the service or repository packages — it accepts whatever satisfies this
|
|
// shape. Production wiring in cmd/server/main.go injects the postgres
|
|
// CertificateRepository (which has GetVersionBySerial); tests inject a fake.
|
|
//
|
|
// Audit fix #7.
|
|
type CertificateLookupRepo interface {
|
|
// GetVersionBySerial returns the certificate version (the row that
|
|
// holds the PEM chain) whose SerialNumber matches the supplied
|
|
// serial, scoped to the issuerID. Returns sql.ErrNoRows when no
|
|
// match exists. Per RFC 5280 §5.2.3 serials are unique only within
|
|
// a single issuer, so the scope is required.
|
|
GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error)
|
|
}
|
|
|
|
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
|
// (Let's Encrypt, Sectigo, ZeroSSL, etc.).
|
|
//
|
|
// It supports HTTP-01 challenge solving via a built-in temporary HTTP server.
|
|
// The challenge server starts when needed and stops after validation completes.
|
|
//
|
|
// For HTTP-01 to work, the domain(s) being validated must resolve to the machine
|
|
// running this connector, and the configured HTTP port must be reachable from the internet.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
client *acme.Client
|
|
accountKey *ecdsa.PrivateKey
|
|
|
|
// HTTP-01 challenge solver state
|
|
challengeMu sync.RWMutex
|
|
challengeTokens map[string]string // token → key authorization
|
|
|
|
// DNS-01 challenge solver (nil if using HTTP-01)
|
|
dnsSolver DNSSolver
|
|
|
|
// issuerID + certLookup are wired by the registry's Rebuild via
|
|
// SetIssuerID + SetCertificateLookup. When certLookup is nil, the
|
|
// serial-only revoke path falls back to the legacy "not supported"
|
|
// error so old wiring paths keep their behaviour. Audit fix #7.
|
|
issuerID string
|
|
certLookup CertificateLookupRepo
|
|
}
|
|
|
|
// SetIssuerID records the issuer ID so the serial-only revoke path can
|
|
// scope the cert-version lookup correctly. Per RFC 5280 §5.2.3 serial
|
|
// numbers are only unique within a single issuer, so the scope is
|
|
// required for the lookup to be deterministic. Mirrors the existing
|
|
// SetIssuerID setter on local.Connector.
|
|
//
|
|
// Audit fix #7.
|
|
func (c *Connector) SetIssuerID(id string) {
|
|
c.issuerID = id
|
|
}
|
|
|
|
// SetCertificateLookup wires the cert-version lookup so the ACME
|
|
// connector can recover the leaf-cert PEM (and thus the DER bytes
|
|
// needed by RFC 8555 §7.6) from a serial-only revoke request. nil
|
|
// means revoke-by-serial is not supported (the historical V1
|
|
// behaviour, preserved for old wiring paths).
|
|
//
|
|
// Audit fix #7.
|
|
func (c *Connector) SetCertificateLookup(repo CertificateLookupRepo) {
|
|
c.certLookup = repo
|
|
}
|
|
|
|
// New creates a new ACME connector with the given configuration and logger.
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
if config != nil {
|
|
if config.HTTPPort == 0 {
|
|
config.HTTPPort = 80
|
|
}
|
|
if config.ChallengeType == "" {
|
|
config.ChallengeType = "http-01"
|
|
}
|
|
if config.DNSPropagationWait == 0 {
|
|
config.DNSPropagationWait = 30
|
|
}
|
|
}
|
|
|
|
c := &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
challengeTokens: make(map[string]string),
|
|
}
|
|
|
|
// Initialize DNS solver if dns-01 or dns-persist-01 challenge type is configured
|
|
if config != nil && (config.ChallengeType == "dns-01" || config.ChallengeType == "dns-persist-01") && config.DNSPresentScript != "" {
|
|
c.dnsSolver = NewScriptDNSSolver(config.DNSPresentScript, config.DNSCleanUpScript, logger)
|
|
logger.Info("DNS challenge solver configured",
|
|
"challenge_type", config.ChallengeType,
|
|
"present_script", config.DNSPresentScript,
|
|
"cleanup_script", config.DNSCleanUpScript)
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// httpClient returns an HTTP client configured for the ACME connector.
|
|
// When Insecure is true (e.g., for Pebble test servers), TLS verification is skipped.
|
|
func (c *Connector) httpClient() *http.Client {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
if c.config != nil && c.config.Insecure {
|
|
client.Transport = &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Intentional for test ACME servers (Pebble)
|
|
}
|
|
}
|
|
return client
|
|
}
|
|
|
|
// ValidateConfig checks that the ACME directory URL is reachable and valid.
|
|
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 ACME config: %w", err)
|
|
}
|
|
|
|
if cfg.DirectoryURL == "" {
|
|
return fmt.Errorf("ACME directory_url is required")
|
|
}
|
|
|
|
if cfg.Email == "" {
|
|
return fmt.Errorf("ACME email is required")
|
|
}
|
|
|
|
c.logger.Info("validating ACME configuration", "directory_url", cfg.DirectoryURL, "insecure", cfg.Insecure)
|
|
|
|
// Apply config so httpClient() can use it for the directory probe.
|
|
// This persists across the function — if validation fails early, the config
|
|
// will still be set, but that's fine since a failed ValidateConfig means
|
|
// the connector won't be used.
|
|
c.config = &cfg
|
|
|
|
// Verify that the directory URL is reachable
|
|
httpClient := c.httpClient()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DirectoryURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reach ACME directory: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("ACME directory returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
if cfg.HTTPPort == 0 {
|
|
cfg.HTTPPort = 80
|
|
}
|
|
|
|
if cfg.ChallengeType == "" {
|
|
cfg.ChallengeType = "http-01"
|
|
}
|
|
|
|
// Validate challenge type
|
|
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" && cfg.ChallengeType != "dns-persist-01" {
|
|
return fmt.Errorf("invalid challenge_type: %s (must be http-01, dns-01, or dns-persist-01)", cfg.ChallengeType)
|
|
}
|
|
|
|
// Validate profile if set (alphanumeric + hyphens only)
|
|
if cfg.Profile != "" {
|
|
for _, ch := range cfg.Profile {
|
|
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-') {
|
|
return fmt.Errorf("invalid profile: %q (must contain only alphanumeric characters and hyphens)", cfg.Profile)
|
|
}
|
|
}
|
|
}
|
|
|
|
// DNS-01 and DNS-PERSIST-01 require a present script
|
|
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript == "" {
|
|
return fmt.Errorf("dns_present_script is required for %s challenge type", cfg.ChallengeType)
|
|
}
|
|
|
|
// DNS-PERSIST-01 requires an issuer domain
|
|
if cfg.ChallengeType == "dns-persist-01" && cfg.DNSPersistIssuerDomain == "" {
|
|
return fmt.Errorf("dns_persist_issuer_domain is required for dns-persist-01 challenge type (e.g., \"letsencrypt.org\")")
|
|
}
|
|
|
|
if cfg.DNSPropagationWait == 0 {
|
|
cfg.DNSPropagationWait = 30
|
|
}
|
|
|
|
c.config = &cfg
|
|
|
|
// Re-initialize DNS solver if switching to dns-01 or dns-persist-01
|
|
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript != "" {
|
|
c.dnsSolver = NewScriptDNSSolver(cfg.DNSPresentScript, cfg.DNSCleanUpScript, c.logger)
|
|
}
|
|
|
|
c.logger.Info("ACME configuration validated",
|
|
"challenge_type", cfg.ChallengeType)
|
|
return nil
|
|
}
|
|
|
|
// ensureClient initializes the ACME client and account key if not already done.
|
|
func (c *Connector) ensureClient(ctx context.Context) error {
|
|
if c.client != nil {
|
|
return nil
|
|
}
|
|
|
|
// Generate an ECDSA P-256 account key
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate account key: %w", err)
|
|
}
|
|
c.accountKey = key
|
|
|
|
c.client = &acme.Client{
|
|
Key: key,
|
|
DirectoryURL: c.config.DirectoryURL,
|
|
HTTPClient: c.httpClient(),
|
|
}
|
|
|
|
// Register or retrieve the ACME account
|
|
acct := &acme.Account{
|
|
Contact: []string{"mailto:" + c.config.Email},
|
|
}
|
|
|
|
// Auto-fetch EAB credentials from ZeroSSL if directory URL is ZeroSSL and no EAB provided.
|
|
// ZeroSSL offers a public endpoint that returns EAB credentials given an email address,
|
|
// so users don't need to visit the ZeroSSL dashboard manually.
|
|
if c.config.EABKid == "" && c.config.EABHmac == "" && isZeroSSL(c.config.DirectoryURL) {
|
|
kid, hmac, eabErr := fetchZeroSSLEAB(ctx, c.config.Email)
|
|
if eabErr != nil {
|
|
return fmt.Errorf("failed to auto-fetch ZeroSSL EAB credentials: %w", eabErr)
|
|
}
|
|
c.config.EABKid = kid
|
|
c.config.EABHmac = hmac
|
|
c.logger.Info("auto-fetched EAB credentials from ZeroSSL", "eab_kid", kid)
|
|
}
|
|
|
|
// External Account Binding (required by ZeroSSL, Google Trust Services, SSL.com, etc.)
|
|
if c.config.EABKid != "" && c.config.EABHmac != "" {
|
|
hmacKey, decodeErr := base64.RawURLEncoding.DecodeString(c.config.EABHmac)
|
|
if decodeErr != nil {
|
|
return fmt.Errorf("failed to decode EAB HMAC key (expected base64url): %w", decodeErr)
|
|
}
|
|
acct.ExternalAccountBinding = &acme.ExternalAccountBinding{
|
|
KID: c.config.EABKid,
|
|
Key: hmacKey,
|
|
}
|
|
c.logger.Info("using External Account Binding for ACME registration", "eab_kid", c.config.EABKid)
|
|
}
|
|
|
|
_, err = c.client.Register(ctx, acct, acme.AcceptTOS)
|
|
if err != nil {
|
|
// Account may already exist, try to get it
|
|
_, getErr := c.client.GetReg(ctx, "")
|
|
if getErr != nil {
|
|
return fmt.Errorf("failed to register ACME account: %w (get existing: %v)", err, getErr)
|
|
}
|
|
c.logger.Info("using existing ACME account")
|
|
} else {
|
|
c.logger.Info("registered new ACME account", "email", c.config.Email)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB
|
|
// credentials. Variable (not const) to allow test overrides AND operator
|
|
// overrides at startup via the CERTCTL_ZEROSSL_EAB_URL env var.
|
|
//
|
|
// Bundle E / Audit L-009: pre-bundle the URL was hardcoded; if ZeroSSL
|
|
// changed the endpoint or an operator wanted to point at an internal
|
|
// proxy/mirror, only a code change would have done it. Now any non-empty
|
|
// CERTCTL_ZEROSSL_EAB_URL at process start replaces the default. The
|
|
// HTTP client at the call site already enforces a 15-second timeout
|
|
// (line ~329) — audit's "no timeout" claim was incorrect; the timeout
|
|
// has been in place since the auto-EAB feature shipped.
|
|
var zeroSSLEABEndpoint = func() string {
|
|
if v := os.Getenv("CERTCTL_ZEROSSL_EAB_URL"); v != "" {
|
|
return v
|
|
}
|
|
return "https://api.zerossl.com/acme/eab-credentials-email"
|
|
}()
|
|
|
|
// isZeroSSL returns true if the ACME directory URL points to ZeroSSL.
|
|
func isZeroSSL(directoryURL string) bool {
|
|
return strings.Contains(strings.ToLower(directoryURL), "zerossl.com")
|
|
}
|
|
|
|
// fetchZeroSSLEAB retrieves EAB credentials from ZeroSSL's public API endpoint.
|
|
// ZeroSSL provides this so users don't need to visit the dashboard manually.
|
|
// Returns (kid, hmac_key, error). The HMAC key is already base64url-encoded.
|
|
func fetchZeroSSLEAB(ctx context.Context, email string) (string, string, error) {
|
|
if email == "" {
|
|
return "", "", fmt.Errorf("email is required for ZeroSSL EAB auto-fetch")
|
|
}
|
|
|
|
form := url.Values{"email": {email}}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, zeroSSLEABEndpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", fmt.Errorf("ZeroSSL API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
EABKid string `json:"eab_kid"`
|
|
EABHmac string `json:"eab_hmac_key"`
|
|
ErrorMsg string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return "", "", fmt.Errorf("parse response: %w", err)
|
|
}
|
|
|
|
if !result.Success || result.EABKid == "" || result.EABHmac == "" {
|
|
errDetail := result.ErrorMsg
|
|
if errDetail == "" {
|
|
errDetail = string(body)
|
|
}
|
|
return "", "", fmt.Errorf("ZeroSSL EAB generation failed: %s", errDetail)
|
|
}
|
|
|
|
return result.EABKid, result.EABHmac, nil
|
|
}
|
|
|
|
// IssueCertificate submits a certificate issuance request to the ACME CA.
|
|
//
|
|
// Flow:
|
|
// 1. Create a new order with the CA for the requested identifiers
|
|
// 2. Solve HTTP-01 challenges for each authorization
|
|
// 3. Finalize the order by submitting the CSR
|
|
// 4. Download the issued certificate and chain
|
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
|
c.logger.Info("processing ACME issuance request",
|
|
"common_name", request.CommonName,
|
|
"san_count", len(request.SANs))
|
|
|
|
if err := c.ensureClient(ctx); err != nil {
|
|
return nil, fmt.Errorf("ACME client init: %w", err)
|
|
}
|
|
|
|
// Build the list of identifiers (domains)
|
|
identifiers := buildIdentifiers(request.CommonName, request.SANs)
|
|
|
|
// Step 1: Create order (with optional profile for CAs that support it)
|
|
order, err := c.authorizeOrderWithProfile(ctx, identifiers, c.config.Profile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create ACME order: %w", err)
|
|
}
|
|
c.logger.Info("ACME order created", "order_url", order.URI, "status", order.Status)
|
|
|
|
// Save FinalizeURL and URI before WaitOrder — WaitOrder returns a new Order
|
|
// object that may have empty FinalizeURL and URI fields (Go's crypto/acme
|
|
// WaitOrder doesn't populate Order.URI on the returned struct).
|
|
finalizeURL := order.FinalizeURL
|
|
orderURI := order.URI
|
|
|
|
// Step 2: Solve authorizations (HTTP-01 challenges)
|
|
if order.Status == acme.StatusPending {
|
|
if err := c.solveAuthorizations(ctx, order.AuthzURLs); err != nil {
|
|
return nil, fmt.Errorf("failed to solve challenges: %w", err)
|
|
}
|
|
|
|
// Wait for the order to be ready
|
|
order, err = c.client.WaitOrder(ctx, orderURI)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order failed after challenge: %w", err)
|
|
}
|
|
// Update finalizeURL from the waited order if it has one
|
|
if order.FinalizeURL != "" {
|
|
finalizeURL = order.FinalizeURL
|
|
}
|
|
// Preserve orderURI — WaitOrder doesn't populate Order.URI
|
|
if order.URI != "" {
|
|
orderURI = order.URI
|
|
}
|
|
}
|
|
|
|
if order.Status != acme.StatusReady {
|
|
return nil, fmt.Errorf("order not ready, status: %s", order.Status)
|
|
}
|
|
|
|
// Step 3: Parse CSR and finalize order
|
|
csrDER, err := parseCSRPEM(request.CSRPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
|
}
|
|
|
|
if finalizeURL == "" {
|
|
return nil, fmt.Errorf("ACME order has no finalize URL (order URI: %s, status: %s)", order.URI, order.Status)
|
|
}
|
|
|
|
// Step 3b: Finalize the order and fetch the certificate.
|
|
// CreateOrderCert POSTs the CSR to the finalize URL and attempts to retrieve
|
|
// the certificate. Some ACME servers (notably Pebble) return the order object
|
|
// per RFC 8555 rather than redirecting to the cert, which can cause
|
|
// CreateOrderCert's internal cert URL resolution to fail. In that case, we
|
|
// fall back to WaitOrder (to get the CertURL) + FetchCert.
|
|
derChain, _, err := c.client.CreateOrderCert(ctx, finalizeURL, csrDER, true)
|
|
if err != nil {
|
|
c.logger.Warn("CreateOrderCert failed, attempting manual certificate fetch",
|
|
"error", err, "order_uri", orderURI)
|
|
|
|
// The finalize POST likely succeeded (the CA issued the cert) but cert
|
|
// retrieval failed. WaitOrder returns the order in "valid" state with
|
|
// CertURL populated.
|
|
validOrder, waitErr := c.client.WaitOrder(ctx, orderURI)
|
|
if waitErr != nil {
|
|
return nil, fmt.Errorf("failed to finalize order: %w (wait fallback: %v)", err, waitErr)
|
|
}
|
|
|
|
if validOrder.CertURL == "" {
|
|
return nil, fmt.Errorf("order finalized but no certificate URL returned (original error: %w)", err)
|
|
}
|
|
|
|
c.logger.Info("fetching certificate via fallback", "cert_url", validOrder.CertURL)
|
|
fetchedChain, fetchErr := c.client.FetchCert(ctx, validOrder.CertURL, true)
|
|
if fetchErr != nil {
|
|
return nil, fmt.Errorf("failed to fetch certificate: %w (original finalize error: %v)", fetchErr, err)
|
|
}
|
|
derChain = fetchedChain
|
|
}
|
|
|
|
if len(derChain) == 0 {
|
|
return nil, fmt.Errorf("ACME returned empty certificate chain")
|
|
}
|
|
|
|
// Step 4: Convert DER chain to PEM
|
|
certPEM, chainPEM, serial, notBefore, notAfter, err := parseDERChain(derChain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
|
|
}
|
|
|
|
c.logger.Info("ACME certificate issued",
|
|
"common_name", request.CommonName,
|
|
"serial", serial,
|
|
"not_after", notAfter)
|
|
|
|
return &issuer.IssuanceResult{
|
|
CertPEM: certPEM,
|
|
ChainPEM: chainPEM,
|
|
Serial: serial,
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
OrderID: orderURI,
|
|
}, nil
|
|
}
|
|
|
|
// RenewCertificate renews a certificate by creating a new ACME order.
|
|
// The process is identical to issuance — ACME doesn't distinguish between new and renewal.
|
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
|
c.logger.Info("processing ACME 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,
|
|
})
|
|
}
|
|
|
|
// RevokeCertificate revokes a certificate at the ACME CA. RFC 8555 §7.6
|
|
// requires the certificate DER bytes (not just the serial) on the revoke
|
|
// wire — but a CLM platform's job is to abstract over that limitation.
|
|
// Operators routinely have only the serial in hand: lost PEM, rotated
|
|
// key, GUI revoke action driven by a row in the certs list.
|
|
//
|
|
// This method recovers the leaf-cert DER by looking the serial up in
|
|
// the local cert-version store (CertificateLookupRepo, wired by the
|
|
// registry's Rebuild), decoding the PEM chain into DER, and calling
|
|
// golang.org/x/crypto/acme.Client.RevokeCert with (accountKey, der,
|
|
// reasonCode). The reason is mapped from the RFC 5280 string in the
|
|
// request via mapRevocationReason; nil reason maps to 0 (unspecified).
|
|
//
|
|
// Audit fix #7. Pre-fix this returned the literal error
|
|
// "ACME revocation by serial not supported in V1; provide certificate
|
|
// DER" which made GUI-driven revoke unusable for ACME-issued certs.
|
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
|
c.logger.Info("processing ACME revocation request", "serial", request.Serial)
|
|
|
|
if c.certLookup == nil {
|
|
// Backward-compat fallback. Only fires in test paths or old
|
|
// wiring where SetCertificateLookup was not called. The audit
|
|
// mandates the lookup wire as the production path; this is
|
|
// retained for the test cases that build the connector
|
|
// directly without the registry.
|
|
return fmt.Errorf("ACME revocation by serial requires CertificateLookup wiring; call SetCertificateLookup")
|
|
}
|
|
|
|
if c.issuerID == "" {
|
|
// Same backward-compat reasoning. The registry calls
|
|
// SetIssuerID alongside SetCertificateLookup; both are
|
|
// required for the lookup to be deterministic per RFC 5280
|
|
// §5.2.3.
|
|
return fmt.Errorf("ACME revocation by serial requires issuer ID wiring; call SetIssuerID")
|
|
}
|
|
|
|
version, err := c.certLookup.GetVersionBySerial(ctx, c.issuerID, request.Serial)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("ACME revoke: no local cert with serial %s for issuer %s (cert may not have been issued through certctl)", request.Serial, c.issuerID)
|
|
}
|
|
return fmt.Errorf("ACME revoke: cert version lookup: %w", err)
|
|
}
|
|
|
|
if version == nil || version.PEMChain == "" {
|
|
return fmt.Errorf("ACME revoke: local cert version row has empty PEM chain (corrupt row?) — serial=%s", request.Serial)
|
|
}
|
|
|
|
// PEMChain is "leaf cert PEM\nchain PEMs..."; we only need the
|
|
// leaf for the ACME revoke wire. pem.Decode returns the FIRST
|
|
// block, which is exactly the leaf, then leaves the rest in the
|
|
// trailing slice (which we discard).
|
|
block, _ := pem.Decode([]byte(version.PEMChain))
|
|
if block == nil {
|
|
return fmt.Errorf("ACME revoke: cert version PEM malformed — no PEM block found in chain (serial=%s)", request.Serial)
|
|
}
|
|
if block.Type != "CERTIFICATE" {
|
|
return fmt.Errorf("ACME revoke: cert version PEM has unexpected block type %q (expected CERTIFICATE, serial=%s)", block.Type, request.Serial)
|
|
}
|
|
der := block.Bytes
|
|
|
|
if err := c.ensureClient(ctx); err != nil {
|
|
return fmt.Errorf("ACME revoke: client init: %w", err)
|
|
}
|
|
|
|
reasonCode, err := mapRevocationReason(request.Reason)
|
|
if err != nil {
|
|
return fmt.Errorf("ACME revoke: %w", err)
|
|
}
|
|
|
|
// golang.org/x/crypto/acme.Client.RevokeCert authenticates the
|
|
// revoke with the supplied account key (RFC 8555 §7.6 case 1,
|
|
// "revocation request signed with account key"). The same account
|
|
// key issued the cert, so this path covers all certctl-issued
|
|
// ACME certs. Revocation via the cert's private key is the
|
|
// alternative auth path (RFC 8555 §7.6 case 2) and is out of
|
|
// scope here.
|
|
c.logger.Info("ACME revoke: issuing RevokeCert", "serial", request.Serial, "reason_code", reasonCode)
|
|
if err := c.client.RevokeCert(ctx, c.accountKey, der, reasonCode); err != nil {
|
|
return fmt.Errorf("ACME RevokeCert: %w", err)
|
|
}
|
|
|
|
c.logger.Info("ACME certificate revoked", "serial", request.Serial)
|
|
return nil
|
|
}
|
|
|
|
// mapRevocationReason translates an RFC 5280 §5.3.1 reason string (as
|
|
// it appears in a RevocationRequest from the certctl service layer)
|
|
// into the integer reason code that
|
|
// golang.org/x/crypto/acme.CRLReasonCode expects. Codes match RFC 5280 §5.3.1: 0 unspecified,
|
|
// 1 keyCompromise, 2 cACompromise, 3 affiliationChanged, 4 superseded,
|
|
// 5 cessationOfOperation, 6 certificateHold, 8 removeFromCRL,
|
|
// 9 privilegeWithdrawn, 10 aACompromise. (7 is reserved.)
|
|
//
|
|
// A nil reason maps to 0 (unspecified) per RFC 5280 §5.3.1's "if the
|
|
// reason code extension is absent the reason is unspecified". An
|
|
// unknown reason string returns an error rather than silently mapping
|
|
// to unspecified — operators rely on the reason for compliance
|
|
// reporting (PCI-DSS / HIPAA) and a silent demotion would obscure a
|
|
// real bug.
|
|
//
|
|
// Accepted forms: the canonical RFC 5280 camelCase ("keyCompromise"),
|
|
// underscore_lower ("key_compromise"), and ALL_CAPS_UNDERSCORE
|
|
// ("KEY_COMPROMISE"). The certctl revocation service emits the
|
|
// camelCase form today, but the more relaxed parsing makes it
|
|
// trivially safe for operators typing reasons via the API.
|
|
//
|
|
// Audit fix #7.
|
|
func mapRevocationReason(reason *string) (acme.CRLReasonCode, error) {
|
|
if reason == nil || *reason == "" {
|
|
return acme.CRLReasonUnspecified, nil
|
|
}
|
|
// Normalise: lowercase, strip underscores. "keyCompromise",
|
|
// "key_compromise", "KEY_COMPROMISE" all collapse to
|
|
// "keycompromise" and match.
|
|
normalized := strings.ToLower(strings.ReplaceAll(*reason, "_", ""))
|
|
switch normalized {
|
|
case "unspecified":
|
|
return acme.CRLReasonUnspecified, nil
|
|
case "keycompromise":
|
|
return acme.CRLReasonKeyCompromise, nil
|
|
case "cacompromise":
|
|
return acme.CRLReasonCACompromise, nil
|
|
case "affiliationchanged":
|
|
return acme.CRLReasonAffiliationChanged, nil
|
|
case "superseded":
|
|
return acme.CRLReasonSuperseded, nil
|
|
case "cessationofoperation":
|
|
return acme.CRLReasonCessationOfOperation, nil
|
|
case "certificatehold":
|
|
return acme.CRLReasonCertificateHold, nil
|
|
case "removefromcrl":
|
|
return acme.CRLReasonRemoveFromCRL, nil
|
|
case "privilegewithdrawn":
|
|
return acme.CRLReasonPrivilegeWithdrawn, nil
|
|
case "aacompromise":
|
|
return acme.CRLReasonAACompromise, nil
|
|
default:
|
|
return 0, fmt.Errorf("unknown revocation reason %q (expected one of: unspecified, keyCompromise, cACompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aACompromise)", *reason)
|
|
}
|
|
}
|
|
|
|
// GetOrderStatus retrieves the current status of an ACME order.
|
|
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
|
c.logger.Info("fetching ACME order status", "order_id", orderID)
|
|
|
|
if err := c.ensureClient(ctx); err != nil {
|
|
return nil, fmt.Errorf("ACME client init: %w", err)
|
|
}
|
|
|
|
order, err := c.client.GetOrder(ctx, orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get order: %w", err)
|
|
}
|
|
|
|
status := &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: string(order.Status),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// solveAuthorizations processes all authorization URLs and solves their challenges.
|
|
// Supports HTTP-01, DNS-01, and DNS-PERSIST-01 challenge types based on configuration.
|
|
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
|
|
switch c.config.ChallengeType {
|
|
case "dns-01":
|
|
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
|
case "dns-persist-01":
|
|
return c.solveAuthorizationsDNSPersist01(ctx, authzURLs)
|
|
default:
|
|
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
|
|
}
|
|
}
|
|
|
|
// solveAuthorizationsHTTP01 solves challenges using the HTTP-01 method.
|
|
func (c *Connector) solveAuthorizationsHTTP01(ctx context.Context, authzURLs []string) error {
|
|
// Start the challenge server
|
|
srv, err := c.startChallengeServer()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start challenge server: %w", err)
|
|
}
|
|
defer func() {
|
|
// Derive the challenge-server shutdown context from the parent ctx so
|
|
// values (trace IDs, deadlines) propagate, but detach from its
|
|
// cancellation so Shutdown always gets its full budget even when the
|
|
// parent was cancelled (M-2 / D-3).
|
|
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
c.logger.Debug("challenge server stopped")
|
|
}()
|
|
|
|
for _, authzURL := range authzURLs {
|
|
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
|
}
|
|
|
|
if authz.Status == acme.StatusValid {
|
|
continue
|
|
}
|
|
|
|
// Find the HTTP-01 challenge
|
|
var httpChallenge *acme.Challenge
|
|
for _, ch := range authz.Challenges {
|
|
if ch.Type == "http-01" {
|
|
httpChallenge = ch
|
|
break
|
|
}
|
|
}
|
|
|
|
if httpChallenge == nil {
|
|
return fmt.Errorf("no HTTP-01 challenge found for %s", authz.Identifier.Value)
|
|
}
|
|
|
|
// Compute the key authorization
|
|
keyAuth, err := c.client.HTTP01ChallengeResponse(httpChallenge.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to compute key authorization: %w", err)
|
|
}
|
|
|
|
// Store it for the challenge server to serve
|
|
c.challengeMu.Lock()
|
|
c.challengeTokens[httpChallenge.Token] = keyAuth
|
|
c.challengeMu.Unlock()
|
|
|
|
c.logger.Info("accepting HTTP-01 challenge",
|
|
"domain", authz.Identifier.Value,
|
|
"token", httpChallenge.Token)
|
|
|
|
// Tell the CA we're ready
|
|
if _, err := c.client.Accept(ctx, httpChallenge); err != nil {
|
|
return fmt.Errorf("failed to accept challenge: %w", err)
|
|
}
|
|
|
|
// Wait for authorization to be valid
|
|
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
|
return fmt.Errorf("authorization failed for %s: %w", authz.Identifier.Value, err)
|
|
}
|
|
|
|
c.logger.Info("authorization validated", "domain", authz.Identifier.Value)
|
|
|
|
// Clean up token
|
|
c.challengeMu.Lock()
|
|
delete(c.challengeTokens, httpChallenge.Token)
|
|
c.challengeMu.Unlock()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// solveAuthorizationsDNS01 solves challenges using the DNS-01 method.
|
|
// DNS-01 is required for wildcard certificates (*.example.com) and works
|
|
// when the server is not publicly reachable on port 80.
|
|
func (c *Connector) solveAuthorizationsDNS01(ctx context.Context, authzURLs []string) error {
|
|
if c.dnsSolver == nil {
|
|
return fmt.Errorf("DNS-01 challenge type configured but no DNS solver available")
|
|
}
|
|
|
|
for _, authzURL := range authzURLs {
|
|
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
|
}
|
|
|
|
if authz.Status == acme.StatusValid {
|
|
continue
|
|
}
|
|
|
|
// Find the DNS-01 challenge
|
|
var dnsChallenge *acme.Challenge
|
|
for _, ch := range authz.Challenges {
|
|
if ch.Type == "dns-01" {
|
|
dnsChallenge = ch
|
|
break
|
|
}
|
|
}
|
|
|
|
if dnsChallenge == nil {
|
|
return fmt.Errorf("no DNS-01 challenge found for %s", authz.Identifier.Value)
|
|
}
|
|
|
|
// Compute the DNS-01 key authorization (base64url-encoded SHA-256 digest)
|
|
keyAuth, err := c.client.DNS01ChallengeRecord(dnsChallenge.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to compute DNS-01 key authorization: %w", err)
|
|
}
|
|
|
|
domain := authz.Identifier.Value
|
|
|
|
c.logger.Info("presenting DNS-01 challenge",
|
|
"domain", domain,
|
|
"token", dnsChallenge.Token)
|
|
|
|
// Create the DNS TXT record
|
|
if err := c.dnsSolver.Present(ctx, domain, dnsChallenge.Token, keyAuth); err != nil {
|
|
return fmt.Errorf("failed to present DNS record for %s: %w", domain, err)
|
|
}
|
|
|
|
// Wait for DNS propagation (ctx-aware so graceful shutdown can interrupt — F-003)
|
|
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
|
|
c.logger.Info("waiting for DNS propagation",
|
|
"domain", domain,
|
|
"wait_seconds", c.config.DNSPropagationWait)
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
|
|
return ctx.Err()
|
|
case <-time.After(propagationWait):
|
|
}
|
|
|
|
// Tell the CA we're ready
|
|
if _, err := c.client.Accept(ctx, dnsChallenge); err != nil {
|
|
// Clean up even on failure
|
|
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
|
|
return fmt.Errorf("failed to accept DNS-01 challenge: %w", err)
|
|
}
|
|
|
|
// Wait for authorization to be valid
|
|
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
|
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
|
|
return fmt.Errorf("DNS-01 authorization failed for %s: %w", domain, err)
|
|
}
|
|
|
|
c.logger.Info("DNS-01 authorization validated", "domain", domain)
|
|
|
|
// Clean up the DNS record
|
|
if err := c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth); err != nil {
|
|
c.logger.Warn("failed to clean up DNS record (non-fatal)",
|
|
"domain", domain,
|
|
"error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// solveAuthorizationsDNSPersist01 solves challenges using the DNS-PERSIST-01 method.
|
|
// DNS-PERSIST-01 uses a standing TXT record at _validation-persist.<domain> that persists
|
|
// across renewals. The record contains the CA's issuer domain and the ACME account URI,
|
|
// authorizing unlimited future issuances without per-renewal DNS updates.
|
|
//
|
|
// Flow:
|
|
// 1. For each authorization, check if it's already valid (standing record exists)
|
|
// 2. If pending, find the dns-persist-01 challenge
|
|
// 3. Build the TXT record value: "<issuer-domain>; accounturi=<account-uri>"
|
|
// 4. Create the _validation-persist TXT record via the present script (one-time)
|
|
// 5. Wait for propagation, then accept the challenge
|
|
// 6. No cleanup — the record is permanent by design
|
|
//
|
|
// See: draft-ietf-acme-dns-persist (IETF), CA/Browser Forum ballot SC-088v3
|
|
func (c *Connector) solveAuthorizationsDNSPersist01(ctx context.Context, authzURLs []string) error {
|
|
if c.dnsSolver == nil {
|
|
return fmt.Errorf("dns-persist-01 challenge type configured but no DNS solver available")
|
|
}
|
|
|
|
// Get the account URI for the TXT record value
|
|
if err := c.ensureClient(ctx); err != nil {
|
|
return fmt.Errorf("ACME client init for dns-persist-01: %w", err)
|
|
}
|
|
acct, err := c.client.GetReg(ctx, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get ACME account URI for dns-persist-01: %w", err)
|
|
}
|
|
|
|
for _, authzURL := range authzURLs {
|
|
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
|
}
|
|
|
|
// If already valid (standing record recognized), skip
|
|
if authz.Status == acme.StatusValid {
|
|
c.logger.Info("dns-persist-01 authorization already valid (standing record recognized)",
|
|
"domain", authz.Identifier.Value)
|
|
continue
|
|
}
|
|
|
|
// Find the dns-persist-01 challenge
|
|
var persistChallenge *acme.Challenge
|
|
for _, ch := range authz.Challenges {
|
|
if ch.Type == "dns-persist-01" {
|
|
persistChallenge = ch
|
|
break
|
|
}
|
|
}
|
|
|
|
// Fallback: if the CA doesn't offer dns-persist-01 yet, try dns-01
|
|
if persistChallenge == nil {
|
|
c.logger.Warn("dns-persist-01 challenge not offered by CA, falling back to dns-01",
|
|
"domain", authz.Identifier.Value)
|
|
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
|
}
|
|
|
|
domain := authz.Identifier.Value
|
|
|
|
// Build the persistent TXT record value per draft-ietf-acme-dns-persist:
|
|
// "<issuer-domain>; accounturi=<account-uri>"
|
|
recordValue := fmt.Sprintf("%s; accounturi=%s", c.config.DNSPersistIssuerDomain, acct.URI)
|
|
|
|
c.logger.Info("creating persistent DNS validation record",
|
|
"domain", domain,
|
|
"fqdn", "_validation-persist."+domain,
|
|
"issuer_domain", c.config.DNSPersistIssuerDomain,
|
|
"account_uri", acct.URI)
|
|
|
|
// Create the standing TXT record via the present script.
|
|
// The script receives CERTCTL_DNS_FQDN="_validation-persist.<domain>"
|
|
// and CERTCTL_DNS_VALUE="<issuer-domain>; accounturi=<account-uri>".
|
|
if err := c.presentPersistRecord(ctx, domain, persistChallenge.Token, recordValue); err != nil {
|
|
return fmt.Errorf("failed to create persistent DNS record for %s: %w", domain, err)
|
|
}
|
|
|
|
// Wait for DNS propagation (ctx-aware so graceful shutdown can interrupt — F-003)
|
|
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
|
|
c.logger.Info("waiting for DNS propagation",
|
|
"domain", domain,
|
|
"wait_seconds", c.config.DNSPropagationWait)
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(propagationWait):
|
|
}
|
|
|
|
// Tell the CA we're ready
|
|
if _, err := c.client.Accept(ctx, persistChallenge); err != nil {
|
|
return fmt.Errorf("failed to accept dns-persist-01 challenge: %w", err)
|
|
}
|
|
|
|
// Wait for authorization to be valid
|
|
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
|
return fmt.Errorf("dns-persist-01 authorization failed for %s: %w", domain, err)
|
|
}
|
|
|
|
c.logger.Info("dns-persist-01 authorization validated (record is now permanent)",
|
|
"domain", domain)
|
|
|
|
// No cleanup — the record is permanent by design.
|
|
// Future renewals will skip challenge solving entirely (authz.Status == StatusValid).
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// presentPersistRecord creates a _validation-persist TXT record using the DNS solver.
|
|
// Unlike dns-01 which uses _acme-challenge, dns-persist-01 uses _validation-persist.
|
|
func (c *Connector) presentPersistRecord(ctx context.Context, domain, token, recordValue string) error {
|
|
if c.dnsSolver == nil {
|
|
return fmt.Errorf("DNS solver not configured")
|
|
}
|
|
|
|
// Use PresentPersist if available (ScriptDNSSolver) — targets _validation-persist prefix.
|
|
if solver, ok := c.dnsSolver.(*ScriptDNSSolver); ok {
|
|
return solver.PresentPersist(ctx, domain, token, recordValue)
|
|
}
|
|
|
|
// For other DNSSolver implementations, fall back to Present.
|
|
// Custom implementations should read CERTCTL_DNS_FQDN to determine the record name.
|
|
return c.dnsSolver.Present(ctx, domain, token, recordValue)
|
|
}
|
|
|
|
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
|
|
// It listens on the configured HTTP port and serves challenge tokens at
|
|
// /.well-known/acme-challenge/{token}.
|
|
func (c *Connector) startChallengeServer() (*http.Server, error) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, r *http.Request) {
|
|
token := r.URL.Path[len("/.well-known/acme-challenge/"):]
|
|
|
|
c.challengeMu.RLock()
|
|
keyAuth, ok := c.challengeTokens[token]
|
|
c.challengeMu.RUnlock()
|
|
|
|
if !ok {
|
|
c.logger.Warn("unknown challenge token", "token", token)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
c.logger.Debug("serving challenge response", "token", token)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write([]byte(keyAuth))
|
|
})
|
|
|
|
addr := fmt.Sprintf(":%d", c.config.HTTPPort)
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
|
}
|
|
|
|
go func() {
|
|
c.logger.Info("challenge server started", "address", addr)
|
|
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
c.logger.Error("challenge server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
// buildIdentifiers constructs ACME domain identifiers from common name and SANs.
|
|
func buildIdentifiers(commonName string, sans []string) []acme.AuthzID {
|
|
seen := make(map[string]bool)
|
|
var ids []acme.AuthzID
|
|
|
|
// Add CN first
|
|
if commonName != "" {
|
|
seen[commonName] = true
|
|
ids = append(ids, acme.AuthzID{Type: "dns", Value: commonName})
|
|
}
|
|
|
|
// Add SANs, deduplicating
|
|
for _, san := range sans {
|
|
if san != "" && !seen[san] {
|
|
seen[san] = true
|
|
ids = append(ids, acme.AuthzID{Type: "dns", Value: san})
|
|
}
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
// parseCSRPEM decodes a PEM-encoded CSR to DER bytes.
|
|
func parseCSRPEM(csrPEM string) ([]byte, error) {
|
|
block, _ := pem.Decode([]byte(csrPEM))
|
|
if block == nil {
|
|
return nil, fmt.Errorf("failed to decode CSR PEM")
|
|
}
|
|
if block.Type != "CERTIFICATE REQUEST" {
|
|
return nil, fmt.Errorf("unexpected PEM type: %s (expected CERTIFICATE REQUEST)", block.Type)
|
|
}
|
|
return block.Bytes, nil
|
|
}
|
|
|
|
// parseDERChain converts a DER certificate chain to PEM strings and extracts metadata.
|
|
func parseDERChain(derChain [][]byte) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
|
if len(derChain) == 0 {
|
|
err = fmt.Errorf("empty certificate chain")
|
|
return
|
|
}
|
|
|
|
// First cert is the leaf
|
|
leafCert, parseErr := x509.ParseCertificate(derChain[0])
|
|
if parseErr != nil {
|
|
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
|
return
|
|
}
|
|
|
|
serial = leafCert.SerialNumber.String()
|
|
notBefore = leafCert.NotBefore
|
|
notAfter = leafCert.NotAfter
|
|
|
|
// Encode leaf to PEM
|
|
certPEM = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: derChain[0],
|
|
}))
|
|
|
|
// Encode remaining chain certs to PEM
|
|
for i := 1; i < len(derChain); i++ {
|
|
chainPEM += string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: derChain[i],
|
|
}))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// GenerateCRL is not supported by ACME issuers.
|
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
|
return nil, fmt.Errorf("ACME issuers do not support CRL generation")
|
|
}
|
|
|
|
// SignOCSPResponse is not supported by ACME issuers.
|
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
|
return nil, fmt.Errorf("ACME issuers do not support OCSP response signing")
|
|
}
|
|
|
|
// GetCACertPEM is not supported by ACME issuers (the CA chain is returned per-issuance).
|
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
|
return "", fmt.Errorf("ACME issuers do not provide a static CA certificate; chain is returned per-issuance")
|
|
}
|