Closes Top-10 fix#4 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix, both
adapters had only happy-path test coverage with a single generic
ServerError pair each. Cloud CAs are typically the first-deployed
issuer in enterprise pilots; their diligence reviews dig hard into
IAM-error / cloud-error coverage. This commit lands the contract
tests.
AWSACMPCA — 5 tests in awsacmpca_failure_test.go. Each injects a
typed AWS SDK v2 error via the existing mockACMPCAClient seam and
asserts (1) error non-nil, (2) errors.As against the SDK's typed
value succeeds (so the wrap chain through fmt.Errorf("...%w", ...)
is intact), and (3) operator-actionable substring is present.
1. Issue_AccessDenied — *smithy.GenericAPIError with
Code="AccessDeniedException" (the SDK does NOT generate a
typed *types.AccessDeniedException; AWS uses the smithy
APIError shape for IAM denials). Asserts ErrorCode +
"not authorized" + IAM resource path preserved through wrap.
2. Issue_ResourceNotFound — *types.ResourceNotFoundException
names the missing CA ARN.
3. Issue_Throttling — *smithy.GenericAPIError with
Code="ThrottlingException", Fault=FaultServer. Asserts the
retryable class (FaultServer) is preserved through wrap so
upstream retry logic can engage.
4. Issue_MalformedCSR — *types.MalformedCSRException is terminal
(operator must fix the CSR, not retry); asserts the
validation-issue substring survives.
5. Issue_RequestInProgress — *types.RequestInProgressException
wraps cleanly; classification (retry vs reissue) is upstream's
responsibility per the spec's "no new retry logic" rule.
GoogleCAS — 5 tests in googlecas_failure_test.go. The adapter uses
stdlib net/http directly (NO Google Cloud Go SDK dependency in
googlecas.go), so SDK typed-error assertions don't translate. Each
test runs an httptest.Server that returns the canonical Google API
JSON error envelope:
{"error":{"code":N,"message":"...","status":"<STATUS>"}}
and asserts (1) error non-nil, (2) operator-actionable substring,
and (3) the canonical status string ("PERMISSION_DENIED",
"NOT_FOUND", "UNAVAILABLE") survives the wrap chain so upstream
classification can branch on it.
1. Issue_PermissionDenied — 403 / PERMISSION_DENIED; surfaced
error names the IAM resource path.
2. Issue_CAPoolNotFound — 404 / NOT_FOUND; surfaced error names
the missing pool resource.
3. Issue_OAuth2TokenRefreshFailure — token endpoint returns 401
invalid_grant; surfaced error mentions "token" so an operator
reading the log immediately distinguishes a credential failure
(rotate SA key) from a CA-side error (fix IAM binding). Test
also asserts the CAS endpoint is NOT reached when the token
exchange fails.
4. Issue_RegionalAPIUnavailable — 503 / UNAVAILABLE; surfaced
error preserves the retryable class markers (status code +
UNAVAILABLE string) for upstream retry classification.
5. Revoke_PermissionDenied — adapter does NOT silently swallow
the failure; pin the contract so the audit-row atomicity
guarantee from Bundle G (which lives in the service-layer
wrapper, not the adapter) continues to apply. Test also
verifies the revoke endpoint was actually reached, guarding
against a future regression that short-circuits before the
HTTP call.
Coverage delta:
awsacmpca: 71.0% → 71.0% (failure tests reuse existing wrap
code paths; behaviour-pin contract tests, not coverage tests).
googlecas: 83.4% → 84.4% (+1.0pp).
go.mod: smithy-go moved indirect → direct, since the new AWSACMPCA
test file imports it. CI's go-mod-tidy-drift gate enforces this.
Test-only commit. No production code changes.
Verified locally:
- gofmt clean.
- go vet ./internal/connector/issuer/awsacmpca/...
./internal/connector/issuer/googlecas/... clean.
- go test -short -count=1 ./internal/connector/issuer/... green.
- go test -race -count=10 ./internal/connector/issuer/awsacmpca
./internal/connector/issuer/googlecas green.
Audit reference: cowork/issuer-coverage-audit-2026-05-03/RESULTS.md
Top-10 fix#4.
Two-fer in one commit:
(1) Dependabot security alerts on go-jose/v4 v4.0.4. Both alerts
flagged on commit 27bd660 (the Phase 1b push that introduced
the dep):
- GHSA-c6gw-w398-hv78 (CVE-2025-27144): DoS in JWS Compact
parsing when input has many `.` characters; excessive
memory consumption via strings.Split. Fixed in v4.0.5.
Same shape as CVE-2025-22868 in golang.org/x/oauth2/jws.
- GHSA-78h2-9frx-2jm8 (CVE-2026-34986): JWE decryption
panic when alg is a key-wrapping algorithm (`*KW` other
than the GCMKW family) and encrypted_key is empty. Maps
to a denial-of-service via panic. Fixed in v4.1.4.
The certctl ACME server only invokes ParseSigned for JWS verify
(the JWS path); we never call ParseEncrypted/Decrypt. So the JWE
panic doesn't reach our code path. The JWS DoS is a low-grade
concern (an attacker submitting JWS objects with many dots
could amplify memory). Both are still real CVEs; upgrading
is cheap and right.
(2) ci: fix `go mod tidy` drift on commit 4f42dfb. When I added
go-jose to the direct require block, I missed removing the
duplicate `// indirect` line in the indirect block. CI's
`go mod tidy && git diff --exit-code go.mod go.sum` flagged
the drift. Running `go mod tidy` (combined with the v4.1.4
upgrade above) cleans up both.
Verified locally:
- go.mod has exactly one `github.com/go-jose/go-jose/v4 v4.1.4`
line (in the direct require block); no `// indirect` duplicate.
- go test -count=1 -short ./internal/api/acme/ green —
confirms v4.1.4 has the same API surface (ParseSigned with
SignatureAlgorithm allowlist, Header.ExtraHeaders[HeaderKey],
JSONWebKey.Thumbprint(crypto.SHA256), Signer with
SignerOptions.WithHeader). 16-case JWS verifier suite all
pass.
- go test -count=1 -short ./internal/service/ green.
- go test -count=1 -short ./internal/api/handler/ -run TestACME
green.
- go build ./cmd/server → server binary clean.
Phase 1b push (commit 27bd660) failed three CI guards. None were
caught by `make verify` locally because they're CI-only guards
that aren't part of the Makefile target. This commit fixes all
three.
1. go.mod tidy diff. The go-jose v4 dep was added with `// indirect`
in go.mod after the initial `go get`, but the codebase imports it
directly from internal/api/acme/jws.go + service/acme.go +
handler/acme.go. CI's `go mod tidy && git diff --exit-code go.mod
go.sum` flagged the staleness. Promoted to a direct require in
the same `require (...)` block as github.com/aws/aws-sdk-go-v2
etc.
2. G-3-env-docs-drift.sh. The guard greps `\bCERTCTL_[A-Z_]+\b` in
docs/ and complains when the bare-prefix forms don't match
anything defined in config.go. Phase 1a + 1b's docs/acme-server.md
intro and migration header use bare-prefix forms `CERTCTL_ACME_*`
and `CERTCTL_ACME_SERVER_*` to describe namespace separation
(consumer-side ACMEConfig vs server-side ACMEServerConfig). Same
precedent as the existing CERTCTL_SCEP_ + CERTCTL_TLS_ +
CERTCTL_QA_* prefix entries already in the guard's ALLOWED list.
Added CERTCTL_ACME_ + CERTCTL_ACME_SERVER_ to the ALLOWED list
with a justification comment block matching the existing
integration-surface allowlist convention.
3. openapi-handler-parity.sh. Distinct from
internal/api/router/openapi_parity_test.go (which runs at `go
test` time and has its own SpecParityExceptions map I extended
in 1a + 1b) — this is a separate CI-only guard that reads
api/openapi-handler-exceptions.yaml. The 6 Phase-1a routes + 4
Phase-1b routes (10 ACME endpoints total) were never added to
that yaml. Same rationale as the SCEP/SCEP-mTLS entries already
in the file: ACME is a JWS-signed-JSON wire protocol per
RFC 8555 + RFC 9773, not an OpenAPI-shape REST surface.
Documenting every endpoint in openapi.yaml would duplicate the
RFC. The canonical reference is docs/acme-server.md. Phases 2-4
will add their routes to this yaml in lockstep with router.go.
Verified locally:
- bash scripts/ci-guards/G-3-env-docs-drift.sh → clean.
- bash scripts/ci-guards/openapi-handler-parity.sh → clean
(152 router routes, 136 OpenAPI ops, 18 documented exceptions).
- All other ci-guards/*.sh → clean.
- go.mod diff after `go mod tidy` is empty.
Layers JWS-authenticated POST machinery onto the Phase 1a foundation
(commit e146b00). After this commit, an ACME client can run
POST /acme/profile/<id>/new-account
against certctl and successfully register an account. Account update
+ deactivation via POST /acme/profile/<id>/account/<acc-id> work.
Orders + challenges remain Phase 2 / 3.
Background:
Two prior dispatch attempts at the original Phase 1 ("skeleton +
directory + new-nonce + new-account" as a single commit) failed on
go-jose v4 API speculation (jws.GetPayload, sig.Algorithm,
jose.SHA256, etc. — none of those exist in v4). Splitting Phase 1
into 1a (foundation, no go-jose) and 1b (this commit, all go-jose
in one place) concentrated the JWS work where attention pays off.
The verifier reads the actual go-jose v4 surface — ParseSigned with
closed alg allow-list, Header struct fields (Algorithm, KeyID,
JSONWebKey, Nonce, ExtraHeaders[HeaderKey]), JWK.Thumbprint with
stdlib crypto.SHA256.
What ships:
- internal/api/acme/jws.go: 487-line verifier + sentinel error
family. Enforces RFC 8555 §6.2 + §6.4 + §6.5 invariants:
- alg in {RS256, ES256, EdDSA} (closed allow-list passed to
jose.ParseSigned — HS256 / none / etc. rejected at parse time)
- exactly one of `kid` / `jwk` in protected header (per
endpoint policy — new-account demands jwk, others demand kid)
- protected `url` matches request URL exactly
- protected `nonce` consumed against acme_nonces (badNonce on
miss/replay/expiry per RFC 8555 §6.5.1)
- kid round-trips against canonical AccountKID(accountID) URL
(catches cross-profile / cross-host replay)
- kid path: account exists + status=valid (deactivated /
revoked accounts cannot authenticate)
- signature verifies; post-Verify payload bytes equal
UnsafePayloadWithoutVerification (defense in depth)
+ JWK persistence helpers (JWKToPEM / ParseJWKFromPEM round-
trip a public-only JWK as a PEM-wrapped JSON envelope; stored
as TEXT in acme_accounts.jwk_pem for diff-friendliness) +
JWKThumbprint per RFC 7638.
- internal/api/acme/jws_test.go: 16 cases covering happy paths
(RS256 kid, ES256 jwk, EdDSA kid) + every named failure mode
(alg-not-allowed, bad-sig, missing-nonce, unknown-nonce,
replay, url-mismatch, mixed kid+jwk, deactivated-account,
cross-host kid). Uses real keypairs + real go-jose Signer to
build JWS objects.
- internal/api/acme/account.go: NewAccountRequest /
AccountUpdateRequest payload shapes (RFC 8555 §7.3 + §7.3.2 +
§7.3.6) + AccountResponseJSON wire shape + MarshalAccount
helper.
- internal/domain/acme.go: ACMEAccount struct + ACMEAccountStatus
closed enum (valid / deactivated / revoked).
- internal/repository/postgres/acme.go: full account CRUD path
(CreateAccountWithTx with 23505-unique-violation sentinel
translation, GetAccountByID, GetAccountByThumbprint,
UpdateAccountContactWithTx, UpdateAccountStatusWithTx) +
sql.ErrNoRows-wrapped repository.ErrNotFound on lookup misses.
- internal/service/acme.go: ACMERepo interface extended;
SetTransactor + SetAuditService wires; NewAccount (idempotent
re-registration per RFC 8555 §7.3.1 — same JWK returns existing
row without an update or new audit event); LookupAccount;
UpdateAccount; DeactivateAccount; VerifyJWS adapter that bridges
api/acme.VerifierConfig to the service-layer ACMERepo; per-op
metrics extended (new_account_total + _failures_total +
_idempotent_total + update_account_total + _failures_total +
deactivate_account_total).
- internal/service/acme_test.go: 8 new tests covering
new-account happy path / idempotent re-registration / only-
return-existing match + no-match / contact update / deactivate
/ lookup-not-found / requires-transactor.
- internal/api/handler/acme.go: NewAccount + Account handlers.
Account dispatches POST-as-GET (RFC 8555 §6.3 — empty body or
{} payload returns the account row), contact update, and
deactivation from the same endpoint. Defense-in-depth check
that the kid path-segment matches the URL path-segment (the
verifier already round-tripped the kid against canonical URL,
but the handler re-asserts to catch any future verifier
refactor).
- internal/api/handler/acme_handler_test.go: 7 new cases
covering happy-create, idempotent-200, only-return-existing-
no-match-400, malformed-JWS-400, kid-URL-mismatch-401,
deactivate, contact-update, POST-as-GET.
- internal/api/router/router.go: 4 new Register calls (per-
profile + shorthand for new-account and account/{acc_id}).
- internal/api/router/openapi_parity_test.go: SpecParityExceptions
extended with the 4 new routes (RFC 8555 wire-protocol surface,
not OpenAPI-shaped — same precedent as Phase 1a).
- cmd/server/main.go: SetTransactor + SetAuditService on
acmeService at startup so the WithinTx-based new-account /
update / deactivate paths run with the same transactor instance
shared across CertificateService / RevocationSvc / RenewalService.
- docs/acme-server.md: Phase status updated; endpoints table grows
new-account + account/<acc_id> rows; new "JWS verification
(Phase 1b)" section enumerates the 7 invariants the verifier
enforces; phases-cross-reference table marks 1b live.
- go.mod / go.sum: github.com/go-jose/go-jose/v4 v4.0.4 added.
Atomicity: every account-state mutation writes its acme_accounts row
+ its audit_events row inside one repository.Transactor.WithinTx
call — the canonical certctl atomicity contract (matches
CertificateService.Create at internal/service/certificate.go:131).
Idempotent re-registration explicitly does NOT write an audit row
(RFC 8555 §7.3.1 returns the existing row unmodified).
Tests: 16 jws_test.go cases + 11 service tests + 11 handler tests
all pass under -short. Bad-signature test uses a real registered
account whose stored JWK is a different keypair from the signer's,
so the JWS parses cleanly but jose.Verify rejects — exercises the
ErrJWSSignatureInvalid path directly.
Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1b".
Closes the #9 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. Pre-fix, JobService.ProcessPendingJobs ran every
claimed job sequentially in a single goroutine: safe but slow, and
operators with large fleets had no lever to dial throughput up.
Switching to fire-and-forget per-job goroutines would have unbounded
the upstream-CA call rate and tripped DigiCert / Entrust / Sectigo
rate limits — certctl's response to 429 was to retry on the next
tick, re-fanning out the same calls and digging deeper into the
limit. Operators need a knob.
This commit:
- Adds CERTCTL_RENEWAL_CONCURRENCY env var (default 25) loaded via
the existing getEnvInt pattern in internal/config/config.go.
Documented inline as the cap for the per-tick renewal/issuance/
deployment goroutine fan-out, with operator-tuning guidance:
permissive upstream limits + large fleets (>10k certs) → 100;
strict limits or async-CA-heavy fleets → 25 or lower.
- Wires golang.org/x/sync/semaphore.Weighted around the per-job
goroutine launch in JobService.ProcessPendingJobs. Acquire(ctx, 1)
is the load-bearing piece — it BLOCKS the loop when at the cap,
providing real backpressure rather than fire-and-forget. The
fan-out is split into processPendingJobsSequential (legacy,
preserved for unit-test wiring that doesn't call
SetRenewalConcurrency) and processPendingJobsConcurrent (production,
delegates to a generic boundedFanOut helper).
- boundedFanOut takes the per-job work as a closure so the cap can
be tested directly without standing up the renewal/deployment
service graph. processed/failed counters use atomic.Int64 to
avoid mutex overhead on every job completion; final log line
reads both AFTER wg.Wait so the counts reflect every dispatched
job. ctx-aware Acquire ensures a shutdown ctx cancel interrupts
the dispatch loop promptly; in-flight goroutines drain via Wait
before the function returns so no goroutine outlives the
scheduler tick.
- shouldSkipJob extracted as a package-private helper so the
agent-routed-deployment skip logic is shared between the
sequential and concurrent paths byte-for-byte (the audit prompt's
"channel-based semaphore without ctx-aware acquire" anti-pattern
is explicitly avoided — semaphore.Weighted.Acquire returns on ctx
done; channel <- struct{}{} would block forever).
- SetRenewalConcurrency setter on JobService normalises ≤0 to 1.
semaphore.NewWeighted(0) constructs a semaphore that blocks every
Acquire forever; the normalisation prevents a misconfigured env
var from wedging the scheduler.
- cmd/server/main.go wires SetRenewalConcurrency(cfg.Scheduler.
RenewalConcurrency) on the freshly-built jobService, immediately
after SetAuditService. Production deployments always take the
bounded path; tests that build JobService directly via
NewJobService keep their strict-sequential behaviour because
renewalConcurrency is the zero value.
- Tests in internal/service/job_concurrency_test.go:
* TestBoundedFanOut_CapHolds — primary regression guard. 50 jobs
× 50ms work × cap=5 → asserts peak in-flight never exceeds 5
AND reaches 5 at least once (catches both upper-bound regressions
and gates that incorrectly cap below the configured value).
Lock-free max via CompareAndSwap so the measurement instrument
doesn't itself constrain concurrency.
* TestBoundedFanOut_AllJobsRun — lower-bound: every non-skipped
job is dispatched.
* TestBoundedFanOut_SkipsAgentRoutedDeployments — pins the
shouldSkipJob contract.
* TestBoundedFanOut_CtxCancelInterrupts — ctx cancellation
interrupts a stuck fan-out within the timeout budget.
* TestBoundedFanOut_FailedJobsCounted — per-job errors don't
abort the fan-out.
* TestSetRenewalConcurrency_NormalizesNonPositive — ≤0 → 1 fail-safe
pinned across negative/zero/positive inputs.
- docs/features.md: scheduler-loop table augmented with the
concurrency-cap env-var pointer alongside the job-processor row.
- docs/architecture.md: Concurrency Safety section gains a paragraph
explaining the cap, the operator-tuning guidance, the ctx-aware
Acquire semantics, and the audit reference.
Operator-facing impact: the first big renewal sweep no longer
takes down the upstream CA's rate-limit budget. Existing deployments
get the bounded path automatically (default 25); operators can
override via env var without code changes.
Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- go test -short -count=1 across service / scheduler / config /
integration: green
- Six new tests under TestBoundedFanOut* + TestSetRenewalConcurrency*:
green
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix#9.
Closes the #1 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. The production New() constructor previously hardcoded
&stubClient{}, which returned "AWS SDK client not initialized (stub)" on
every method. Tests passed green via NewWithClient mock injection — a
path the production constructor never took. AWSACMPCA was wired into
the factory, the seed file, the test suite, and marketing collateral
but did not actually issue, retrieve, or revoke certificates.
This commit:
- Adds aws-sdk-go-v2/{config,service/acmpca,aws} to go.mod (with
acmpca/types as a sub-package). go mod tidy could not be completed
in the sandbox due to virtiofs concurrent-open-file ceiling on the
module cache; the require blocks were arranged manually so the three
directly-imported packages are non-indirect. Build, vet, staticcheck,
and the full test suite are green; operator should run `go mod tidy`
on the workstation to confirm cosmetic ordering before pushing.
- Implements sdkClient wrapping *acmpca.Client with local input/output
type translation. Each method translates the connector's local input
type to the SDK's typed input, calls the SDK, and translates the SDK
output back to the local output type. aws-sdk-go-v2 types do not
leak out of the awsacmpca package.
- Deletes stubClient (the four "AWS SDK client not initialized (stub)"
methods). After this commit, there is no fall-back stub; production
New() always wires the SDK.
- Rewrites New() to load credentials via awsconfig.LoadDefaultConfig
with awsconfig.WithRegion(config.Region) and construct the SDK client
via acmpca.NewFromConfig. Returns (*Connector, error). When config
is nil or config.Region is empty, New defers SDK loading; ValidateConfig
builds the client lazily on the first successful validation. This
preserves the test pattern of New(nil, logger) → ValidateConfig.
- Wires acmpca.NewCertificateIssuedWaiter (5-minute default timeout)
inside sdkClient.IssueCertificate so the connector's two-call
pattern (IssueCertificate → GetCertificate) sees synchronous-via-
waiter semantics. The waiter is hidden from the ACMPCAClient
interface so mock implementations stay simple.
- Maps RFC 5280 revocation reasons to acmpcatypes.RevocationReason
via the existing mapRevocationReason helper plus a cast at the
sdkClient.RevokeCertificate boundary.
- Updates the issuerfactory.NewFromConfig call site at factory.go:L88
for the new (*Connector, error) signature; the factory's outer
signature already returns (issuer.Connector, error) so the change
is local.
- Adds nil-client guards on the four client-using connector methods
(IssueCertificate, RevokeCertificate, GetCACertPEM, plus the
RenewCertificate path via IssueCertificate). When the connector is
used before ValidateConfig has been called, these methods fail-fast
with a "client not initialized" sentinel error instead of panicking.
- Fixes the copy-paste env-var doc-comments at awsacmpca.go:L41,L45
(CERTCTL_GOOGLE_CAS_PROJECT / CERTCTL_GOOGLE_CAS_CA_ARN →
CERTCTL_AWS_PCA_REGION / CERTCTL_AWS_PCA_CA_ARN). The actual config
loader at internal/config/config.go:L1556-L1561 already used the
correct env-var names; only the doc-comments were wrong.
- Updates the package doc-comment at awsacmpca.go:L1-L36 to clarify
the synchronous-via-waiter behavior (issuance is asynchronous at
the API level; the waiter inside sdkClient.IssueCertificate hides
the asynchrony).
- Adds TestNew_ProductionPath/ValidConfigBuildsRealClient: calls
production New() (NOT NewWithClient) with a valid config, asserts
err is nil, then calls IssueCertificate with a bogus CSR and asserts
the resulting error is the expected PEM-decode error rather than
the deleted stubClient's "client not initialized" sentinel. This is
the regression-marker test the audit's D11 blocker called out as
missing — if anyone re-introduces a stub-style placeholder from
production New() in the future, this test fails.
- Adds TestNew_ProductionPath/NilConfigDefersClientInit: documents the
lazy-init contract for the New(nil, logger) → ValidateConfig pattern.
- Adds TestNew_ProductionPath/ValidateConfigBuildsClientLazily: verifies
that ValidateConfig wires the SDK client when New was called with
nil config.
- Adds TestNew_ProductionPath/{Revoke,GetCAPEM}BeforeInitFailsFast:
verifies the nil-client guards on the other client-using methods.
- Adds TestNew_ErrorPaths covering AccessDeniedException-shaped errors,
transient 5xx errors, and ctx-cancel propagation via the existing
mockACMPCAClient.
- Updates docs/connectors.md:L490-L555 with: the synchronous-via-waiter
behavior, a complete IAM policy example scoped to the four ACM PCA
actions, a worked POST /api/v1/issuers example, and a troubleshooting
section with three known failure modes (AccessDeniedException,
ResourceNotFoundException, waiter timeout).
Live AWS integration testing is intentionally not added: ACM PCA is a
Pro-tier feature in localstack and the existing interface-mock tests
cover correctness end-to-end. Operators with AWS credentials can
validate by following the worked example in docs/connectors.md.
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix#1 (Part 3, narrative section).
Closes L-009 + L-010 + L-011 + L-013 + L-020 + L-021 from
comprehensive-audit-2026-04-25. L-004 deferred — recon found NO
rotation infrastructure exists at all; building it from scratch is
a feature project, not a Bundle-E mechanical sweep.
L-009 — ZeroSSL EAB URL configurable
Audit's 'no timeout' claim was wrong: ari.go:329 has 15s timeout.
internal/connector/issuer/acme/acme.go: zeroSSLEABEndpoint now
lazily reads CERTCTL_ZEROSSL_EAB_URL from env at package init;
defaults to ZeroSSL public endpoint. Pre-existing test override
path preserved.
L-010 — Verified-already-clean
grep -rn 'mock\.Anything' --include='*_test.go' . returned 0.
certctl uses hand-rolled struct mocks (mockJobRepo, mockAuditRepo,
etc.) with explicit method bodies; no testify-style mocks anywhere.
L-011 — IPv6 bracket-aware dialing pinned
Every production net.Dial / DialTimeout site audited:
cmd/agent/main.go:293 — intentional IPv4 literal '8.8.8.8:80'
verify.go / tlsprobe / network_scan — net.Dialer (no string addr)
email.go — net.JoinHostPort (bracket-aware)
ssh.go — addr derives from JoinHostPort upstream
ssrf.go — net.Dialer
internal/connector/notifier/email/email_ipv6_test.go (NEW):
TestJoinHostPort_IPv6BracketsRoundTrip pins IPv4/IPv6/zone variants;
TestSMTPDialerUsesJoinHostPort source-greps email.go and fails CI
if a future refactor swaps in 'host:port' concatenation.
L-013 — Verified-already-clean (monotonic-safe)
Only one site uses now.Sub: middleware.go:393 in tokenBucket.allow().
Both 'now' and tb.lastRefill come from time.Now() which carries
monotonic-clock readings per Go's time package contract;
intra-process now.Sub is monotonic-safe by construction. Doc
comment block added above the call to make the invariant explicit.
L-020 (CWE-563) — ineffassign sweep, 8 unique sites
certificate.go:135 — sortDir initial value dropped (set
unconditionally below by SortDesc branch).
certificate.go:169,175 — argCount post-increments dropped (var
not read past the LIMIT/OFFSET formatting).
agent_group.go, profile.go — page/perPage truly vestigial,
replaced with _ = page; _ = perPage.
issuer.go:633, owner.go:131, target.go:267, team.go:131 — same
treatment for the audit-flagged second-function ListXxx clamps.
First-function List() in issuer/owner/target/team KEEPS its
clamp because page/perPage is used for in-memory slice
pagination — ineffassign correctly didn't flag those.
Build + tests green post-sweep.
L-021 — Transitive CVE bump
go get golang.org/x/crypto@v0.45.0 golang.org/x/net@v0.47.0
(crypto required net@0.47.0). go-text@v0.31.0 transitively
bumped.
Per tool-output govulncheck-verbose: x/net@v0.45.0 fixes
GO-2026-4441 + GO-2026-4440; x/crypto@v0.45.0 fixes
GO-2025-4134 + GO-2025-4135 + GO-2025-4116 — all 5 advisories
cleared. Bundle B's ISV grep guard + Bundle D's release-time
govulncheck step are the going-forward monitor + bump pass.
L-004 — Deferred to dedicated bundle
Recon: zero hits for RotateAPIKey / rotated_at / key_status
anywhere in source. API keys configured via
CERTCTL_API_KEYS_NAMED env var; rotation is operator-managed
(edit env + restart). Building rotation infrastructure from
scratch is a feature project, not a mechanical sweep.
Documented in audit-report.md with scope-pivot note.
Audit deliverables:
audit-report.md: score 46/55 -> 52/55 closed
(Low 14/19 -> 19/19 — 100% Low closed except L-004 deferred)
findings.yaml: 6 status flips
certctl/CHANGELOG.md: Bundle E section
Verification:
go test -count=1 -short ./internal/service ./internal/connector/issuer/acme
./internal/connector/notifier/email green
go vet on changed packages clean
Go 1.25.9 (released Apr 7 2026) fixes:
- GO-2026-4947: unexpected work during chain building in crypto/x509
- GO-2026-4946: inefficient policy validation in crypto/x509
- GO-2026-4870: unauthenticated TLS 1.3 KeyUpdate DoS in crypto/tls
- GO-2026-4865: JsBraceDepth context tracking XSS in html/template
Update CI workflow and go.mod to pin 1.25.9. govulncheck now reports
0 vulnerabilities in called code.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a new target connector enabling certificate deployment to any
Linux/Unix server without installing the certctl agent binary. Uses the
proxy agent pattern — a single agent in the same network zone deploys
certs to remote servers over SSH/SFTP.
Key additions:
- SSH/SFTP connector with key auth (file/inline) + password auth
- Injectable SSHClient interface for cross-platform testing (25 tests)
- Shell injection prevention via validation.ValidateShellCommand()
- Configurable cert/key/chain paths with octal permissions
- GUI: 11 SSH config fields in target create wizard
Also fixes pre-existing frontend bug where all target type strings
(nginx, apache, etc.) were sent as lowercase but the backend expects
proper-case (NGINX, Apache, etc.), breaking GUI-created targets.
Adds missing TargetTypeSSH to validTargetTypes service map.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Complete the IIS target connector with dual-mode deployment:
- WinRM proxy agent mode via masterzen/winrm for remote Windows servers
- Base64 PFX transfer with try/finally cleanup on remote host
- GUI wizard updated with 13 IIS config fields including WinRM settings
- TargetDetailPage sensitive field redaction (password/secret/token/key)
- OpenAPI TargetType enum updated (added Traefik, Caddy)
- connectors.md fully documented with WinRM proxy config example
- 38 total IIS tests (10 new WinRM tests), all passing with race detection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes 12 production bugs preventing the full issuance→deployment flow
from working with ACME (Pebble/Let's Encrypt) and step-ca issuers:
ACME connector (acme.go):
- Save orderURI before WaitOrder overwrites it (Go crypto/acme bug)
- Add CreateOrderCert fallback via WaitOrder+FetchCert
- Remove defer-reset in ValidateConfig that caused nil pointer panic
- Add Insecure TLS option for self-signed ACME servers (Pebble)
step-ca connector (stepca.go, jwe.go):
- Real JWE provisioner key loading + decryption (was using ephemeral keys)
- Fix JWT audience (/1.0/sign), sha claim (key fingerprint), kid header
- Custom root CA trust via RootCertPath config
- Remove hardcoded 90-day validity default (let step-ca decide)
NGINX target connector (nginx.go):
- Use sh -c for validate/reload commands (shell interpretation)
- Use filepath.Dir instead of fragile string slicing
- Add private key file writing (agent-mode keys were never deployed)
- Make chain_path write conditional
Server/service layer:
- TriggerRenewalWithActor now creates actual Job records (was no-op)
- createDeploymentJobs falls back to DB query when cert.TargetIDs empty
- ProcessPendingJobs skips agent-routed deployment jobs
- Agent cert pickup path parsing: len(parts)<4 → len(parts)<3
- Health/ready/auth-info endpoints bypass auth middleware
- Write timeout 15s→120s for ACME issuance
- Cert fingerprint computed on CSR submission
Integration test environment (deploy/test/):
- 10-phase test script covering Local CA, ACME, step-ca, revocation,
discovery, renewal, and API spot checks
- Docker Compose with 7 containers (server, agent, postgres, nginx,
pebble, challtestsrv, step-ca) on isolated network
- TLS verification checks SAN (not just Subject CN) for modern CA compat
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add certificate export in PEM (JSON or file download) and PKCS#12 formats.
Private keys are never included — they stay on agents. Add EKU-aware
issuance threading profile EKUs (serverAuth, clientAuth, codeSigning,
emailProtection, timeStamping) through the full issuance pipeline. Fix
agent CSR SAN splitting for email addresses, adaptive KeyUsage flags for
S/MIME vs TLS, and a pre-existing generateID collision bug in deployment
job creation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements 3 deferred security tickets (TICKET-003, TICKET-007, TICKET-010)
and performs comprehensive documentation audit to eliminate drift between
code and docs.
Code changes:
- TICKET-003: Repository integration tests with testcontainers-go (50+ subtests)
- TICKET-007: CertificateService decomposition into RevocationSvc + CAOperationsSvc
- TICKET-010: Request body size limits via http.MaxBytesReader middleware
- Fix missing slog import in certificate.go after service decomposition
Documentation updates:
- README: Fix endpoint count (97→93), expand env var reference (15→39 vars)
- CLAUDE.md: Fix OpenAPI operation count (85→93), update file locations
- architecture.md: Add body size limits section, middleware chain ordering
- CONTRIBUTING.md: New contributor guide with architecture conventions,
test patterns, middleware ordering, CI thresholds
- SECURITY_REMEDIATION.md: Removed from repo (moved to cowork, gitignored)
- Test files: Add doc comments to all new test files
Documentation that should exist but doesn't yet:
- Architecture diagrams (C4 model or similar)
- Threat model document
- Testing philosophy guide
- Disaster recovery runbook
- Upgrade guide (migration between versions)
- API versioning strategy document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Upgrade from Go 1.22 to 1.25 (minimum for MCP SDK, actively supported).
CI updated to match.
Codebase audit fixes:
- Local CA parseIP() now uses net.ParseIP — IP SANs no longer silently dropped
- Nil pointer guards in agent.go GetWorkWithTargets for target/cert enrichment
- MCP CreateCertificateInput marks owner_id/team_id as required
- NGINX connector uses CombinedOutput() — captures diagnostic output on failure
- Jobs handler validates JSON decode on rejection body — returns 400 on malformed
- CRL/OCSP handlers propagate requestID for error tracing
MCP server tests (26 tests):
- client_test.go: HTTP client coverage (GET/POST/PUT/DELETE, auth, 204, errors, binary)
- tools_test.go: tool registration, pagination, end-to-end flows with mock API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The MCP Go SDK (modelcontextprotocol/go-sdk) requires Go 1.23+. Previous
commit accidentally bumped to 1.25 via go mod tidy on a newer toolchain.
Pin to 1.23 as the minimum compatible version — closest to our original
1.22 baseline. CI updated to match.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Separate standalone binary (cmd/mcp-server/) using official MCP Go SDK
(modelcontextprotocol/go-sdk v1.4.1) with stdio transport. Stateless HTTP
proxy translates MCP tool calls to certctl REST API requests. 76 tools
across 16 resource domains with typed input structs and jsonschema tags
for automatic LLM-friendly schema generation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
golang.org/x/crypto v0.49.0 requires Go 1.25.0 which doesn't exist
yet, breaking both Docker builds and CI. Downgraded to v0.31.0 which
requires only Go 1.20+ and includes the same stable ACME v2 package.
Note: go.sum needs regeneration. Run `go mod tidy` before building.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
go.mod was set to go 1.25.0 which doesn't exist yet, causing Docker
builds to fail with "go.mod requires go >= 1.25.0 (running go 1.22.12)".
The project uses Go 1.22 as documented in Dockerfiles, CI, and README.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Wire issuer connector end-to-end with IssuerConnectorAdapter (dependency inversion)
- Renewal/issuance job processor: RSA key + CSR generation, Local CA signing, cert version storage
- Agent work API (GET /agents/{id}/work) and job status API (POST /agents/{id}/jobs/{job_id}/status)
- Agent-side deployment: WorkItem enrichment with target type/config, NGINX/F5/IIS connector invocation
- Full ACME v2 implementation: HTTP-01 challenge solving, account registration, order lifecycle
- Update all docs (README, architecture, connectors, demo-advanced, quickstart) for M1-M2
- Fix go vet warning in deployment.go (non-constant format string)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>