Closes Top-10 fix#2 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix,
vault.Config.Token and digicert.Config.APIKey were plain string
fields. Practical impact:
1. GET /api/v1/issuers responses marshalled the credential into
the JSON body. An acquirer's procurement engineer running
'curl /api/v1/issuers | jq' saw the token / API key in plain
text on screen.
2. DEBUG-level HTTP request logging printed the credential
header verbatim.
3. A heap dump of the running server contained the credential
as readable bytes for the lifetime of the process.
Bundle I from the 2026-05-01 audit closed this for AWSACMPCA,
EJBCA, GlobalSign, Sectigo (Phase 1+2). Vault and DigiCert were
left out. This commit ports the same migration onto them.
Mechanics:
- Config.Token / Config.APIKey type changed from 'string' to
'*secret.Ref'. UnmarshalJSON of a JSON string populates the
Ref via NewRefFromString — operator config files are
unchanged.
- Every header-write call site routed through Ref.Use, with the
byte buffer zeroed after the callback returns. Vault: 3 sites
(IssueCertificate, RevokeCertificate, GetCACertPEM). DigiCert:
5 sites (ValidateConfig, IssueCertificate, RevokeCertificate,
pollOrderOnce, downloadCertificate).
- ValidateConfig nil-checks switch from 'cfg.Token == ""' to
'cfg.Token.IsEmpty()' (mirrors Sectigo's existing pattern).
- Tests migrated: every Config{Token:"..."} →
Config{Token: secret.NewRefFromString("...")}. The
'json.Marshal(config) → ValidateConfig(rawConfig)' round-trip
pattern in DigiCert's ValidateConfig_Success test is now
broken by the redact-on-marshal contract — switched that one
to construct the rawConfig as a JSON literal (mirrors
Sectigo's existing test pattern).
- Two new tests pin the redact-on-marshal contract:
- TestVault_Config_TokenMarshalsAsRedacted (vault_redact_test.go)
- TestDigiCert_Config_APIKeyMarshalsAsRedacted (digicert_redact_test.go)
Both assert the marshaled JSON contains '"[redacted]"' and
does NOT contain the plaintext bytes.
Operator-visible: GET /api/v1/issuers responses for type=vault
and type=digicert now show the credential as '[redacted]'.
Existing config files keep working — the Ref unmarshal accepts
strings.
CHANGELOG note: certctl/CHANGELOG.md is intentionally not
hand-edited; release notes are auto-generated from commit
messages between consecutive tags. This commit's message body is
the release-note artifact.
Verified locally:
- gofmt clean across the repo.
- go vet ./... clean across the repo.
- go test -race -count=1 -short
./internal/connector/issuer/vault/...
./internal/connector/issuer/digicert/...
./internal/secret/... green.
Audit reference: cowork/issuer-coverage-audit-2026-05-03/
RESULTS.md Top-10 fix#2.
Phase 1 of the #5 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Pre-fix, four async-CA connectors (DigiCert, Sectigo,
Entrust, GlobalSign) had GetOrderStatus paths that polled the upstream
on every scheduler tick with no exponential backoff, no max-retry cap,
and no deadline. The scheduler's tick rate (typically 30s) was the
only throttle — an unready order got hit every 30s indefinitely, and
a 429 from a rate-limited upstream produced "retry on the next tick"
which re-fanned-out the same call.
This commit ships the shared infrastructure (asyncpoll package) and
refactors DigiCert as the reference. Sectigo / Entrust / GlobalSign
follow the same mechanical pattern; they land in Phase 2.
Phase 1 (this commit):
- internal/connector/issuer/asyncpoll/asyncpoll.go: shared Poller
with exponential backoff (5s → 15s → 45s → 2m → 5m capped),
±20% jitter, configurable MaxWait deadline (default 10m), and
ctx-aware cancellation.
- Result enum: StillPending / Done / Failed. PollFunc returns
(Result, err); Poll handles the wait loop, deadline check, and
ctx propagation.
- ErrMaxWait sentinel for callers that want to distinguish
"deadline exhausted" from "fn errored".
- asyncpoll_test.go: 11 tests covering happy path, transient error
keep-polling, Failed terminates immediately, MaxWait timeout,
MaxWait+lastErr wrap, ctx cancel, multiplicative backoff, jitter
bounds (statistical), pct=0 deterministic, defaults applied.
- DigiCert refactor: GetOrderStatus now wraps pollOrderOnce in
asyncpoll.Poll. Status-code triage:
2xx + parse + status="issued" → Done with cert
2xx + parse + status="pending" → StillPending
2xx + parse + status="rejected"/"denied" → Done with status="failed"
2xx + parse fail → Failed (permanent)
4xx (not 429) → Failed (404 = order
doesn't exist)
429 / 5xx / network → StillPending
- Config.PollMaxWaitSeconds (env: CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS)
exposes the per-call deadline knob; default 600 (10m).
- Test helper buildDigicertConnector + GetOrderStatus_Pending test
set PollMaxWaitSeconds=1 so async-pending tests don't block 10
minutes on the production default.
Phase 2 (separate follow-up commit, not in this PR):
- Sectigo refactor (collectNotReady sentinel maps to StillPending).
- Entrust refactor (approval-pending → longer per-issuer MaxWait).
- GlobalSign refactor (serial-tracking; same Poller).
- Per-connector cadence integration tests against fake HTTP servers.
- docs/async-polling.md + docs/connectors.md updates.
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix#5 — Phase 1.
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 71b2245) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
CI flagged one more QF1002 hit at digicert_failure_test.go:102:5
that I missed in the prior fix (only got the three at 32/51/70).
Same fix: 'switch { case r.URL.Path == "/user/me" }' →
'switch r.URL.Path { case "/user/me" }'.
The remaining switches in this file (lines 126, 149) mix
r.URL.Path == "x" with strings.Contains(r.URL.Path, "..."),
which can't be expressed as tagged switches — staticcheck
correctly does not flag those (same shape as the sectigo
switches that pass clean).
Verification: go test -short -count=1 ./internal/connector/issuer/
digicert/... PASS in 0.6s.
Bundle: N.AB-ci-fix-2
CI's golangci-lint flagged 3 staticcheck QF1002 hits on
internal/connector/issuer/digicert/digicert_failure_test.go at
lines 32, 51, 70 — 'could use tagged switch on r.URL.Path'.
Fix: convert each 'switch { case r.URL.Path == "/user/me": ... }'
to 'switch r.URL.Path { case "/user/me": ... }'. Same shape as
the Bundle J QF1002 fix-up.
Why digicert and not sectigo: sectigo's switches mix literal path
checks (case r.URL.Path == "/ssl/v1/types") with prefix checks
(case strings.HasPrefix(r.URL.Path, "/ssl/v1/collect/")), which
can't be expressed as a tagged switch. CI didn't flag sectigo.
Verification
=================
- go test -short -count=1 ./internal/connector/issuer/digicert/...:
PASS in 0.6s
- go vet ./internal/connector/issuer/digicert/...: clean
- staticcheck -checks=QF1002 across all extension test files:
clean (0 hits)
Bundle: N.AB-ci-fix
Closes M-001 partially; M-002, M-003, and CI threshold raise #2 deferred.
Stubs coverage shipped across 8 issuer connectors via per-connector
<conn>_stubs_test.go (~50 LoC each) pinning the not-supported
issuer.Connector interface methods (GenerateCRL, SignOCSPResponse,
GetCACertPEM, GetRenewalInfo). Most CAs delegate CRL/OCSP/CA-cert
distribution to managed services, so these are documented stubs that
return errors. Pinning them ensures the stubs aren't silently replaced
with no-ops in a future refactor.
Coverage delta:
digicert: 79.3% -> 81.0% (+1.7pp)
ejbca: 75.8% -> 76.5% (+0.7pp)
entrust: 70.8% -> 70.8% (stubs already covered)
sectigo: 78.0% -> 79.4% (+1.4pp)
vault: 81.0% -> 84.1% (+3.1pp)
openssl: 76.9% -> 78.0% (+1.1pp)
googlecas: 81.0% -> 83.4% (+2.4pp)
globalsign: 75.9% -> 78.2% (+2.3pp)
(awsacmpca not included; its 0%-coverage hotspots are stubClient methods
structurally different from the others' interface stubs. Already at 83.5%.)
Why the gates aren't yet met: the stub functions are tiny (1-2 lines
each, mostly 'return nil, fmt.Errorf("not supported")'). Lifting each
connector to >=85% requires per-connector failure-mode test files
mirroring Bundle J's ACME pattern (httptest.Server + canned 401/403/
429+Retry-After/5xx/malformed responses against the actual API methods).
That's ~200-300 LoC x 9 connectors = ~2000-2700 LoC of bespoke per-CA
mock work; exceeds this session's budget. Tracked as follow-on
Bundle N.A-extended / N.B-extended.
Deferred sub-batches:
N.C (M-002 + M-003): internal/service (70.5%) + internal/api/handler
(79.4%) round-out NOT YET STARTED. Tracked as Bundle N.C-extended.
N.CI (CI threshold raise #2): prescribed raises require underlying
coverage at proposed floors first. Premature raise would fail CI
immediately. Tracked as Bundle N.CI-extended.
Verification:
go vet ./internal/connector/issuer/{8-pkgs}/... clean
gofmt -l clean
go test -short -count=1 PASS for all 8
Audit deliverables:
gap-backlog.md: M-001 partial-strikethrough with per-connector table
+ Bundle N closure-log entry covering all 4 sub-batch statuses
closure-plan.md: Bundle N [~] with per-sub-batch status breakdown
CHANGELOG.md: [unreleased] Bundle N entry
Convert `switch { case r.URL.Path == ... }` to `switch r.URL.Path { ... }`
in Vault and DigiCert connector tests to pass golangci-lint CI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>