Phase 2 of the #6 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 520cda3) shipped the secret.Ref opaque
credential type with PBKDF2-derived key, ChaCha20-Poly1305 envelope,
String/MarshalJSON redaction to "[redacted]", and the Use callback
that zero-fills the per-call buffer after the consumer returns.
This commit applies the type to the three connectors flagged by the
audit and adds the JSON-roundtrip glue that the production factory
path needs.
Shared (internal/secret/):
- Add UnmarshalJSON on *Ref so json.Unmarshal of a stored config
blob (issuerfactory.NewFromConfig) parses the bytes-as-string into
NewRefFromString without callers having to know the field type
changed. Null and missing keys leave the receiver nil; non-string
payloads (numbers, bools) are rejected with a typed error. Pinned
by TestRef_UnmarshalJSON: string_value, null, missing_key,
number_rejected, roundtrip_marshal_then_unmarshal (the round-trip
goes through "[redacted]" intentionally — JSON-marshal-then-
unmarshal of a Config with secrets is NOT a supported test pattern;
callers that construct a rawConfig must use a JSON literal with
the real values).
Per-connector migration:
- EJBCA (ejbca.go): Config.Token: string → *secret.Ref. ValidateConfig
empty-check uses Token.IsEmpty() (nil-safe). setAuthHeaders rewritten
to call Token.Use; the Bearer header string is built inside the
callback and the buffer is zeroed on return. mTLS path is
unaffected.
- GlobalSign (globalsign.go): Config.APIKey + Config.APISecret: string
→ *secret.Ref. Both ValidateConfig empty-checks use IsEmpty().
Extracted setAuthHeaders helper consolidates the four duplicated
triple-Set sites (ValidateConfig probe, IssueCertificate,
RevokeCertificate, pollCertificateOnce) so any future header-shape
change applies once. ValidateConfig now pulls from the local cfg
(post-Unmarshal) so the helper takes a *Config rather than the
receiver — needed because ValidateConfig writes the validated cfg
onto c.config only AFTER the probe succeeds.
- Sectigo (sectigo.go): Config.Login + Config.Password: string →
*secret.Ref. CustomerURI stays plain string (org identifier, not
a credential). setAuthHeaders rewritten to call Login.Use +
Password.Use; ValidateConfig's inline header writes use the same
pattern (the ValidateConfig probe writes to a local cfg, not
c.config, so it can't share setAuthHeaders without rewiring — the
inline form is fine, kept consistent in shape).
Test migration:
- ejbca_test.go, ejbca_failure_test.go, ejbca_stubs_test.go: bulk
Token: "X" → Token: secret.NewRefFromString("X") via sed; secret
import added.
- globalsign_test.go, globalsign_failure_test.go: same pattern for
APIKey + APISecret.
- sectigo_test.go, sectigo_failure_test.go: same pattern for Login +
Password.
Two tests (TestGlobalSign_ServerTLSConfig/PinnedCA_TrustsExpectedServer
and TestSectigoConnector/ValidateConfig_Success) used to construct
rawConfig via json.Marshal(config) → ValidateConfig(rawConfig). After
the migration, json.Marshal redacts *secret.Ref to "[redacted]" by
design, so the roundtripped rawConfig wrote "[redacted]" as the
actual header value and the mock server's auth-header check 403'd.
Both tests now build rawConfig as a JSON literal (the production-
shape input — the factory path always feeds rawConfig from the DB
or env, never from json.Marshal of an in-memory Config). The new
tests have a comment explaining the trap so the next person who
adds a similar test sees the pattern.
Out of scope (intentional):
- The `internal/config/config.SectigoConfig` / `GlobalSignConfig` /
`EJBCAConfig` env-var-loader structs are still plain strings —
those types are the env-load shape, not the steady-state runtime
shape. The seed path in service/issuer.go json-marshals them into
a map[string]interface{} which the factory then UnmarshalJSON's
into the connector Config; the new UnmarshalJSON on *Ref handles
the conversion at the boundary.
- DigiCert.APIKey + Vault.Token are still plain strings; Phase 3
will pick them up. The audit explicitly named EJBCA / GlobalSign /
Sectigo as the Phase 2 scope (RESULTS.md L633).
Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck across all four packages clean
- go test -short -count=1 across secret, ejbca, globalsign, sectigo,
issuerfactory, service, api/handler: green
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix#6 — Phase 2.
Phase 2 of the #5 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 593210f) shipped the shared asyncpoll
package and refactored DigiCert as the reference. This commit applies
the same pattern to the remaining three async-CA connectors and adds
the operator-facing docs.
Per-connector refactors:
- Sectigo (sectigo.go): GetOrderStatus now wraps pollEnrollmentOnce in
asyncpoll.Poll. The collectNotReady sentinel (cert approved by SCM
but not yet retrievable from the collect endpoint) maps to
StillPending and rides the backoff schedule rather than the prior
"return pending immediately" branch. Added isPermanentStatusError
helper to distinguish transient HTTP errors (5xx / 429 / network)
from permanent ones (4xx / parse failure) — the wrapped checkStatus
errors get triaged at the poll closure boundary.
- Entrust (entrust.go): GetOrderStatus wraps pollEnrollmentOnce. The
AWAITING_APPROVAL status maps to StillPending; operators using
approval-pending workflows where humans approve enrollments should
bump CERTCTL_ENTRUST_POLL_MAX_WAIT_SECONDS to 86400 (24h) so a
single scheduler tick can wait through the approval window. The
default 10-minute deadline matches the other three connectors.
- GlobalSign (globalsign.go): GetOrderStatus wraps pollCertificateOnce.
GlobalSign tracks orders by serial number rather than order ID, but
the polling shape is identical to the other three. Status-code
triage matches DigiCert: 4xx (not 429) is permanent, 5xx / 429 /
network is transient.
Per-connector Config field added:
- DigiCert.PollMaxWaitSeconds (env CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS)
- Sectigo.PollMaxWaitSeconds (env CERTCTL_SECTIGO_POLL_MAX_WAIT_SECONDS)
- Entrust.PollMaxWaitSeconds (env CERTCTL_ENTRUST_POLL_MAX_WAIT_SECONDS)
- GlobalSign.PollMaxWaitSeconds (env CERTCTL_GLOBALSIGN_POLL_MAX_WAIT_SECONDS)
internal/config/config.go env-var loaders updated for all four. Default
is 600 seconds (10 minutes); zero falls back to the asyncpoll package
default.
Test-helper updates: every existing test that exercises the pending
branch (collectNotReady, AWAITING_APPROVAL, status="pending", etc.)
now sets PollMaxWaitSeconds=1 in its Config so the test doesn't block
on the production-default 10-minute deadline. Tests that exercise
permanent-error branches (404, 401, malformed JSON, etc.) continue
to return immediately.
Test sites updated:
- buildSectigoConnector helper + GetOrderStatus_CollectNotReady test
- buildEntrustConnector helper + GetOrderStatus_Pending test
- buildGlobalsignConnector helper + GetOrderStatus_Pending test +
the GetHTTPClient_NoMTLSCertPaths test (network failure now rides
the backoff schedule rather than returning immediately)
Documentation:
- docs/async-polling.md: new operator reference covering the backoff
schedule, status-code triage, the four env vars, failure modes, and
where the implementation lives. Audit blocker citation included.
- docs/connectors.md: per-issuer sections for DigiCert, Sectigo,
Entrust, GlobalSign each gain the PollMaxWaitSeconds env var row
and a cross-link to async-polling.md.
Lint cleanup: simplified the isPermanentStatusError branch to satisfy
staticcheck S1008 (single-line return for a final boolean check).
Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 across all 4 connector packages + config + asyncpoll: green
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix#5 — Phase 2.
The GlobalSign Atlas HVCA connector previously used InsecureSkipVerify:true
on its mTLS TLS config, disabling server certificate validation and
defeating the purpose of the client-side mTLS handshake. This was a
CWE-295 Improper Certificate Validation vulnerability silently degrading
trust on every production call to GlobalSign's signing API.
Remediation (per H-5 audit finding, Lens 4.4):
- Remove InsecureSkipVerify from all three http.Client construction sites
(ValidateConfig, getHTTPClient, and legacy initialisation path).
- Introduce buildServerTLSConfig() helper that constructs tls.Config with
MinVersion: tls.VersionTLS12 (addresses adjacent L-1 recommendation).
- New optional config field `server_ca_path` (env:
CERTCTL_GLOBALSIGN_SERVER_CA_PATH). When unset the connector trusts the
system root CA bundle (correct default for GlobalSign's publicly-trusted
HVCA endpoints). When set the bundle is loaded via x509.NewCertPool() +
AppendCertsFromPEM, and only those roots are trusted (supports private
HVCA deployments and defence-in-depth root pinning).
- Error wrapping chain: "failed to read server CA bundle at %s" and
"no valid PEM certificates found in server CA bundle at %s" surface
config problems at ValidateConfig time instead of silently failing at
request time.
Docs, config, service env-seed, and GUI issuer type definition updated to
expose the new field. Tests: 9 dead `InsecureSkipVerify: true` client
TLSClientConfig blocks (no-ops against httptest.NewServer plain-HTTP)
replaced with bare http.Client; new TestGlobalSign_ServerTLSConfig covers
pinned-CA trust, untrusted-server rejection, missing-file and invalid-PEM
error paths.
Verification:
- go build ./... clean
- go vet ./... clean
- go test -race ./internal/connector/issuer/globalsign/... ./internal/config/... ./internal/service/... ok
- go test ./... (excluding testcontainers-gated repo layer) ok
- golangci-lint run ./... 0 issues
- govulncheck ./... 0 reachable vulns
- Per-layer coverage: service 68.7% (≥55), handler 83.6% (≥60), domain 82.0% (≥40), middleware 63.8% (≥30)
- globalsign package coverage: 75.9%
- Invariant sweep: 0 InsecureSkipVerify references remain in globalsign
package (only a test-file comment documenting the removal).
Add three new issuer connectors completing commercial and open-source CA
coverage. Entrust uses mTLS client certificate auth with sync/async
issuance. GlobalSign Atlas uses mTLS + API key/secret dual auth with
serial-based tracking. EJBCA supports dual auth (mTLS or OAuth2) for
self-hosted Keyfactor CAs.
Each connector implements the full issuer.Connector interface (9 methods),
includes httptest-based unit tests (~14 each), and follows established
patterns (injectable HTTP clients, RFC 5280 revocation reason mapping,
CRL/OCSP delegated to CA).
Also includes: issuer factory cases, env var seeding, config structs,
domain types, seed data (3 rows, all disabled), OpenAPI enum updates,
frontend issuer catalog entries with config fields, and full docs
(connectors.md, architecture.md, features.md, README).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>