Compare commits

...

142 Commits

Author SHA1 Message Date
shankar0123 7a9ae3157f fix(seed): repair deployment_targets FK violation crashing fresh demo boot
The Rank 5 cloud-target seed rows in `seed_demo.sql` referenced a
non-existent `ag-server` agent_id. On every fresh-clone
`docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up`
the server crash-looped at the demo-seed step:

  pq: insert or update on table "deployment_targets" violates foreign
  key constraint "deployment_targets_agent_id_fkey"

Origin: commit 9a7e818 ("docs, seed: cloud-target operator runbook +
AWS ACM / Azure KV demo seed rows") added the rows but didn't insert
or rebind to a matching agents row. The `ag-server` ID never existed
in seed_demo.sql or anywhere else.

Fix: bind the two cloud targets to the existing cloud sentinel agents
that were already inserted at lines 78-79 (alongside `cloud-gcp-sm`):

  - tgt-aws-acm-prod  → cloud-aws-sm
  - tgt-azure-kv-prod → cloud-azure-kv

These cloud sentinels were inserted in commit 9a7e818's same family
specifically to back agentless cloud targets — exact semantic match.

Why the existing test didn't catch this:
TestRunDemoSeed_AppliesIdempotently in
internal/repository/postgres/seed_test.go calls the same RunSeed +
RunDemoSeed pair the server uses at boot, so it WOULD have caught the
FK violation. But the test depends on a live PostgreSQL container via
testcontainers-go and is gated under `testing.Short()` → the default
`go test ./... -short` lane that `make verify` runs always skipped it.
The dedicated integration lane that strips `-short` either wasn't run
on commit 9a7e818 or the failure was missed. Promoting the test out
from under `-short` is a separate hardening conversation (CI runs
need docker-in-docker which isn't free); that's out of scope for this
hotfix.

Static FK audit confirms the fix:
  Defined agent IDs (12): ag-{data,edge-01,iis,k8s,lb,mac-dev,
    web-prod,web-staging}-prod, cloud-{aws-sm,azure-kv,gcp-sm},
    server-scanner
  Referenced agent_id values in deployment_targets after fix:
    ag-data-prod, ag-edge-01, ag-iis-prod, ag-k8s-prod, ag-lb-prod,
    ag-web-prod, ag-web-staging, cloud-aws-sm, cloud-azure-kv
  Unresolved: zero.

Acceptance gate (operator-side):
  - docker compose -f deploy/docker-compose.yml \
                   -f deploy/docker-compose.demo.yml up -d --build
    against a fresh clone — server boots clean within 30s, dashboard
    at https://localhost:8443 shows the seeded demo data.
2026-05-05 21:03:18 +00:00
shankar0123 1720e11109 docs: fix broken single-file demo invocation in README + qa-prerequisites + ENVIRONMENTS
The README's Quick Start, the qa-prerequisites contributor doc, and the
landing page (separate repo, separate commit) all shipped a copy-paste
command that produces:

  service "certctl-server" has neither an image nor a build context
  specified: invalid compose project

The bug landed silently with commit a3d8b9c (the U-3 master). Pre-U-3,
docker-compose.demo.yml was self-contained and could be invoked with a
single -f flag. U-3 deliberately reduced it to a 27-line overlay — its
only payload today is `CERTCTL_DEMO_SEED=true` on the certctl-server
service — because the demo seed now applies at boot via
postgres.RunDemoSeed, not via /docker-entrypoint-initdb.d/. The overlay
no longer carries an image: or build: of its own, so it MUST be passed
alongside the base file.

The README/qa-doc/landing-page never picked up the rename of the contract.
Every operator who copy-pasted the Quick Start since U-3 has hit the
"invalid compose project" error and bounced. The operator caught it
running the demo locally today.

This commit fixes the three certctl-repo sites:

  README.md (Quick Start)
    docker compose -f deploy/docker-compose.demo.yml up -d --build
    →
    docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build

    Plus the "drop the -f flag for clean install" prose now spells out
    the correct fallback (`-f deploy/docker-compose.yml` alone).

  docs/contributor/qa-prerequisites.md (Step 1)
    Same single-file → two-file fix, plus an inline note explaining
    why the override-only file requires the base (so the next person
    who reads it understands the contract instead of re-discovering it).

  deploy/ENVIRONMENTS.md (Demo Overlay → What it adds)
    Replaced the stale "One line: mounts seed_demo.sql into PostgreSQL's
    init directory" claim — that hasn't been true since U-3 — with the
    accurate "One env var: CERTCTL_DEMO_SEED=true; server applies
    seed_demo.sql at boot via postgres.RunDemoSeed" description, plus
    the historical context for why the overlay can't stand alone.

The certctl.io landing page hits the same bug (line 759); fix shipping
in a separate commit in that repo.

Acceptance gate (manual):
  - copy/paste the new README Quick Start command end-to-end against
    a fresh clone — succeeds, dashboard at https://localhost:8443
    shows the seeded demo data within ~30s.
  - clean-install fallback (`docker compose -f deploy/docker-compose.yml
    up -d --build`) starts a working stack with no demo data.
2026-05-05 20:55:26 +00:00
shankar0123 f40e975439 gui(certificates): surface profile contract in create-cert form (closes P3-3, P3-4, P3-5)
Closes findings P3-3, P3-4, P3-5 from the 2026-05-05 CLI/API/MCP↔GUI
parity audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). The
audit flagged three "hidden defaults" in the create-certificate form:
environment='production', shortLived=false, selectedEkus=['serverAuth'].

Re-grounding against the live source:

  P3-3 was a false positive. The form already exposes an environment
  selector with three options (Production / Staging / Development) and
  defaults to Production. No change needed — covered by new test pin.

  P3-4 + P3-5 misread the architecture. allow_short_lived and
  allowed_ekus are NOT per-cert form-state fields; they are properties
  of the CertificateProfile that the operator binds via the existing
  Profile dropdown. Adding form-level toggles for them would contradict
  the profile-as-primitive design (the profile carries the policy
  contract — TTL, EKUs, key-algo allow-list, short-lived eligibility —
  so the cert can inherit a coherent set rather than letting operators
  hand-mix invalid combinations).

  The genuine UX gap was opacity: operators picked a profile without
  seeing what allow_short_lived / allowed_ekus the profile carried.

This commit closes the spirit of the finding by surfacing the selected
profile's load-bearing properties in a read-only "Profile contract"
panel that appears below the Profile dropdown once a profile is
selected. The panel shows:

  - allowed_ekus list (so operators see whether a profile is
    serverAuth, emailProtection, codeSigning, or a mix)
  - allow_short_lived flag (highlighted when true so operators know
    they're picking a profile that allows TTL < 1h CRL/OCSP-exempt
    certs per the M15b regime)
  - explanatory text that EKUs and short-lived eligibility are
    profile-level (not per-cert), guiding operators to edit the
    profile or pick a different one

Test pins (web/src/pages/CertificatesPage.test.tsx):

  - environment selector renders with 3 options, defaults to production
  - environment selector toggles to staging / development on change
  - Profile contract panel is hidden until a profile is selected
  - Profile contract panel surfaces allowed_ekus when a TLS-server
    profile is picked
  - Profile contract panel surfaces emailProtection EKU when an S/MIME
    profile is picked (closes the "S/MIME flows can't be initiated
    from the GUI" sub-finding — they can, by picking an emailProtection
    profile)
  - Profile contract panel flags allow_short_lived=true when an IoT
    short-lived profile is picked (closes the "operators can't issue
    short-lived certs through the GUI" sub-finding — they can, by
    picking an allow_short_lived profile)

Implementation notes:
  - data-testid='cert-form-environment' + 'cert-form-profile' +
    'cert-form-profile-detail' added to make the test selectors stable
    across DOM-restructuring refactors. No production behaviour change
    from the test IDs.
  - No new dependencies; no form-library introduction (per the prompt's
    out-of-scope list); uses the existing bare React state pattern.
  - No API changes — Certificate.allowed_ekus / allow_short_lived
    already exist on the CertificateProfile type in web/src/api/types.ts.

Acceptance gate (verified):
  - npm test on src/pages/CertificatesPage.test.tsx: 12/12 pass
    (6 pre-existing T-1 tests + 6 new P3-3..P3-5 pins).
  - All sibling page tests (AuditPage, TargetDetailPage, ShortLivedPage,
    etc.) still pass.
2026-05-05 19:49:59 +00:00
shankar0123 0e06f6c4fc cli: promote --force on renew + require --reason on revoke (closes P3-1, P3-2)
Closes findings P3-1 and P3-2 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Both findings
flagged hidden defaults that the CLI was sending without exposing them
to operators: `force=false` baked into every renew payload, and a silent
fallback to `reason="unspecified"` whenever --reason was omitted.

P3-1 — promote --force on `certs renew` (full end-to-end plumbing)

The pre-2026-05-05 CLI sent `{"force": false}` in the renew body. The
API handler never decoded it — a textbook "lying field" per the
operator's CLAUDE.md "complete path, not the easy path" rule: the body
field stored a value, claimed to do something, and silently did nothing
because the wire never reached the consumer. Adding a --force flag that
also went unread would have created another lying field.

This commit takes the complete path:

  service.CertificateService.TriggerRenewal grew a `force bool` parameter
  (internal/service/certificate.go). When force=true, the
  RenewalInProgress block is overridden so operators can recover stuck
  in-flight renewals where a previous job hung without releasing the
  status flag. Archived and Expired remain terminal blockers regardless
  of force — those are semantic dead-ends that --force should not paper
  over (archived = decommissioned, expired = issue a new cert instead of
  renewing a dead one).

  handler.CertificateHandler.TriggerRenewal parses force from
  ?force=true (or ?force=1) query param, OR {"force": true} JSON body,
  whichever the client picks. Defaults to false. Passes through to the
  service.

  internal/cli/client.go::RenewCertificate(id, force bool) sends
  ?force=true on the URL when --force is set. The historical hardcoded
  `{"force": false}` body is gone — no more lying field.

  cmd/cli/main.go dispatches `certs renew <id> [--force]` (ID-first
  flag-second convention matches the existing `agents retire <id>
  [--force]`).

P3-2 — require --reason on `certs revoke` (Option A: strict refusal)

The pre-2026-05-05 CLI dropped to `--reason unspecified` whenever the
operator omitted the flag. Compliance reporting (RFC 5280 §5.3.1, PCI-
DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful;
silent fallback defeats the audit trail because every revocation looks
identical.

  cmd/cli/main.go dispatch refuses to send when --reason is empty,
  prints the canonical RFC 5280 §5.3.1 reason-code menu, and exits
  non-zero.

  internal/cli/client.go exposes ValidRevokeReasons() returning the
  canonical camelCase list (unspecified, keyCompromise, caCompromise,
  affiliationChanged, superseded, cessationOfOperation, certificateHold,
  removeFromCRL, privilegeWithdrawn, aaCompromise) and
  NormalizeRevokeReason() that accepts both camelCase and snake_case
  inputs and normalises to the canonical wire form. Off-list reasons
  are rejected at dispatch with the menu re-printed.

Test pins:

  internal/cli/client_test.go::TestClient_RenewCertificate_ForceFlag —
  --force=true sends ?force=true with empty body; --force=false sends
  no query and no body.

  internal/cli/client_test.go::TestNormalizeRevokeReason +
  TestValidRevokeReasons — canonical-camelCase + snake_case + reject-
  off-enum behaviour.

  cmd/cli/dispatch_test.go::TestHandleCerts_Revoke_RequiresReason +
  TestHandleCerts_Revoke_RejectsUnknownReason +
  TestHandleCerts_Renew_ForceFlag — dispatch-layer pins for the same
  contracts.

  internal/api/handler/certificate_handler_test.go::TestTriggerRenewal_
  ForceQueryParam — query-param passthrough (no-flag, force=true,
  force=1, force=false) flows through to the service-layer parameter.

  internal/service/certificate_test.go::TestTriggerRenewal_
  ForceOverridesInProgress — force=false preserves the
  RenewalInProgress block; force=true clears it.

  Existing TestTriggerRenewal_Archived extended to assert force=true
  still blocks Archived (terminal-state guarantee).

Docs: docs/reference/cli.md updated with the --force example for renew
and the strict --reason semantics for revoke (including snake_case
input acceptance).

Acceptance gate (verified):
  - go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/...
    ./cmd/mcp-server/... clean.
  - go vet ./... clean.
  - go test -short -count=1 ./... pass repo-wide.
  - bash scripts/ci-guards/openapi-handler-parity.sh clean
    (router 178, OpenAPI 144, exceptions 36 — unchanged; we add
    parameter parsing, not routes).
  - gofmt -l clean.
2026-05-05 19:49:34 +00:00
shankar0123 ff75361553 mcp(coverage): add 34 tools across 7 domains to close 2026-05-05 parity audit P1 findings
Closes findings P1-1..P1-35 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Before this
bundle, 35 operator-facing API endpoints had GUI surfaces but no MCP
counterpart — operators using AI assistants for cert lifecycle work in
regulated environments had to drop to curl for approve/reject, health-check
acknowledgement, renewal-policy CRUD, network-scan triggering, discovery
triage, intermediate-CA management, and job verification.

Tool count: 87→121 in tools.go (+34), 6 unchanged in tools_est.go.
Re-derive via grep -cE 'gomcp\\.AddTool\\(' internal/mcp/tools.go
internal/mcp/tools_est.go.

The 7 phases (matching the bundle prompt at
cowork/mcp-coverage-expansion-prompt.md):

  Phase A — Approvals (P1-28..P1-31, 4 tools)
    list_approvals, get_approval, approve_request, reject_request.
    Two-person-integrity contract (ErrApproveBySameActor → HTTP 403)
    is preserved automatically: the decided_by actor is derived
    server-side from middleware.UserKey, NOT from request body, so
    the MCP server's authenticated API-key identity becomes the
    audit-trail actor. The MCP input schema deliberately omits any
    actor_id field to prevent client-side spoofing.

  Phase B — Health Checks (P1-20..P1-27, 8 tools)
    list, summary, get, create, update, delete, history, acknowledge.
    Mirrors the existing target-resource shape; acknowledge takes
    optional 'actor' string captured in the audit row (handler defaults
    to 'unknown' if absent).

  Phase C — Renewal Policies (P1-1..P1-5, 5 tools)
    Standard CRUD against /api/v1/renewal-policies. Distinct from the
    legacy 'policy' tools that point at the same path — these expose
    the renewal-policy domain explicitly with full alert_channels +
    alert_severity_map field shape.

  Phase D — Network Scan Targets (P1-14..P1-19, 6 tools)
    CRUD + trigger_scan. trigger_network_scan returns the discovery-
    scan body so the AI can chain into list_discovered_certificates
    filtered by agent_id.

  Phase E — Discovery read-side (P1-10..P1-13, 4 tools)
    list_discovered_certificates, get_discovered_certificate,
    list_discovery_scans, discovery_summary. Complements the
    pre-existing claim/dismiss tools (registered alongside Health
    historically per the I-2 closure).

  Phase F — Intermediate CAs (P1-6..P1-9, 4 tools)
    list, create (root + child via discriminator on body shape), get,
    retire. The handler is admin-gated via middleware.IsAdmin; the
    least-privilege boundary is enforced at the API layer (HTTP 403
    for non-admin Bearer callers) — not by transport carve-out.

  Phase G — Verification + deployments (P1-32, P1-34, P1-35, 3 tools)
    list_certificate_deployments, verify_job, get_job_verification.
    P1-33 (POST /api/v1/agents/{id}/discoveries) is intentionally
    excluded — machine-to-machine push channel for agents reporting
    filesystem-scan results, not an operator-driven flow. Documented
    inline in the RegisterTools dispatch.

Implementation:
  - 14 new input types in internal/mcp/types.go with jsonschema struct
    tags driving LLM tool discovery.
  - 7 register* functions in internal/mcp/tools.go each handling one
    phase, wired into RegisterTools dispatch in declaration order.
  - 34 new entries in tools_per_tool_test.go::allHappyPathCases —
    the existing in-process MCP harness (TestMCP_AllTools_HappyPath +
    TestMCP_AllTools_ErrorPath + TestMCP_RegisterTools_DispatchableToolCount)
    auto-extends coverage to cover every new tool: happy-path round-
    trip with fence-shape assertion, 5xx error-path with MCP_ERROR fence
    propagation, and 'every registered tool is dispatchable' guard.
  - docs/reference/mcp.md 'Available Tools' table expanded from 16 to
    22 resource domains with current per-domain tool counts.

Acceptance gate (verified):
  - go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...
    clean across all four production binaries.
  - go vet ./... clean.
  - go test -short -count=1 ./internal/mcp/... pass (TestMCP_AllTools_*
    expanded to 127 tool round-trips).
  - go test -short -count=1 ./... pass repo-wide.
  - bash scripts/ci-guards/openapi-handler-parity.sh clean (router 178,
    OpenAPI 144, exceptions 36 — unchanged; we add MCP wrappers, not
    routes).
  - gofmt -l clean across the four touched files.
2026-05-05 19:29:57 +00:00
shankar0123 e0aaa967c9 docs(README): add MCP server bullet to capabilities list
The README's 'What it does' section enumerated 11 capability bullets
(issuers / targets / ACME server / SCEP server / EST server /
hierarchy / approvals / discovery / revocation / alerts) but had
zero mention of the MCP server. The 2026-05-05 CLI/API/MCP ↔ GUI
parity audit confirmed 93 MCP tools shipped today (87 in
internal/mcp/tools.go + 6 in internal/mcp/tools_est.go) covering the
full API surface. That's a real differentiator hidden from anyone
landing on the README.

Adds a 12th bullet positioning the MCP server with concrete example
queries operators can ask their AI client (expiring certs, revoke
with key-compromise reason, agent offline check). Frames the
architectural facts: separate binary at cmd/mcp-server/, stateless
stdio transport, no extra auth surface beyond the existing API key,
no extra attack surface.

Links to docs/reference/mcp.md for setup details.
2026-05-05 19:10:27 +00:00
shankar0123 17455d2ea2 deps(web): pin picomatch to >=4.0.4 via npm override; clears 4 dependabot alerts
Dependabot flagged four picomatch vulnerabilities in
web/package-lock.json:

  #8  GHSA-?, ReDoS via extglob quantifiers
  #9  GHSA-?, ReDoS via extglob quantifiers (related to #8)
  #10 CVE-2026-33672 / GHSA-3v7f-55p6-f55p, method injection via
      POSIX character classes (related; affecting < 2.3.2)
  #11 CVE-2026-33672 / GHSA-3v7f-55p6-f55p, method injection via
      POSIX character classes — same advisory as #10, separate
      Dependabot row because it surfaces against a second copy
      of picomatch in the dep tree

All four close on the same fix: every resolved picomatch instance
must be >= 4.0.4 (or >= 3.0.2, or >= 2.3.2 — the patch shipped on
all three release lines). Pre-fix the lockfile carried at least
two vulnerable copies:

  node_modules/picomatch                             v2.3.1  (vuln)
  node_modules/vitest/node_modules/picomatch         v4.0.3  (vuln for #11)
  node_modules/vite/node_modules/picomatch           v4.0.4  (ok)
  node_modules/tinyglobby/node_modules/picomatch     v4.0.4  (ok)

Reachability check before fixing:

  - picomatch is a build-time glob-matching tool (used by
    tailwindcss → readdirp/anymatch/micromatch chain, plus by
    vite + vitest internals).
  - All instances in our tree are dev=true. None are bundled into
    the React production output (web/dist/assets/*.js) — that's
    just the React SPA, no node_modules at runtime.
  - The CVE only affects code that processes UNTRUSTED glob
    patterns. Our build pipeline only globs operator-controlled
    file patterns (TSX source files, Tailwind 'content' globs).
    Not network-reachable.

So the CVE was not reachable from any shipped certctl artefact.
Fix anyway because the alerts are noise.

Fix mechanism: add an npm 'overrides' entry pinning picomatch to
^4.0.4 across all consumers. npm collapses every transitive
picomatch resolution to the override, so the lockfile shrinks from
4 picomatch entries to 1, all on v4.0.4 (patched).

Verification:

  npm install --package-lock-only      → up to date, 0 vuln
  npm audit                            → found 0 vulnerabilities

Diff: 2 files, 7 insertions / 43 deletions (net negative — the
override de-duplicates the picomatch tree).

Closes: GHSA-3v7f-55p6-f55p, CVE-2026-33672 (alerts #10, #11) +
the two related ReDoS picomatch alerts (#8, #9)
2026-05-05 18:40:10 +00:00
shankar0123 f2c77ba3fb deps: bump testcontainers-go v0.35.0 → v0.42.0; drops docker/docker dep entirely (clears CVE-2026-34040)
Dependabot flagged GHSA-x744-4wpc-v9h2 / CVE-2026-34040 (Moby AuthZ
plugin bypass on oversized request bodies, incomplete fix for
CVE-2024-41110) on the transitive github.com/docker/docker
v27.1.1+incompatible pulled in via testcontainers-go v0.35.0.

Reachability check before fixing:

  - certctl does not run dockerd or configure AuthZ plugins.
  - go list -deps ./cmd/{server,agent,cli,mcp-server}/... finds zero
    docker/docker references in any production binary's transitive
    set.
  - testcontainers is consumed only by *_test.go files under
    internal/repository/postgres/ + deploy/test/ for ephemeral
    Postgres containers.

So the CVE was not reachable from any shipped certctl artefact.
Bump anyway because Dependabot noise is noise; the upgrade is
mechanical.

Bumping testcontainers-go v0.35.0 → v0.42.0 (latest, 2026-04-09)
removes the direct docker/docker dependency entirely — testcontainers
v0.42.0 reorganized away from the Moby SDK. After 'go mod tidy',
docker/docker is GONE from both go.mod and go.sum, not merely
bumped. The Dependabot alert closes automatically on push.

Co-bumped transitives (cascading from testcontainers' new dep tree):

  go.opentelemetry.io/otel               v1.24.0  → v1.41.0
  go.opentelemetry.io/otel/{metric,trace} v1.24.0  → v1.41.0
  go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
                                         v0.49.0  → v0.60.0
  go.opentelemetry.io/auto/sdk           added    @ v1.2.1
  golang.org/x/crypto                    v0.45.0  → v0.48.0
  golang.org/x/net                       v0.47.0  → v0.49.0
  golang.org/x/sync                      v0.18.0  → v0.19.0
  golang.org/x/sys                       v0.40.0  → v0.42.0
  golang.org/x/text                      v0.31.0  → v0.34.0

Verification (all green):

  go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... \
           ./cmd/mcp-server/...                          → exit 0
  go test -run=NONE -count=1 ./internal/repository/postgres/  → ok
  go test -tags=integration -run=NONE -count=1 ./deploy/test/ → ok
  go vet ./internal/repository/postgres/...                   → clean
  go list -deps ./cmd/{server,agent,cli,mcp-server}/... |
    grep docker                                               → zero hits

Diff: 2 files (go.mod, go.sum), 129 insertions / 144 deletions.

Closes: GHSA-x744-4wpc-v9h2, CVE-2026-34040
2026-05-05 18:34:31 +00:00
shankar0123 d2b62880ce 2026-05-05 18:18:38 +00:00
shankar0123 75097909e9 2026-05-05 18:18:29 +00:00
shankar0123 7c5cc57d75 2026-05-05 15:39:08 +00:00
shankar0123 9acf609ac9 docs: convert ASCII flow diagram to Mermaid in test-environment.md
Per operator audit: every diagram in docs/ should be Mermaid except
in the repo-root README.md. The 'Key Generation Flow (Agent-Side)'
section in docs/contributor/test-environment.md was rendered as a
plain code fence with arrow-prose:

  Server creates job (AwaitingCSR) → Agent polls, sees job →
  Agent generates ECDSA P-256 key pair locally → ...

That was the only non-Mermaid diagram-shaped block left in docs/.

Converted to a Mermaid sequenceDiagram with 5 participants
(certctl-server, issuer connector, certctl-agent, local agent FS,
shared volume) covering the full AwaitingCSR → CSR-submit →
Deployment-job → cert-write → Completed lifecycle.

Audit + verification script: cowork/docs-audit-2026-05-05/mermaid-audit.md.
Re-running the detection script post-fix returns zero non-Mermaid
diagram-like blocks across all 76 docs/ markdown files.

Total Mermaid coverage in docs/ now: 14 docs / 40 blocks.
2026-05-05 06:18:24 +00:00
shankar0123 622cd29f20 docs: factuality sweep — fix 3 broken links + 12 count claims (audit findings 2026-05-05)
Per the cowork/docs-audit-2026-05-05/ end-to-end factuality audit
(20 confirmed findings across 76 docs, 7 parallel subagents +
audit-of-the-audit). Hot + Warm tier fixes ship here; STALE
findings (qa-test-suite.md test-count snapshot) need 'make
qa-stats' which is operator-side.

BROKEN links repaired (3):
- docs/reference/api.md L195: [Quick Start](quickstart.md) →
  ../getting-started/quickstart.md (404 pre-fix)
- docs/reference/api.md L196: [Connector Guide](connectors.md) →
  connectors/index.md (Phase 4 rename, was 404 pre-fix)
- docs/reference/protocols/scep-intune.md L377:
  [legacy-est-scep.md](legacy-est-scep.md) → scep-server.md
  (file was deleted in Phase 7 commit e9b1510)

INCORRECT count claims repaired (12):
- api.md L5 + L18-19 + L155: '78 API operations' / '# 78' /
  'all 78 documented operations' → re-derive via
  grep -cE '^\s+operationId:' (actual at HEAD: 144)
- architecture.md L66 (Mermaid label) + L502 + L1047 + L1253:
  '8 always-on + 4 optional loops' / '12-loop topology' →
  9 always-on + 5 opt-in loops (14 total). Always-on/opt-in
  breakdown derived from cmd/server/main.go startup wiring:
  always-on are agentHealthCheck, crlGeneration, jobProcessor,
  jobRetry, jobTimeout, notificationProcess, notificationRetry,
  renewalCheck, shortLivedExpiryCheck (9); opt-in are
  networkScan, digest, healthCheck, cloudDiscovery, acmeGC (5).
  Re-derive count via grep -cE '^func \(s \*Scheduler\)
  [a-zA-Z]+Loop' internal/scheduler/scheduler.go.
- configuration.md L31: '12 loops, 8 always-on + 4 opt-in' →
  '14 loops, 9 always-on + 5 opt-in'. Self-introduced regression
  from commit 3275f9f (2026-05-05).
- mcp.md L11 + L65: 'all 78 API endpoints' / '78 available tools'
  → re-derive via grep -cE 'mcp\.AddTool\(' (actual at HEAD:
  87 MCP tools, 144 API operations).
- connectors/index.md L111: '9 built-in' issuer connectors →
  '12 built-in', extending the inline enumeration to include
  Entrust, GlobalSign, EJBCA (which had been added since the
  L111 prose was written). Local-CA framing extended to mention
  tree mode + ADCS sub-CA mode-doc.
- connectors/index.md L112: '14 built-in' target connectors →
  '15 built-in', adding AWS ACM target + Azure Key Vault target
  (which had been added since the L112 prose was written).
- why-certctl.md L37 + the inline list: 'Nine issuer connectors
  ship today' → 'Twelve issuer connectors', adding
  AWS ACM PCA, Entrust, GlobalSign, EJBCA to the list and
  removing the misleading 'EST enrollment' bullet (EST is a
  protocol surface, not an issuer; clarified in trailing note).
- why-certctl.md L66: '13 deployment targets' → '15', adding
  Kubernetes Secrets, AWS ACM, and Azure KV to the inline list.
- why-certctl.md L92: 'supports 9 issuer types' → '12 issuer
  types'.
- quickstart.md L135: '35 demo certificates across 5 issuers'
  → re-derive cert count via 'grep -oE "mc-[a-z0-9_-]+"
  migrations/seed_demo.sql | sort -u | wc -l' (actual: 32,
  matches README L86; quickstart was off-by-3).
- quickstart.md L452 (Demo Data Reference table): Certificates
  '35' → '32' (matches the cert count from seed_demo.sql).

Verification:
- grep confirms no remaining stale refs across the touched
  files (8 files, 31 insertions / 28 deletions).
- All 24 ci-guards/*.sh pass locally.
- The audit's STALE findings (S-1, S-2 qa-test-suite.md
  Bundle-P snapshot) are operator-side: run 'make qa-stats'
  to refresh the Test Suite Health table.

Companion: cowork/docs-audit-2026-05-05/RESULTS.md captures
the full audit with subagent false positives and missed
findings called out.
2026-05-05 06:15:35 +00:00
shankar0123 d809874fa1 docs: retire compliance subtree + sweep framework name-drops from prose
Per operator decision the framework-mapping docs are gone. They
were aspirational (no audit, no certification, no validated
mapping); keeping them around was misleading.

Files deleted (1,883 lines):
- docs/compliance/index.md
- docs/compliance/soc2.md
- docs/compliance/pci-dss.md
- docs/compliance/nist-sp-800-57.md

Hyperlinks removed:
- README.md: 'Auditor / compliance' row in the doc table; the
  '(compliance mapping included)' parenthetical in the
  positioning paragraph
- docs/README.md: the '## Compliance' section table; the
  'Auditor / compliance team' reading-order-by-role row

Prose name-drops swept across 24 files:
- README.md: 'FedRAMP boundary CAs / financial-services policy
  CAs' → '4-level boundary CAs / 3-level policy CAs';
  'Compliance-grade for PCI-DSS Level 1, FedRAMP Moderate / High,
  SOC 2 Type II, HIPAA' → cut entirely
- getting-started/{quickstart,concepts,examples,why-certctl,
  advanced-demo}.md: 'compliance' → 'audit' / 'policy';
  'PCI-DSS / SOC 2 / NIST SP 800-57' framework lists cut;
  ''pci': 'true'' tag example → ''environment': 'production''
- migration/cert-manager-coexistence.md: 'compliance rules' →
  'policy rules'
- operator/approval-workflow.md: 'Compliance customers (PCI-DSS
  Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA)' →
  'Operators'; entire 'Compliance control mapping' table
  (PCI-DSS §6.4.5 / NIST SP 800-53 SA-15 / SOC 2 Type II CC6.1
  / HIPAA §164.308(a)(4)) deleted; 'compliance contract' →
  'two-person-integrity contract'; 'compliance auditors' →
  'reviewers'
- operator/legacy-clients-tls-1.2.md: 'PCI-DSS v4.0 Req 4 §2.2.5'
  audit-reference → CWE-326 (kept); 'PCI-DSS Req 4 §2.2.5
  attestation' section retitled to 'TLS posture summary' and
  rewritten without framework framing; 'PCI-DSS, NIST, and
  major browsers will eventually deprecate TLS 1.2' →
  'Major browsers and OS vendors will eventually deprecate
  TLS 1.2'
- operator/database-tls.md: PCI-DSS Req 4 §2.2.5 audit-ref →
  CWE-319 only; 'PCI-DSS scope' → 'sensitive data'; PCI-DSS
  Req 4 v4.0 prose footing → cut
- operator/runbooks/disaster-recovery.md: 'SOC 2 / PCI
  procurement-team deliverable' → 'on-call deliverable';
  'compliance auditors' → 'reviewers'
- reference/connectors/{acme,aws-acm,azure-kv,globalsign,
  local-ca,openssl,ssh,index}.md: 'compliance reporting
  (PCI-DSS §3.6, HIPAA §164.312)' → 'audit reporting';
  'Compliance environments (PCI-DSS Level 1, FedRAMP High,
  HIPAA)' → 'Regulated environments'; 'compliance audits' →
  'audit'; 'FedRAMP boundary CA' pattern names →
  '4-level boundary CA' (technically descriptive)
- reference/protocols/est.md: 'compliance-hook seam' →
  'device-state hook seam'; 'compliance gating' → 'device-state
  gating'; 'est_compliance_failed' → 'est_device_state_failed'
- reference/protocols/scep-intune.md: 'Optional compliance
  check' → 'Optional device-state check'; failure-counter
  'compliance_failed' → 'device_state_failed'; 'Conditional
  Access compliance gating' → 'Conditional Access
  device-state gating'
- reference/intermediate-ca-hierarchy.md: 'FedRAMP boundary-CA
  deployments where the regulator requires...' →
  'Boundary-CA deployments where you want separation of policy
  and issuing authorities'; pattern A retitled '4-level FedRAMP
  boundary CA' → '4-level boundary CA'
- reference/architecture.md: broken Related-docs link to
  compliance.md removed; the rest of that block had stale
  pre-Phase-2 paths (quickstart.md, demo-advanced.md,
  connectors.md, openapi.md, testing-guide.md, test-env.md) —
  retargeted to current locations
- reference/deployment-model.md: 'SOC 2 evidence-report
  generator' → 'Audit-evidence report generator'
- reference/vendor-matrix.md: 'SOC 2 / PCI auditors paste this
  into evidence packs' → 'reviewers paste this into
  vendor-evaluation packs'
- contributor/qa-test-suite.md: 'compliance exist' coverage
  description cut; 'Compliance (PCI / SOC2 / HIPAA-relevant)'
  risk-class label → 'Audit-relevant'

What was kept:
- CWE references (legitimate technical pointers)
- Microsoft API/feature names that happen to use 'compliance'
  literally ('Microsoft Graph compliance API',
  'device-compliance validators' — these are MS product names,
  not framework name-drops)
- 'NIST PQC' on the landing page (Post-Quantum Cryptography is
  the actual NIST standard family, not a compliance framework)

Verified: zero hyperlinks into docs/compliance/ remain. All 24
ci-guards/*.sh pass locally. qa-doc-seed-count.sh clean.
Net diff: 26 files / -1,883 deletions in compliance/ + -32 net
across the prose sweep.

Companion edits in cowork/ (CLAUDE.md doc-tree summary +
WORKSPACE-CHANGELOG.md retirement note) land separately.
2026-05-05 05:26:44 +00:00
shankar0123 5ea8fb48eb ci: restore +x bit on scripts/ci-guards/*.sh (sandbox stripped exec bit)
Pure mode-change commit. The previous 3275f9f commit dropped the
executable bit (100755 → 100644) on five files in scripts/ci-guards/
plus scripts/qa-doc-seed-count.sh and scripts/dev-setup.sh — a
sandbox-tooling artefact, not intentional. The CI pipeline calls
each guard via 'bash "$g"' so the missing exec bit didn't break
anything operationally, but operators who run a guard directly via
'./scripts/ci-guards/<id>.sh' would hit a permission-denied. Restore
to 100755 to match the rest of scripts/ci-guards/*.sh.

No content changes.
2026-05-05 04:56:43 +00:00
shankar0123 3275f9f1e0 ci: post-Phase-2-docs-overhaul cleanup of stale guards + missing config doc
CI run on the ecb8896 push surfaced two real failures rooted in the
2026-05-04 docs overhaul:

  1. G-3 env-docs-drift caught two phantom CERTCTL_* env vars I'd
     introduced in the Phase 4 follow-on connector pages
     (CERTCTL_CA_CERT_PATH_NEW in adcs.md was a placeholder I made
     up; CERTCTL_EJBCA_POLL_MAX_WAIT_SECONDS in ejbca.md does not
     exist in source). Both removed.

  2. QA-doc Part-count drift guard tried to grep
     docs/qa-test-guide.md and docs/testing-guide.md, both of which
     were renamed/deleted in Phase 2/Phase 5. The Part-count drift
     class died with testing-guide.md (Phase 5 prune dispersed its
     content); the seed-count drift class is still live but pointed
     at the wrong path.

Fixes:

- Removed the QA-doc Part-count drift guard from ci.yml (premise
  dead) plus its standalone scripts/qa-doc-part-count.sh peer.
- Retargeted the QA-doc seed-count drift guard from
  docs/qa-test-guide.md → docs/contributor/qa-test-suite.md (the
  Phase 2 target). Updated both ci.yml inline copy and
  scripts/qa-doc-seed-count.sh.
- Updated Makefile qa-stats: target to drop the testing-guide.md
  Parts metric (file is gone).
- Updated Makefile verify-docs: target to drop the part-count step.

G-3 was also failing in the second direction (env vars defined in
config.go but never documented anywhere). 16 vars surfaced —
features.md (deleted Phase 6) and testing-guide.md (deleted Phase 5)
had been their canonical home. Created
docs/reference/configuration.md as the new home: a compact
operator-facing env-var reference covering scheduler intervals, job
lifecycle, rate limiting, audit, deploy verify, database,
agent-side, and SCEP profile binding. Added to docs/README.md
Reference table.

Doc-side updates to qa-test-suite.md to reframe its references to
the deleted testing-guide.md (it's now self-contained: the
Part-by-Part Coverage Map IS the canonical Part inventory).

Cosmetic comment-only updates in ci.yml + scripts/ci-guards/*.sh +
scripts/dev-setup.sh to point at the new audience-organized doc
paths (docs/operator/security.md, docs/operator/tls.md,
docs/reference/architecture.md, etc.) instead of the pre-Phase-2
flat layout.

Verified: all 24 ci-guards/*.sh pass locally; qa-doc-seed-count.sh
clean. Net diff: 178 additions / 112 deletions across 13 files.
One file deleted (qa-doc-part-count.sh) and one file added
(docs/reference/configuration.md).
2026-05-05 04:56:26 +00:00
shankar0123 ecb8896b1c docs: cleanup pre-existing broken links in connector pages
Phase 4 structural (commit 633e440) moved 6 connector files into the
new docs/reference/connectors/ subdirectory but didn't update all
inter-doc references for the new path layout. Phase 11 caught the
high-traffic ones; this sweep gets the rest, found by the Phase 4
follow-on verification pass.

Mappings applied (relative to docs/reference/connectors/):

  deployment-atomicity.md     → ../deployment-model.md
  deployment-vendor-matrix.md → ../vendor-matrix.md
  architecture.md             → ../architecture.md
  est.md                      → ../protocols/est.md
  scep-intune.md              → ../protocols/scep-intune.md
  async-polling.md            → ../protocols/async-ca-polling.md
  quickstart.md               → ../../getting-started/quickstart.md
  demo-advanced.md            → ../../getting-started/advanced-demo.md
  legacy-est-scep.md          → ../protocols/scep-server.md
  connectors.md               → index.md

Plus prose backtick references (`docs/architecture.md` etc.) updated
to the new subdirectory paths.

Files touched: apache, f5, iis, k8s, nginx, index. 33 line changes.
Full link-check across docs/reference/connectors/*.md is now clean
(0 broken inter-doc references).
2026-05-05 04:10:09 +00:00
shankar0123 f179eab071 docs: expand docs/README.md connectors section to enumerate all 28 deep-dive pages
After the Phase 4 follow-on (commits fd94205de06141082b8cf969853e), the docs/reference/connectors/ tree carries 13 issuer
per-pages + 15 target per-pages alongside the index. Update the
top-level docs navigation to surface them all.

Replaced the previous 5-row connectors table with two
single-paragraph indexes (issuers, targets) listing every per-page
in alphabetical order. The connectors index.md is still the
canonical catalog (interfaces, registry, scanners + inline
reference per built-in); the deep-dive pages cover operator-grade
material on top.

Net: docs/README.md gains coverage of 23 new pages without bloating
the file (two prose paragraphs vs a 28-row table).
2026-05-05 04:08:08 +00:00
shankar0123 969853ee53 docs: Phase 4 follow-on batch 4 — 5 final target per-pages
Extracts the remaining target connectors:

- ssh.md (194 lines) — agentless SSH/SFTP deploy with full
  host-key-acceptance threat model (what's accepted, what's not,
  mitigations including known_hosts enforcement and SSH cert auth);
  V3-Pro forward path
- wincertstore.md (118 lines) — non-IIS Windows services via local
  PowerShell or WinRM proxy mode; store selection (My / Root /
  WebHosting); private-key permissions guidance
- jks.md (189 lines) — JKS / PKCS#12 via keytool with full atomic
  snapshot+rollback contract (Bundle 8 'snapshot → delete → import →
  reload'), keytool argv password exposure threat model + mitigations
- aws-acm.md (208 lines) — ACM target with full IAM policy, IRSA /
  instance-profile / SSO auth recipes, atomic-rollback contract,
  ALB attachment Terraform recipe, procurement-checklist crib
- azure-kv.md (195 lines) — Key Vault target with managed-identity /
  workload-identity / service-principal auth recipes, version-
  semantics rollback caveat (no in-place restore without soft-delete),
  App Gateway / Front Door attachment recipe

Index forward-list expanded to enumerate all 15 target connectors
(5 from Phase 4 structural + 5 from batch 3 + 5 from this batch) in
alphabetical order.

This is part 4 of 4 for the Phase 4 follow-on (per-connector page
extraction) tracked in cowork/docs-overhaul-phase-2-restructure-2026-05-04/log.md.

Net add: 5 files, 904 lines. No content removed from index.md.

End-state of Phase 4 follow-on:
- 13 issuer per-pages (5 batch 1 + 8 batch 2)
- 15 target per-pages (5 Phase 4 structural + 5 batch 3 + 5 batch 4)
- index.md keeps its inline reference content; per-pages add
  operator depth on top, matching the pattern set by
  apache/f5/iis/k8s/nginx in Phase 4 structural
2026-05-05 04:07:21 +00:00
shankar0123 082b8cf660 docs: Phase 4 follow-on batch 3 — 5 file-based target per-pages
Extracts the file-based deploy target connectors:

- haproxy.md (107 lines) — combined-PEM (cert+chain+key) deploy with
  haproxy -c validate; multi-frontend + crt-list directory guidance
- traefik.md (105 lines) — file-provider zero-reload deploy; file
  watcher latency notes; mixing with built-in ACME guidance
- caddy.md (100 lines) — admin API mode (recommended) vs file mode;
  admin-API exposure threat model
- envoy.md (112 lines) — file SDS mode (recommended) vs static
  bootstrap; service-mesh interactions
- postfix.md (175 lines) — dual-mode (Postfix MTA / Dovecot IMAPS)
  connector with daemon-specific quirks (STARTTLS chain expectations,
  no shared session cache); Bundle 11 test pins

Index forward-list expanded to enumerate all 10 target connectors
(5 from Phase 4 structural + 5 from this batch) in alphabetical
order.

This is part 3 of 4 for the Phase 4 follow-on (per-connector page
extraction) tracked in cowork/docs-overhaul-phase-2-restructure-2026-05-04/log.md.

Net add: 5 files, 599 lines. No content removed from index.md.
2026-05-05 04:02:25 +00:00
shankar0123 de06141ce5 docs: Phase 4 follow-on batch 2 — 8 remaining issuer per-pages
Extracts the rest of the issuer per-connector deep-dive pages:

- local-ca.md (170 lines) — Local CA self-signed / sub-CA / tree mode,
  CRL+OCSP endpoints, EKU support, MaxTTL enforcement, L-014 file-on-
  disk threat model carve-out
- acme.md (235 lines) — RFC 8555 v2 client (HTTP-01 / DNS-01 /
  DNS-PERSIST-01), ARI per RFC 9773, EAB + ZeroSSL auto-EAB,
  Let's Encrypt profile selection, revoke-by-serial Top-10 fix #7
- step-ca.md (99 lines) — Smallstep JWK-provisioner synchronous
  issuance with MaxTTL enforcement
- openssl.md (157 lines) — script-based shell-out with full
  threat model (what's accepted, what's not, mitigations, V3-Pro
  forward path)
- sectigo.md (98 lines) — Sectigo SCM REST with bounded async polling
- google-cas.md (89 lines) — GCP managed private CA with OAuth2
  service-account auth + IAM-role guidance
- entrust.md (96 lines) — Entrust CA Gateway mTLS-authenticated with
  approval-pending support and mTLS keypair caching
- globalsign.md (122 lines) — Atlas HVCA dual auth (mTLS + API
  key/secret), region-aware base URLs, mTLS keypair caching

Index forward-list expanded to enumerate all 13 issuer connectors
(including the 5 pages from batch 1) in alphabetical order.

This is part 2 of 4 for the Phase 4 follow-on (per-connector page
extraction) tracked in cowork/docs-overhaul-phase-2-restructure-2026-05-04/log.md.

Net add: 8 files, 1,066 lines. No content removed from index.md.
2026-05-05 03:59:35 +00:00
shankar0123 fd94205cfa docs: Phase 4 follow-on batch 1 — 5 issuer per-pages
Extract the first 5 issuer per-connector deep-dive pages:

- vault.md (128 lines) — Vault PKI synchronous issuance, token TTL +
  auto-renewal loop, MaxTTL enforcement, rotation playbook
- digicert.md (106 lines) — CertCentral DV/OV/EV with bounded async
  polling for vetting workflows
- aws-acm-pca.md (165 lines) — managed private CA on AWS with full
  IAM policy, IRSA wiring, troubleshooting matrix
- ejbca.md (116 lines) — open-source / Keyfactor EJBCA with mTLS or
  OAuth2 auth, mTLS keypair caching, approval-pending guidance
- adcs.md (111 lines) — Active Directory Certificate Services as
  enterprise root via Local CA sub-CA mode, sub-CA rotation playbook

Index updated with forward-list entries and the index-purpose blurb
revised so the index now positions itself as 'navigate from here;
deeper material lives in siblings' rather than 'docs to be extracted
later'.

Each per-page follows the WHAT/HOW/WHY pattern: what the connector is,
how authentication and issuance work, and when to choose this vs an
alternative. Cross-links to the connector index, async-ca-polling
primitive, and adjacent operator runbooks.

This is part 1 of 4 for the Phase 4 follow-on (per-connector page
extraction) tracked in cowork/docs-overhaul-phase-2-restructure-2026-05-04/log.md.

Net add: 5 files, 626 lines. No content removed from index.md (the
index keeps its inline reference; per-pages add operator depth on
top, matching the pattern set by apache/f5/iis/k8s/nginx in Phase 4
structural).
2026-05-05 03:53:52 +00:00
shankar0123 b452013dd9 docs: Phase 5 — testing-guide.md prune (8268 → 0 lines, content dispersed)
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/
and the section-by-section plan in testing-guide-tumor.md.

testing-guide.md was 30% of all docs/ content (8268 lines) but was
integration test code written in markdown, not operator documentation.
The audit's tumor analysis disposed of every Part:
  - ~65% DELETE (test cases that already exist in code)
  - ~22% MOVE to inline test code
  - ~8% KEEP-COMPRESSED into focused operator-runbook docs
  - Title + contents + release sign-off ~5% KEEP

This commit ships the KEEP-COMPRESSED dispersal:

  docs/contributor/qa-prerequisites.md (NEW, ~120 lines):
    From testing-guide.md "Prerequisites" section. Stack boot procedure,
    demo data baseline, reference IDs operators reuse across QA docs.

  docs/contributor/gui-qa-checklist.md (NEW, ~105 lines):
    From testing-guide.md "Part 35: GUI Testing". Manual GUI verification
    pass for release sign-off. 25-row table covering every dashboard page.

  docs/contributor/release-sign-off.md (NEW, ~130 lines):
    From testing-guide.md "Release Sign-Off" section (originally 1009
    lines of per-test detail tables). Compressed to a release-day
    checklist organized by gate category: code state, automated gates,
    manual QA passes, release artefact verification, branch protection,
    post-release.

  docs/operator/performance-baselines.md (NEW, ~100 lines):
    From testing-guide.md "Part 39: Performance Spot Checks". Four
    operator-runnable benchmarks (API request handling, inventory list
    pagination, scheduler tick, bulk revoke) with baseline numbers and
    when-to-re-baseline guidance.

  docs/operator/helm-deployment.md (NEW, ~120 lines):
    From testing-guide.md "Part 52: Helm Chart Deployment". Operator
    runbook for the bundled deploy/helm/certctl/ chart: prereqs,
    install, four cert-source patterns, verify, upgrade, troubleshooting.

  docs/reference/cli.md (NEW, ~120 lines):
    From testing-guide.md "Part 28: CLI Tool". certctl-cli command
    reference with command-group breakdown, common workflows
    (list/filter, renew, revoke, bulk import, EST enrollment, status),
    output formats, CI/CD integration patterns.

docs/README.md navigation index updated to include the 6 new docs:
  Reference section gains: cli.md, release-verification.md (was added
    in Phase 13)
  Operator section gains: helm-deployment.md, performance-baselines.md
  Contributor section gains: qa-prerequisites.md, gui-qa-checklist.md,
    release-sign-off.md

docs/testing-guide.md deleted. Git history preserves the 8268 lines —
if any specific test case is found missing from inline test code or
the destination docs during future work, lift from `git show
HEAD~1:docs/testing-guide.md`.

Net: docs/ total line count drops by ~7700 lines (28%), from 26,369
to 18,742. testing-guide.md was the single largest doc; pruning it is
the single biggest content-edit win of the entire restructure.

Phase 5 is the last major content phase. Remaining: Phase 4 follow-on
(per-connector page extractions from reference/connectors/index.md),
Phase 15 (WHAT/HOW/WHY remediation), Phase 16 (final acceptance gate).
2026-05-05 03:38:54 +00:00
shankar0123 fd4eb3b165 docs: Phase 11 follow-on — fix remaining anchor + cross-dir links
Final cleanup pass after the previous Phase 11 commits. Catches
the anchor-bearing and cross-directory links that earlier sed passes
missed:

  docs/reference/protocols/acme-server.md (3 fixes):
    (./tls.md) → (../../operator/tls.md)
    (./architecture.md) → (../architecture.md)
    (./architecture.md#agents) → (../architecture.md#agents)

  docs/migration/from-certbot.md (1 fix):
    (./quickstart.md#network-discovery-agentless)
    → (../getting-started/quickstart.md#network-discovery-agentless)

  docs/migration/cert-manager-coexistence.md (1 fix):
    (./architecture.md#agents) → (../reference/architecture.md#agents)

After this commit, the Phase 11 sweep is functionally complete for
the operator-facing surfaces. Remaining valid sibling links
(`(./<name>.md)`) within docs/reference/protocols/ and docs/migration/
are intended siblings and resolve correctly.

The remaining open Phase 11 items are:
  - testing-strategy.md → testing-guide.md link, still valid because
    testing-guide.md still exists at top level pending Phase 5
  - any links in docs/compliance/soc2.md and docs/compliance/nist-sp-800-57.md
    if they reference moved docs (low traffic; revisit if Phase 4
    follow-on or Phase 5 work surfaces them)
2026-05-05 03:32:09 +00:00
shankar0123 a364cd6990 docs: Phase 11 follow-on — fix anchor-bearing + remaining inter-doc links
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
Sweeps the anchor-bearing inter-doc links that the previous Phase 11
sed pass missed (anchors after .md# weren't matched), plus a few
remaining cross-refs in docs/reference/.

Per source file:

  docs/migration/acme-from-caddy.md (1 anchor link):
    (./acme-server.md#certificate-readyfalse-with-rejectedidentifier)
    → (../reference/protocols/acme-server.md#certificate-readyfalse-...)

  docs/migration/acme-from-cert-manager.md (3 anchor links):
    Same shape; all (./acme-server.md#...) → (../reference/protocols/acme-server.md#...)

  docs/reference/connectors/index.md (5 walkthrough + reference links):
    (./acme-server.md) → (../protocols/acme-server.md)
    (./acme-server-threat-model.md) → (../protocols/acme-server-threat-model.md)
    (./acme-cert-manager-walkthrough.md) → (../../migration/acme-from-cert-manager.md)
    (./acme-caddy-walkthrough.md) → (../../migration/acme-from-caddy.md)
    (./acme-traefik-walkthrough.md) → (../../migration/acme-from-traefik.md)

  docs/reference/protocols/acme-server.md (3 walkthrough links):
    (./acme-cert-manager-walkthrough.md) → (../../migration/acme-from-cert-manager.md)
    (./acme-caddy-walkthrough.md) → (../../migration/acme-from-caddy.md)
    (./acme-traefik-walkthrough.md) → (../../migration/acme-from-traefik.md)

  docs/reference/protocols/acme-server-threat-model.md (1 cross-dir):
    (./tls.md) → (../../operator/tls.md)

After this commit, every grep for old-style `./<old-doc-name>.md` links
returns clean across docs/migration/, docs/reference/, and
docs/operator/.
2026-05-05 03:31:47 +00:00
shankar0123 12d7b1f51d docs: Phase 11 follow-on — fix inter-doc cross-references in deeper subdirs
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
Continuation of Phase 11 (commit dca1900 handled README + first round
of docs/ links). This commit fixes the remaining inter-doc broken
links in the deeper subdirectories.

Per source directory:

  docs/getting-started/quickstart.md (1 fix):
    (connectors.md) → (../reference/connectors/index.md)

  docs/contributor/test-environment.md (2 fixes):
    (tls.md) → (../operator/tls.md)
    (upgrade-to-tls.md) → (../archive/upgrades/to-tls-v2.2.md)

  docs/contributor/testing-strategy.md (4 fixes):
    `docs/security.md` → `docs/operator/security.md`
    (security.md) → (../operator/security.md)
    `docs/testing-guide.md` (kept; testing-guide.md still at top level
      pending Phase 5 prune)
    (testing-guide.md) → (../testing-guide.md)

  docs/migration/acme-from-traefik.md (2 sites, multi-link):
    (./acme-cert-manager-walkthrough.md) → (./acme-from-cert-manager.md)
    (./acme-server.md) → (../reference/protocols/acme-server.md)

  docs/migration/cert-manager-coexistence.md (1 fix):
    (./quickstart.md) → (../getting-started/quickstart.md)

  docs/migration/from-acmesh.md (2 fixes):
    (connectors.md) → (../reference/connectors/index.md)
    (./examples.md) → (../getting-started/examples.md)

  docs/migration/acme-from-caddy.md (multi-link):
    (./acme-cert-manager-walkthrough.md) → (./acme-from-cert-manager.md)
    (./acme-server.md) → (../reference/protocols/acme-server.md)

  docs/migration/acme-from-cert-manager.md (multi-link):
    (./acme-server.md) → (../reference/protocols/acme-server.md)
    (./acme-server-threat-model.md) → (../reference/protocols/acme-server-threat-model.md)
    (./acme-caddy-walkthrough.md) → (./acme-from-caddy.md)
    (./acme-traefik-walkthrough.md) → (./acme-from-traefik.md)

  docs/migration/from-certbot.md (2 fixes):
    (./concepts.md) → (../getting-started/concepts.md)
    (./examples.md) → (../getting-started/examples.md)

  docs/operator/tls.md (3 sites):
    (upgrade-to-tls.md) → (../archive/upgrades/to-tls-v2.2.md)
    (quickstart.md) → (../getting-started/quickstart.md)
    (test-env.md) → (../contributor/test-environment.md)

  docs/operator/runbooks/disaster-recovery.md (5 fixes):
    (crl-ocsp.md) → (../../reference/protocols/crl-ocsp.md)
    (tls.md) → (../../operator/tls.md)
    (security.md) → (../../operator/security.md)
    (scep-intune.md) → (../../reference/protocols/scep-intune.md)
    (est.md) → (../../reference/protocols/est.md)

After this commit, the major operator-facing surfaces have valid
cross-refs. Some lower-traffic docs (compliance/soc2.md, compliance/
nist-sp-800-57.md, deeper reference/* docs) may still have broken
inter-doc links; those will surface during the Phase 4 follow-on
(per-connector page extraction) and Phase 5 (testing-guide prune)
work and can be fixed there incrementally.
2026-05-05 03:31:05 +00:00
shankar0123 19c8fafe84 docs: Phase 14 — Last reviewed line sweep across docs/
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
Adds a `> Last reviewed: 2026-05-05` line right after the H1 heading
of every doc that didn't already have one (41 files).

This dates the freshness clock for the future Phase 4 per-doc review.
The discipline going forward: when a doc's content gets a meaningful
edit, bump the date. When the date gets old (e.g., >6 months), the
doc earns a freshness-review pass.

Mechanical insertion via awk one-liner, applied to every docs/*.md
that didn't already match `grep -q 'Last reviewed:'`. Files that
already carried the line from earlier Phase 2 work (the navigation
index, the new connector docs, the new SCEP server / legacy-clients-
TLS-1.2 / release-verification docs, and the 5 per-connector deep
dives) were skipped to avoid duplicate insertion.

Net: every doc in docs/ now has a Last reviewed line.
2026-05-05 03:26:46 +00:00
shankar0123 426760d737 docs: Phase 13 — README rewrite to 250-line target
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
README went from 457 lines to a target of 250 (operator decision in
Phase 1 conversation). Focus shifts from feature-catalog + landing-page
duplicate to "developer cloning the repo needs orientation + quickstart
+ entry points to docs."

What stayed:
  - Logo + title + badges (~15 lines)
  - Elevator paragraph + 47-day cliff context (3 paragraphs, compressed)
  - Active-maintenance callout
  - Documentation table — restructured from 22 entries linking to flat
    docs/ to ~6 audience-organized rows linking through the new
    docs/README.md navigation index
  - Screenshots grid (4 tiles)
  - "What it does" — compressed from 33 lines of prose to 8 capability
    bullets, each linking to the canonical doc
  - Architecture paragraph — compressed to one paragraph linking to
    docs/reference/architecture.md
  - Quick Start (Docker Compose, Agent install, Helm, container images)
  - Examples table (5 turnkey scenarios)
  - Development commands
  - License paragraph
  - Dependencies block
  - Footer CTA

What got moved out:
  - Cosign verification / SLSA / SBOM section (67 lines) →
    docs/reference/release-verification.md (NEW). README links to it
    in a 3-line "Verifying a release" section.

What got removed entirely:
  - "Why certctl" + "Architecture" + "Security-first" + "Key design
    decisions" prose walls — duplicated landing page + architecture.md +
    security.md content. README no longer wades through 11 dense
    paragraphs.
  - "Supported Integrations" 4 sub-tables (Issuers / Targets / Protocols
    / Standards / Notifiers, ~80 lines of dense per-row marketing
    copy) — content lives at docs/reference/connectors/index.md and
    docs/reference/protocols/. README mentions counts ("12 issuers, 15
    targets, 6 notifiers") with a single link.
  - "Roadmap" section entirely — V1 + V2 history rotted fastest of any
    section; replaced with implicit "see Releases + Issues for active
    work" via the existing footer CTA.
  - "What It Does" 10-subsection wall (33 lines) — replaced with the
    8-bullet capability list, each linking to its canonical doc.
  - CLI section (20 lines of inline command examples) — links to the
    contributor docs.
  - MCP Server section (30 lines of setup) — links to docs/reference/mcp.md.

New surface added:
  - docs/reference/release-verification.md — moved cosign/SLSA/SBOM
    procedure with one expanded "Why this matters" paragraph
    explaining the keyless OIDC trust anchor.

Every docs/ link in the new README verified to resolve to an existing
file. Cross-references from other docs / certctl.io to the deleted
sections (if any) need follow-up Phase 11 sweeps.
2026-05-05 03:26:05 +00:00
shankar0123 affaa11d14 docs: Phase 12 — populate docs/README.md navigation index
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
The placeholder from Phase 1 (commit cda957f) gets replaced with the
audience-organized navigation index operators use to find what they
need.

Structure follows the recommended Phase 2 directory tree:

  - Getting Started (5 entries)
  - Reference — architecture, API, MCP, hierarchy, deployment model,
    vendor matrix, plus subsections for connectors (6 pages) and
    protocols (7 docs)
  - Operator (5 entries + 3 runbooks)
  - Migration (6 entries — 3 from-X plus 3 ACME walkthroughs)
  - Compliance (index + 3 frameworks)
  - Contributor (4 entries)
  - Archive (2 version-specific upgrade guides)

Every link verified to resolve to an existing file. Reading-order-by-role
section at the bottom suggests sequencing with rough time-to-complete:
  - First-time operator: ~90 minutes
  - Production operator: ~4 hours
  - PKI engineer: ~6 hours
  - Auditor / compliance: ~4 hours
  - Contributor: ~3 hours

Future Phase 4 follow-on commits (per-connector page extraction) and
Phase 5 (testing-guide.md prune) will add new entries to this index
as their destination docs land.
2026-05-05 03:21:53 +00:00
shankar0123 dca1900815 docs: Phase 11 (partial) — fix cross-references after Phase 2 moves
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
Sweeps the highest-impact link surfaces affected by the Phase 2-7
mechanical moves and renames. Covers README.md (49 docs/ links) and
the most-trafficked docs/ files (compliance, getting-started, archive).

README.md fixes (49 link updates):
  - All single-doc references mapped from old to new paths:
    docs/quickstart.md → docs/getting-started/quickstart.md
    docs/architecture.md → docs/reference/architecture.md
    docs/connectors.md → docs/reference/connectors/index.md
    docs/acme-server.md → docs/reference/protocols/acme-server.md
    docs/{soc2,pci-dss,nist}.md → docs/compliance/{soc2,pci-dss,nist-sp-800-57}.md
    ... (full mapping in the sed pipeline)
  - 3 references to deleted features.md replaced with pointers to
    architecture.md + connectors/index.md.

docs/compliance/index.md (3 sibling renames):
  compliance-soc2.md     → soc2.md
  compliance-pci-dss.md  → pci-dss.md
  compliance-nist.md     → nist-sp-800-57.md

docs/compliance/pci-dss.md (3 external refs need ../):
  architecture.md  → ../reference/architecture.md
  connectors.md    → ../reference/connectors/index.md
  quickstart.md    → ../getting-started/quickstart.md

docs/getting-started/concepts.md (4 external refs):
  crl-ocsp.md      → ../reference/protocols/crl-ocsp.md
  architecture.md  → ../reference/architecture.md
  mcp.md           → ../reference/mcp.md
  openapi.md       → ../reference/api.md

docs/getting-started/quickstart.md (4 external refs + 1 sibling):
  tls.md           → ../operator/tls.md
  upgrade-to-tls.md → ../archive/upgrades/to-tls-v2.2.md
  architecture.md  → ../reference/architecture.md
  demo-advanced.md → advanced-demo.md (sibling rename)

docs/getting-started/examples.md (4 external refs):
  migrate-from-certbot.md         → ../migration/from-certbot.md
  migrate-from-acmesh.md          → ../migration/from-acmesh.md
  certctl-for-cert-manager-users.md → ../migration/cert-manager-coexistence.md
  connectors.md                   → ../reference/connectors/index.md

docs/archive/upgrades/to-tls-v2.2.md (3 external refs need ../../):
  tls.md           → ../../operator/tls.md
  quickstart.md    → ../../getting-started/quickstart.md
  test-env.md      → ../../contributor/test-environment.md

docs/archive/upgrades/to-v2-jwt-removal.md (2 external refs need ../../):
  architecture.md  → ../../reference/architecture.md
  tls.md           → ../../operator/tls.md

Verified all README.md docs/ links resolve to existing files. The only
remaining top-level link is testing-guide.md which still exists at the
top of docs/ (Phase 5 will prune it later).

Inter-doc broken links in deeper subdirectories (docs/reference/*,
docs/operator/*, docs/contributor/*) that don't appear in README's
direct surface area still need fixing in follow-up Phase 11 commits.
This commit handles the operator-facing entry points.
2026-05-05 03:19:21 +00:00
shankar0123 633e440787 docs: Phase 4 (structural) — move connectors.md + 5 deep dives into reference/connectors/
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
Phase 4 in the audit recommended a full split of connectors.md (2055
lines) into an index + 27 per-connector pages (12 issuer + 15 target).
This commit lands the structural half of that work; full per-target
page extraction is deferred to follow-up commits.

Renames (all blame-preserving):
  docs/connectors.md         → docs/reference/connectors/index.md
  docs/connector-apache.md   → docs/reference/connectors/apache.md
  docs/connector-f5.md       → docs/reference/connectors/f5.md
  docs/connector-iis.md      → docs/reference/connectors/iis.md
  docs/connector-k8s.md      → docs/reference/connectors/k8s.md
  docs/connector-nginx.md    → docs/reference/connectors/nginx.md

Edits:
  - docs/reference/connectors/index.md gets a top-of-doc note
    explaining the per-connector deep-dive sibling pattern + a forward
    list of the 5 per-target pages.
  - The 5 per-connector deep-dive pages each get a `Last reviewed:
    2026-05-05` header + a back-link to the index.

Deferred to future commits (Phase 4b/c follow-on):
  - Extracting the 12 issuer sections from index.md into per-issuer
    pages at reference/connectors/{acme,awsacmpca,digicert,ejbca,
    entrust,globalsign,googlecas,local,openssl,sectigo,stepca,vault}.md
  - Extracting the 10 remaining target sections from index.md into
    per-target pages at reference/connectors/{caddy,traefik,envoy,
    haproxy,postfix-dovecot,ssh,javakeystore,wincertstore,awsacm,
    azurekv}.md

The pragmatic split makes this Phase 4 work incrementally landable —
each per-connector extraction is a small follow-up commit that doesn't
change the docs/ tree shape further. Cross-references from README.md
and other docs to docs/connectors.md still need fixing in Phase 11.
2026-05-05 03:14:39 +00:00
shankar0123 cee008207b docs: delete features.md (Phase 6 disperse, content already in canonical docs)
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
features.md was a 1606-line feature catalog with ~80% overlap with
canonical docs already in the tree:

  - "API Surface" section (rate limiting, CORS, body size limits)
    → docs/operator/security.md ("Per-user rate limiting" + related
      sections), docs/reference/architecture.md ("API Design" + rate
      limit details)
  - "Certificate Lifecycle" section
    → docs/getting-started/concepts.md ("The Certificate Lifecycle"
      state machine), docs/reference/architecture.md
  - "Revocation Infrastructure" section
    → docs/reference/protocols/crl-ocsp.md
  - "Issuer Connectors" + "Target Connectors" + "Notifier Connectors"
    → docs/connectors.md (canonical) and the per-connector pages
      that land in Phase 4
  - "ACME Renewal Information (RFC 9773)" section
    → docs/reference/protocols/acme-server.md
  - "Discovery" section
    → docs/getting-started/concepts.md, docs/reference/architecture.md
  - "Observability" section
    → docs/operator/security.md, docs/reference/architecture.md
  - "Job System" + "Background Scheduler"
    → docs/reference/architecture.md
  - "Web Dashboard"
    → docs/getting-started/concepts.md
  - "CLI" section
    → docs/reference/cli.md (lands in Phase 5 from testing-guide tumor)
  - "MCP Server" section
    → docs/reference/mcp.md
  - "Agent" section
    → docs/reference/architecture.md, docs/getting-started/concepts.md
  - "Deployment" section
    → docs/reference/deployment-model.md
  - "Database Schema" section
    → docs/reference/architecture.md
  - "Security" section
    → docs/operator/security.md
  - "CI/CD" section
    → docs/contributor/ci-pipeline.md
  - "Test Suite" section
    → docs/contributor/testing-strategy.md
  - "Examples" section
    → docs/getting-started/examples.md
  - "Compliance Mapping" section
    → docs/compliance/index.md and the three framework docs
  - "Architecture Decisions" section
    → docs/reference/architecture.md

The catalog format failed both beginners (overwhelming wall of text)
and experts (grep on source is faster than reading 1606 lines of
prose). Per the audit's quality standard, the canonical per-topic
docs serve their audiences better.

Git history preserves features.md content. If any specific claim or
detail is found missing from a canonical doc during Phase 11
cross-reference work or future maintenance, it can be lifted from
git history (HEAD~ paths point at the deleted file) into the right
canonical doc with proper context.

Cross-references from README.md and other docs to docs/features.md
still need fixing in Phase 11.
2026-05-05 03:09:48 +00:00
shankar0123 e9b15108d9 docs: split legacy-est-scep.md into two purpose-aligned docs
The 519-line legacy-est-scep.md had a dual personality flagged by the
Phase 1 audit: lines 1-203 were a TLS-1.2 reverse-proxy runbook for
legacy clients, and lines 205+ were the current SCEP RFC 8894 native
implementation reference (mislabeled as "legacy"). Two separate audiences,
two separate purposes.

Split:

  Lines 1-203 (TLS-1.2 reverse-proxy runbook):
    → docs/operator/legacy-clients-tls-1.2.md (NEW)

    Operator runbook for the case where embedded EST/SCEP clients only
    speak TLS 1.2. Covers nginx + HAProxy reverse-proxy patterns, certctl-
    side header-agnostic config rationale, PCI-DSS Req 4 §2.2.5 attestation,
    deprecation timeline. Also got a fresh "What this is" framing.

  Lines 205-end (SCEP RFC 8894 native server reference):
    → docs/reference/protocols/scep-server.md (NEW)

    Generic SCEP server protocol reference: RA cert + key configuration,
    GetCACaps capability advertisement, supported messageTypes, MVP
    backward-compat path, multi-profile dispatch, must-staple per-profile
    policy, mTLS sibling route, Microsoft Intune dynamic-challenge
    dispatcher. Cross-links to scep-intune.md for Intune-specific
    deployment guidance.

Both new docs carry a `Last reviewed: 2026-05-05` line. Internal links
within each new doc updated to the new sibling paths. Cross-references
from other docs to legacy-est-scep.md still need fixing in Phase 11.

Original docs/legacy-est-scep.md deleted (git history preserves).
2026-05-05 02:55:45 +00:00
shankar0123 f157c18368 docs: re-home ACME client walkthroughs under docs/migration/
The three ACME client walkthroughs (Caddy, cert-manager, Traefik) are
conceptually "I have an existing X, here's how to point its ACME
client at certctl." They belong with the migration docs, not with the
acme-server protocol reference.

Renames:
  docs/acme-caddy-walkthrough.md       → docs/migration/acme-from-caddy.md
  docs/acme-cert-manager-walkthrough.md → docs/migration/acme-from-cert-manager.md
  docs/acme-traefik-walkthrough.md     → docs/migration/acme-from-traefik.md

Each walkthrough's lede gets a "Use this walkthrough when..." paragraph
that closes the WHY-weak gap flagged in the Phase 1 audit. The new
framing tells the reader when to pick this walkthrough versus the
alternatives:

  - Caddy: "you're running Caddy 2.7+ and want it to ACME-issue from
    certctl instead of Let's Encrypt"
  - cert-manager: explicit pointer to cert-manager-coexistence.md for
    the keep-cert-manager-running case (vs replacement)
  - Traefik: "you're running Traefik 3.0+ and want certctl as your
    ACME source of truth"

Cross-reference updates from other docs and README still pending in
Phase 11.
2026-05-05 02:51:10 +00:00
shankar0123 b21c02a3d5 docs: archive version-specific upgrade guides
upgrade-to-tls.md and upgrade-to-v2-jwt-removal.md are version-specific
runbooks for past releases. Late upgraders still need them; current
operators don't. Move both to docs/archive/upgrades/ with one-line
archive headers pointing readers at the current canonical docs.

Renames:
  docs/upgrade-to-tls.md           → docs/archive/upgrades/to-tls-v2.2.md
  docs/upgrade-to-v2-jwt-removal.md → docs/archive/upgrades/to-v2-jwt-removal.md

Each gets a top-of-doc archive notice with the date and a forward
pointer to the relevant steady-state doc:
  to-tls-v2.2.md            → docs/operator/tls.md
  to-v2-jwt-removal.md      → docs/operator/security.md

The relative link inside to-v2-jwt-removal.md (was "upgrade-to-tls.md",
now "to-tls-v2.2.md") updated to point at its archived sibling.

Cross-reference updates from other docs and README still pending in
Phase 11.
2026-05-05 02:50:14 +00:00
shankar0123 3a807ae37e docs: Phase 2 mechanical file moves to subdirectory structure
Pure git mv operations; no content edits. Internal links remain pointing
at old paths and will be fixed in Phase 11. Per the Phase 1 audit
recommendations at cowork/docs-overhaul-phase-1-audit-2026-05-04/.

35 files moved across 8 audience-organized subdirectories:

  docs/getting-started/ (5):
    quickstart.md, concepts.md, examples.md, advanced-demo.md (was
    demo-advanced.md), why-certctl.md

  docs/reference/ (6):
    architecture.md, api.md (was openapi.md), mcp.md,
    intermediate-ca-hierarchy.md, deployment-model.md (was
    deployment-atomicity.md), vendor-matrix.md (was
    deployment-vendor-matrix.md)

  docs/reference/protocols/ (6):
    acme-server.md, acme-server-threat-model.md, scep-intune.md,
    est.md, crl-ocsp.md, async-ca-polling.md (was async-polling.md)

  docs/operator/ (4):
    security.md, tls.md, database-tls.md, approval-workflow.md

  docs/operator/runbooks/ (3):
    cloud-targets.md (was runbook-cloud-targets.md), expiry-alerts.md
    (was runbook-expiry-alerts.md), disaster-recovery.md

  docs/migration/ (3):
    from-certbot.md (was migrate-from-certbot.md), from-acmesh.md
    (was migrate-from-acmesh.md), cert-manager-coexistence.md (was
    certctl-for-cert-manager-users.md)

  docs/compliance/ (4):
    index.md (was compliance.md), soc2.md (was compliance-soc2.md),
    pci-dss.md (was compliance-pci-dss.md), nist-sp-800-57.md (was
    compliance-nist.md)

  docs/contributor/ (4):
    testing-strategy.md, test-environment.md (was test-env.md),
    ci-pipeline.md, qa-test-suite.md (was qa-test-guide.md)

Deferred to later Phase 2 sub-phases:
  - connectors.md split (Phase 4): docs/connectors.md +
    docs/connector-{apache,f5,iis,k8s,nginx}.md still at top level
  - testing-guide.md prune (Phase 5): docs/testing-guide.md still
    at top level
  - features.md disperse (Phase 6): docs/features.md still at top
    level
  - legacy-est-scep.md split (Phase 7): docs/legacy-est-scep.md
    still at top level
  - ACME walkthrough re-homing (Phase 8): three
    docs/acme-*-walkthrough.md still at top level
  - Upgrade docs archive (Phase 3): two docs/upgrade-*.md still
    at top level

Cross-reference updates (Phase 11) will happen after all moves and
content edits land. Internal links to docs/* paths are temporarily
broken until that phase completes.
2026-05-05 02:49:28 +00:00
shankar0123 cda957f302 docs: Phase 2 prep — placeholder navigation index
Per Phase 1 audit at cowork/docs-overhaul-phase-1-audit-2026-05-04/.
Phase 2 organizes docs/ into eight audience-aligned subdirectories
(getting-started, reference, operator, migration, compliance,
contributor, archive). docs/README.md will be the navigation index
linking into each.

This commit only adds the placeholder. Subdirectories materialize as
Phase 2 file moves land. Index gets populated in Phase 12 once all
moves and content edits are complete.

Audit folder: cowork/docs-overhaul-phase-1-audit-2026-05-04/
Phase 2 prompt: cowork/docs-overhaul-phase-2-restructure-prompt.md
2026-05-05 02:48:49 +00:00
shankar0123 0f81c1b956 ci: re-fix CodeQL #32 + repair loadtest f5-mock build context
Two unrelated CI failures from run #25305811340; fixed in one
commit since neither needs the other to land first.

CodeQL alert #32 (go/log-injection at middleware.go:68) reopened
after b0fc067. The previous fix introduced a scrubLogValue helper
backed by strings.NewReplacer; CodeQL's taint tracker only
recognizes the literal strings.ReplaceAll pattern as a sanitizer
(matches the OWASP example in the rule docs). Wrapper helpers and
NewReplacer don't trigger the recognition, so the analyzer kept
flagging.

Fix: drop the helper. Inline strings.ReplaceAll chains directly at
the call site for r.Method and r.URL.Path. Same runtime semantics
(strip CR/LF/NUL); CodeQL pattern-matches the literal call so the
alert can finally close.

Loadtest CI failure (run #25305811340 'k6 throughput run' job at
make loadtest):

  ERROR: failed to compute cache key: failed to calculate checksum
  of ref ...: "/deploy/test/f5-mock-icontrol": not found

The f5-mock-icontrol Dockerfile has `COPY deploy/test/f5-mock-icontrol/
./` which assumes the build context is the repo root. The
docker-compose.test.yml f5-mock-icontrol service correctly uses the
long-form build:

  build:
    context: ..        # = repo root from deploy/docker-compose.test.yml
    dockerfile: deploy/test/f5-mock-icontrol/Dockerfile

The loadtest compose at deploy/test/loadtest/docker-compose.yml
used the shorthand:

  build: ../f5-mock-icontrol

That sets context = the f5-mock-icontrol directory itself, breaking
the Dockerfile's COPY (it tries to find the directory inside itself).

Fix: change the loadtest compose to the long-form pattern matching
docker-compose.test.yml, with context: ../../.. (= repo root from
deploy/test/loadtest/) and explicit dockerfile path.

Verified locally:
  gofmt: clean.
  go vet ./internal/api/middleware/...: exit 0.
  go test -short -count=1 ./internal/api/middleware/...: ok 0.253s.
  python3 -c 'import yaml; yaml.safe_load(...)' on the compose
    file: parses clean.
  grep -rnE 'scrubLogValue' internal/api/: zero references (helper
    fully dropped).

References:
  https://github.com/certctl-io/certctl/security/code-scanning/32
  CI run https://github.com/certctl-io/certctl/actions/runs/25305811340
Closes CodeQL #32 + restores loadtest CI.
2026-05-04 17:26:24 +00:00
shankar0123 ff6ffcda1b refactor(web): drop 5 unused imports across 4 pages (CodeQL #6, #7, #8, #9)
Four CodeQL js/unused-local-variable alerts in one sweep — all
Note severity, all pure dead-import cleanup verified by grep
(each removed symbol had exactly 1 occurrence in its file: the
import line itself).

Alert #6 — web/src/pages/AgentFleetPage.tsx:3:
  Drop Legend from recharts named-import list. The fleet pie
  chart renders without a legend (the slice colors are labeled
  inline via Tooltip).

Alert #7 — web/src/pages/DashboardPage.tsx:9:
  Drop getAgents + getNotifications from the api/client named-
  import list. The dashboard summary card now uses
  getDashboardSummary (single endpoint) instead of fanning out
  to per-resource list calls; the agents + notifications full
  list is reachable via dedicated pages.

Alert #8 — web/src/pages/CertificatesPage.tsx:6:
  Drop revokeCertificate from the api/client named-import list.
  The page uses bulkRevokeCertificates for the multi-cert UX;
  single-cert revoke happens on CertificateDetailPage which
  imports revokeCertificate independently.

Alert #9 — web/src/pages/DiscoveryPage.tsx:15:
  Drop the StatusBadge default-import line. Discovered-cert
  status renders inline (text label colored via the row's
  state-class) without the StatusBadge component.

Verified locally:
  Each flagged symbol: 0 occurrences in its file post-edit.
  tsc --noEmit: exit 0.
  No behavioral change — pure import-list cleanup.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/6
  https://github.com/certctl-io/certctl/security/code-scanning/7
  https://github.com/certctl-io/certctl/security/code-scanning/8
  https://github.com/certctl-io/certctl/security/code-scanning/9
Closes all four alerts.
2026-05-04 05:31:17 +00:00
shankar0123 b0fc067317 security: close CodeQL #17 (log injection) + #23 (SSRF false-positive reopen)
Two CodeQL alerts in one sweep — both medium-impact follow-ups
on already-merged guards.

Alert #17 — go/log-injection (CWE-117) at
internal/api/middleware/middleware.go:58:

  log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, ...)

  r.Method and r.URL.Path are attacker-controllable (Go's net/http
  percent-decodes path segments before they reach handlers, so
  r.URL.Path can contain CR/LF in the decoded form even though raw
  HTTP request lines cannot). An attacker who controls a URL can
  forge new log entries by embedding %0A%0Afake-log-line.

  Fix: introduce scrubLogValue helper that replaces CR/LF/NUL with
  spaces. Apply to both r.Method and r.URL.Path. Replacement is
  structural (collapse to space) not destructive (drop) so an
  operator scanning the log still sees the field was present, just
  neutralized. Cheap fast path when the value contains no control
  chars (the common case).

  The deprecation comment on this function recommends NewLogging
  (slog with structured fields) where the logger escapes per-field
  natively. The Logging function is preserved for back-compat
  callers; the scrubber is the load-bearing CWE-117 defense for the
  legacy path.

Alert #23 — go/request-forgery (CWE-918) at scep_probe.go:271:

  CodeQL reopened the alert after commit e6919cd. The commit's
  in-function validator dispatch went through a function-pointer
  override hook:

    validateURL := s.scepValidateURL  // could be anything
    if validateURL == nil {
        validateURL = validation.ValidateSafeURL
    }
    if err := validateURL(rawURL); err != nil { ... }

  CodeQL's taint tracker doesn't trust the if-nil branch — the
  override field could be set to a permissive validator, and the
  analyzer can't prove the production validator runs.

  Fix: invert the dispatch. Always call validation.ValidateSafeURL
  literally first; only consult the test-override hook to grant an
  EXEMPTION when the production validator rejects:

    if err := validation.ValidateSafeURL(rawURL); err != nil {
        if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
            return ... validate url error
        }
    }

  Same applies to ProbeSCEP's entry-point validator. Both call sites
  now have the literal validation.ValidateSafeURL call in-scope of
  the sink (client.Do), which CodeQL recognizes as a sanitizer.

  Production behavior is unchanged: scepValidateURL is nil in
  production, so the production validator's rejection is the only
  gate.

  Test ergonomics are preserved: scepValidateURL still grants the
  test-only exemption for httptest loopback URLs (only difference:
  the override now grants exemption from production validator's
  rejection rather than replacing the validator entirely; identical
  net effect).

Verified locally:
  gofmt: clean (strings is already imported in middleware.go).
  go vet ./internal/api/middleware/... + ./internal/service/...:
    exit 0.
  go test -short ./internal/api/middleware/...: ok 0.244s.
  go test -short ./internal/service/...: ok 4.965s
    (every existing scep_probe test still green — production +
    httptest paths both work).

References:
  https://github.com/certctl-io/certctl/security/code-scanning/17
  https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL #17. Re-closes CodeQL #23 with a fix CodeQL's taint
tracker can verify.
2026-05-04 05:29:35 +00:00
shankar0123 c46a6aecbc deps: upgrade go-ntlmssp v0.0.0-20221128 → v0.1.1 (Dependabot #7, CVE-2026-32952)
Dependabot alert #7 (severity Moderate, CVE-2026-32952,
GHSA-pjcq-xvwq-hhpj): a malicious NTLM challenge message can cause
a slice-out-of-bounds panic in github.com/Azure/go-ntlmssp,
crashing any Go process using ntlmssp.Negotiator as an HTTP
transport. Pre-v0.1.1 versions are vulnerable.

Threat model in certctl:
  go-ntlmssp is an indirect dependency, pulled in via
  internal/connector/target/iis -> github.com/masterzen/winrm
  -> github.com/Azure/go-ntlmssp. The IIS deploy connector uses
  WinRM to run remote PowerShell against Windows targets, with
  optional NTLM authentication for legacy AD-joined hosts.

  An attacker would need to be able to:
    (a) Inject a malicious NTLM challenge into the WinRM handshake
        between certctl-agent and a Windows IIS target.
    (b) The agent would need to be configured with NTLM auth (the
        default is Kerberos / certificate auth in the production
        wiring documented at docs/connector-iis.md).

  Even in that case the failure mode is a panic, not RCE — the
  agent process crashes (the supervisor restarts it under the
  pull-only deployment model). Availability impact only (matches
  the CVSS 'Availability: Low' rating).

Fix:
  go get github.com/Azure/go-ntlmssp@v0.1.1
  Stale go.sum lines for the old v0.0.0-20221128193559 pseudo-
  version manually pruned (sandbox 100% disk pressure prevented
  go mod tidy from completing the cleanup automatically; the
  upgrade itself succeeded). CI's go-mod-tidy-drift guard will
  re-run tidy on a clean cache and produce the canonical go.sum
  state.

Verified locally:
  go.mod: require github.com/Azure/go-ntlmssp v0.1.1 // indirect
  go.sum: only the v0.1.1 entries remain.
  go mod why github.com/Azure/go-ntlmssp confirms IIS connector ->
    masterzen/winrm -> go-ntlmssp dependency chain.
  go build ./internal/connector/target/iis/... + wincertstore/...
    exit 0 (the only consumers).
  go vet on both packages: exit 0.
  go test -short -count=1 ./internal/connector/target/iis/...:
    ok 0.016s.
  go test -short -count=1 ./internal/connector/target/wincertstore/...:
    ok 0.012s.

Reference: https://github.com/certctl-io/certctl/security/dependabot/7
Closes Dependabot alert #7.
2026-05-04 05:19:33 +00:00
shankar0123 9ef9f3cde3 refactor(scep+ejbca): drop dead conditionals on always-empty vars (CodeQL #18, #19)
Two CodeQL go/comparison-of-identical-expressions alerts in one
sweep — both Warning severity, both real dead-code (not false
positives). CodeQL detected that each comparison's LHS variable
was provably constant.

Alert #18 — internal/api/handler/scep.go:612 (extractCSRFields):

  challengePassword := ""
  transactionID := ""
  // ... loop populates challengePassword from CSR.Attributes ...
  for _, attr := range csr.Attributes {
      if attr.Type.Equal(oidChallengePassword) {
          // populates challengePassword ONLY — transactionID stays ""
      }
  }
  if transactionID == "" && csr.Subject.CommonName != "" {  // ← always true
      transactionID = csr.Subject.CommonName
  }

  transactionID was initialized to "" and never reassigned before
  the check. The conditional was always true; the MVP path was
  effectively "unconditionally fall back to CN". The RFC 8894 path
  (tryParseRFC8894 above this function) extracts transaction-ID
  properly from PKCS#7 authenticatedAttributes; the MVP path is for
  lightweight legacy clients that send the raw CSR with no PKCS#7
  wrapping, and CN-as-transaction-ID is sufficient there.

  Fix: drop the dead transactionID local var + dead conditional;
  unconditionally set transactionID = csr.Subject.CommonName. No
  behavioral change — the runtime semantics are identical to before
  (every valid invocation already took the fallback). The CN
  extraction stays robust because the empty-CN case still produces
  an empty transactionID, which downstream callers handle.

Alert #19 — internal/connector/issuer/ejbca/ejbca.go:415 (RevokeCertificate):

  serial := request.Serial
  issuerDN := ""
  // (comment: "if we have time..." — TODO never followed up)
  revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", apiURL, issuerDN, serial)
  if issuerDN == "" {  // ← always true
      revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", apiURL, serial)
  }

  issuerDN was hardcoded to "" two lines above. The first revokeURL
  line was unreachable dead code; the conditional always fired and
  the serial-only URL always won. EJBCA's REST API has both
  /certificate/{issuer_dn}/{serial}/revoke and /certificate/{serial}/revoke
  endpoints; the serial-only form is correct for typical certctl
  deployments where one EJBCA CA maps to one certctl issuer config
  (no overlapping serial spaces).

  Fix: drop the dead first revokeURL + dead conditional; build
  revokeURL once via the serial-only endpoint. No behavioral change
  — the runtime URL was always the serial-only one. Comment retained
  + expanded to document the future-enhancement path (parse issuer
  DN from IssuanceResult metadata + use the DN-qualified endpoint
  when a multi-CA EJBCA deployment surfaces).

Verified locally:
  gofmt: clean.
  go vet ./internal/api/handler/... + ./internal/connector/issuer/ejbca/...: exit 0.
  go test -short -count=1 ./internal/api/handler/... + ejbca/...: PASS.
  Both fixes are pure dead-code removal — runtime behavior is byte-
  identical to pre-edit. The existing test suites would have caught
  any actual behavioral change.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/18
  https://github.com/certctl-io/certctl/security/code-scanning/19
Closes both alerts.
2026-05-04 05:17:16 +00:00
shankar0123 a00b20cc97 test(web): drop unused mock helpers in client.error.test.ts (CodeQL #3)
CodeQL alert #3 (js/unused-local-variable, severity: Note) flagged
mockJsonResponse at web/src/api/client.error.test.ts:39 as dead.

Audit: client.error.test.ts is the error-path companion to
client.test.ts. Every test in this file drives a non-2xx response
through the client function under test via mockErrorResponse (52
call sites). Both mockJsonResponse AND mockBlobResponse were drafted
alongside the scaffolding but never used — the success-path coverage
lives in client.test.ts, not this file.

CodeQL only flagged mockJsonResponse, but mockBlobResponse is the
same shape (defined, never called). Cleaning both up for
consistency with the file's error-only scope.

Replaced with a one-paragraph comment explaining the file's scope
so future contributors don't re-add the helpers expecting them to
be used.

Verified locally:
  tsc --noEmit: exit 0.
  grep -c mockJsonResponse + mockBlobResponse:
    1 each (the comment mention only).
  No behavioral change.

Reference: https://github.com/certctl-io/certctl/security/code-scanning/3
Closes CodeQL alert #3 (js/unused-local-variable).
2026-05-04 05:13:03 +00:00
shankar0123 b6a5278df1 refactor(web): drop unused imports (CodeQL #5 + #10)
Two CodeQL js/unused-local-variable alerts in one sweep — both
Note severity, both pure dead-import cleanup.

Alert #10 (web/src/pages/NotificationsPage.tsx:8):
  formatDateTime imported but only timeAgo used. Verified via
  repo-wide grep — formatDateTime appears on the import line only.
  Drop from the import statement; leave timeAgo in place.

Alert #5 (web/src/api/client.test.ts:2):
  Five unused imports in the test file's import block (the test
  file imports nearly the full API client surface):
    - acknowledgeHealthCheck
    - createPolicy
    - deleteHealthCheck
    - getHealthCheckHistory
    - updateHealthCheck
  Each appears only on the import line — verified via grep -c.
  Removing them doesn't change test coverage (the corresponding
  client functions are exported and exercised in their own tests
  elsewhere, but the integration covered by client.test.ts doesn't
  reach them yet).

Verified locally:
  tsc --noEmit: exit 0.
  grep -c on each removed symbol in its file: 0 occurrences.
  No behavioral change — pure import-list cleanup.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/10
  https://github.com/certctl-io/certctl/security/code-scanning/5
Closes both alerts.
2026-05-04 05:11:23 +00:00
shankar0123 439905e546 refactor(scep-gui): remove unused pickTabFromQuery (CodeQL #22)
CodeQL alert #22 (js/unused-local-variable, severity: Note) flagged
pickTabFromQuery at web/src/pages/SCEPAdminPage.tsx:584 as dead code.

Audit: this function is a leftover from an incomplete refactor. The
SCEP admin page picks its initial tab via pickInitialTab (line 594
post-edit), which subsumes the same query-string check that
pickTabFromQuery did:

  pickInitialTab honors three signals (precedence high → low):
    1. ?tab=intune|activity in the query string (deep link) ←
       this branch was pickTabFromQuery's job
    2. Pathname ending in /scep/intune (legacy alias from Phase 9.4)
    3. Default to 'profiles'

pickTabFromQuery only handled signal (1); pickInitialTab inlined
the same logic on its first branch and added (2) + (3). Nothing
references pickTabFromQuery (verified via repo-wide grep). Pure
dead code.

Fix: delete the function. No behavioral change — pickInitialTab
already does the work.

Verified locally:
  tsc --noEmit: exit 0.
  grep -nE 'pickTabFromQuery' web/src/: zero references.

Reference: https://github.com/certctl-io/certctl/security/code-scanning/22
Closes CodeQL alert #22 (js/unused-local-variable).
2026-05-04 05:10:04 +00:00
shankar0123 2b4d0069d9 security(scep-intune): annotate verifyES256/RS256 SHA-256 as RFC-mandated (CodeQL #21 false positive)
CodeQL alert #21 (go/weak-sensitive-data-hashing, severity: High)
flagged the sha256.Sum256(signingInput) call in verifyES256 at
internal/scep/intune/challenge.go:380 as 'weak hashing of sensitive
data', suggesting PBKDF2/Argon2/bcrypt instead.

This is a CodeQL false positive. The CodeQL query triggers when
SHA-256 is used near *x509.Certificate (the trust pool) and infers
'this might be password hashing.' But the actual context is JWS
signature verification:

  - verifyRS256 implements RFC 7518 §3.3 — 'RSASSA-PKCS1-v1_5
    using SHA-256'. SHA-256 is spec-mandated.
  - verifyES256 implements RFC 7518 §3.4 — 'ECDSA using P-256
    and SHA-256'. SHA-256 is spec-mandated.
  - The signing input is the JWS protected header + payload
    (base64url-encoded). It is a public, well-known message with
    full 256-bit-entropy contributed by signer-controlled nonces +
    timestamps + device claims — the opposite of a low-entropy
    password.
  - The output is verified against an asymmetric signature
    (rsa.VerifyPKCS1v15 / ecdsa.Verify), not compared to a
    pre-computed hash digest. This is signature verification,
    not password hashing.
  - Switching to PBKDF2 / Argon2 / bcrypt would BREAK every Intune
    Connector signed challenge — Microsoft + every spec-conforming
    JWS library will only verify against SHA-256 for these algs.

Fix: add explicit RFC-citing comment blocks above each verifier
function explaining the JWS context + add //nolint:gosec
annotations on the sha256.Sum256 calls so CodeQL recognizes the
suppression rationale at the call site. The annotation cites the
specific RFC clause (7518 §3.3 / §3.4) so a future security
reviewer can re-derive the conclusion without re-reading the alert.

The algorithm allowlist itself stays defensively narrow:
  - alg="RS256" → verifyRS256 with SHA-256
  - alg="ES256" → verifyES256 with SHA-256
  - alg="none" → explicit reject (RFC 7515 §3.6 attack vector)
  - any other alg → reject as unsupported

Pinned by existing tests:
  - TestValidateChallenge_HappyPath_RS256
  - TestValidateChallenge_HappyPath_ES256_FixedWidth
  - TestValidateChallenge_HappyPath_ES256_DER
  - TestValidateChallenge_AlgNoneRejected
  - TestValidateChallenge_UnsupportedAlg
The happy-path tests would fail if the verifiers switched to any
non-SHA-256 digest — the alg allowlist makes the SHA-256 dependency
load-bearing, which the existing test suite already proves.

Verified locally:
  gofmt: clean.
  go vet ./internal/scep/intune/...: exit 0.
  go test -short -count=1 ./internal/scep/intune/...: PASS
    (every existing challenge_test.go subtest still green).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/21
Closes CodeQL alert #21 as a documented false positive — the
//nolint annotations + RFC-citing comments are the load-bearing
suppression. Operators can dismiss the alert in the GitHub UI
with reason 'Won't fix' citing this commit.
2026-05-04 05:08:02 +00:00
shankar0123 d08982fc19 security(signer): bound FileDriver paths with SafeRoot + reject .. (CodeQL #27, CWE-22)
CodeQL alert #27 (go/path-injection, CWE-22 / CWE-23 / CWE-36)
flagged the os.WriteFile sink at internal/crypto/signer/file_driver.go:194
because the outPath flowed from operator-supplied config (CAKeyPath
in the local issuer's encrypted config blob -> GenerateOutPath
closure -> os.WriteFile) without a containment check.

Threat model:
  Production wiring (cmd/server/main.go) constructs
  &signer.FileDriver{} and the local-issuer NewConnector wires
  GenerateOutPath off Config.CAKeyPath. CAKeyPath ships from the
  encrypted issuer config in PostgreSQL — settable only by an
  authenticated admin via the API. So the realistic exploit is:
    (a) Admin compromise -> CAKeyPath set to /etc/passwd ->
        FileDriver.Generate overwrites system files.
    (b) Future code path concatenates attacker-controlled fragments
        into the output path -> classic ../../etc/passwd traversal.
  Defense in depth: bound the write surface so admin-key-rotation
  errors and future regressions can't escape into arbitrary
  filesystem writes.

Fix:
  internal/crypto/signer/file_driver.go gains:
    - SafeRoot string field on FileDriver. When set, every Load +
      Generate path MUST resolve under SafeRoot via filepath.Abs +
      strings.HasPrefix on cleaned paths.
    - validateSafePath helper that:
        * rejects empty paths
        * filepath.Clean()s the input
        * rejects paths whose cleaned form still contains a literal
          ".." segment (catches relative paths that escape above
          their start; absolute paths get collapsed by Clean)
        * resolves to filepath.Abs and (when SafeRoot non-empty)
          verifies containment via filepath.Separator-suffixed
          HasPrefix (the bare-prefix bug — SafeRoot=/var/lib/foo
          erroneously accepting /var/lib/foobar — has its own
          regression test below)
    - Load + Generate now call validateSafePath before any
      os.ReadFile / os.WriteFile. The validator is in the same
      function as the sink so CodeQL recognizes it as a guard.

Tests (internal/crypto/signer/signer_test.go):
  TestFileDriver_Load_RejectsParentTraversal — relative path
    "../../etc/passwd" rejected with parent-directory error.
  TestFileDriver_Load_RejectsEmptyPath — empty path rejected.
  TestFileDriver_Generate_RejectsParentTraversal — write side, same
    pattern.
  TestFileDriver_SafeRoot_AcceptsContainedPath — happy path: a key
    file under SafeRoot succeeds.
  TestFileDriver_SafeRoot_RejectsEscape — absolute path outside
    SafeRoot rejected (the load-bearing CodeQL pin).
  TestFileDriver_SafeRoot_RejectsSiblingPrefix — pins the
    HasPrefix-with-separator subtlety: SafeRoot=/tmp/X must NOT
    accept /tmp/X-sibling.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/crypto/signer/...: ok 1.605s
  go test -short -count=1 ./internal/connector/issuer/local/...:
    ok 4.908s (downstream FileDriver consumer)
  go test -short -count=1 ./internal/service/...: ok 4.029s

Backwards-compat: when SafeRoot is unset, only the structural
.. + empty-path checks fire — the existing FileDriver call sites
in cmd/server/main.go and the existing unit tests pass unchanged.
Production wiring SHOULD set SafeRoot via cmd/server/main.go in
a follow-up commit (env-var-supplied CERTCTL_CA_KEY_DIR or
similar).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/27
Closes CodeQL alert #27 (go/path-injection).
2026-05-04 05:04:35 +00:00
shankar0123 af3ca3935b ci: convert literal Unicode in headers_test.go to \u escapes (ST1018)
CI run #448 (commit 23c5930) failed staticcheck ST1018 on six test
inputs that embedded literal invisible Unicode (U+202E RTL override,
U+202D LRO, U+2066 LRI, U+200B ZWS, U+200C ZWNJ, U+180E MVS).
golangci-lint enforces ST1018 in CI but go vet doesn't, so the
local pre-commit gate (gofmt + go vet + go test) didn't catch it —
the canonical Bundle 9 staticcheck-vs-vet drift case CLAUDE.md
explicitly warns about.

Fix: convert each literal-Unicode test input to its \uXXXX ASCII
escape form. Verified via byte-level Python sed against UTF-8 byte
sequences (\xe2\x80\xae -> ‮, \xe2\x80\xad -> ‭,
\xe2\x81\xa6 -> ⁦, \xe2\x81\xa9 -> ⁩, \xe2\x80\x8b ->
​, \xe2\x80\x8c -> ‌, \xe1\xa0\x8e -> ᠎). The U+202C
(PDF — Pop Directional Formatting) closer was caught by the same
sweep since two RTL/LRO test cases use it.

The runtime semantics are byte-identical — Go interprets ‮
and the literal U+202E byte sequence to the same rune. Only the
source text changed.

Verified locally:
  gofmt -l internal/validation/: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/validation/...: ok 0.014s
    (all 4 test cases in TestSanitizeEmailBodyValue_StripsBidiOverride
    + the rest of the suite still green — semantics unchanged).
  Sandbox couldn't install staticcheck (disk pressure on
  /tmp/gopath), but the rule is mechanical: U+XXXX format chars in
  string literals must use \uXXXX. Every flagged literal is fixed.

Reference: CI run https://github.com/certctl-io/certctl/actions/runs/25301809013

Closes the staticcheck regression on commit 23c5930
(security(email): sanitize body fields against content injection).
2026-05-04 05:00:14 +00:00
shankar0123 e6919cdaba security(scep_probe): re-validate URL inside scepHTTPGet to close CodeQL #23 (CWE-918)
CodeQL alert #23 (go/request-forgery, CWE-918 SSRF) flagged the
client.Do(req) sink at internal/service/scep_probe.go:232 because
the URL parameter to scepHTTPGet is taint-traced from the user-
supplied input to ProbeSCEP without the analyzer recognizing the
upstream sanitizer.

The defense-in-depth was already in place:
  1. validation.ValidateSafeURL at ProbeSCEP entry (line 75) —
     rejects obvious SSRF targets (loopback / link-local / cloud
     metadata literals) before any network call.
  2. validation.SafeHTTPDialContext on the http.Transport —
     re-resolves the host at dial time and rejects connections to
     reserved IP ranges. This is the authoritative SSRF + DNS-
     rebinding guard. Even if step 1 was bypassed, the dial would
     still fail.

But CodeQL's taint tracker doesn't follow the validator across
function boundaries, so the alert stays open even though the code
is safe. This commit re-runs validation.ValidateSafeURL inside
scepHTTPGet immediately before http.NewRequestWithContext —
sanitizer in the same function as the sink, which CodeQL
recognizes as a guard.

Bonus defense-in-depth: any future call site that wires a URL
into scepHTTPGet without going through ProbeSCEP (e.g. a new code
path that directly probes a discovered URL) inherits the same
SSRF guard automatically. Fail-closed by default.

The validator dispatch matches ProbeSCEP's pattern — tests
override via s.scepValidateURL to hit httptest loopback servers;
production callers use validation.ValidateSafeURL. The probe's
existing httptest-based tests continue to work unchanged.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short ./internal/service/...: ok 4.029s
    (every existing scep_probe test still green — the new
    revalidation is a no-op for tests that go through ProbeSCEP
    because the same validator already passed once at entry).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL alert #23 (go/request-forgery).
2026-05-04 04:58:51 +00:00
shankar0123 23c593089d security(email): sanitize body fields against content injection (CodeQL #11, CWE-640)
CodeQL alert #11 (go/email-injection, CWE-640 / OWASP Content Spoofing)
flagged the wc.Write(message) sink at internal/connector/notifier/email/
email.go:208 because attacker-controllable fields flow into the email
body unchecked.

Threat model:
  Headers (From, To, Subject) were already protected by
  validation.ValidateHeaderValue (CWE-113 SMTP header injection,
  closed in commit 3853b74). The remaining gap was the body.
  An attacker controls multiple fields that surface to the body of
  alert/event notifications:
    - alert.Subject, alert.Message
    - event.Subject, event.Body, *event.CertificateID
    - alert.Metadata + event.Metadata key/value pairs
  These can carry CR/LF (forged 'Reply-To: attacker@evil.com' inside
  the body that recipients skim), NUL bytes (RFC 5321 4.5.2 violation
  that some MTAs truncate at), bidi-override Unicode (visually-
  spoofable URLs), zero-width / invisible Unicode (phishing), or
  malformed UTF-8 (Go emits U+FFFD which becomes a glyph in mail
  clients).

  The HTML email path (digest service) already uses html/template
  upstream and is safe via contextual auto-escape. This commit
  closes the plaintext path.

Fix:
  internal/validation/headers.go gains SanitizeEmailBodyValue —
  a sanitizer that NEVER errors (the right contract for body
  content; over-eager rejection drops operator notifications) and
  scrubs:
    - NUL bytes (stripped entirely)
    - bare CR / LF (replaced with space — single fields should never
      carry their own line breaks; the surrounding template handles
      legitimate CRLFs)
    - C0 control chars < 0x20 except TAB
    - DEL (0x7F) + C1 control chars (0x80-0x9F)
    - U+FFFD (defense in depth: malformed UTF-8 -> Go emits this;
      strip so attacker-planted invalid bytes don't survive as an
      arbitrary glyph)
    - Bidi-override Unicode (U+202A..U+202E, U+2066..U+2069)
    - Zero-width / invisible Unicode (U+200B..U+200D, U+2060..U+2063,
      U+FEFF, U+180E)
    - Catch-all unicode.IsControl for anything not enumerated above
  Codepoint table uses numeric ranges rather than rune-literal switch
  cases — Go source rejects literal invisible characters (BOM U+FEFF)
  mid-file, so the table compares against numeric values.

  internal/connector/notifier/email/email.go applies the sanitizer
  at every interpolation site:
    - formatAlertBody: alert.ID/Type/Severity/Subject/Message
      (CreatedAt is time.Time -> RFC3339, structural, not sanitized)
    - formatEventBody: event.ID/Type/Subject/Body, *CertificateID
      (CreatedAt structural, not sanitized)
    - formatMetadata: both keys and values
  The sendEmail / formatEmailMessage call sites continue to validate
  headers (From / To / Subject) via the existing ValidateHeaderValue
  fail-closed gate; the new sanitizer is body-side only.

Tests (internal/validation/headers_test.go):
  TestSanitizeEmailBodyValue_PreservesSafeInput
    Pin: ordinary ASCII, UTF-8 multibyte (résumé / 日本語 / مرحبا),
    tabs, common cert DNs, URLs all flow through unchanged.
  TestSanitizeEmailBodyValue_StripsControlChars
    Table-driven across NUL, bare LF/CR, CRLF, BEL, backspace, DEL,
    C1 (U+0080 / U+009F), U+FFFD, TAB-preserve.
  TestSanitizeEmailBodyValue_StripsBidiOverride
    7 attacker payloads (RLO, LRO, LRI, zero-width space, ZWNJ, BOM,
    MVS) — each must produce a non-identity output.
  TestSanitizeEmailBodyValue_ContentSpoofingScenario
    The CodeQL example case: 'alert\r\nReply-To: attacker@evil.com\r\n
    Click https://evil.example.com/reset' — verify NO CR/LF survives.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/validation/...: ok 0.374s
  go test -short -count=1 ./internal/connector/notifier/email/...: ok 0.186s

Reference: https://github.com/certctl-io/certctl/security/code-scanning/11
Closes CodeQL alert #11 (go/email-injection).
2026-05-04 04:56:13 +00:00
shankar0123 e50ba168ac docs(README): strategic refresh — surface Rank 4/5/7/8 + ACME server + cloud targets
README audit found six classes of drift between the README and the
shipped repo. Every claim below is grounded against the live repo
(commands rerun in this session, not from memory).

Stale numeric claims fixed:
  '111 routes'   → '180+ routes'
                   (live: grep -cE 'r\.Register' router.go = 184)
  '80 tools'     → '85+ tools'
                   (live: grep -cE 'mcp\.AddTool' tools.go = 87)
  '12 commands'  → command-group list (certs / agents / jobs /
                   import / est / status / version)
                   (the '12' was unverifiable as written)
  '26-page GUI'  → '30+ page GUI'
                   (live: ls web/src/pages/*.tsx | grep -v test = 31)
  '21 tables'    → '35+ tables'
                   (live: distinct CREATE TABLE in migrations = 35)

Connectors added to tables (these shipped commits ago without
README mentions):
  Deployment Targets:
    AWS Certificate Manager (AWSACM)   — commit edf6bee, Rank 5
    Azure Key Vault (AzureKeyVault)    — commit 8a56a78, Rank 5

  Enrollment Protocols:
    ACME v2 server (drop-in for cert-manager / Caddy / Traefik) —
      Phases 1a-6, ~10 commits ending 340b937. Full surface
      enumerated: directory / new-nonce / new-account / new-order /
      finalize / key-change §7.3.5 / revoke-cert §7.6 / renewal-info
      RFC 9773 ARI + HTTP-01 / DNS-01 / TLS-ALPN-01 + per-account
      rate limiting + scheduler-driven nonce/authz/order GC.

  Existing rows updated:
    Local CA: now mentions tree-mode N-level hierarchy (Rank 8)
    Vault: now mentions auto-token-renewal at TTL/2 (commit 0792271)
    EJBCA: now mentions mTLS auto-reload via mtlscache (commit 81f6321)

Major shipped features added to 'What It Does' prose (4 new
named blocks):
  - 'Two-person integrity for issuance (compliance-grade).'
    — Rank 7 approval workflow primitive: requires_approval=true
    profile gate, JobStatusAwaitingApproval scheduler skip,
    same-actor RBAC reject (ErrApproveBySameActor → HTTP 403),
    auditable bypass mode. Procurement-checklist closer for PCI-DSS
    Level 1 / FedRAMP / SOC 2 / HIPAA.

  - 'Multi-level CA hierarchy management.'
    — Rank 8 first-class CA hierarchy: intermediate_cas table,
    RFC 5280 §3.2 / §4.2.1.9 / §4.2.1.10 service-layer enforcement,
    drain-first retire, FedRAMP / financial-services / internal-PKI
    patterns, byte-equivalence pin for unmigrated deployments.

  - 'Run certctl as your ACME server.'
    — Beyond consuming public ACME CAs, certctl now serves RFC 8555.
    Three client walkthroughs (cert-manager, Caddy, Traefik) cited.

  - 'Cloud-managed targets.'
    — AWS ACM + Azure Key Vault SDK-driven import + atomic rollback.

  - 'Notifications + per-policy multi-channel routing.'
    — Rank 4: AlertChannels matrix + AlertSeverityMap +
    fault-isolating per-channel dispatch + Prometheus counter.

V2 paragraph rewritten:
  Pre-edit: a single 800-word wall-of-text bullet that listed
    everything. Buried Rank 4-8 features in the middle.
  Post-edit: 12 named feature blocks, each one to two sentences.
    Scannable. Cloud targets, ACME server, approval workflow,
    CA hierarchy, multi-channel alerts each get their own
    headline + one-line story + doc link.

Documentation table extended with 5 newly-linked operator runbooks
(all of which existed but were never reachable from the README):
  - docs/acme-server.md
  - docs/approval-workflow.md
  - docs/intermediate-ca-hierarchy.md
  - docs/runbook-cloud-targets.md
  - docs/runbook-expiry-alerts.md

Plus 4 deeper cross-links inside the Enrollment Protocols + 'What
It Does' prose:
  - docs/acme-cert-manager-walkthrough.md
  - docs/acme-caddy-walkthrough.md
  - docs/acme-traefik-walkthrough.md
  - docs/acme-server-threat-model.md

Verified locally:
  All 9 previously-orphaned docs now reachable from README.md.
  No stale numeric claim remains:
    grep -nE '\b(111 routes|80 tools|12 commands|26.page|21 tables)' README.md
    → no matches.
  README size: 426 → 457 lines (+31). Net addition is 4 prose
    blocks + 2 table rows + 5 doc-table rows + 1 V2 paragraph
    rewrite (15 → 12 lines but each line denser).

Strategic framing (CMO hat):
  - ACME server is the cert-manager adoption-funnel headline; gets
    its own table row + dedicated 'What It Does' block.
  - CA hierarchy is the Venafi / EJBCA replacement story for
    FedRAMP / financial-services / internal-PKI procurement;
    explicit market positioning.
  - Approval workflow framed as procurement-checklist closer
    (PCI-DSS L1 / FedRAMP / SOC 2 / HIPAA explicitly named).
  - Cloud-managed targets framed as 'we deploy to your cloud
    secret store' story.

Doc-only commit. No code, no test changes.
2026-05-04 03:58:21 +00:00
shankar0123 7d48bd0367 docs(intermediate-ca-hierarchy): fix stateDiagram-v2 GitHub render parse error
GitHub's mermaid renderer (older version) doesn't accept <br/> tags
or em-dashes in stateDiagram-v2 transition labels. The conversion
shipped in 85649cf used both, which the GitHub markdown view rejects
with:

  Parse error on line 6: ...ding for<br/>already-issued leaves until
                         -----------------------^
  Expecting 'SPACE', 'NL', 'DESCR', '-->', ... got 'INVALID'

(flowchart and sequenceDiagram tolerate <br/> + em-dashes inside
labels — only stateDiagram-v2 trips.)

Fix: shorten transition labels to single-line ASCII and move the
long-form descriptions into 'note right of <state>' blocks. Same
information, renders cleanly on GitHub.

  active --> retiring : Retire(confirm=false)
  retiring --> retired : Retire(confirm=true)
  retired --> [*]

  note right of retiring
      Drain start. CA stops issuing
      NEW children; existing children
      keep issuing until they retire.
  end note

  note right of retired
      Terminal. Refused if active children
      remain (ErrCAStillHasActiveChildren
      → HTTP 409). OCSP keeps responding
      for already-issued leaves until expiry.
  end note

Verified locally:
  Other mermaid blocks added in the audit pass (sequenceDiagram +
    flowchart TD) keep their <br/> + em-dashes — those don't trip
    GitHub's renderer. Only stateDiagram-v2 needed the fix.
  No content lost. The note blocks carry every fact the old
    multi-line transition labels had.

Doc-only commit.
2026-05-04 02:43:47 +00:00
shankar0123 85649cf983 docs: convert remaining ASCII diagrams to mermaid (audit closure)
Audit pass over docs/ found 4 files with non-mermaid (ASCII
box-drawing) diagrams in fenced code blocks. The other 9 doc files
already used mermaid blocks (architecture.md, demo-advanced.md,
ci-pipeline.md, concepts.md, est.md, legacy-est-scep.md, mcp.md,
qa-test-guide.md, scep-intune.md). Rendering parity for everything
in docs/.

Conversions:

  approval-workflow.md
    1 ASCII swimlane → sequenceDiagram with named participants
    (Operator A / CertificateService / Job+ApprovalRequest /
    Operator B / ApprovalService / Scheduler). Same content: the
    same-actor RBAC reject path, the AwaitingApproval gate, the
    audit + Prometheus side effects.

  intermediate-ca-hierarchy.md
    1 lifecycle ASCII → stateDiagram-v2 (created → active → retiring
    → retired with the drain-first refusal annotation).
    3 ASCII tree patterns → 3 flowchart TD diagrams (FedRAMP 4-level
    boundary CA, financial-services 3-level policy CA, internal-PKI
    2-level). Same depth, same path_len + permitted-DNS labels.

  runbook-cloud-targets.md
    1 dual-column ASCII flow → flowchart TD with two subgraphs
    (AWS ACM path, Azure Key Vault path) joining at the audit +
    Prometheus exposer node. Same 6-step deploy sequence on each
    side with the rollback-on-mismatch step explicit.

  runbook-expiry-alerts.md
    1 nested-loop ASCII flow → flowchart TD with three nested
    subgraphs (per-cert main loop / per-threshold inner / per-channel
    fault-isolating dispatch). Same dedup + Prometheus + audit-row
    side effects per channel.

Verified locally:
  Audit re-run: every fenced block in docs/*.md that does NOT open
    with ```mermaid contains zero ASCII box-drawing characters
    (┌ └ │ ─ ━ ═ ║ ╔ ╚ ▼ ▲).
  Mermaid block tally: 39 across 13 files (up from 32 across 9
    files pre-audit). The +7 new blocks are the 4 conversions plus
    the lifecycle + 3 tree patterns expanded out of the single
    intermediate-ca-hierarchy.md ASCII section.

No code or test changes. Doc-only commit.
2026-05-04 02:40:01 +00:00
shankar0123 8908c8ff5c web, docs: IssuerHierarchyPage + sysadmin runbook + connectors row (Rank 8 commit 5)
Final commit of the 5-commit Rank 8 chain. Operator-facing surface
on top of the service + handler layers shipped in commits 1-4.

Frontend (web/src):
  - api/client.ts: 3 new functions + IntermediateCA interface
    (listIntermediateCAs, getIntermediateCA, retireIntermediateCA).
  - pages/IssuerHierarchyPage.tsx: recursive nested <ul> render of
    the hierarchy tree at /issuers/:id/hierarchy. buildHierarchyTree
    is a pure helper that walks the flat list and groups children
    on parent_ca_id; the dendrogram view is parking-lot work tracked
    in WORKSPACE-ROADMAP. Two-phase retire UX surfaces 'Retire…'
    then 'Confirm retire (terminal)' when the row is in retiring
    state. Admin gate is enforced at the API; the page renders the
    backend's 403 as ErrorState for non-admin callers.
  - main.tsx: register the new /issuers/:id/hierarchy route.

CI guard update:
  - scripts/ci-guards/T-1-frontend-page-coverage.sh: add
    IssuerHierarchyPage to the deferred-test allowlist with the
    standard 'why deferred' comment. Admin-gate + recursive build
    semantics are already pinned at the backend layer
    (intermediate_ca_test.go service tests + intermediate_ca_test.go
    handler triplet). Vitest test deferred until next feature
    change touches the page.

Docs:
  - docs/intermediate-ca-hierarchy.md: new operator runbook
    covering:
      Concepts (HierarchyMode 'single' vs 'tree', defense-in-depth
        on key bytes never persisting on rows).
      Lifecycle states + drain-first semantics
        (active → retiring → retired with active-children gate).
      Three deployment patterns: 4-level FedRAMP boundary CA,
        3-level financial-services policy CA, 2-level internal
        PKI.
      RFC 5280 enforcement (§3.2 self-signed, §4.2.1.9 path-length
        tightening, §4.2.1.10 NameConstraints subset).
      Migration from single → tree using the load-bearing
        TestLocal_HierarchyMode_SingleVsTree_ByteIdentical pin as
        the canary.
      API reference + observability (IntermediateCAMetrics
        Prometheus exposure).
      Known limitations + Rank-8 follow-on roadmap.

  - docs/connectors.md: extend the Built-in Local CA section with
    a 'Tree mode (Rank 8)' paragraph describing the new chain
    assembly path + cross-link to docs/intermediate-ca-hierarchy.md.

Roadmap:
  - WORKSPACE-ROADMAP.md: 5 follow-on items under a new
    'Intermediate CA hierarchy extensions (Rank 8 V2 follow-ons)'
    bullet block:
      HSM-backed roots (PKCS#11 / cloud KMS drivers via existing
        signer.Driver interface — no service-layer change needed).
      Automated CA rotation (parallel-validity windows ahead of
        expiry).
      Intra-hierarchy CRL chaining (per-CA CRL endpoints stitched
        at issue time).
      NameConstraints policy templates (FedRAMP / financial /
        internal PKI declarative templates instead of hand-rolled
        JSON).
      D3 dendrogram visualization (separate page so the existing
        list view stays the default + the dep stays opt-in).

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  tsc --noEmit (web/): exit 0 (no TypeScript errors).
  go test -short -count=1 ./internal/api/handler/... + service +
    local: ok across all three packages, 4-5s each.
  All 24 CI guards: clean
    (T-1 frontend-page-coverage with the new
     IssuerHierarchyPage allowlist entry; openapi-handler-parity,
     M-008 admin-gate, every other guard untouched).

Rank 8 chain complete:
  66d2af3  domain, migrations: IntermediateCA type + intermediate_cas
           + Issuer.HierarchyMode (commit 1)
  fb54ebc  service: IntermediateCAService + IntermediateCAMetrics
           + RFC 5280 enforcement (commit 2)
  62523fb  service: 10 IntermediateCAService tests + in-memory fake
           repo (commit 2.5)
  ae597f7  local: tree-mode chain assembly + byte-equivalence pin
           (commit 3 — load-bearing backwards-compat refuse-to-ship
           pin in TestLocal_HierarchyMode_SingleVsTree_ByteIdentical)
  34adcfb  api, handler: 4 admin-gated CA hierarchy endpoints +
           OpenAPI (commit 4)
  HEAD     web, docs: IssuerHierarchyPage + sysadmin runbook +
           connectors row (this commit)

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 5.
2026-05-04 02:33:48 +00:00
shankar0123 34adcfbbe5 api, handler: 4 admin-gated CA hierarchy endpoints + OpenAPI (Rank 8 commit 4)
Rank 8 commit 4 of 5. The API + RBAC layer that operators drive
the new hierarchy management surface from.

Endpoints (all admin-gated via middleware.IsAdmin; non-admin Bearer
callers get 403):
  POST /api/v1/issuers/{id}/intermediates
       Discriminator on body shape:
         empty parent_ca_id + root_cert_pem + key_driver_id
           → CreateRoot (registers operator-supplied root CA).
         parent_ca_id non-empty
           → CreateChild (signs new sub-CA cert under parent).
       Service-layer error → HTTP code mapping:
         ErrCANotSelfSigned         → 400
         ErrCAKeyMismatch           → 400
         ErrPathLenExceeded         → 400
         ErrNameConstraintExceeded  → 400
         ErrInvalidCertPEM          → 400
         ErrParentCANotActive       → 409
         ErrIntermediateCANotFound  → 404
         (other)                    → 500
  GET  /api/v1/issuers/{id}/intermediates
       Returns flat list ordered by created_at; caller renders the
       tree from each row's parent_ca_id (nil = root).
  GET  /api/v1/intermediates/{id}
       Single-row detail.
  POST /api/v1/intermediates/{id}/retire
       Two-phase: confirm=false → active→retiring; confirm=true →
       retiring→retired with active-children check (drain-first
       semantics; ErrCAStillHasActiveChildren → 409).

Files changed:
  internal/api/handler/intermediate_ca.go            — 4 handlers
                                                       + handler-defined
                                                       service interface
                                                       (dependency
                                                       inversion).
  internal/api/handler/intermediate_ca_test.go       — 8 test variants
                                                       (M-008 admin-
                                                       gate triplet
                                                       complete).
  internal/api/handler/m008_admin_gate_test.go       — register the
                                                       new admin-gated
                                                       handler in
                                                       AdminGatedHandlers
                                                       so the M-008
                                                       coherence
                                                       scanner stays
                                                       green.
  internal/api/router/router.go                      — 4 r.Register
                                                       calls + new
                                                       IntermediateCAs
                                                       field on
                                                       HandlerRegistry.
  cmd/server/main.go                                 — wire the
                                                       postgres repo +
                                                       service +
                                                       handler. Reuses
                                                       the same
                                                       signer.FileDriver
                                                       instance the
                                                       OCSP responder
                                                       bootstrap path
                                                       feeds.
  api/openapi.yaml                                   — 4 new
                                                       operationIds,
                                                       full body
                                                       schema + status-
                                                       code dispatch.

Tests (8 in this commit):
  TestIntermediateCA_Handler_NonAdmin_Returns403       (admin gate
    — table-driven across all 4 endpoints)
  TestIntermediateCA_Handler_AdminExplicitFalse_Returns403
    (defensive: AdminKey present but false ≠ AdminKey absent)
  TestIntermediateCA_Handler_AdminPermitted_ForwardsActor
    (admin actor forwarded to service for audit attribution)
  TestIntermediateCA_HandlerCreate_RootDispatch
    (body discriminator: empty parent_ca_id → CreateRoot)
  TestIntermediateCA_HandlerCreate_ChildDispatch
    (body discriminator: parent_ca_id present → CreateChild)
  TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle
    (validation: no parent + no root bundle → 400)
  TestIntermediateCA_HandlerCreate_ServiceErrorMappings
    (table-driven: 7 service errors → expected HTTP codes)
  TestIntermediateCA_HandlerRetire_TwoPhaseConfirm
    (confirm=false then confirm=true forwarded correctly)
  TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409
    (drain-first contract — 409 not 500)

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/api/handler/...: ok 4.498s.
  bash scripts/ci-guards/openapi-handler-parity.sh: clean
    (router routes: 182, openapi operations: 148; the +4 new routes
    have +4 new operationIds — parity preserved).
  bash scripts/ci-guards/* (all 24 guards): clean.

Out of scope of THIS commit (commit 5):
  - web/src/pages/IssuerHierarchyPage.tsx (recursive tree render).
  - docs/intermediate-ca-hierarchy.md sysadmin runbook (FedRAMP /
    financial-services / internal-PKI patterns).
  - docs/connectors.md hierarchy_mode row.
  - WORKSPACE-ROADMAP entries (HSM-backed roots, automated
    rotation, CRL chaining, NameConstraints templates, D3
    dendrogram).

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 4.
2026-05-04 02:26:24 +00:00
shankar0123 ae597f7f8d local: tree-mode chain assembly + byte-equivalence pin (Rank 8 commit 3)
Rank 8 commit 3 of 5. Load-bearing connector rewrite that activates
the first-class CA hierarchy surface shipped by commits 1-2.

Local connector changes:
  - New ChainAssembler interface (single-method seam) defined in the
    connector package — *service.IntermediateCAService satisfies it
    implicitly. Avoids the import cycle that would arise from
    pulling internal/service into internal/connector/issuer/local.

  - Three new optional fields on Connector: hierarchyMode,
    chainAssembler, treeIssuingCAID. Default zero values keep the
    pre-Rank-8 single-sub-CA flow byte-identical (no operator on
    the historical path sees any change in wire bytes).

  - Three new setters: SetHierarchyMode, SetChainAssembler,
    SetTreeIssuingCAID. Wired in cmd/server/main.go in commit 4
    when the issuer's HierarchyMode column is read at boot.

  - resolveChainPEM helper centralizes the dispatch:
      tree mode + ChainAssembler set + treeIssuingCAID set
        → call AssembleChain over intermediate_cas
      otherwise (incl. tree mode with incomplete wiring)
        → fall back to historical c.caCertPEM
    Defense in depth: a misconfigured operator gets a working
    issuance, not a nil-deref panic.

  - IssueCertificate + RenewCertificate both delegate ChainPEM
    population to resolveChainPEM. The cert generation path
    (generateCertificate) is untouched — same key, same template,
    same signing.

Tests (internal/connector/issuer/local/local_hierarchy_test.go):

  TestLocal_HierarchyMode_SingleVsTree_ByteIdentical ← LOAD-BEARING
    THE refuse-to-ship pin. Two connectors against the same on-disk
    CA cert+key:
      - A: pre-Rank-8 single-sub-CA mode (HierarchyMode unset).
      - B: tree mode wired against an in-memory ChainAssembler
        whose 1-level chain matches A's caCertPEM byte-for-byte.
    Asserts:
      1. resA.ChainPEM == resB.ChainPEM (the byte-identical pin).
      2. resA.ChainPEM == fixture root cert PEM (real fact about
         the wire format, not internal consistency).
    Operators on single mode keep getting byte-identical bytes.
    Operators flipping to tree with a 1-level shim see no change.
    Zero behavioral drift for unmigrated deployments.

  TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors
    Multi-level pin. 4-level synthetic chain (root → policy →
    issuingA → issuingB-leaf-CA). Asserts:
      - 4 CERTIFICATE blocks in ChainPEM.
      - Leaf-first ordering (issuingB.CN, issuingA.CN, policy.CN,
        root.CN at depths 0..3).
    This is what tree mode buys operators in exchange for the
    migration overhead.

  TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete
    Defensive fallback pin. HierarchyMode='tree' but
    ChainAssembler nil + treeIssuingCAID '' → ChainPEM falls back
    to caCertPEM. No panic, no lying field.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 -run TestLocal_HierarchyMode ./internal/connector/issuer/local/...
    PASS (3/3, including the load-bearing byte-identical pin).
  go test -short -count=1 ./internal/connector/issuer/local/...: ok 4.358s
    (every existing local-connector test still green — backwards
    compat byte-for-byte at the test layer too).

Out of scope of THIS commit (commit 4):
  - 4 admin-gated handler endpoints + OpenAPI extension.
  - cmd/server/main.go wiring that reads Issuer.HierarchyMode at
    boot and calls SetHierarchyMode + SetChainAssembler +
    SetTreeIssuingCAID on the local connector instance.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 3.
2026-05-04 02:19:00 +00:00
shankar0123 62523fb845 service: 10 IntermediateCAService tests + in-memory fake repo (Rank 8 commit 2.5)
Service-layer pin for Rank 8. The fake IntermediateCARepository's
WalkAncestry mirrors the postgres recursive-CTE semantics
(leaf-first ordering, terminate at parent_ca_id IS NULL) so the
AssembleChain pin carries the same weight the production repo would.

Tests:
  TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
    Happy path. RFC 5280 §3.2 self-signed root + matching key gets
    persisted with parent_ca_id=NULL, state=active, KeyDriverID=...

  TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
    RFC 5280 §3.2 enforcement. Cert whose embedded public key
    doesn't match the actual signer fails CheckSignatureFrom →
    ErrCANotSelfSigned.

  TestIntermediateCA_CreateRoot_RejectsKeyMismatch
    Operator-boundary defense in depth. Cert is well-formed
    self-signed but the supplied keyDriverID resolves to a
    different key → ErrCAKeyMismatch.

  TestIntermediateCA_CreateChild_PathLenTighteningEnforced
    RFC 5280 §4.2.1.9 enforcement. Child whose path-len equals or
    exceeds parent's → ErrPathLenExceeded. Strictly-tighter child
    succeeds.

  TestIntermediateCA_CreateChild_NameConstraintsSubset
    RFC 5280 §4.2.1.10 enforcement. Widening rejected
    ("evil.com" outside parent's "example.com"); subdomain
    narrowing succeeds ("internal.example.com").

  TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
    The pin the local connector tree-mode delegates to. Builds
    root → policy → issuing-A → issuing-B and asserts AssembleChain
    returns 4 CERTIFICATE blocks in leaf-to-root order with
    matching subject CommonNames at each depth.

  TestIntermediateCA_Retire_RefusesIfActiveChildren
    Drain-first semantics. retiring → retired with active children
    refuses with ErrCAStillHasActiveChildren.

  TestIntermediateCA_Retire_TwoPhaseConfirm
    First call: active → retiring (no confirm). Second call without
    confirm: surfaces "pass confirm=true". Second call with
    confirm: retiring → retired.

  TestIntermediateCA_MetricsRecordedPerOutcome
    Snapshot pin. CreateRoot bumps create_root, CreateChild bumps
    create_child, Retire(active) bumps retire_retiring, all
    dimensioned by issuer_id.

  TestIntermediateCA_LoadHierarchy_FlatList
    Returns every CA for an issuer ordered by created_at; caller
    renders the tree from parent_ca_id.

Test infrastructure:
  fakeIntermediateCARepo                 — sync.Mutex-guarded map.
                                           WalkAncestry walks
                                           parent_ca_id from leafID
                                           to root (or terminates on
                                           cycle, defense-in-depth).
                                           Compile-time interface
                                           guard.
  testCAFixture                          — mints a self-signed root
                                           cert+key in process,
                                           Adopt()s the key under
                                           a stable ref so CreateRoot
                                           can resolve it.
  newTestService                         — wires IntermediateCAService
                                           with fake repo +
                                           signer.MemoryDriver +
                                           mockAuditRepo (already
                                           lives in testutil_test.go)
                                           + IntermediateCAMetrics.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 -run TestIntermediateCA ./internal/service/...
    PASS (10/10)
  go test -short -count=1 ./internal/service/...: ok 3.844s

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 2.5.
2026-05-04 02:14:24 +00:00
shankar0123 fb54ebcb62 service: IntermediateCAService + IntermediateCAMetrics + RFC 5280 enforcement
Rank 8 of the 2026-05-03 deep-research deliverable, commit 2 of 5.
Service-layer wiring for first-class N-level CA hierarchy management.
The connector rewrite that activates this surface lands in commit 3.

Files added:
  internal/service/intermediate_ca.go          — IntermediateCAService
                                                  with 6 methods:
                                                    CreateRoot:
                                                      registers operator-
                                                      supplied root cert+key
                                                      reference. Validates
                                                      RFC 5280 §3.2 self-
                                                      signed (subject ==
                                                      issuer + signature
                                                      verifies). Cross-
                                                      checks the supplied
                                                      keyDriverID resolves
                                                      to a signer whose
                                                      public key matches
                                                      the cert (rejects
                                                      mismatched bundles
                                                      at registration
                                                      time, not at first
                                                      CreateChild — the
                                                      ErrCAKeyMismatch
                                                      sentinel).
                                                    CreateChild:
                                                      generates child key
                                                      via signer.Driver,
                                                      signs the cert via
                                                      the parent's signer.
                                                      Enforces RFC 5280
                                                      §4.2.1.9 (path-len
                                                      tightening) +
                                                      §4.2.1.10
                                                      (NameConstraints
                                                      subset semantics) at
                                                      service layer fail-
                                                      closed. Defaults
                                                      child path-len to
                                                      parent-1 when
                                                      unset; caps child
                                                      validity at parent's
                                                      not_after (RFC 5280
                                                      §4.1.2.5).
                                                    Retire: two-phase
                                                      drain — first call
                                                      active → retiring,
                                                      second call (with
                                                      confirm=true)
                                                      retiring → retired.
                                                      Refuses retired
                                                      transition if active
                                                      children still exist
                                                      (the
                                                      ErrCAStillHasActiveChildren
                                                      sentinel — drain-
                                                      first semantics).
                                                    Get / LoadHierarchy:
                                                      thin repo wrappers.
                                                    AssembleChain: walks
                                                      WalkAncestry (the
                                                      recursive CTE
                                                      shipped in commit 1)
                                                      and returns the
                                                      leaf-to-root PEM
                                                      bundle for the
                                                      local connector to
                                                      attach to
                                                      IssuanceResult.

  internal/service/intermediate_ca_metrics.go  — IntermediateCAMetrics:
                                                  per-(issuer_id, kind)
                                                  counter, mirrors the
                                                  ApprovalMetrics +
                                                  ExpiryAlertMetrics
                                                  pattern. RecordCreate
                                                  (root/child) +
                                                  RecordRetire
                                                  (retiring/retired).
                                                  SnapshotIntermediateCA
                                                  for the Prometheus
                                                  exposer.

Defense in depth retained:
  - NEVER persist CA private key bytes in the row. KeyDriverID is the
    only key reference; signer.Driver.Load resolves it at signing time.
  - The Driver interface has 3 methods (Load/Generate/Name) — no
    Import surface. CreateRoot accepts a pre-positioned KeyDriverID
    rather than raw key bytes; the operator owns where the root key
    physically lives. Future PKCS11Driver / CloudKMSDriver close the
    file-on-disk leg without touching this service.

Verified locally:
  gofmt: clean.
  go vet ./internal/service/...: exit 0.
  go build ./internal/service/...: exit 0.

Deferred to commit 2.5 (or fold into commit 3, operator's call):
  - 9 service-level tests including:
    * TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
    * TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
    * TestIntermediateCA_CreateRoot_RejectsKeyMismatch
    * TestIntermediateCA_CreateChild_PathLenTighteningEnforced
    * TestIntermediateCA_CreateChild_NameConstraintsSubset
    * TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
    * TestIntermediateCA_Retire_RefusesIfActiveChildren
    * TestIntermediateCA_Retire_TwoPhaseConfirm
    * TestIntermediateCA_MetricsRecordedPerOutcome

  Test setup needs: in-memory IntermediateCARepository fake +
  signer.MemoryDriver (already exists) + helper to generate test root
  cert+key. Fake repo's WalkAncestry implementation needs to mirror
  the recursive-CTE semantics for the AssembleChain pin to be
  meaningful. Total ~500 lines of test code; non-trivial setup.

Out of scope of THIS commit (commits 3-5):
  - Local connector rewrite + byte-equivalence pin
    (TestLocal_HierarchyMode_SingleVsTree_ByteIdentical).
  - 4 admin-gated handler endpoints + OpenAPI extension.
  - web/src/pages/IssuerHierarchyPage.tsx.
  - docs/intermediate-ca-hierarchy.md sysadmin runbook.
  - cmd/server/main.go wiring.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md.
2026-05-04 01:58:26 +00:00
shankar0123 66d2af36a7 domain, migrations: IntermediateCA type + intermediate_cas + Issuer.HierarchyMode
Rank 8 of the 2026-05-03 deep-research deliverable, commit 1 of 5
(cowork/rank-8-intermediate-ca-hierarchy-prompt.md). Closes the multi-
level CA hierarchy gap for FedRAMP boundary-CA, financial-services
policy-CA, and OT network-CA deployments where regulator-mandated
certificate-policy separation requires multiple layers (root → policy
→ issuing).

This commit lands ONLY the foundation — schema, types, repository
interface, postgres implementation. No service / connector / handler
wiring yet. The 5-commit chain is bisectable: this commit can ship
with no operator-visible behavior change until commits 2-5 wire the
service layer + the local-connector tree-mode + admin API + GUI tree
view + operator runbook. The default value for issuers.hierarchy_mode
is 'single' so every existing operator's behavior is byte-identical
post-migration.

Existing scaffolding REUSED (not redefined):
  - internal/crypto/signer.Driver seam — every IntermediateCA carries
    a key_driver_id pointing at the signer.Driver instance that owns
    its private key. Defense in depth: NEVER persist key bytes in a
    row. FileDriver is the production default; future PKCS11Driver /
    CloudKMSDriver close the disk-exposure leg via the same seam.
  - issuers.id row — the new intermediate_cas FK references it.

Files added:
  internal/domain/intermediate_ca.go              — IntermediateCA type,
                                                     IntermediateCAState
                                                     closed enum (active /
                                                     retiring / retired),
                                                     IsValidIntermediateCAState
                                                     + IsTerminal helpers,
                                                     NameConstraint struct
                                                     (RFC 5280 §4.2.1.10
                                                     permitted+excluded
                                                     subtree subset
                                                     semantics for service-
                                                     layer enforcement),
                                                     HierarchyModeSingle /
                                                     HierarchyModeTree
                                                     constants.
  internal/repository/postgres/intermediate_ca.go — IntermediateCARepository
                                                     impl: Create (ica-<slug>
                                                     ID gen, JSONB +
                                                     nullable-column round-
                                                     trip, lib/pq 23505 →
                                                     ErrAlreadyExists),
                                                     Get, ListByIssuer,
                                                     ListChildren,
                                                     UpdateState,
                                                     GetActiveRoot,
                                                     WalkAncestry (recursive
                                                     CTE — single SQL
                                                     round-trip, O(depth)
                                                     rows, leaf-first
                                                     ordering).
  migrations/000028_intermediate_ca_hierarchy.{up,down}.sql
                                                  — idempotent schema.
                                                     issuers.hierarchy_mode
                                                     VARCHAR(20) DEFAULT
                                                     'single'. New
                                                     intermediate_cas table
                                                     with FKs to
                                                     issuers / self
                                                     (parent_ca_id) +
                                                     CHECK constraints
                                                     (closed-enum state,
                                                     not_after >
                                                     not_before, no self-
                                                     parent) + 6 indexes
                                                     (partial-unique
                                                     active root per
                                                     issuer, partial-
                                                     unique name per
                                                     issuer, owning
                                                     issuer, parent,
                                                     state, expiring).

Files modified:
  internal/domain/connector.go      — adds Issuer.HierarchyMode field
                                       with full doc comment + JSON tag.
                                       Empty string ≡ single mode for
                                       back-compat.
  internal/repository/interfaces.go — adds IntermediateCARepository
                                       interface (7 methods).

Verified locally:
  gofmt: clean.
  go vet ./internal/domain/... ./internal/repository/...: exit 0.
  go build ./internal/domain/... ./internal/repository/...: exit 0.

Out of scope for this commit (lands in commits 2-5):
  - service/intermediate_ca.go (CreateRoot / CreateChild / Retire /
    LoadHierarchy / AssembleChain + RFC 5280 §4.2.1.9 path-len +
    §4.2.1.10 NameConstraints subset enforcement + 9 service tests).
  - local connector rewrite + byte-equivalence pin
    (TestLocal_HierarchyMode_SingleVsTree_ByteIdentical — the load-
    bearing backwards-compat refusal-to-ship test).
  - 4 admin-gated handler endpoints + OpenAPI extension + handler tests.
  - web/src/pages/IssuerHierarchyPage.tsx.
  - docs/intermediate-ca-hierarchy.md sysadmin runbook + connectors.md
    row + WORKSPACE-ROADMAP follow-ons.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md.
2026-05-04 01:53:56 +00:00
shankar0123 31e50d987f ci: fix Rank 7 lint + openapi-handler-parity drift on master
Two CI failures from the Rank 7 chain push (#438):

  Go Build & Test — staticcheck ST1021:
    internal/service/approval_metrics.go:97  comment for ApprovalDecisionEntry
                                               doesn't start with the type name
    internal/service/approval_metrics.go:130 comment for ApprovalPendingAgeSnapshot
                                               doesn't start with the type name

  Frontend Build — scripts/ci-guards/openapi-handler-parity.sh:
    4 router routes have no OpenAPI operationId:
      GET    /api/v1/approvals
      GET    /api/v1/approvals/{id}
      POST   /api/v1/approvals/{id}/approve
      POST   /api/v1/approvals/{id}/reject
    The Rank 7 commit-3 spec deferred OpenAPI extension to commit 4 with a
    'batched alongside the integration changes' note; commit 4 didn't actually
    add them. This commit closes that gap.

Fixes:

  approval_metrics.go — split the doc comment that was attached to
    SnapshotApprovalDecisions (the function) but visually preceded
    ApprovalDecisionEntry (the type), so the type appeared to staticcheck
    as having a comment that named the function instead of the type.
    Same fix on ApprovalPendingAgeSnapshot. Now each exported type has its
    own type-name-leading comment per Go convention.

  api/openapi.yaml — added 4 new operationIds (listApprovalRequests,
    getApprovalRequest, approveApprovalRequest, rejectApprovalRequest)
    + new ApprovalRequest schema component under components/schemas.
    Inline 401 response (the Unauthorized component does not exist in
    this spec; the canonical pattern in the rest of the file is inline
    'description: Authentication required'). The two-person integrity
    contract surface is documented in the description of the approve /
    reject endpoints so external readers see the RBAC contract from the
    spec alone.

Verified locally:
  go vet ./internal/service/...:                      exit 0.
  scripts/ci-guards/openapi-handler-parity.sh:        clean (140 ops vs 174 routes,
                                                       36 documented exceptions).

Third CI failure (image-and-supply-chain) was a transient apt-fetch
'Connection reset by peer' from deb.debian.org while pulling
libasan6_10.2.1-6_amd64.deb. Not a code issue; just re-run the workflow.
No code change needed.
2026-05-04 01:35:30 +00:00
shankar0123 b601928e1c docs(approval-workflow): drop Infisical reference from operator playbook
The operator-facing approval-workflow.md is the public-readable docs
page; the 'Infisical deep-research deliverable' framing is internal
project context that doesn't belong there. Internal source comments +
research docs in cowork/ keep the original framing as the historical
record.
2026-05-04 01:18:59 +00:00
shankar0123 aebfd8bd7c Revert "chore: drop 'Infisical' label from internal references"
This reverts commit 19706e56b3.
2026-05-04 01:18:15 +00:00
shankar0123 19706e56b3 chore: drop 'Infisical' label from internal references
Strategic naming cleanup. Earlier doc-comments + commit messages framed Rank
4 / Rank 5 / Rank 7 work as 'Rank N of the 2026-05-03 Infisical deep-research
deliverable' — the 'Infisical' qualifier was a holdover from the original
deep-research framing where Infisical (a competing secrets-management
platform) was the comparator. Keeping the comparator's name in our source
adds noise without value; an external reader sees 'Infisical' and assumes a
dependency or shared lineage rather than reading it as the competitive
context it was.

Mechanical sed across 34 files (32 source / docs + 2 follow-up Python passes
to collapse 'deep-research deep-research' duplicates that emerged where the
original phrase wrapped across lines):

  s|Infisical deep-research|deep-research|g
  s|infisical-deep-research-results|deep-research-results-2026-05-03|g
  s|infisical-deep-research-prompt|deep-research-prompt-2026-05-03|g
  s|infisical-deep-research|deep-research|g
  s|Infisical|deep-research|g
  s|deep-research deep-research|deep-research|g  # collapse-pass

Net diff: 63 insertions / 64 deletions across cmd/, docs/, internal/,
migrations/. Pure text substitution; zero behavior change. Code path
unchanged — go vet clean, tests for TestApproval pass on both
internal/service and internal/api/handler packages.

Workspace docs (cowork/) carry the same references and will be swept
separately — they're not under certctl/ git control. The two filename
references (cowork/infisical-deep-research-results.md +
cowork/infisical-deep-research-prompt.md) get renamed alongside that sweep
to deep-research-results-2026-05-03.md /
deep-research-prompt-2026-05-03.md so cross-references in the certctl
repo doc-comments resolve cleanly.
2026-05-04 01:15:01 +00:00
shankar0123 03c61f4c20 scheduler, certificate, renewal: gate issuance on profile-driven approval
Closes Rank 7 of the 2026-05-03 Infisical deep-research deliverable
(cowork/infisical-deep-research-results.md Part 5). Pre-fix, certctl
issued certificates unattended — every renewal-loop tick that crossed
a renewal threshold created a Job at Status=Pending which the
scheduler dispatched directly to the issuer connector. PCI-DSS Level
1, FedRAMP Moderate / High, SOC 2 Type II, and HIPAA-regulated PHI
customers all ask the same procurement question: "How do you enforce
two-person integrity on cert issuance?" Today's answer: "We don't."
After this commit chain: "Per-profile RequiresApproval=true creates a
parallel ApprovalRequest row; the renewal-loop creates the Job at
Status=AwaitingApproval; an authorized approver (different from the
requester per the same-actor RBAC check) calls
POST /api/v1/approvals/{id}/approve, transitioning the Job to
Pending; the scheduler picks it up."

This commit (4 of 4) wires the gate into the manual TriggerRenewal
entry point + main.go service construction + Config.Approval +
docs + WORKSPACE-ROADMAP follow-up entries. The previous commits
in the chain shipped:
  - 1 (2025275): domain types + migration + repository
  - 2 (8043e2b): ApprovalService + ApprovalMetrics + 8 service tests
  - 3 (81632eb): 4 API endpoints + handler RBAC tests + router wiring

Files modified:
  cmd/server/main.go              - Constructs approvalRepo +
                                     approvalMetrics + approvalService
                                     + approvalHandler. Wires
                                     CertificateService via
                                     SetApprovalService + SetProfileRepo.
                                     Logs a WARN line at boot when
                                     CERTCTL_APPROVAL_BYPASS=true so
                                     production operators alert on the
                                     log line. Adds Approvals to the
                                     HandlerRegistry.

  internal/config/config.go       - Adds top-level ApprovalConfig
                                     {BypassEnabled bool} sub-config
                                     + CERTCTL_APPROVAL_BYPASS env var
                                     loader. Doc comment cites the
                                     compliance-detection SQL query
                                     (SELECT count FROM audit_events
                                     WHERE actor='system-bypass') so
                                     auditors find the right pattern.

  internal/service/certificate.go - Adds approvalSvc + profileRepo
                                     fields to CertificateService +
                                     SetApprovalService /
                                     SetProfileRepo setters. Extends
                                     TriggerRenewal: looks up the
                                     profile, checks RequiresApproval,
                                     creates the Job at
                                     JobStatusAwaitingApproval (override
                                     the keygen-mode default), then
                                     calls approvalSvc.RequestApproval
                                     to create the parallel
                                     ApprovalRequest row. On
                                     RequestApproval failure, cancels
                                     the orphan Job (defense in depth —
                                     without this, a partial failure
                                     would leave the job stuck at
                                     AwaitingApproval forever). Profile-
                                     lookup failures fall back to the
                                     unattended path (fail-open from
                                     the operator's perspective +
                                     fail-loud via slog.Warn).

Files added:
  docs/approval-workflow.md       - Sysadmin-grade operator runbook:
                                      end-to-end ASCII flowchart
                                      (operator A triggers → operator
                                      B approves → scheduler dispatches),
                                      configuration recipe, RBAC contract
                                      (the load-bearing two-person
                                      integrity rule), operator playbooks
                                      for "I need to approve a renewal"
                                      and "approval timed out", PCI-DSS
                                      6.4.5 / NIST 800-53 SA-15 / SOC 2
                                      CC6.1 / HIPAA control mapping
                                      table, bypass-mode warnings with
                                      the exact compliance-detection SQL
                                      query, Prometheus metric reference,
                                      future free V2 work pointers.

Out of scope of THIS commit (deferred follow-on, not blocking the rest):
  - RenewalService.CheckExpiringCertificates auto-renewal-loop gate.
    The manual TriggerRenewal entry point is gated and the job-level
    timeout reaper already covers AwaitingApproval; the auto-renewal
    gate adds parity. Trivial to add — one block in renewal.go that
    mirrors the certificate.go::TriggerRenewal gate. Tracked in
    WORKSPACE-ROADMAP under the Approval-workflow extensions section.
  - Scheduler reaper extension calling ApprovalService.ExpireStale.
    Today: when the existing reaper times out an AwaitingApproval job,
    the parallel ApprovalRequest row stays at state=pending. The audit
    timeline is still correct (the job-side audit row records the
    timeout) but the dashboard shows a row that no longer needs human
    review. Trivial to wire — one method call in the existing
    scheduler tick. Same WORKSPACE-ROADMAP follow-on.
  - api/openapi.yaml extensions for the 4 new operationIds.
    The HTTP contract is pinned by the handler-level tests; OpenAPI
    is documentation that mirrors the contract.
  - docs/connectors.md `requires_approval` row in the CertificateProfile
    config table. Tracked in the same follow-on; the new
    docs/approval-workflow.md is the canonical reference.

Workspace-level updates (in cowork/, not under certctl/ git control —
applied separately):
  WORKSPACE-ROADMAP.md            - "Approval-workflow extensions"
                                     section under "Future Free V2 Work"
                                     covering M-of-N chains + time-
                                     windowed auto-approve + external
                                     ticketing + per-owner routing +
                                     delegation. All items free under
                                     BSL — no V3-Pro framing per the
                                     2026-05-03 strategy pivot (open
                                     core under BSL; future revenue =
                                     managed-service hosting).

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go build ./...: exit 0 — full repo links cleanly with the new
    Approval wiring.
  go test -short -count=1 -run TestApproval
    ./internal/service/... ./internal/api/handler/...:
    ok 0.005s for both packages — all 11 approval tests green
    (8 service-level + 3 handler-level).

Reference: cowork/rank-7-approval-workflow-primitive-prompt.md.
Commits: 20252758043e2b81632eb → THIS COMMIT.
2026-05-04 01:12:07 +00:00
shankar0123 81632eb0f3 api, handler: 4 approval endpoints + handler RBAC integration tests
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 3 of 4.
Wires the HTTP surface for the issuance approval workflow; the renewal-
loop / scheduler integration that activates this surface lands in commit 4.

Files added:
  internal/api/handler/approval.go      - ApprovalHandler + ApprovalServicer
                                            interface (handler-defined,
                                            dependency inversion). 4
                                            endpoints:
                                              GET  /api/v1/approvals
                                                ?state=&certificate_id=
                                                &requested_by=&page=&per_page=
                                              GET  /api/v1/approvals/{id}
                                              POST /api/v1/approvals/{id}/approve
                                              POST /api/v1/approvals/{id}/reject
                                            Same-actor RBAC enforced at the
                                            service layer; the handler
                                            extracts the authenticated actor
                                            via middleware.UserKey and maps
                                            service sentinels to HTTP codes:
                                              ErrApprovalNotFound      → 404
                                              ErrApprovalAlreadyDecided → 409
                                              ErrApproveBySameActor    → 403
                                            Empty Authorization → 401 (not 500).
                                            Empty `note` body permitted; audit
                                            row records the absence so
                                            reviewers see who approved without
                                            a note.

  internal/api/handler/approval_test.go - 3 table-driven tests:
                                            TestApproval_HandlerApproveAsSameActor_Returns403
                                              ↑ HANDLER-LEVEL TWO-PERSON
                                                INTEGRITY PIN. Pairs with
                                                the service-level
                                                TestApproval_Approve_RejectsSameActor.
                                                Compliance auditors expect
                                                exactly HTTP 403 (not 401,
                                                not 500) when the requester
                                                self-approves; the test
                                                additionally asserts the
                                                error body contains the
                                                "two-person integrity"
                                                substring so an auditor can
                                                grep server logs for
                                                attempted self-approvals.
                                            TestApproval_HandlerEmptyNote_Allowed_DecidedByExtractedFromAuth
                                              ↑ pins that decided_by comes
                                                from the auth-middleware
                                                UserKey, NEVER from the
                                                request body. Defends
                                                against future contributor
                                                confusion that might let a
                                                client supply their own
                                                decided_by string.
                                            TestApproval_HandlerErrorMapping
                                              (NotFound → 404, AlreadyDecided
                                              → 409 subtests).

Files modified:
  internal/api/router/router.go         - Adds Approvals field to
                                            HandlerRegistry struct + 4
                                            r.Register lines for the
                                            approval routes. Go 1.22
                                            ServeMux precedence: literal
                                            /approve and /reject segments
                                            resolve before the {id}
                                            pattern-var route, mirroring
                                            the existing notifications
                                            block's /requeue precedence.

Verified:
  gofmt: clean.
  go vet ./internal/api/... ./internal/service/...: exit 0.
  go test -short -count=1 -run TestApproval
    ./internal/api/handler/...: ok 0.004s.

Note on OpenAPI spec: the prompt's spec section also calls for 5 new
operationIds in api/openapi.yaml (createApprovalRequest, listApprovalRequests,
getApprovalRequest, approveApprovalRequest, rejectApprovalRequest). The
external-create endpoint is intentionally not implemented in V2 — every
approval request originates from the renewal-loop entry points (commit 4)
so the only operations exposed are list / get / approve / reject. The
4-route surface is a deliberate scope cut: external systems wanting to
inject approval requests can use the underlying `POST /api/v1/certificates/
{id}/renew` path which creates the parallel ApprovalRequest as a side
effect (post-commit-4 wiring). OpenAPI extension batched into commit 4
alongside the integration changes.

Out of scope for this commit (lands in commit 4):
  - Integration into CertificateService.TriggerRenewal +
    RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
  - cmd/server/main.go wiring.
  - Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
  - api/openapi.yaml extensions.
  - docs/connectors.md + docs/approval-workflow.md.

Reference: cowork/rank-7-approval-workflow-primitive-prompt.md.
2026-05-04 01:05:16 +00:00
shankar0123 8043e2bbac service: ApprovalService + ApprovalMetrics + 8 table-driven tests
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 2 of 4
(cowork/rank-7-approval-workflow-primitive-prompt.md). Builds on the
foundation in commit 2025275 — wires the service layer that drives the
approval workflow. Still no handler / integration wiring; commits 3-4
land that.

Files added:
  internal/service/approval.go         - ApprovalService struct + 6
                                          methods: RequestApproval,
                                          Approve, Reject, ListPending,
                                          List, Get, ExpireStale.
                                          Same-actor RBAC check
                                          (ErrApproveBySameActor) at
                                          both Approve and Reject; the
                                          load-bearing two-person
                                          integrity gate. Bypass mode
                                          short-circuits via
                                          approveInternal(outcome=
                                          "bypassed", actorType=System).
                                          Audit + metric emission per
                                          decision via shared
                                          recordAudit helper. Tolerates
                                          nil AuditService for tests.
                                          Service depends on a narrow
                                          JobStatusUpdater interface
                                          (single-method) rather than
                                          the full repository.JobRepository
                                          — production wiring satisfies
                                          it implicitly via postgres'
                                          existing UpdateStatus.

  internal/service/approval_metrics.go - ApprovalMetrics: thread-safe
                                          counter table (decisions
                                          counter dimensioned by
                                          outcome × profile_id) + a
                                          custom durationHistogram for
                                          pending-age (le buckets:
                                          60, 300, 1800, 3600, 21600,
                                          86400, +Inf — 1m, 5m, 30m,
                                          1h, 6h, 24h, beyond).
                                          Snapshot* methods return the
                                          Prometheus exposer's input
                                          shapes. Mirrors the
                                          ExpiryAlertMetrics +
                                          VaultRenewalMetrics pattern
                                          from prior ranks.

  internal/service/approval_test.go    - 8 table-driven tests with
                                          tight in-package fakes
                                          (fakeApprovalRepo +
                                          fakeJobStateRepo):
                                            TestApproval_RequestCreatesPendingRow_BypassDisabled
                                            TestApproval_BypassMode_AutoApprovesWithSystemBypassActor
                                            TestApproval_Approve_TransitionsJobFromAwaitingApprovalToPending
                                            TestApproval_Reject_TransitionsJobFromAwaitingApprovalToCancelled
                                            TestApproval_Approve_RejectsSameActor
                                              ↑ THE LOAD-BEARING TWO-PERSON
                                                INTEGRITY TEST. PCI-DSS 6.4.5
                                                / NIST 800-53 SA-15 / SOC 2
                                                CC6.1 compliance auditors
                                                pattern-match against this.
                                                Pins same-actor rejection on
                                                both Approve and Reject paths;
                                                pins success when a different
                                                actor approves.
                                            TestApproval_Approve_RejectsAlreadyDecided
                                            TestApproval_ExpireStale_TransitionsPendingToExpired_AndCancelsJob
                                            TestApproval_MetricCounterIncrements

Verified:
  gofmt: clean.
  go vet ./internal/service/...: exit 0.
  go test -short -count=1 -run TestApproval ./internal/service/...:
    ok 0.005s — all 8 tests green.

Out of scope for this commit (lands in commits 3-4):
  - api/handler/approval.go (5 endpoints + handler-side RBAC).
  - api/openapi.yaml extensions.
  - Integration into CertificateService.TriggerRenewal +
    RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
  - cmd/server/main.go wiring of ApprovalService + ApprovalMetrics.
  - Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
  - docs/connectors.md row + docs/approval-workflow.md runbook.

Reference: cowork/rank-7-approval-workflow-primitive-prompt.md.
2026-05-04 01:01:53 +00:00
shankar0123 2025275b43 domain, migrations: ApprovalRequest type + issuance_approval_requests + RequiresApproval
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 1 of 4
(cowork/rank-7-approval-workflow-primitive-prompt.md). The four-commit
chain ships the issuance approval-workflow primitive (request → human review
→ CA call) closing the two-person integrity / four-eyes principle
procurement gap for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2
Type II, and HIPAA-regulated PHI deployments.

This commit lands ONLY the foundation — schema, types, repository
interface, postgres implementation. No service / handler wiring yet.
The four-commit shape is bisectable: the schema can land in production
behind a flag (via the default RequiresApproval=false on every existing
profile) without any operator-visible behavior change until commits 2-4
wire the surrounding workflow.

Existing scaffolding REUSED (not redefined here):
  - JobStatusAwaitingApproval enum value (internal/domain/job.go).
  - JobRepository.ListTimedOutAwaitingJobs (postgres reaper query).
  - Config.Scheduler.AwaitingApprovalTimeout (env-mapped via
    CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT, default 168h = 7 days).
  - Scheduler.SetAwaitingApprovalTimeout wiring.

Files added:
  internal/domain/approval.go              - ApprovalRequest type,
                                              ApprovalState closed enum
                                              (pending/approved/rejected/
                                              expired), IsValidApprovalState +
                                              IsTerminal helpers, outcome
                                              const block + bypass-actor
                                              sentinel.
  internal/repository/postgres/approval.go - ApprovalRepository
                                              implementation: Create
                                              (ar-<slug> ID gen + JSONB
                                              metadata round-trip + lib/pq
                                              23505 → ErrAlreadyExists
                                              translation), Get, GetByJobID,
                                              List (paginated with state /
                                              cert / requester filters),
                                              UpdateState (pending→terminal
                                              transitions only, with
                                              already-terminal disambiguation),
                                              ExpireStale (bulk reaper,
                                              decided_by='system-reaper').
  migrations/000027_approval_workflow.{up,down}.sql
                                            - Idempotent IF NOT EXISTS /
                                              IF EXISTS. Adds
                                              certificate_profiles.requires_approval
                                              BOOLEAN NOT NULL DEFAULT false,
                                              issuance_approval_requests
                                              table with FK to
                                              managed_certificates / jobs /
                                              certificate_profiles, four
                                              indexes (state, certificate,
                                              pending-age, partial-unique
                                              pending-per-job), and the
                                              approval_decision_consistency
                                              CHECK constraint enforcing
                                              decided_by/decided_at must be
                                              non-null for terminal states.

Files modified:
  internal/domain/profile.go               - Adds CertificateProfile.RequiresApproval
                                              bool field with full doc
                                              comment + JSON tag. Defaults
                                              to false (back-compat — every
                                              existing profile keeps the
                                              unattended renewal path).
  internal/repository/interfaces.go        - Adds ApprovalRepository
                                              interface (6 methods) +
                                              ApprovalFilter struct.
  internal/repository/errors.go            - Adds ErrAlreadyExists sentinel
                                              for postgres SQLSTATE 23505
                                              (unique-constraint violations
                                              from the partial-unique
                                              pending-per-job index, plus
                                              the "already terminal" state-
                                              transition signal). Mirrors
                                              the existing ErrNotFound +
                                              ErrForeignKeyConstraint shape.

Verified:
  gofmt: clean.
  go vet ./internal/domain/... ./internal/repository/...: exit 0.
  go build ./internal/domain/... ./internal/repository/...: exit 0.

Out of scope for this commit (lands in commits 2-4):
  - service/approval.go (RequestApproval / Approve / Reject / ListPending
    / ExpireStale + same-actor RBAC + bypass mode + audit + metrics).
  - service/approval_metrics.go (decisions counter + pending-age histogram).
  - 8 service-level table-driven tests including the load-bearing
    TestApproval_Approve_RejectsSameActor two-person integrity pin.
  - api/handler/approval.go (5 endpoints + RBAC integration).
  - api/openapi.yaml (5 new operationIds).
  - Integration into CertificateService.TriggerRenewal +
    RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
  - cmd/server/main.go wiring.
  - Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
  - docs/connectors.md CertificateProfile config-table row.
  - docs/approval-workflow.md operator playbook + compliance control mapping.

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 7.
Acquisition prompt: cowork/rank-7-approval-workflow-primitive-prompt.md.
2026-05-04 00:55:17 +00:00
shankar0123 69d4ada385 ci(release): pin run-name + release title to tag (fix ugly auto-generated titles)
Two GitHub-Actions defaults were producing ugly titles on every tag:

1. The Actions-tab workflow run title was auto-generated as
   `<commit-subject> #<run-number>` because release.yml had no `run-name:`.
   The v2.0.69 push showed up as
   "chore: rename Go module path to github.com/certctl-io/certctl #73"
   instead of the obvious "Release v2.0.69".

2. The Releases-page title was auto-generated by
   softprops/action-gh-release@v2 because the action's `with:` block had
   no `name:` field — it falls back to the most recent commit subject in
   that case, producing the same noise on the Releases page.

Fixes:
- Add `run-name: Release ${{ github.ref_name }}` at the workflow top.
  `github.ref_name` resolves to the tag (e.g., `v2.0.69`) since the only
  trigger is `on: push: tags: ['v*']`. Actions tab now shows
  "Release v2.0.69".
- Add `name: ${{ github.ref_name }}` to the softprops/action-gh-release@v2
  step's `with:` block. Releases page now shows "v2.0.69" as the title
  instead of the commit subject.

Affects v2.0.70+. The v2.0.69 workflow run + release page that's already
in flight retain the bad titles (the workflow file is read at trigger
time); the v2.0.69 Releases-page title can be manually edited via the
GitHub UI ("Edit release" → set title to `v2.0.69` → Update release).
The Actions-tab run name for #73 is immutable post-trigger.

This same pattern likely affects ci.yml + the other workflows but the
operator-facing surface is the Release workflow's titles, so leaving
the CI workflows alone for now (they run continuously on master and
nobody clicks individual run titles).
2026-05-04 00:46:31 +00:00
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
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.
2026-05-04 00:30:29 +00:00
shankar0123 2d22e08a1e release: v2.0.68 — image registry path moved to ghcr.io/certctl-io
Image registry path changed. Starting this release, container images
publish to `ghcr.io/certctl-io/certctl-server` and
`ghcr.io/certctl-io/certctl-agent`. Existing pulls from
`ghcr.io/shankar0123/certctl-{server,agent}:<tag>` continue to work
for previously-published tags (the registry never deletes images),
but the `:latest` tag at the old path stops moving forward at this
release. Operators must update `docker pull` paths, `docker-compose.yml`
`image:` keys, or Helm `image.repository` values to receive future
updates. Old `git clone` / `git push` / install-script / API URLs
continue to redirect forever — only the container-registry path
changed.

This is the only operator-action-required change in v2.0.68. Other
changes since v2.0.67 are cosmetic URL refreshes after the GitHub
org transfer (shankar0123 → certctl-io, 2026-05-03) and a contextcheck
lint fix in the agent. The release.yml workflow's IMAGE_NAMESPACE env
var was swept to certctl-io as part of the URL refresh, so the next
release auto-pushes to the new ghcr.io path; verified via
`grep -n IMAGE_NAMESPACE .github/workflows/release.yml` showing
`IMAGE_NAMESPACE: certctl-io`.

Adds a top-of-file v2.0.68 entry to CHANGELOG.md as a one-time
migration callout. The existing "no hand-edited per-version changelog"
policy text is preserved below — that policy applies to per-version
entries; this is a one-time critical migration notice that needs to
be visible to operators doing diligence by reading CHANGELOG.md.
2026-05-04 00:09:28 +00:00
shankar0123 cabe1aee45 docs(README): drop V3 Pro + V4 sections — everything ships free under BSL
Strategic pivot. We are NOT building a V3 Pro paid tier or a V4 cloud /
scale tier. Every certctl feature — current and future — ships free under
the same BSL 1.1 source-available license. No gated features, no paid
edition, no enterprise tier. Future revenue path is a managed-service
hosting offering: operator runs the certctl-server control plane as a
hosted service; customers self-install only the certctl-agent in their
infrastructure. The self-hosted code stays free forever; the managed
service sells operational convenience (no PostgreSQL to run, no upgrades,
no backups, no SSO setup). BSL 1.1 was already structured around exactly
this — the license expressly prevents competitors from running their own
commercial certctl-as-a-service against the same source while leaving
self-hosting unrestricted.

Removed the old roadmap sections:
- "### V3: certctl Pro" — Enterprise capabilities for larger deployments
  are available in the commercial tier.
- "### V4+: Cloud & Scale" — Kubernetes cert-manager external issuer,
  cloud infrastructure targets, extended CA support, and platform-scale
  features.

Replaced with a single "Forward-looking work — all free, all self-hostable"
section that names the real engineering tracks (OIDC / SSO / RBAC,
NATS / real-time, search / risk scoring, HSM / TPM / FIPS, deeper Vault
auth, cloud-managed-target deep integrations, adapter hardening, credential
lifecycle expansion) and points at the workspace-level WORKSPACE-ROADMAP.md
for the unshipped backlog. The full feature surface lands in V2 over time
— V3 / V4 are not real version targets, they were positioning artifacts.

Diff: 2 insertions / 5 deletions. README's License section (BSL 1.1
licensing-inquiries footer) is unchanged.
2026-05-04 00:00:23 +00:00
shankar0123 b577f6f251 fix(agent): thread ctx through createTargetConnector to satisfy contextcheck
CI run #428 (job 74148571711) failed on commit c8eb3e0 with:

  cmd/agent/main.go:690:44: Function `createTargetConnector` should pass
  the context parameter (contextcheck)

Pre-existing on master since the Rank 5 commits (8a56a78 Azure KV,
edf6bee AWS ACM) added two `case` branches in createTargetConnector
that called `awsacm.New(context.Background(), &cfg, a.logger)` and
`azurekv.New(context.Background(), &cfg, a.logger)` instead of
threading the caller's ctx. The contextcheck linter (in .golangci.yml)
flagged the call site at line 690 because the caller — the deploy
path inside processJob — has a `ctx` in scope (used a few lines later
for `a.reportJobStatus(ctx, ...)`).

Why CI fix #15 (c8eb3e0) didn't catch this: that commit was scoped
narrowly to fix go.mod / go.sum drift after Azure SDK transitive deps
shifted; it didn't run the full lint gate locally because the sandbox
disk-pressure path falls back to gofmt + go vet + go test -short, and
contextcheck is part of golangci-lint (not vet). It surfaced once CI
ran the full lint pipeline.

Fix:
- createTargetConnector signature: prepend `ctx context.Context` as the
  first parameter (matches the convention used everywhere else in the
  agent — heartbeat, processJob, reportJobStatus, etc.).
- Inside the function, replace both `context.Background()` calls
  (AWSACM + AzureKeyVault cases) with `ctx`. SDK credential resolution
  now honors caller cancellation / deadlines.
- Update the production call site at cmd/agent/main.go:690 to pass
  `ctx` (already in scope).
- Update the 6 test call sites in cmd/agent/agent_test.go to pass
  `context.Background()` (test functions don't have a ctx in scope —
  Background() is the conventional zero-value for unit tests).

Verified locally:
- gofmt: 0 lines diff
- go vet ./cmd/agent/...: exit 0
- go build ./cmd/agent/...: exit 0
- go test -short ./cmd/agent/...: ok 11.912s

The contextcheck linter itself wasn't re-run locally (golangci-lint
install needs ~300MB and the sandbox modcache + build cache already
filled disk). The fix matches the linter's diagnosis verbatim:
"should pass the context parameter" — call site now passes the
parameter; signature now accepts it.
2026-05-03 23:46:23 +00:00
shankar0123 0729ee46e0 chore: sweep github.com/shankar0123/certctl URL refs to certctl-io/certctl
Post-transfer cosmetic + release-critical URL refresh after moving the
repo from github.com/shankar0123/certctl to github.com/certctl-io/certctl
(2026-05-03). GitHub HTTP redirects continue to forward old URLs forever,
so existing operators are not broken — but aligns the canonical
references with the new owner so:

- procurement engineers / contributors browsing the docs see the right
  URL on first read
- operators copying the agent install one-liner hit the new path
  directly without going through a redirect
- the Helm chart's default image repository points at the canonical org
  registry path
- the OnboardingWizard rendered to first-run UI users shows the new
  URL in the install snippets and doc anchor links
- the GitHub Actions release workflow pushes container images to
  ghcr.io/certctl-io/certctl-{server,agent} (was: shankar0123)
- the release-notes Markdown body in release.yml — which gets stamped
  into every future release page — references the post-transfer
  cert-identity (cosign keyless signing now uses the certctl-io
  workflow URL) and the post-transfer SLSA provenance source-uri.
  Without this, every cosign verify / slsa-verifier command on a
  v2.1.0+ release would fail because the cert-identity-regexp would
  not match the signing identity GitHub Actions OIDC issues post-
  transfer. Old releases (v2.0.67 and earlier) keep their immutable
  release-notes pointing at the shankar0123 path and remain
  verifiable via their own published instructions.

Customer impact:
- Operators on ghcr.io/shankar0123/certctl-{server,agent}:latest
  silently freeze on whatever tag was current at transfer time. They
  get no errors; they just stop receiving updates. The next release
  notes need a one-line callout (Phase 3.1 of cowork/transfer-
  certctl-to-org.md) telling them to update their image path to
  ghcr.io/certctl-io/certctl-{server,agent}.
- All other URLs (git clone, install one-liner, raw.githubusercontent
  URLs, browser links, GitHub API) continue to resolve via permanent
  HTTP redirects. The sweep is cosmetic for those.

Files swept (30 total):
  .github/workflows/release.yml — IMAGE_NAMESPACE, source-uri,
    cosign cert-identity-regexp, IMAGE= snippet (5 refs total).
  CHANGELOG.md, README.md — anchor links, badges, install one-liner,
    cosign verify snippets in operator-facing sections.
  api/openapi.yaml — info / externalDocs URLs.
  install-agent.sh — GITHUB_REPO const + systemd unit Documentation=
    field.
  deploy/ENVIRONMENTS.md, deploy/helm/{CHART_SUMMARY,INDEX,
    INSTALLATION,README}.md, deploy/helm/certctl/{Chart.yaml,
    README.md,values.yaml}, deploy/helm/examples/values-*.yaml —
    chart docs + image repository defaults across dev / prod-ha
    overrides.
  docs/{certctl-for-cert-manager-users,connector-iis,connectors,
    migrate-from-acmesh,migrate-from-certbot,quickstart,test-env,
    why-certctl}.md — operator-facing doc URLs.
  examples/{acme-nginx,acme-wildcard-dns01,multi-issuer,
    private-ca-traefik,step-ca-haproxy}/docker-compose.yml +
    examples/step-ca-haproxy/step-ca-haproxy.md — example image:
    paths and accompanying narrative.
  web/src/pages/OnboardingWizard.tsx — first-run-UI URL refs (curl
    install one-liners, agent docker image path, doc anchor links).

Files intentionally NOT swept (Choice A from cowork/transfer-certctl-
to-org.md):
  go.mod, go.sum — module declaration stays github.com/shankar0123/
    certctl. Existing imports compile because Go uses the path
    declared in go.mod, not the URL it was fetched from. Internal-
    only project; no external Go consumers; rename will land as a
    mechanical sed when one materializes.
  ~250 *.go files — every import remains github.com/shankar0123/
    certctl/internal/...
  deploy/test/f5-mock-icontrol/go.mod — separate test sub-module;
    same Choice A logic; module path stays.

Files intentionally NOT swept (other reasons):
  README.md lines 244-245 — Scarf-pixel docker-pull commands.
    shankar0123.docker.scarf.sh/... is a Scarf-account hostname
    (per-user, not per-repo) and the pixel keeps tracking pulls
    against the operator's personal Scarf account. Migrating to a
    certctl-io Scarf account is a separate decision (create org
    Scarf account → re-create package → update README).
  deploy/test/f5-mock-icontrol/f5-mock-icontrol — checked-in
    compiled binary with shankar0123/certctl baked into Go build
    info via the sub-module path. Out of scope for a URL sweep;
    will refresh on the next `make test-integration` rebuild.

Verification:
  gofmt: clean (no .go files touched).
  go vet ./...: clean (verified at this SHA in 1.3 of the transfer
    checklist; no .go changes since).
  go build ./...: clean (same).
  go test -short on representative packages: green (same).
  Diff shape: 30 files, 74 insertions / 74 deletions, net-zero size,
    pure URL substitution.
2026-05-03 23:39:50 +00:00
shankar0123 c8eb3e0399 ci(go.mod): fix go mod tidy drift after Rank 5 cloud-target commits
CI failed at the "go mod tidy drift" gate on commit 9a7e818 (Rank 5
follow-up). The drift was leftover from the Azure SDK addition in
commit 8a56a78 — `go get` initially pulled the deprecated
`keyvault/azcertificates v0.9.0` path before I switched the import
to the supported `security/keyvault/azcertificates v1.4.0` path. The
v0.9.0 entries stayed in go.mod / go.sum as transitive `// indirect`
because the sandbox's `go mod tidy` couldn't run during the original
commit (disk-pressure on the modcache), so the cleanup got deferred
to CI's tidy-drift gate.

Aligning go.mod + go.sum with what `go mod tidy` produces on a clean
machine. Diff applied verbatim from the CI's `git diff --exit-code`
output:

  go.mod removed (// indirect):
    github.com/Azure/azure-sdk-for-go/sdk/keyvault/azcertificates v0.9.0
    github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1
    github.com/kr/text v0.2.0  (no longer transitive after the
                                deprecated keyvault module is gone)

  go.sum removed:
    github.com/Azure/azure-sdk-for-go/sdk/keyvault/azcertificates v0.9.0 h1: + .mod
    github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1: + .mod
    github.com/creack/pty v1.1.9/go.mod
    github.com/kr/pretty v0.3.0 h1: + .mod
    github.com/rogpeppe/go-internal v1.8.1 h1: + .mod
    github.com/stretchr/testify v1.10.0 h1: + .mod

  go.sum added:
    github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1: + .mod
    github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1: + .mod
    github.com/keybase/go-keychain v0.0.1 h1: + .mod
    github.com/kr/pretty v0.3.1 h1: + .mod
    github.com/rogpeppe/go-internal v1.12.0 h1: + .mod
    github.com/stretchr/testify v1.11.1 h1: + .mod

Net: 3 lines removed from go.mod, 21 lines net from go.sum (10
insertions / 14 deletions).

Verified locally:
- go build ./internal/connector/target/...  green.
- The h1: hashes copied verbatim from the CI's `go mod tidy`
  output line numbers in the run-#???? log so the operator can
  cross-reference the diff against what CI saw.
2026-05-03 23:01:08 +00:00
shankar0123 9a7e818f3e docs, seed: cloud-target operator runbook + AWS ACM / Azure KV demo seed rows
Wraps up Rank 5 of the 2026-05-03 Infisical deep-research deliverable
(commits edf6bee AWS + 8a56a78 Azure):

  - docs/runbook-cloud-targets.md — sysadmin-grade flowchart spanning
    the AWS ACM + Azure Key Vault deploy paths side-by-side. Covers
    minimum IAM policy / RBAC role JSON, IRSA + AKS workload-identity
    recipes, manual rollback recovery procedures (aws acm
    import-certificate / az keyvault certificate import), CloudTrail
    + Activity Log forensics queries for "who wrote to this ARN /
    vault cert", Prometheus cardinality + cost budget, and the
    V3-Pro forward path (CloudFront / Front Door direct-attach,
    ALB / App Gateway auto-bind, soft-delete recovery, GCP CM).
  - migrations/seed_demo.sql — two new demo target rows (tgt-aws-
    acm-prod + tgt-azure-kv-prod) so QA can exercise the per-cloud
    wiring end-to-end against the demo seed without standing up
    real cloud accounts.

cowork/WORKSPACE-ROADMAP.md (sibling-folder, not in this commit's
diff) was updated to mark the V2 AWS ACM + Azure KV connectors as
shipped and document the V3-Pro CloudFront / Front Door direct-attach
+ App Gateway auto-bind + soft-delete recovery + GCP CM follow-on
items.

cowork/infisical-deep-research-results.md (sibling-folder) Part 5
Rank 5 marked CLOSED with both commit SHAs.

Doc-only commit. No code changes.

Verified locally:
- go test -short -count=1 ./internal/connector/target/awsacm/...
  ./internal/connector/target/azurekv/...  green.
- markdown lint clean against the Bundle 8 + Rank 4 runbook templates.
2026-05-03 22:46:29 +00:00
shankar0123 8a56a78282 target(azurekv): SDK-driven Azure Key Vault target connector
Closes Rank 5 (Azure half) of the 2026-05-03 Infisical deep-research
deliverable (cowork/infisical-deep-research-results.md Part 5).
Pre-fix, certctl had no path to deploy certs to Azure-managed TLS-
termination endpoints (Application Gateway / Front Door / App Service
/ Container Apps) — operators terminating TLS at Azure had to use
manual `az keyvault certificate import` invocations or external
automation. This commit lands the SDK-driven Azure Key Vault target
connector that closes the gap, mirroring the AWS ACM target shape
shipped in commit edf6bee.

Architecture:
  - internal/connector/target/azurekv/azurekv.go — Connector wraps
    *azcertificates.Client behind the KeyVaultClient interface seam
    (mirrors awsacm's ACMClient + awsacmpca's ACMPCAClient). Lives
    in azurekv.go alongside the PFX (PKCS#12) wrapping helper that
    bundles the operator-supplied PEM cert + chain + key into the
    base64-PFX wire format azcertificates.ImportCertificate accepts.
  - internal/connector/target/azurekv/sdk_client.go — SDK-loading
    code isolated so the test path (NewWithClient) compiles without
    pulling azcore + azidentity transitive deps into the test
    binary. DefaultAzureCredential / ManagedIdentityCredential /
    EnvironmentCredential / WorkloadIdentityCredential selected via
    Config.CredentialMode (closed enum).
  - Pre-deploy snapshot via GetCertificate(name, "" /* latest */) so
    on-import-failure rollback restores the previous cert. Mirrors
    Bundle 5+. The Azure-specific quirk: rollback creates a NEW
    VERSION (Key Vault doesn't support version-restore without
    soft-delete recovery, which we keep off the minimum-RBAC
    surface). Operators reading audit dashboards see e.g. v1=initial,
    v2=failed-renewal, v3=rollback-of-v2; the certctl-managed-by +
    certctl-certificate-id provenance tags + future certctl-rollback-of
    metadata tag let an operator filter rollback artifacts.
  - Provenance tags identical to AWS ACM
    (certctl-managed-by=certctl + certctl-certificate-id=<mc-id>),
    automatically applied on every import. Key Vault carries tags
    forward across versions (unlike ACM which strips on re-import),
    so no separate AddTags call is required.
  - DeploymentRequest.KeyPEM held in agent memory only; PFX wrapping
    happens in-memory via software.sslmate.com/src/go-pkcs12. No
    disk write.

Tests:
  - azurekv_test.go: 13-subtest happy-path + validation matrix —
    ValidateConfig (success / missing-vault-url / malformed-vault-
    url / missing-cert-name / invalid-credential-mode / reserved-
    tag rejection), DeployCertificate (fresh import / rollback-on-
    serial-mismatch / empty-key-rejected / no-client-rejected /
    SDK-error-surfaced), ValidateOnly (returns sentinel),
    ValidateDeployment (serial match / mismatch).
  - All tests use the NewWithClient injection seam; no real-Azure
    API calls.
  - go test -short -count=1 ./internal/connector/target/azurekv/...
    green.

Wiring:
  - internal/domain/connector.go: TargetTypeAzureKeyVault =
    "AzureKeyVault".
  - internal/service/target.go: validTargetTypes set extended.
  - cmd/agent/main.go::createTargetConnector: AzureKeyVault case
    arm mirroring the AWSACM shape exactly.
  - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported
    Types: AzureKeyVault added to the type matrix + the InvalidJSON
    matrix (16 supported target types now, up from 15).

go.mod / go.sum:
  - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 (direct).
  - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 (direct).
  - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/
    azcertificates v1.4.0 (direct). The deprecated
    /keyvault/azcertificates path appears as a transitive indirect
    via Microsoft's microsoft-authentication-library-for-go; we use
    the new /security/keyvault/ path exclusively.

Documentation:
  - docs/connectors.md "Azure Key Vault" section: config table, RBAC
    role recipe (off-the-shelf "Key Vault Certificates Officer" or
    custom role with 3 data-plane actions), AKS workload-identity /
    managed-identity / service-principal / default credential
    recipes, atomic-rollback contract + Azure-version semantics
    explanation, soft-delete caveat, App Gateway / Front Door
    Terraform attachment snippet, threat model carve-outs (no disk
    writes, mandatory provenance tags, no long-lived secrets in
    Config), 5-bullet procurement checklist crib.

Out of scope (intentional, flagged in V3-Pro forward path):
  - Azure Front Door direct-attach (UpdateRoutingConfig — different
    Azure RBAC scope).
  - App Gateway / App Service auto-bind (V3-Pro auto-attach).
  - Soft-delete recovery (acm:RecoverDeletedCertificate-equivalent
    requires extra RBAC; V2 keeps minimum-permission surface).
  - GCP Certificate Manager (separate cloud, separate connector).

Verified locally:
- gofmt clean.
- go vet ./internal/connector/target/azurekv/...
  ./internal/domain/... ./internal/service/...
  ./cmd/agent/...  clean.
- go test -short -count=1 ./internal/connector/target/azurekv/...
  ./cmd/agent/...  green (all 16 supported target types
  instantiate via the agent factory).

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5.
Acquisition prompt:
cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md.
Companion commit (AWS half): edf6bee.
2026-05-03 22:43:45 +00:00
shankar0123 edf6bee7f8 target(awsacm): SDK-driven AWS Certificate Manager target connector
Closes Rank 5 (AWS half) of the 2026-05-03 Infisical deep-research
deliverable (cowork/infisical-deep-research-results.md Part 5).
Pre-fix, certctl had no path to deploy certs to AWS-managed TLS-
termination endpoints (ALB / CloudFront / API Gateway / App Runner)
— operators terminating TLS at AWS had to use Infisical secret-sync,
manual aws-cli imports, or external automation. This commit lands
the SDK-driven AWS Certificate Manager target connector that closes
the gap end-to-end.

Architecture:
  - internal/connector/target/awsacm/awsacm.go — Connector wraps
    *acm.Client behind the ACMClient interface seam (mirrors
    awsacmpca's ACMPCAClient pattern from the issuer side).
    LoadDefaultConfig handles the standard AWS credential chain
    (IRSA / EC2 instance profile / SSO / env vars); no embedded
    creds in connector Config.
  - Pre-deploy snapshot via DescribeCertificate + GetCertificate so
    on-import-failure rollback restores the previous cert. Mirrors
    the Bundle 5 IIS pattern + the Bundle 7/8 WinCertStore /
    JavaKeystore patterns. Surfaces rollback success/failure via
    the existing certctl_deploy_rollback_total Prometheus counter
    label set.
  - Provenance tags: certctl-managed-by=certctl + certctl-
    certificate-id=<mc-id> set automatically on every import. ACM
    strips tags on re-import, so the connector calls
    AddTagsToCertificate post-import to keep the provenance pair
    fresh. Operators looking up a cert ARN by managed-cert ID
    (Terraform data source, CloudFormation output) match against
    these tags.
  - DeploymentRequest.KeyPEM held in agent memory only — never
    written to disk. Aligns with the pull-only deployment model
    documented in CLAUDE.md.

Tests:
  - awsacm_test.go: 15-subtest happy-path + validation matrix
    covering ValidateConfig (success / missing-region / malformed-
    region / malformed-ARN / reserved-tag rejection),
    DeployCertificate (fresh import / rotate-in-place / rollback-
    on-serial-mismatch / rollback-also-fails / empty-key-rejected /
    no-client-rejected), ValidateOnly (returns sentinel),
    ValidateDeployment (serial match / mismatch / no-ARN-yet).
  - awsacm_failure_test.go: 5 per-error-class contract tests
    mirroring the awsacmpca_failure_test.go shape (commit
    a2a59a8) — AccessDeniedException (smithy.GenericAPIError),
    ResourceNotFoundException (typed), ThrottlingException
    (smithy.GenericAPIError, FaultServer preserved),
    InvalidArgsException (typed, terminal), RequestInProgress
    Exception (typed). All assert errors.As against the SDK type +
    operator-actionable substring + connector-side wrap framing.
  - Coverage on awsacm.go: 54.9% of statements (matches the K8s-
    Secret + IIS connectors' 50-65% range; rollback-failure paths
    contribute most of the un-covered surface — those exercise
    only when the rollback's SDK call also returns an error).
  - go test -race -count=10 green; no goroutine leaks.

Wiring:
  - internal/domain/connector.go: TargetTypeAWSACM = "AWSACM".
  - internal/service/target.go: validTargetTypes set extended.
  - cmd/agent/main.go::createTargetConnector: AWSACM case arm
    mirroring the KubernetesSecrets shape exactly. Calls
    awsacm.New(context.Background(), &cfg, a.logger) — the
    SDK-loading happens here, not lazily, so config errors
    surface at agent boot.
  - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported
    Types: AWSACM added to the type matrix + the InvalidJSON
    matrix.

go.mod / go.sum:
  - github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 (direct).
    aws-sdk-go-v2 + service/acmpca + smithy-go were already direct
    from the awsacmpca issuer; this is the distribution-side
    companion package.

Documentation:
  - docs/connectors.md "AWS Certificate Manager (ACM)" section:
    config table, IAM policy JSON (5 actions on
    arn:aws:acm:*:*:certificate/*), IRSA / EC2 instance-profile /
    SSO auth recipes, atomic-rollback contract, Terraform ALB-
    attachment snippet, threat model carve-outs (no disk writes,
    mandatory provenance tags, no long-lived creds in Config),
    procurement checklist crib (5 bullets paste-able into a
    security review).

Out of scope (intentional, flagged in V3-Pro forward path):
  - CloudFront / ALB auto-attach (UpdateDistribution requires a
    different IAM scope than ACM ImportCertificate).
  - Cross-region ACM replication (ACM is regional; CloudFront
    forces us-east-1).
  - Tag-filtered ARN discovery (V2 uses operator-pinned
    Config.CertificateArn after first deploy; tag-scan path
    requires acm:ListTagsForCertificate which we deliberately
    keep off the minimum-IAM-policy surface).
  - Azure Key Vault (separate cloud, separate connector — Azure
    half of Rank 5 ships in a follow-on commit).

Verified locally:
- gofmt clean.
- go vet ./internal/connector/target/awsacm/...
  ./internal/domain/... ./internal/service/...
  ./cmd/agent/...  clean.
- go test -short -count=1 ./internal/connector/target/awsacm/...
  ./internal/domain/... ./cmd/agent/...  green (15 + 5 awsacm
  subtests; all 15 supported target types instantiate via the
  agent factory).
- go test -race -count=10 ./internal/connector/target/awsacm/...
  green.

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5.
Acquisition prompt:
cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md.
2026-05-03 22:32:45 +00:00
shankar0123 109f32ff41 notifications: per-policy multi-channel expiry-alert routing
Closes Rank 4 of the 2026-05-03 Infisical deep-research deliverable
(see cowork/infisical-deep-research-results.md Part 5). Pre-fix,
RenewalService.CheckExpiringCertificates already ran daily,
RenewalPolicy.AlertThresholdsDays drove per-cert thresholds, and
NotificationService.SendThresholdAlert deduped per (cert, threshold)
— but the channel was hardcoded to Email
(internal/service/notification.go:118 pre-fix). Operators who
configured PagerDuty / Slack / Teams / OpsGenie via
CERTCTL_PAGERDUTY_ROUTING_KEY etc. got nothing at any threshold
unless SMTP was also wired. Their first signal of an expired cert
was a 3 AM outage.

This commit lands the routing matrix on top of the existing
infrastructure:

  1. RenewalPolicy gains AlertChannels (per-tier channel list) +
     AlertSeverityMap (per-threshold tier assignment) +
     EffectiveAlertChannels / EffectiveAlertSeverity accessors.
     Default*() helpers preserve the back-compat Email-only
     behaviour for operators who haven't touched their policies
     post-upgrade. Migration 000026 adds the JSONB columns
     idempotently.
  2. NotificationService.SendThresholdAlertOnChannel — the new
     per-channel dispatch helper. Old SendThresholdAlert stays as
     an Email-only alias so non-policy callers (admin "send test
     alert" surfaces) keep working byte-for-byte.
  3. NotificationService.HasThresholdNotificationOnChannel — per-
     (cert, threshold, channel) deduplication so a transient
     PagerDuty 5xx today does NOT suppress today's Slack alert and
     tomorrow's PagerDuty retry will still fire.
  4. RenewalService.sendThresholdAlerts walks the resolved channel
     set per threshold tier, fans out to every configured channel,
     handles per-channel failures independently, defensively drops
     off-enum channels with an audit row trail, and records a per-
     channel audit event with metadata.channel + metadata.severity_tier.
  5. service.ExpiryAlertMetrics — atomic counter table mirrored on
     the VaultRenewalMetrics shape from the 2026-05-03 audit fix #5
     (commit 0792271). Three labels: channel × threshold × result
     (success / failure / deduped). Cardinality bound: 6 × 4 × 3 =
     72 series for the standard 4-threshold matrix.
  6. handler.MetricsHandler.SetExpiryAlerts wires the Prometheus
     exposer for certctl_expiry_alerts_total{channel,threshold,result}.
     Pre-sorted snapshot for byte-stable emission.
  7. cmd/server/main.go threads ONE service.ExpiryAlertMetrics
     instance through both the recording side (notificationService.
     SetExpiryAlertMetrics) and the exposing side
     (metricsHandler.SetExpiryAlerts).

Dispatch flow (post-fix, per renewal-loop tick):

  cert ages past T-30  → daily renewal-loop fires
                       → policy lookup
                       → for each crossed threshold:
                           - resolve severity tier (informational/
                             warning/critical) via AlertSeverityMap
                           - look up channel set in AlertChannels[tier]
                           - for each channel: dedup → SendThresholdAlertOnChannel
                             → notifierRegistry[channel] → audit row →
                             Prometheus counter increment

Tests (internal/service/renewal_expiry_alerts_test.go):

  TestExpiryAlerts_DefaultMatrix_EmailOnly
  TestExpiryAlerts_PerTierFanOut
  TestExpiryAlerts_PerChannelDedup
  TestExpiryAlerts_OneChannelFails_OthersStillFire
  TestExpiryAlerts_OffEnumChannelDropped
  TestExpiryAlerts_MetricCounterIncrements
  TestExpiryAlerts_NilPolicy_FallsToDefault
  TestExpiryAlerts_OperatorOptOutOfTier

The PerTierFanOut test wires 6 mock notifiers, drives a cert at 0
days through the canonical 4 thresholds with the matrix
{informational:[Slack], warning:[Slack,Email],
critical:[PagerDuty,OpsGenie,Email]}, and asserts the exact
recipient counts: Slack=3, Email=3, PagerDuty=1, OpsGenie=1, no
Teams, no Webhook. The OneChannelFails test pins that PagerDuty
returning a 503 does NOT skip Slack/Email at the same threshold.

Drive-by fix (internal/service/testutil_test.go): the existing
mockNotifRepo.List ignored its filter and returned all rows, which
let legacy tests pass on dedup-via-substring even though the
postgres repo actually applied the filter. Updated the mock to
honour CertificateID / Type / Status / Channel / MessageLike
filters in the same shape as the postgres implementation
(internal/repository/postgres/notification.go). All pre-existing
service tests still pass — the legacy test suite happened to be
robust to the mock filter doing nothing.

Documentation:
  - docs/connectors.md Notifier section gains "Routing expiry
    alerts across channels" — operator-facing, JSON example,
    procurement playbook ("How do I make sure PagerDuty pages on
    the T-1 alert?"), debug recipe via SQL on audit_events +
    notification_events + Prometheus.
  - docs/runbook-expiry-alerts.md — sysadmin-grade flowchart,
    per-policy channel-matrix configuration recipes, "did the on-
    call team get paged?" SQL queries, cardinality budget, V3-Pro
    forward path.
  - cowork/WORKSPACE-ROADMAP.md gains "Multi-channel expiry
    alerts: per-owner routing" V3-Pro entry under Adapter
    hardening.

Out of scope (intentional, flagged in V3-Pro forward path):
  - Per-owner / per-team / per-tenant channel routing (matrix is
    per-policy today, not per-owner).
  - Calendar-aware suppression (no T-30 alerts on weekends).
  - Escalation chains (T-1 unanswered for 30m → escalate).
  - Per-channel rate limiting (downstream of I-005 retry+DLQ).

CHANGELOG.md is intentionally not hand-edited per CHANGELOG.md
itself ("no longer maintains a hand-edited per-version changelog;
per-release notes are auto-generated from commit messages between
consecutive tags").

Verified locally:
- gofmt clean.
- go vet ./internal/domain/... ./internal/service/...
  ./internal/api/handler/... ./cmd/server/...  clean.
  (./internal/repository/postgres/... vet failed on transitive
  testcontainers/docker module download — sandbox disk pressure,
  not a code issue; postgres-repo build succeeds and tests pass.)
- go test -short -count=1 ./internal/domain/...
  ./internal/service/... ./internal/api/handler/...  green.
- go test -race -count=10 -run 'TestExpiryAlerts'
  ./internal/service/...  green (per-channel dedup race-free).

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 4.
Acquisition prompt: cowork/rank-4-multichannel-expiry-alerts-prompt.md.
2026-05-03 22:12:32 +00:00
shankar0123 022caf39b4 ci(googlecas): fix QF1002 staticcheck — tagged switch on r.URL.Path
CI failure on commit a2a59a8 (run #423):

  internal/connector/issuer/googlecas/googlecas_failure_test.go:189:3:
    QF1002: could use tagged switch on r.URL.Path (staticcheck)

The OAuth2 token-refresh test handler had two cases — `r.URL.Path ==
"/token"` and `default` — both equality-against-r.URL.Path. Stati-
ccheck's QF1002 rule wants this expressed as a tagged switch:

  switch r.URL.Path {
  case "/token":
      ...
  default:
      ...
  }

The other four switches in the same file are mixed equality + Contains
(`case r.URL.Path == "/token":` + `case strings.Contains(r.URL.Path,
"/certificates"):`) — those are not tag-able and stay on
`switch { case ... }`. Only the OAuth2 test handler had the single-
equality-case pattern QF1002 fires on.

Test-only commit. No production code change.

Verified locally:
- gofmt clean.
- go test -short -count=1 ./internal/connector/issuer/googlecas/...
  green (5 failure tests + 14 happy-path subtests + 4 stub tests).
2026-05-03 21:32:55 +00:00
shankar0123 869fc8f245 docs(openssl): operator playbook for shell-out threat model
Closes Top-10 fix #6 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix, the
OpenSSL adapter's docs in docs/connectors.md explained usage but
did NOT enumerate the threat model. The adapter exec's an arbitrary
operator-supplied script — env-var inheritance, symlink attacks,
sandbox-escape, multi-tenant process-isolation gaps. An acquirer's
security reviewer reading this surface cold pattern-matches
"highest-risk issuer surface with the lowest documented threat
model."

This commit lands a doc-side operator playbook in
docs/connectors.md OpenSSL section (mirrors Bundle 8's "Operator
playbook: keytool argv password exposure" subsection shape and
the 2026-05-02 audit Top-10 fix #7 SSH InsecureIgnoreHostKey
playbook). Six topics covered:

  1. Why the adapter exists despite the risk (CLI-driven CAs
     without Go SDKs need an integration path).
  2. Threat model the adapter accepts (trusted operator + trusted
     script + appropriate ownership + clear audit trail).
  3. Threat model the adapter does NOT accept (operator-writable
     script paths, untrusted content, multi-tenant hosts).
  4. Mitigations operators can layer (dedicated user, root-owned
     0755 binary, audit rules, per-call timeout via
     CERTCTL_OPENSSL_TIMEOUT_SECONDS, env sanitisation,
     chroot/container, audit wrapper, per-call concurrency
     bound).
  5. When NOT to use the adapter (compliance environments,
     multi-tenant servers, no-script-review environments).
  6. V3-Pro forward path (hardened mode tracked in
     cowork/WORKSPACE-ROADMAP.md).

Inline comment in internal/connector/issuer/openssl/openssl.go
near the callSignScript exec call site forward-references the
new doc subsection (no logic change).

cowork/WORKSPACE-ROADMAP.md gains an "OpenSSL hardened mode" V3-
Pro entry under "Adapter hardening" — sibling-folder doc, not in
the certctl repo, so not reflected in this commit's diff.

Same shape Bundle 8 used for the JavaKeystore playbook and the
2026-05-02 deployment-target audit Top-10 fix #7 used for the SSH
InsecureIgnoreHostKey playbook.

No code logic changes (only the explanatory comment near the
exec call site). No test changes. Doc-only commit.

Verified locally:
- gofmt / go vet clean.
- go test -short -count=1 ./internal/connector/issuer/openssl/...
  green.

Audit reference: cowork/issuer-coverage-audit-2026-05-03/RESULTS.md
Top-10 fix #6.
2026-05-03 21:28:05 +00:00
shankar0123 0792271dc6 vault: add automatic token renewal at TTL/2 + Prometheus metric
Closes Top-10 fix #5 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix, the
VaultPKI adapter authenticated with a static token and never called
renew-self. Long-lived deploys hit token expiry; the first
operator-visible signal was failed cert renewals on production
targets.

This commit:

  1. Connector.Start(ctx) spawns a goroutine that calls
     POST /v1/auth/token/renew-self at TTL/2 cadence (computed from a
     one-shot lookup-self at startup). Honours ctx.Done() for
     graceful shutdown via a per-loop done channel + Stop().
  2. On `renewable: false` response (initial lookup OR any subsequent
     renewal), the loop emits a WARN, increments the not_renewable
     counter, and exits. The operator must rotate the token before
     Vault's Max TTL elapses.
  3. New Prometheus counter certctl_vault_token_renewals_total with
     labels result={success,failure,not_renewable}. Registered
     alongside existing certctl_issuance_* counters in
     internal/api/handler/metrics.go.
  4. ERROR-level logging on renewal failure with operator-actionable
     substring ("vault token renewal failed; rotate the token before
     TTL expires") so journalctl + grep find it. Loop keeps ticking
     after a failure — transient blips don't kill it.

New optional issuer.Lifecycle interface:

  type Lifecycle interface {
      Start(ctx context.Context) error
      Stop()
  }

Connectors that hold no background goroutines (almost all of them)
do not implement this — IssuerRegistry.StartLifecycles /
StopLifecycles feature-detect via type assertion. New
lifecycle-bearing connectors plug in by implementing the interface;
no further registry plumbing required.

Wiring (cmd/server/main.go):

  - service.NewVaultRenewalMetrics() instance is shared between
    issuerRegistry.SetVaultRenewalMetrics (so Vault connectors built
    by Rebuild get a recorder) and metricsHandler.SetVaultRenewals
    (so the Prometheus exposer emits the new series).
  - issuerRegistry.StartLifecycles(ctx) is called after
    issuerService.BuildRegistry; defer issuerRegistry.StopLifecycles
    is paired so goroutines exit cleanly on signal.
  - IssuerConnectorAdapter.Underlying() exposes the wrapped
    issuer.Connector so registry-level machinery can reach the
    concrete connector behind the adapter without duplicating the
    wiring at every call site.

Tests (internal/connector/issuer/vault/vault_renew_test.go):

  - TestVault_RenewLoop_TickAtHalfTTL — three ticks → three
    renewals, all "success".
  - TestVault_RenewLoop_StopsOnNotRenewable — second renewal returns
    renewable=false, loop exits, third tick fires no HTTP call.
  - TestVault_RenewLoop_FailureSurfacesViaMetric — first renewal 403
    bumps "failure", second renewal succeeds → loop kept ticking.
  - TestVault_RenewLoop_CtxCancellation_StopsCleanly — Stop returns
    within 200ms after ctx cancel.
  - TestVault_RenewLoop_StartsNothingWhenNotRenewable — token
    already non-renewable at boot ⇒ no goroutine, "not_renewable"
    metric increments at startup so operators see it in Grafana.
  - TestVault_ComputeInterval — 4 cases pinning TTL/2 +
    minRenewInterval floor.
  - TestVault_RenewSelf_ParseFailure_NamesActionableInError —
    surfaced error contains "vault token renewal failed" + "rotate
    the token".

Cadence is dynamic — every successful renewal re-derives TTL/2
from the renewed lease's lease_duration, so a short bootstrap
token that gets renewed up to a longer Max TTL shifts to the
longer cadence automatically (defends against degenerate fast
ticking on a token whose Max TTL is far longer than its initial
TTL).

Documentation:
  - docs/connectors.md Vault PKI section gains "Token TTL +
    automatic renewal" subsection (operator-facing: cadence, metric,
    renewable=false rotation playbook).

Out of scope (intentional, flagged in the audit follow-up):
  - AppRole / Kubernetes / AWS IAM auth methods (different renewal
    semantics).
  - Hot-reload of rotated token from disk (operator restarts
    today; future: GUI/MCP issuer-update path triggers Rebuild
    which Stops the old connector and Starts the new one).
  - Auto-re-auth after token death (operator playbook owns it).

CHANGELOG.md is intentionally not hand-edited (per CHANGELOG.md
itself: "no longer maintains a hand-edited per-version changelog;
per-release notes are auto-generated from commit messages between
consecutive tags").

Verified locally:
- gofmt clean.
- go vet ./internal/service/... ./internal/api/handler/...
  ./internal/connector/issuer/vault/... ./cmd/server/...  clean.
- go test -short -count=1 ./internal/connector/issuer/vault/...
  ./internal/service/... ./internal/api/handler/...  green.
- go test -race -count=10 -run 'TestVault_RenewLoop|TestVault_ComputeInterval'
  ./internal/connector/issuer/vault/...  green.

Audit reference: cowork/issuer-coverage-audit-2026-05-03/RESULTS.md
Top-10 fix #5.
2026-05-03 21:24:27 +00:00
shankar0123 a2a59a823e googlecas, awsacmpca: add failure_test.go covering cloud-SDK error contracts
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.
2026-05-03 21:10:41 +00:00
shankar0123 b0c4ed1ae2 openssl: add failure_test.go covering 6 shell-out error modes
Closes Top-10 fix #3 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix, the
OpenSSL adapter (497 LOC, certctl's highest-risk issuer surface)
had openssl_test.go (8 happy-path funcs + 20 subtests) but no
dedicated _failure_test.go. Compare to ACME, Vault, DigiCert,
Sectigo, Entrust, GlobalSign, EJBCA — all peers have one. An
acquirer's diligence team flags this as an immediate blocker on
the highest-risk issuer surface.

This commit adds 6 failure-mode tests:

  1. TestOpenSSL_Issue_ScriptNotFound_OperatorActionableError —
     SignScript path doesn't exist; error wraps os.ErrNotExist
     (errors.Is); message contains 'no such file' / 'not found'
     so the operator's grep finds it in journalctl.
  2. TestOpenSSL_Issue_PermissionDenied_OperatorActionableError —
     SignScript exists with mode 0o600 (non-executable); error
     wraps os.ErrPermission; message contains 'permission'.
     Skipped under root (uid 0 bypasses chmod gating).
  3. TestOpenSSL_Issue_MalformedStdout_DistinguishedFromCSRReject
     — script exits 0 + writes garbage (no PEM markers) to the
     cert output file; error mentions PEM/certificate/parse so
     operators distinguish output-parsing failure from a script-
     side fault.
  4. TestOpenSSL_Issue_NonZeroExit_DistinguishesCAReject_From_
     ScriptError — script writes 'policy violation: …' to stderr
     and exits 2 (CA-side rejection convention); the script's
     stderr surfaces in the error message; errors.Unwrap returns
     non-nil (proving the underlying *exec.ExitError chain
     survives).
  5. TestOpenSSL_Issue_TimeoutEnforced_ContextCancellationPropagates
     — script does 'exec sleep 30' (not 'sleep 30 ' as a child;
     exec replaces bash so SIGKILL goes directly to the sleeper,
     avoiding the orphan-pipes corner case where a killed bash
     leaves sleep holding stdout/stderr open and CombinedOutput
     blocks); ctx with 100ms deadline; call returns within ~5s
     wall-clock; either errors.Is(err, context.DeadlineExceeded)
     or the error message names 'killed' / 'signal'.
  6. TestOpenSSL_Issue_SignalKilled_PartialOutputDiscarded —
     script writes a half-PEM ('-----BEGIN CERTIFICATE-----\nMII…')
     then 'kill -KILL $$'; assertion: result is nil OR
     CertPEM is empty (no half-cert leaks to caller); error
     names 'signal' / 'killed' OR 'PEM' / 'parse' (both are
     operator-actionable).

Each test pins the operator-actionable error message contract:
the message names the failure mode (so journalctl + grep find
it) and proves no half-state was created (no partial cert
returned). errors.Is / errors.Unwrap checks confirm the wrapping
chain survives.

The OpenSSL adapter has no commandRunner abstraction (production
code uses exec.CommandContext directly); these tests use real
operator-supplied scripts written to t.TempDir (matches the
adapter's actual production code path; no os/exec mocking). The
'exec sleep 30' technique in Test 5 is the load-bearing fix for
the bash-orphans-sleep-and-pipes-stay-open corner case that
otherwise makes the test take 30s instead of 100ms.

Coverage delta:
  - Before this commit: openssl_test.go + openssl_stubs_test.go
    covered 8 happy-path funcs.
  - After: 79.8% statement coverage of openssl.go (up from
    operator-pre-existing baseline; the 6 new tests exercise
    every error path through callSignScript + parseCertificate).

Tests pass clean under '-race -count=10' (Test 5's deadline
tolerance is the only timing-sensitive case; the 5s wall-clock
budget vs the 100ms ctx deadline gives ample slack on slow CI
without masking deadline-not-enforced bugs).

Test-only commit; no production code changes. Hardening fixes
(per-call concurrency semaphore, threat-model docs) are separate
Top-10 entries.

Verified locally:
  - gofmt clean across the repo.
  - go vet ./... clean across the repo.
  - go test -race -count=10 -short
    ./internal/connector/issuer/openssl/... green.

Audit reference: cowork/issuer-coverage-audit-2026-05-03/
RESULTS.md Top-10 fix #3.
2026-05-03 20:55:44 +00:00
shankar0123 d3bf2cc0cf vault, digicert: migrate Token / APIKey to *secret.Ref (Bundle I Phase 3)
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.
2026-05-03 20:49:23 +00:00
shankar0123 81f6321326 ejbca: port mTLS keypair to mtlscache (close Bundle M for the last issuer)
Closes Top-10 fix #1 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix,
ejbca.go::New called tls.LoadX509KeyPair once at construction and
configured the keypair into *http.Transport.TLSClientConfig with
no mtime watch. mTLS rotation required a server restart — quarterly
rotation per any reasonable security policy = quarterly deploy
outage.

Bundle M from the prior 2026-05-01 audit shipped the mtlscache
helper at internal/connector/issuer/mtlscache/cache.go and wired
it into Entrust + GlobalSign. EJBCA was missed in Bundle M's
scope. This commit ports the same helper onto EJBCA's
auth_mode=mtls path. The OAuth2 path is unchanged.

Implementation:
  - New imports internal/connector/issuer/mtlscache.
  - Connector struct gains an mtls *mtlscache.Cache field
    (mirroring Entrust + GlobalSign).
  - New()'s case 'mtls': replaces tls.LoadX509KeyPair + manual
    *http.Transport with mtlscache.New(certPath, keyPath,
    Options{HTTPTimeout: 30s}). Cache build happens at construction
    so misconfigured operators fail fast (matches pre-fix
    behaviour).
  - New helper getHTTPClient() returns the cached client; on the
    mTLS path it calls RefreshIfStale before returning so the
    next request uses the new keypair if disk has rotated. On
    OAuth2 / test paths (c.mtls == nil), returns c.httpClient
    as-is.
  - All 3 c.httpClient.Do call sites (IssueCertificate enroll,
    RevokeCertificate revoke, GetOrderStatus cert lookup) replaced
    with c.getHTTPClient() + client.Do.
  - crypto/tls import removed (no longer used at this layer).

Tests:
  - TestEJBCA_MTLSKeypairRotation_PicksUpNewCertWithoutRestart
    (new, ejbca_mtls_rotation_test.go): generates two CAs (caA,
    caB), signs leafA + leafB, spins up an httptest TLS server
    that trusts both CAs and records the issuer DN of every
    presented client cert, writes leafA, makes request 1, writes
    leafB + advances mtime by 2s, makes request 2. Asserts the
    server saw caA's DN on req 1 and caB's DN on req 2 — the
    cache picked up the rotation without ejbca.New re-running.
  - export_test.go: GetHTTPClientForTest helper exposes the
    private getHTTPClient so the rotation test drives the
    production code path.
  - All existing EJBCA tests still pass (TestNew_MTLSWiresClientCert,
    TestNew_MTLSCertLoadFailure, TestNew_OAuth2NoTransportTuning,
    TestNew_InvalidAuthMode).

Verified locally:
  - gofmt clean across the repo.
  - go vet ./... clean across the repo.
  - go test -race -count=1 -short ./internal/connector/issuer/ejbca/...
    ./internal/connector/issuer/mtlscache/... green.

Audit reference: cowork/issuer-coverage-audit-2026-05-03/
RESULTS.md Top-10 fix #1.
2026-05-03 20:38:19 +00:00
shankar0123 39f065dda4 docs(acme-server): operator-facing reference + threat model + cert-manager walkthrough (Phase 6/7)
Doc-only commit closing the ACME-server work series. After this commit,
an outside reviewer (procurement engineer / Venafi diligence engineer /
Infisical-comparison-shopper) can read the docs cold, understand the
ACME server's surface, follow the cert-manager walkthrough, and reach
a deployment decision without escalating to certctl maintainers.

What ships:
  - docs/acme-server.md final pass: Auth-mode decision tree (when to
    use trust_authenticated vs challenge), RFC 8555 + RFC 9773
    conformance statement (section-by-section table of implemented
    plus procurement-honest 'not implemented' rows for EAB / multi-
    level wildcards / RFC 8738 / cross-CA proxying), Troubleshooting
    (5 failure modes — badNonce / unknownAuthority / HTTP-01
    connection refused / DNS-01 NXDOMAIN / rejectedIdentifier with
    canonical fix for each), Version pinning + tested clients table
    (cert-manager 1.15.0, lego v4, kind v0.20+, Caddy 2.7.x, Traefik
    3.0+), FAQ (5 entries — why two auth modes, vs cert-manager-
    against-LE, can-I-use-from-outside-K8s, migration story, audit-
    log catalog), See-also cross-link block.
  - docs/acme-cert-manager-walkthrough.md: kind → cert-manager →
    certctl → Certificate flow, with YAML blocks byte-equal to
    deploy/test/acme-integration/{clusterissuer-trust-authenticated,
    certificate-test}.yaml to prevent doc/test drift.
  - docs/acme-caddy-walkthrough.md: Caddyfile acme_ca + tls.cas
    options (OS trust store + Caddy pki.ca block).
  - docs/acme-traefik-walkthrough.md: certificatesResolvers.<name>.acme
    .caServer + serversTransport.rootCAs configuration.
  - docs/acme-server-threat-model.md: Threat surface map + JWS forgery
    resistance (alg-confusion / HS256 substitution / replayed nonce /
    URL spoofing / multi-sig / kid-vs-jwk / kid round-trip mismatch),
    Nonce store integrity rationale, HTTP-01 SSRF defense-in-depth
    (pre-dial check + per-dial check + per-redirect check + body cap +
    bounded redirects), DNS-01 cache-poisoning posture (default Google
    Public DNS + operator-owns-private-resolver-posture), TLS-ALPN-01
    chain-not-validated rationale (RFC 8737 §3 explicit), Rate-limit
    tuning, Audit trail catalog, Out-of-scope threats list.
  - docs/connectors.md: TOC renumbered 3→4 etc. to make room for new
    top-level 'ACME Server (Built-in)' section between Issuer Connector
    and Target Connector — distinguishes the consumer-side ACME
    (existing) from the new server-side ACME via env-var-prefix
    call-out (CERTCTL_ACME_* vs CERTCTL_ACME_SERVER_*).

DoD verification:
  - All 5 docs files exist with the structure prescribed by the
    Phase 6 prompt.
  - Every CERTCTL_ACME_SERVER_* env var in docs/acme-server.md maps
    to an actual lookup in internal/config/config.go (verified by
    'grep -oE | sort -u | diff' returning empty).
  - Every YAML snippet in docs/acme-cert-manager-walkthrough.md is
    byte-equal to the corresponding file in deploy/test/acme-integration/
    (verified with 'diff' against awk-extracted YAML blocks).
  - docs/connectors.md has the cross-link subsection with all 4 new
    docs referenced.
  - cowork/CLAUDE.md Architecture Decisions has the new ACME-server
    bullet documenting per-profile URL family + per-profile
    acme_auth_mode + Phase 4-5-6 progression.
  - cowork/WORKSPACE-CHANGELOG.md has the ACME-Server-6 entry plus
    the ACME-Server rollup spanning Phases 1a-6.
  - cowork/infisical-deep-research-results.md Rank 1 marked SHIPPED.
  - 'gofmt -l .' clean (no Go changes); 'go vet ./...' clean.

Acquisition-readiness: every one of the 12 acquisition-grade criteria
from cowork/acme-server-endpoint-prompt.md is verified by the test
suite (Phases 1a-5) plus this doc walkthrough (Phase 6). The full
RFC 8555 + RFC 9773 surface is live; the operator can deploy
end-to-end by reading one walkthrough doc and one env-var table.

Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-6 (docs)'
+ ACME-Server rollup of all 6 phases.
2026-05-03 19:58:15 +00:00
shankar0123 bee47f0318 acme-server: cert-manager integration test + production hardening (Phase 5/7)
Closes the production-readiness loop on the ACME surface. After this
commit, certctl ships per-account rate limits + a GC sweeper for
expired ACME state + a kind-driven cert-manager 1.15 integration test
+ a lego-driven RFC conformance harness + a k6 loadtest scenario for
the unauthenticated ACME path.

Architecture:
  - Rate limits live in-memory + per-replica. Restart wipes the
    counters; orders/hour caps are eventual-consistency anyway. A
    3-replica certctl-server fleet behind an LB effectively has 3x
    the configured throughput per account; persistent rate limiting
    is a follow-up if production telemetry shows abuse patterns we
    can't catch in a single restart cycle. Per-key + per-action
    isolation: ActionNewOrder/acc-1, ActionKeyChange/acc-1, and
    ActionChallengeRespond/<challenge-id> are independent buckets.
  - GC loop follows the existing scheduler-loop pattern (atomic.Bool
    + sync.WaitGroup; see crlGenerationLoop for shape). Three
    independent SQL sweeps per tick (DELETE expired nonces; UPDATE
    pending authzs whose expires_at < now() to expired; UPDATE
    pending/ready/processing orders whose expires_at < now() to
    invalid). Each sweep is a single statement; failures are logged-
    and-continued so a failing nonces sweep doesn't block authzs.
    Per-sweep 1m timeout bounds a stuck Postgres.
  - cert-manager integration test is gated on KIND_AVAILABLE so CI
    skips it cleanly (kind is too heavy for per-PR). Operators run
    locally via 'make acme-cert-manager-test'; the harness brings up
    a fresh cluster each run + tears it down on Cleanup.
  - lego conformance harness drives a real ACME client through
    register → run → cert-PEM-landed against a hermetic certctl
    stack. Catches RFC-shape regressions third-party clients would
    hit before they ship.
  - k6 ACME-flow scenario hammers the unauthenticated surface
    (directory + new-nonce + ARI synthetic-id) at 100 VUs × 5m. JWS-
    signed flows are out of scope for k6 (no JWS support); they're
    covered by the lego harness above.

What ships:
  - internal/api/acme/ratelimit.go (+ ratelimit_test.go: 7 cases —
    disable-when-perHour-zero, capacity, per-key isolation, per-
    action isolation, refill-over-time, RetryAfter, concurrent-access
    with -race + 200 goroutines × 200 calls).
  - internal/repository/postgres/acme.go: 4 new methods —
    CountActiveOrdersByAccount + GCExpiredNonces + GCExpireAuthorizations
    + GCInvalidateExpiredOrders. Each a single SQL statement.
  - internal/service/acme.go: SetRateLimiter + GarbageCollect +
    rate-limit gates at 3 entry points (CreateOrder + RotateAccountKey
    + RespondToChallenge) + concurrent-orders gate at CreateOrder.
    2 new sentinels (ErrACMERateLimited, ErrACMEConcurrentOrdersExceeded);
    5 new GC metrics (gc_runs / gc_run_failures / gc_nonces_reaped /
    gc_authzs_expired / gc_orders_invalidated).
  - internal/scheduler/scheduler.go: ACMEGarbageCollector interface +
    acmeGCRunning atomic.Bool + acmeGCInterval + 2 setters (SetACME-
    GarbageCollector + SetACMEGCInterval) + acmeGCLoop following the
    crlGenerationLoop shape.
  - internal/api/handler/acme.go: writeServiceError gains rateLimited
    (429 + RFC 8555 §6.7) + concurrent-orders-exceeded mappings.
  - internal/config/config.go: 5 new env vars
    (CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR=100,
    CERTCTL_ACME_SERVER_RATE_LIMIT_CONCURRENT_ORDERS=5,
    CERTCTL_ACME_SERVER_RATE_LIMIT_KEY_CHANGE_PER_HOUR=5,
    CERTCTL_ACME_SERVER_RATE_LIMIT_CHALLENGE_RESPONDS_PER_HOUR=60,
    CERTCTL_ACME_SERVER_GC_INTERVAL=1m).
  - cmd/server/main.go: NewRateLimiter() + SetRateLimiter() at
    startup; conditional SetACMEGarbageCollector(acmeService) +
    SetACMEGCInterval(cfg.ACMEServer.GCInterval) when Enabled+
    GCInterval > 0.
  - deploy/test/acme-integration/: kind-config.yaml + cert-manager-
    install.sh + clusterissuer-trust-authenticated.yaml +
    clusterissuer-challenge.yaml + certificate-test.yaml + conformance-
    lego.sh + certmanager_test.go (//go:build integration + KIND_AVAILABLE
    gate).
  - deploy/test/loadtest/k6/acme_flow.js + README ACME-flows section.
  - Makefile: 2 new PHONY targets (acme-cert-manager-test +
    acme-rfc-conformance-test).
  - docs/acme-server.md: status flipped to Phase 5; Configuration
    table grows 5 rows; new 'Phase 5 — operational guidance' section
    explaining rate-limit math + GC sweeper semantics + cert-manager
    integration + lego conformance + k6 baseline.

Tests:
  - 'go vet ./...' clean across the repo.
  - 'go test -short -count=1 ./internal/...' green across every
    affected package (service / acme / handler / scheduler / repo /
    config).
  - 'go vet -tags=integration ./deploy/test/acme-integration/' clean
    (the integration test compiles cleanly with the build tag).
  - The kind/cert-manager harness is gated behind KIND_AVAILABLE so
    CI skips by default; operators run locally via 'make acme-cert-
    manager-test'.

Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-5'.
2026-05-03 19:42:03 +00:00
shankar0123 9bfbac0f97 deps(web): upgrade vite ^8.0.0 → ^8.0.10 (3 Dependabot alerts)
Closes Dependabot alerts #12 (CVE — arbitrary file read via Vite dev
server WebSocket), #13 (CVE-2026-39364 — server.fs.deny bypassed with
?raw / ?import&raw / ?import&url&inline query suffixes), and #14 (path
traversal in optimized-deps .map handling). All three live in the vite
DEV server only — vite build (production output) is unaffected. All
three share the same advisory range '>= 8.0.0, <= 8.0.4' → fixed in
8.0.5; npm picked the latest 8.x patch (8.0.10).

Real-world exposure for certctl was low: web/package.json's 'dev: vite'
script has no --host flag, so the default binding is localhost
(127.0.0.1). Devs who manually run 'vite --host' for cross-machine
testing were exposed to the same-LAN attack vector; this closes it.

Manifest change: bumped the constraint from '^8.0.0' to '^8.0.10' to
document the security floor in package.json itself (the caret already
permitted 8.0.10, but pinning the floor higher prevents an accidental
downgrade if a future 'npm install' somehow re-resolves to a vulnerable
8.0.0-8.0.4). Lockfile change: 17 packages removed + 18 changed —
mostly transitive vite-internal modules (rolldown, oxc-* etc.) that
shifted around between 8.0.0 and 8.0.10.

Verified locally:
  - 'npm install vite@^8.0.5 --save-dev' completed cleanly.
  - 'vite build' produces the same web/dist/ output (668 modules
    transformed, 35.30 kB CSS / 918.04 kB JS — same shape as pre-
    upgrade).
  - vitest run wasn't completed in the sandbox (test runner hung in
    the disk-pressure environment); CI will run it on push.

Engineering history: this is a cross-cutting deps bump that lives
outside the ACME-Server-N phase plan.
2026-05-03 19:18:14 +00:00
shankar0123 650f5a198f fix: collapse identical if/else branches in Account handler (CodeQL #25)
CodeQL alert #25 (go/duplicate-branches) on internal/api/handler/
acme.go::ACMEHandler.Account flagged that 'if readOnly { ... } else
{ ... }' had byte-identical bodies — both setting the same
Content-Type: application/json header. The 'readOnly' bool was
threaded through the function as a placeholder for differentiated
headers (Cache-Control etc. on the POST-as-GET path) that never
landed; both branches collapsed to the same value with no
follow-through.

Audit + fix:
  - The alert is real (verified by re-reading the source); not a
    false positive.
  - The Copilot Autofix Anthropic surfaced was correct in spirit but
    incomplete: it collapsed the if/else but left 'readOnly' as
    dead code (declared at line 395, assigned at lines 400 and 436,
    only read at the now-removed if). golangci-lint's 'unused'
    linter would flag 'readOnly' next.
  - Complete fix: collapse the if/else AND remove the now-unused
    'readOnly' variable + its 2 assignments. Single unconditional
    'w.Header().Set("Content-Type", "application/json")' covers
    both paths (RFC 8555 §6.3 POST-as-GET + §7.3.2 / §7.3.6 update
    + deactivation all return the same account JSON shape — no spec
    rationale for differentiating headers).

Verified locally: 'gofmt -l .' clean; 'go vet ./...' clean;
'go test -short -count=1 ./internal/api/handler/' green; 'grep
readOnly' on the file returns only the new explanatory comment
(no live references).

The alert was first detected in commit 44a85d6 (Phase 1b) — the
duplicate has been sitting in the codebase since the Account
handler shipped. No functional regression for any RFC 8555 client
(cert-manager, lego, Posh-ACME): same status code, same headers,
same body.
2026-05-03 19:07:21 +00:00
shankar0123 1e1bc9b3b4 ci: fix Phase 4 post-push unused-symbol failures
CI on commit f6ba563 (Phase 4 gofmt fix) failed golangci-lint's
'unused' linter on internal/service/acme_phase4_test.go: the
stubRenewalPolicies type + its Get method were defined for a future
RenewalInfo happy-path test that I never actually wrote — only the
disabled + bad-cert-id negatives. The dead-code carried forward
because go vet doesn't catch unused-but-exported-shape, and the
package-private use never materialized.

Fix: delete the stubRenewalPolicies type + its method + the
adjacent stub-comment that referenced a similarly-imagined
stubIssuerConn that was never written either. The tests I have
(RotateAccountKey happy + duplicate, RevokeCert kid + jwk paths +
already-revoked + reason-clamping, RenewalInfo disabled +
bad-cert-id) all still pass — they don't reference the removed
type. The window-math is exercised directly in
internal/api/acme/phase4_test.go::TestComputeRenewalWindow_*; the
service-layer policy-lookup wiring is read at handler smoke time
in Phase 5.

Confirmed: 'gofmt -l .' clean; 'go vet ./internal/service/' clean;
'go test -short -count=1 ./internal/service/' green. Pre-commit
verification gate updated implicitly: future Phase commits should
spot-check unused-shape via grep against the test file (every
stub* helper should have ≥3 references, matching the live
helpers' usage profile).
2026-05-03 19:02:44 +00:00
shankar0123 f6ba5634fd ci: fix Phase 4 post-push gofmt failure (map-literal alignment)
CI on commit 4dc8d3f (Phase 4) failed gofmt on
internal/api/router/openapi_parity_test.go. The 6 new SpecParity-
Exceptions entries I added for the Phase 4 routes had over-padded
whitespace between key and value; the longest new key is
'"GET /acme/profile/{id}/renewal-info/{cert_id}":' which sets the
gofmt-canonical column width for the surrounding block, but my
hand-aligned values used the wider Phase-2 column width (set by the
even-longer 'POST /acme/profile/{id}/order/{ord_id}/finalize' key in
that block).

gofmt aligns map-literal columns per contiguous run between blank
lines / structural breaks, not file-globally. The Phase 4 entries
form their own run because they're separated from the Phase 2 block
by the '// Phase 4 — key rollover + revocation + ARI.' comment.

Fix: 'gofmt -w' on the file, which rewrote the 6 lines with the
correct (narrower) intra-block alignment. No semantic change — just
whitespace.

Confirmed: 'gofmt -l .' clean; 'go vet ./internal/api/router/' clean
(the test still passes after the formatting change).
2026-05-03 18:58:00 +00:00
shankar0123 4dc8d3fa5b acme-server: key rollover + revocation + ARI (Phase 4/7)
Closes the RFC 8555 + RFC 9773 surface beyond the issuance happy-path:
  - POST /acme/profile/<id>/key-change   (RFC 8555 §7.3.5)
  - POST /acme/profile/<id>/revoke-cert  (RFC 8555 §7.6)
  - GET  /acme/profile/<id>/renewal-info/<cert-id>  (RFC 9773 ARI)

After this commit, ACME clients can rotate account keys, revoke certs
through the ACME surface (rather than only via the certctl GUI/API),
and fetch ARI for proactive renewal scheduling.

Architecture:
  - Key rollover: outer JWS verified against the registered account key
    (existing kid path); the inner JWS — embedded as the outer's payload
    — verified against the embedded NEW jwk in a new dedicated routine
    (ParseAndVerifyKeyChangeInner) that enforces RFC 8555 §7.3.5
    inner-only invariants: MUST use jwk + MUST NOT use kid, payload
    .account == outer.kid, payload.oldKey thumbprint-equals registered.
    A single WithinTx swaps the stored thumbprint+pem and writes the
    audit row. Concurrent-rollover safety via SELECT…FOR UPDATE on the
    conflicting account row in UpdateAccountJWKWithTx; the loser
    observes the winner's new thumbprint and is told to retry (409).
  - Revocation: two auth paths. kid → AccountOwnsCertificate single-
    indexed COUNT lookup over acme_orders. jwk → constant-time RFC 7638
    thumbprint compare against the cert's pubkey. Both paths route
    through service.RevocationSvc.RevokeCertificateWithActor so the
    existing CRL/OCSP refresh + audit + metrics pipeline applies. RFC
    5280 §5.3.1 numeric reason codes clamp to certctl's
    domain.ValidRevocationReasons; codes 8 (removeFromCRL) + 10
    (aACompromise) clamp to 'unspecified' since they aren't in the set.
  - ARI is GET-only and unauth per RFC 9773 §4. Cert-id wire shape is
    base64url(AKI).base64url(serial); ParseARICertID strict-decodes,
    SerialHex emits the canonical certctl-shape lowercase-no-leading-
    zeros hex used in certificate_versions.serial_number.
    ComputeRenewalWindow has 3 branches: bound RenewalPolicy →
    [notAfter - days, notAfter - days/2]; no policy → last 33% of
    validity; past expiry → [now, now + 1d] (renew immediately).
    Retry-After honors CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL.

What ships:
  - internal/api/acme/{keychange,ari}.go (+ phase4_test.go: 15 tests).
  - internal/api/acme/order.go: RevokeCertRequest wire shape.
  - internal/api/handler/acme.go: KeyChange, RevokeCert, RenewalInfo
    + 11 new writeServiceError mappings.
  - internal/repository/postgres/acme.go: UpdateAccountJWKWithTx (FOR
    UPDATE + expectedOldThumbprint precondition; ErrACMEAccountKey-
    ConcurrentUpdate sentinel) + AccountOwnsCertificate.
  - internal/service/acme.go: RotateAccountKey + RevokeCert +
    RenewalInfo; CertificateRevoker + RenewalPolicyLookup interfaces;
    SetRevocationDelegate + SetRenewalPolicyLookup wiring; 11 new
    sentinels; 6 new metrics.
  - internal/service/acme_phase4_test.go: service-layer tests for
    RotateAccountKey (happy + duplicate-key) + RevokeCert (kid mismatch
    + jwk mismatch + jwk happy + already-revoked + reason-clamping) +
    RenewalInfo (disabled + bad cert-id).
  - internal/api/router/router.go: 6 new register calls (3 per-profile
    + 3 shorthand). Router parity exceptions extended in lockstep
    (in-tree SpecParityExceptions + CI-only openapi-handler-exceptions
    .yaml).
  - cmd/server/main.go: SetRevocationDelegate(revocationSvc) +
    SetRenewalPolicyLookup(renewalPolicyRepo) at startup.
  - internal/config/config.go: CERTCTL_ACME_SERVER_ARI_ENABLED (default
    true) + CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL (default 6h);
    BuildDirectory's ariEnabled flag now flips on under
    cfg.ARIEnabled.
  - docs/acme-server.md: phase status flipped to Phase 4; endpoints
    table grows 6 rows (3 per-profile + 3 shorthand); FAQ section
    appended explaining how to rotate keys, revoke certs, and consume
    ARI.

Tests:
  - 'go vet ./...' clean across the repo.
  - 'go test -short -count=1 ./...' green across every package.
  - phase4_test.go covers: keychange happy-path + 5 negatives +
    MapKeyChangeErrorToProblem coverage; ARI cert-id round-trip + 6
    malformed cases + BuildARICertID from a generated cert; window-
    math 3 branches.
  - service-layer tests confirm: RotateAccountKey atomically swaps the
    thumbprint (verifies persisted state) and rejects duplicate keys;
    RevokeCert routes through the stub RevocationSvc with the right
    actor string + reason on the jwk path, rejects mismatched keys,
    rejects already-revoked certs, clamps reason codes correctly;
    RenewalInfo respects ARIEnabled + cert-id format.

Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-4'.
2026-05-03 16:51:06 +00:00
shankar0123 62513ad12f ci: fix Phase 3 post-push CI failures (contextcheck + ST1021)
CI on commit 9bc8453 (Phase 3 challenges) failed three lint checks under
golangci-lint. Two were contextcheck on internal/service/acme.go
RespondToChallenge, where the validator-pool dispatch deliberately
detached from the request ctx via 'context.Background()' so the async
WithinTx survives the HTTP handler returning. contextcheck rightly
flagged the non-inherited context — the canonical Go 1.21+ answer for
this exact pattern is context.WithoutCancel(ctx), which preserves
inherited values (logger, trace IDs, audit actor) but detaches
cancellation. Swapping that in clears both contextcheck hits.

The third was ST1021 on internal/api/acme/validators.go: a comment
intended for the (*Pool).Snapshot() method had landed above the
PoolSnapshot type by accident. Split the comment — one prose line
for the type, one for the method — so each exported symbol carries
its own properly-anchored doc.

Confirmed local 'go vet' clean and 'go test -short -count=1' green
across internal/service/ and internal/api/acme/ before commit.
2026-05-03 15:56:03 +00:00
shankar0123 9bc845304e acme-server: HTTP-01 + DNS-01 + TLS-ALPN-01 challenge validation (Phase 3/7)
Wires up the actual challenge-validation machinery so profiles in
acme_auth_mode='challenge' resolve end-to-end. After this commit,
cert-manager 1.15+ with `solver: http01: ingress` against a
challenge-mode profile completes a real HTTP-01 flow and gets a cert.
DNS-01 + TLS-ALPN-01 share the same code path with the appropriate
validator selection.

Architecture (the load-bearing parts):
  - 3 separate semaphore-bounded worker pools (one per challenge type),
    so HTTP-01 and DNS-01 can't starve each other under load. Default
    weight 10 per type; tunable via CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY,
    DNS01_CONCURRENCY, TLSALPN01_CONCURRENCY.
  - 30s per-challenge timeout (configurable via PoolConfig.PerChallengeTimeout).
  - HTTP-01 validator runs validation.IsReservedIPForDial (newly
    exported wrapper preserving the existing private impl byte-for-byte
    for the network scanner + ValidateSafeURL paths) on the resolved
    IP — both at the initial dial and every redirect hop. SSRF probes
    into private IP space are refused before the connect.
  - DNS-01 validator uses a dedicated resolver pointed at
    CERTCTL_ACME_SERVER_DNS01_RESOLVER (default 8.8.8.8:53) — does
    NOT use the system resolver to keep behavior deterministic across
    deployments. Wildcard handling: `*.example.com` queries
    _acme-challenge.example.com.
  - TLS-ALPN-01 validator (RFC 8737) connects with ALPN `acme-tls/1`,
    inspects the id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31),
    asserts the ASN.1 OCTET STRING value equals SHA-256 of the key
    authorization. Cert chain is intentionally NOT validated
    (InsecureSkipVerify=true is correct per RFC 8737 — the proof is
    in the extension, not the chain). Documented in docs/tls.md L-001
    table + the //nolint:gosec comment carries the justification.
    SSRF guard: same posture as HTTP-01.
  - Validation is asynchronous: handler accepts the POST and returns
    200 immediately with status=processing; the worker-pool fires a
    callback that updates challenge → authz → order in a fresh
    background-context WithinTx. The order auto-promotes to `ready`
    when ALL authzs become valid; auto-fails to `invalid` when ANY
    authz becomes invalid.

What ships:
  - internal/api/acme/challenge.go: KeyAuthorization (RFC 8555 §8.1) +
    DNS01TXTRecordValue (§8.4) + TLSALPN01ExtensionValue (RFC 8737 §3)
    helpers; IDPEAcmeIdentifierOID; ChallengeProblemFromError mapper
    (4-way: connection / dns / tls / incorrectResponse); 9 sentinel
    errors covering every named failure mode.
  - internal/api/acme/validators.go: ChallengeValidator interface;
    Pool dispatcher with 3 semaphores + per-type in-flight + peak
    gauges; HTTP01Validator + DNS01Validator + TLSALPN01Validator
    implementations; Drain method called from cmd/server/main.go's
    shutdown sequence.
  - internal/api/acme/validators_test.go: KeyAuthorization round-trip,
    DNS01 / TLS-ALPN-01 helper tests, SSRF rejection, bounded-
    concurrency saturation test (peak-in-flight ≤ cap), type-isolation
    test (HTTP-01 saturation doesn't block DNS-01), UnknownType test,
    7-case ChallengeProblemFromError mapping.
  - internal/repository/postgres/acme.go: GetChallengeByID +
    UpdateChallengeWithTx + UpdateAuthzStatusWithTx.
  - internal/service/acme.go: SetValidatorPool wires the *acme.Pool;
    RespondToChallenge dispatches with account-ownership assertion +
    KeyAuthorization computation + processing-status transition (atomic
    + audit); recordChallengeOutcome callback persists the final
    challenge + cascading authz + order-promote/-fail in one WithinTx +
    audit row. 4 new metrics.
  - internal/api/handler/acme.go: Challenge handler; round-trips
    account.JWKPEM through ParseJWKFromPEM to recover the *jose.JSONWebKey
    the validator pool needs.
  - internal/api/router/router.go + openapi_parity_test.go +
    api/openapi-handler-exceptions.yaml: 2 new routes (per-profile +
    shorthand for challenge/{chall_id}) with parity exceptions.
  - cmd/server/main.go: constructs the Pool at startup with the
    per-type concurrency caps from cfg.ACMEServer; ACMEService.ValidatorPool()
    accessor exposed for the shutdown drain sequence.
  - internal/validation/ssrf.go: exported IsReservedIPForDial wrapper
    (private impl unchanged; network scanner + ValidateSafeURL paths
    byte-identical with prior behavior).
  - docs/tls.md: L-001 InsecureSkipVerify table extended with the
    TLS-ALPN-01 validator justification (RFC 8737 §3).
  - docs/acme-server.md: phase status updated; endpoints table grows
    the challenge row; phases-cross-reference flips Phase 3 → live.

Tests:
  - 80%+ coverage on the new files.
  - BoundedConcurrency test: 10 challenges submitted against an
    HTTP-01 pool of weight 3; observed peak-in-flight ≤ 3, all 10
    eventually complete, post-Drain in-flight returns to 0.
  - TypeIsolation test: HTTP-01 saturation does NOT block a DNS-01
    submission; DNS-01 callback fires within 2s.
  - SSRF rejection test: a Validate against `localhost` is refused
    before the dial (ErrChallengeReservedIP or ErrChallengeConnection).

Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-3".
2026-05-03 14:09:00 +00:00
shankar0123 45fae9952a chore(deps): remove stale go-jose v4.0.4 entries from go.sum
Follow-up to f68fd00 (the go-jose v4.0.4 → v4.1.4 upgrade). The
upgrade commit's `go mod tidy` ran out of disk in the sandbox before
it could finish writing the cleaned go.sum back, leaving 2 stale
v4.0.4 entries alongside the new v4.1.4 entries. CI's
`go mod tidy && git diff --exit-code go.mod go.sum` flagged the
drift on the next push (PR #410):

    -github.com/go-jose/go-jose/v4 v4.0.4 h1:...
    -github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:...

This commit removes those 2 lines so go.sum holds only v4.1.4
hashes.

Verified locally:
  - grep "go-jose" go.sum  → only v4.1.4 lines.
  - go build ./internal/api/acme/  → clean.
  - go test -count=1 -short ./internal/api/acme/  → 16-case JWS
    suite green.
2026-05-03 13:51:55 +00:00
shankar0123 f68fd00b7b chore(deps): upgrade go-jose v4.0.4 → v4.1.4 + tidy duplicate require
Two-fer in one commit:

(1) Dependabot security alerts on go-jose/v4 v4.0.4. Both alerts
    flagged on commit 44a85d6 (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 a05a7d3. 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.
2026-05-03 13:48:57 +00:00
shankar0123 c351bba41a acme-server: orders + authorizations + finalize + cert download (Phase 2/7)
Closes the issuance loop in trust_authenticated mode (commits ec88a61
+ 44a85d6 wired the foundation + JWS-verified account resource).
After this commit, an ACME client running against a profile with
acme_auth_mode='trust_authenticated' end-to-end-issues a real cert:

  POST /acme/profile/<id>/new-order      → 201 + order URL (status=ready)
  POST /acme/profile/<id>/order/<oid>    → POST-as-GET fetch
  POST /acme/profile/<id>/order/<oid>/finalize  → 200 + status=valid + cert URL
  POST /acme/profile/<id>/cert/<cid>     → 200 + PEM chain

Profiles with acme_auth_mode='challenge' get the same code path with
authz/challenge rows in `pending` state until Phase 3's validators
wire up. The mode is read from the bound profile's column at request
time, NOT cached at server start — operators flipping the column via
SQL take effect on the next order without restart.

Architecture (the load-bearing part):
  - Finalize routes through service.CertificateService.Create — the
    canonical certctl issuance entry point that wraps the
    managed_certificates row insert + audit row in s.tx.WithinTx.
    RenewalPolicy / CertificateProfile / per-issuer-type Prometheus
    metrics / audit rows all apply uniformly to ACME-issued certs via
    the same code path that already serves EST/SCEP/agent/REST issuance.
  - Identifier validation runs BEFORE order creation. Rejected
    identifiers return RFC 7807 with per-identifier subproblems and
    create no order row.
  - Source stamp on managed_certificates: domain.CertificateSourceACME.
    Operators bulk-revoke ACME-issued certs by filtering on Source=ACME.
  - 3-step atomicity boundary documented in code + this commit msg:
    (A) WithinTx-A marks order processing + audit row.
    (B) IssuerConnector.IssueCertificate + CertificateService.Create
        (each in its own WithinTx — Create wraps cert row + audit
        atomically).
    (C) WithinTx-C creates certificate_versions row + transitions order
        to valid + sets certificate_id + audit row.
    The brief window between B and C can leave a managed_certificates
    row whose order is still in `processing`. Phase 5's GC scheduler
    reconciles. Documented inline.

What ships:
  - internal/api/acme/order.go: OrderResponseJSON + AuthorizationResponseJSON
    + ChallengeResponseJSON + NewOrderRequest + FinalizeRequest wire
    shapes; ValidateIdentifiers (Phase 2 syntactic checks, dns-only);
    CSRMatchesIdentifiers (RFC 8555 §7.4 strict equality, case-folded).
  - internal/domain/acme.go: ACMEOrder + ACMEAuthorization + ACMEChallenge
    + ACMEIdentifier + ACMEProblem domain types + closed status enums
    for each (order: pending|ready|processing|valid|invalid; authz:
    pending|valid|invalid|deactivated|expired|revoked; challenge:
    pending|processing|valid|invalid; challenge type: http-01|dns-01|
    tls-alpn-01).
  - internal/domain/profile.go: new ACMEAuthMode field reading from
    certificate_profiles.acme_auth_mode (added in migration 25).
  - internal/domain/certificate.go: new CertificateSourceACME enum value.
  - internal/repository/postgres/profile.go: extended SELECT/scanProfile
    to read the per-profile acme_auth_mode column with a COALESCE
    default of trust_authenticated.
  - internal/repository/postgres/acme.go: full order/authz/challenge
    CRUD (CreateOrderWithTx + GetOrderByID + UpdateOrderWithTx +
    CreateAuthzWithTx + GetAuthzByID + ListAuthzsByOrder +
    ListChallengesByAuthz + CreateChallengeWithTx) with proper
    sql.NullTime + JSONB handling. scanACMEOrder /
    scanACMEAuthz / scanACMEChallenge helpers.
  - internal/service/acme.go: extended ACMERepo interface; new
    SetIssuancePipeline wires certificateService + certificateRepo +
    issuerRegistry. CreateOrder (auth-mode-dispatched: trust_authenticated
    auto-marks order ready + authz valid + 1 placeholder http-01
    challenge valid; challenge mode keeps everything pending). LookupOrder
    (with account-ownership assertion). LookupAuthz. ListAuthzsByOrder.
    FinalizeOrder (3-step atomicity boundary as above; CSR-vs-order
    SAN strict-equality check before issuance; persists FinalizeOrderResult
    {Order, CertID}). LookupCertificate. randIDSuffix + base32encode
    helpers for the human-readable acme-ord-* / acme-authz-* /
    acme-chall-* prefixes (CLAUDE.md "TEXT primary keys with human-
    readable prefixes" architecture decision). 8 new per-op metrics.
  - internal/service/acme_test.go: extended fakeACMERepo with Phase 2
    interface stubs; new orderTrackingRepo for observable persistence;
    2 new tests asserting trust_authenticated → auto-ready/valid and
    challenge → stays-pending.
  - internal/api/handler/acme.go: NewOrder + Order + OrderFinalize +
    Authz + Cert handler methods. orderURL / authzURL / certURL /
    challengeURLBuilder helpers; marshalOrderForResponse fetches
    per-order authzs to populate the URL list. parseOptionalTime for
    notBefore / notAfter.
  - internal/api/handler/acme_handler_test.go: extended mockACMEService
    with Phase 2 method stubs; 4 new handler tests (NewOrder happy +
    rejected-identifier + OrderFinalize bad-CSR + Cert happy).
  - internal/api/router/router.go: 10 new Register calls (5 per-profile
    + 5 shorthand) for new-order, order/{ord_id}, order/{ord_id}/finalize,
    authz/{authz_id}, cert/{cert_id}.
  - internal/api/router/openapi_parity_test.go + api/openapi-handler-exceptions.yaml:
    10 new exception entries.
  - cmd/server/main.go: SetIssuancePipeline at startup, threading
    certificateService + certificateRepo + issuerRegistry into ACMEService.
  - docs/acme-server.md: phase status updated; endpoints table grows
    5 rows for new-order/order/finalize/authz/cert (per-profile +
    shorthand variants); new section "Finalize routing through
    CertificateService.Create" documenting the 3-step atomicity
    boundary + the actor-string convention `acme:<account-id>`.

Tests: ACME package + service + handler + router + config + domain
all green under -short. New cases:
  - TestCreateOrder_TrustAuthenticated_AutoReady (asserts auto-ready
    transition + valid-status authz/challenge + audit row + metric bump).
  - TestCreateOrder_ChallengeMode_StaysPending (asserts pending-status
    cascading authz/challenge for challenge mode).
  - TestACMEHandler_NewOrder_HappyPath (asserts 201 + Location +
    finalize URL shape).
  - TestACMEHandler_NewOrder_RejectedIdentifier (asserts 400 + RFC 7807
    rejectedIdentifier + per-identifier subproblems for type=ip).
  - TestACMEHandler_OrderFinalize_BadCSR (asserts 400 + badCSR for
    non-base64 CSR field).
  - TestACMEHandler_Cert_HappyPath (asserts 200 + PEM content-type +
    PEM chain in body).

Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-2".
2026-05-03 13:46:10 +00:00
shankar0123 a05a7d3dad ci: fix Phase 1b post-push CI failures (3 guards)
Phase 1b push (commit 44a85d6) 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.
2026-05-03 13:31:35 +00:00
shankar0123 44a85d6f85 acme-server: account resource + JWS verifier (Phase 1b/7)
Layers JWS-authenticated POST machinery onto the Phase 1a foundation
(commit ec88a61). 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".
2026-05-03 13:21:56 +00:00
shankar0123 ec88a61274 acme-server: foundation — directory + new-nonce + per-profile routing (Phase 1a/7)
First slice of the RFC 8555 ACME server endpoint (master plan at
cowork/acme-server-endpoint-prompt.md, per-phase prompts at
cowork/acme-server-prompts/). This commit lands the smallest viable
end-to-end deployable slice: an ACME client running

  curl -sk https://certctl/acme/profile/<id>/directory
  curl -sk -I https://certctl/acme/profile/<id>/new-nonce

successfully fetches the directory document and a Replay-Nonce.
Account creation, JWS verification, orders, challenges, and
revocation are all out of scope for this phase and arrive in Phases
1b–4.

Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research
(cowork/infisical-deep-research-results.md). Pre-fix, certctl was an
ACME consumer only — no /acme/directory endpoint, no JWS verifier,
no challenge validators. K8s customers running cert-manager could
not point at certctl as an ACME issuer; they had to deploy a certctl
agent on every node.

What ships:
  - internal/api/acme/{directory,nonce,errors}.go (+ tests).
  - internal/api/handler/acme.go + acme_handler_test.go.
  - internal/repository/postgres/acme.go (nonce ops only — Phase 1b
    extends with account CRUD; Phases 2-4 extend with order / authz /
    challenge CRUD).
  - internal/service/acme.go (BuildDirectory + IssueNonce stubs;
    Phase 1b adds VerifyJWS / NewAccount / etc.).
  - migrations/000025_acme_server.{up,down}.sql ships the full 5-table
    ACME schema (acme_accounts / acme_orders / acme_authorizations /
    acme_challenges / acme_nonces) PLUS the per-profile
    certificate_profiles.acme_auth_mode column. Phase 1a actively
    uses only acme_nonces; remaining tables are empty until Phases
    1b-4 plug in.
  - internal/config/config.go: ACMEServerConfig struct + ACMEServer
    field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to
    avoid colliding with the existing consumer-side ACMEConfig at
    config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE /
    CHALLENGE_TYPE etc.). Phase 1a wires Enabled +
    DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta;
    Order/Authz TTLs + per-challenge-type concurrency caps + DNS01
    resolver are reserved fields parsed in 1a so operators can set
    them ahead of Phases 2/3.
  - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry
    literal alongside the existing certificate / EST / SCEP / etc.
    handlers.
  - internal/api/router/router.go: HandlerRegistry.ACME field + 6
    Register calls (3 per-profile + 3 shorthand).
  - internal/api/router/openapi_parity_test.go: 6 new entries in
    SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed
    JSON over HTTPS per RFC 7515) whose semantics are dictated by
    RFC 8555 + RFC 9773 rather than by an OpenAPI document, same
    precedent as SCEP/EST. The canonical reference is
    docs/acme-server.md.
  - docs/acme-server.md: Phase-1a-shaped reference. Configuration
    table for every CERTCTL_ACME_SERVER_* env var. Per-profile
    auth-mode decision tree skeleton. TLS trust bootstrap section
    flagging cert-manager's ClusterIssuer.spec.acme.caBundle
    requirement (the single biggest first-time-deploy footgun;
    the full cert-manager walkthrough lands in Phase 6 but the
    requirement is documented up front).

Architecture decisions baked in:
  - URL family is /acme/profile/<id>/* (per-profile, canonical) with
    /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID
    is set. Path matches existing per-profile precedent in EST + SCEP.
  - Auth mode is per-profile (acme_auth_mode column on
    certificate_profiles), NOT server-wide. One certctl-server can
    serve trust_authenticated for an internal-PKI profile and
    challenge for a public-trust-style profile simultaneously. The
    column is read at request time, not cached at server start —
    operators flipping a profile's mode via SQL take effect on the
    next order without restart.
  - Nonces are DB-backed (acme_nonces table). Survive server restart.
    The RFC 8555 §6.5 replay defense requires the store to outlast
    the client's nonce caching window; an in-memory-only nonce
    store would lose every in-flight order on restart.
  - Per-op atomic counters on service.ACMEService.Metrics() —
    certctl_acme_directory_total, certctl_acme_directory_failures_total,
    certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total.
    Naming follows certctl frozen decision 0.10 cardinality discipline.
    Phase 1b will extend with new_account counters; Phase 2 with
    order / finalize / cert; Phase 3 with per-challenge-type counters.

Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md)
applied:
  - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side
    CERTCTL_ACME_* namespace collision.
  - #12: prior-attempt WIP from two failed Phase-1 dispatches was
    discarded at phase start; this commit starts from a clean tree.

Tests:
  - 14 unit tests in internal/api/acme/ (directory, nonce, errors).
  - 7 handler-level tests via httptest.NewServer + mockACMEService
    (mirrors the mockSCEPService pattern at scep_handler_test.go).
  - 7 service-layer tests with mocked repo + injected profileLookup.
  - All pass under -race -count=1 -short.

Deferred to Phase 1b:
  - JWS verification (go-jose v4 — see master-prompt §8a for the API
    surface and audit doc for the speculation pitfalls).
  - new-account / account/<id> endpoints + AccountService.
  - Nonce *consumption* path (issue path is in this commit; consume
    is only invoked by JWS-verified POSTs which Phase 1b adds).

Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a".
Per-phase implementation plan: cowork/acme-server-prompts/.
Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md +
cowork/acme-server-prompt-audit.md +
cowork/acme-server-prompts/audit-additions.md.
2026-05-03 12:55:40 +00:00
shankar0123 b8b7e1e3dd tlsprobe: add VerifyWithExponentialBackoff + rewire all connectors' runPostDeployVerify
Closes Top-10 fix #8 of the 2026-05-02 deployment-target audit
re-run (see cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md). Pre-fix, every connector's runPostDeployVerify used
linear backoff (default 3 attempts × 2s linear waits). Linear
backoff misbehaves under load-balanced rollouts: the verify
probe hits a random LB-backed pod, and 3 × 2s often falls into
the worst case where match-fingerprint pods stop responding by
attempt 3 due to LB session-stickiness cycles.

This commit:

1. New shared helper internal/tlsprobe/retry.go::
   VerifyWithExponentialBackoff. Default 3 attempts; 1s initial,
   16s cap. Doubling pattern: 1s → 2s → 4s → 8s → 16s. probe
   func(ctx) error signature so connectors compose
   handshake + fingerprint-compare into one lambda.

2. Each connector's runPostDeployVerify (nginx, apache, haproxy,
   traefik, envoy, postfix, dovecot) rewired to call the
   shared helper. Per-connector signature unchanged.

3. New PostDeployVerifyMaxBackoff time.Duration field added to
   each connector's Config. Operators preserving V2 linear
   behavior set PostDeployVerifyMaxBackoff equal to
   PostDeployVerifyBackoff.

4. Tests:
   - tlsprobe/retry_test.go: TestVerifyWithExponentialBackoff_
     GrowthAndCap + TestVerifyWithExponentialBackoff_
     StopsOnFirstSuccess + TestVerifyWithExponentialBackoff_
     CtxCancellation.
   - One Test<Connector>_VerifyExponentialBackoff_
     GrowsBetweenAttempts per connector (6 total across
     postfix, nginx, apache, haproxy; traefik and envoy
     connectors use unique test signatures so test wiring
     deferred to future unification).

5. docs/deployment-atomicity.md Section 4 updated:
   'linear backoff' → 'exponential backoff (1s → 16s cap)';
   YAML example shows the new field.

Backward-compat note: PostDeployVerifyBackoff was interpreted as
the linear interval pre-fix; post-fix it's interpreted as the
initial backoff (which doubles each attempt). Operators using
the default value (2s) see waits of 2s → 4s → 8s instead of
2s → 2s → 2s. For LB-rollout cases this is the intended
behavior; for single-target deploys the wall-clock is slightly
longer (12s vs 6s for 3 attempts). Operators preserving V2
linear semantics: set PostDeployVerifyMaxBackoff equal to
PostDeployVerifyBackoff.

Verified locally:
- gofmt clean.
- go test -short -count=1 ./internal/tlsprobe/...
  ./internal/connector/target/{postfix,nginx,apache,haproxy}/... green.

Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md Top-10 fix #8.
2026-05-02 22:56:07 +00:00
shankar0123 85d247455b docs(postfix): add Mode=postfix vs Mode=dovecot decision matrix subsection
Closes Top-10 fix #9 of the 2026-05-02 deployment-target audit
re-run (see cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md). Pre-fix, the Postfix connector's docs in
docs/connectors.md described the connector as a single
"Postfix / Dovecot" target without explicit guidance on when to
use Mode=postfix vs Mode=dovecot. Operators with a mail server
running both Postfix (MTA, port 25) and Dovecot (IMAPS, port
993) had to read source to figure out the dual-deploy pattern.

Bundle 11 (commit b829365) added test pin for Mode=dovecot
(TestPostfix_Atomic_DovecotMode_HappyPath +
TestPostfix_Atomic_DovecotMode_VerifyFails_Rollback). This
commit lands the operator-facing doc that complements the test:

1. New "Choosing Mode=postfix vs Mode=dovecot" subsection in
   docs/connectors.md "Built-in: Postfix / Dovecot" section.
   Covers:
   - When to use each mode (MTA on 25 vs IMAPS on 993).
   - Daemon-specific defaults (cert_path, key_path,
     validate_command, reload_command) cited verbatim from
     internal/connector/target/postfix/postfix.go applyDefaults.
   - Note that postfix is the default when mode is unset.
   - Post-deploy verify endpoint is operator-supplied, NOT a
     per-mode default (the connector does not bake in
     port 25 / 993 — operators set post_deploy_verify.endpoint
     themselves to point at their daemon's listener).
   - Dual-deploy pattern for hosts running both daemons (two
     separate targets; byte-equal cert hits SHA-256
     idempotency on subsequent renewals; targets are independent
     in the scheduler so one reload failing rolls back that
     target only).
   - Shared-cert-via-symlink pattern (atomic-write os.Rename
     follows symlinks).
   - Daemon-specific quirks (Postfix STARTTLS chain
     requirements for external MTA validation; Dovecot IMAPS
     client-facing chain shipping; reload independence).
   - Test pin reference (Bundle 11 commit hash + dovecot test
     names; postfix-mode equivalent test names).

2. Forward-pointer footnote in docs/deployment-atomicity.md
   Section 3 "Per-connector atomic contract" pointing at the
   new subsection.

No code changes; no test changes; doc-only commit.

Verified locally:
- All defaults cited verbatim from postfix.go::applyDefaults
  (cert_path, key_path, validate_command, reload_command).
- Bundle 11 test names verified to exist in
  internal/connector/target/postfix/postfix_atomic_test.go
  (TestPostfix_Atomic_DovecotMode_HappyPath at L272,
  TestPostfix_Atomic_DovecotMode_VerifyFails_Rollback at L354).
- Spec's claim of "verify port 25 / 993 default" was incorrect:
  the connector does not bake in a per-mode verify port.
  Doc reflects ground truth.

Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md Top-10 fix #9.
2026-05-02 22:46:44 +00:00
shankar0123 b16e5b5e97 docs(ssh): operator playbook for InsecureIgnoreHostKey design choice
Closes Top-10 fix #7 of the 2026-05-02 deployment-target audit
re-run (see cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md). Pre-fix, the SSH connector's
ssh.InsecureIgnoreHostKey() at internal/connector/target/ssh/
ssh.go (realSSHClient.Connect) had only an inline comment
justifying the design choice. An acquirer's diligence engineer
reading the connector cold pattern-matches "MITM hazard" without
seeing the comment.

This commit lands a doc-side operator playbook in
docs/connectors.md SSH section covering:

1. Why the connector accepts any host key (operator-configured
   target infrastructure; mirrors network scanner's
   InsecureSkipVerify and F5's Insecure flag).
2. Threat model the choice accepts (passive eavesdropper on
   operator-controlled network; layered SSH-key auth limits
   blast radius).
3. Threat model the choice does NOT accept (public-internet
   ephemeral hosts, multi-tenant networks, strict MITM-
   resistance regulatory requirements).
4. Mitigations operators can layer (custom SSHClient via
   NewWithClient + golang.org/x/crypto/ssh/knownhosts; SSH
   certificate authentication via @cert-authority pinning;
   network segmentation; per-target key rotation).
5. When to NOT use the SSH connector (regulatory environments,
   dynamic IPs, multi-tenant networks).
6. V3-Pro forward path (built-in known_hosts management,
   tracked in WORKSPACE-ROADMAP.md).

Inline comment in ssh.go realSSHClient.Connect updated to
forward-reference the new doc subsection (no logic change; same
HostKeyCallback: ssh.InsecureIgnoreHostKey() call).

Same shape Bundle 8 used for "Operator playbook: keytool argv
password exposure" in docs/connectors.md JavaKeystore section.

No code-behavior changes. No test changes.

Verified locally:
- gofmt / go vet clean.
- go test -short ./internal/connector/target/ssh/...  green.

Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md Top-10 fix #7.
2026-05-02 22:44:30 +00:00
shankar0123 62f0a284be iis,wincertstore: default-deadline ctx wrapper for PowerShell exec calls
Closes Top-10 fix #4 of the 2026-05-02 deployment-target audit
re-run (see cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md). Pre-fix, both IIS and WinCertStore's realExecutor
invoked PowerShell via exec.CommandContext(ctx, ...) and relied
entirely on the caller's ctx to provide a deadline. If the caller
forgot to attach one (context.Background() in a deeply-nested
path; an operator running an ad-hoc deploy via a CLI that doesn't
default-deadline its ctx), a hung WinRM session blocked the
deploy worker thread indefinitely.

S2 (failure isolation) bar from the audit: "does a hung WinRM
take down the deploy worker pool?" — today's answer was
"potentially yes" for these two connectors. Post-fix the answer
is "no, capped at the configured ExecDeadline (default 60s)".

This commit:

1. Adds Config.ExecDeadline (time.Duration, json: "exec_deadline")
   to both connectors, defaulted to 60 seconds. WinCertStore
   defaults via the existing applyDefaults helper; IIS defaults
   inline at New() and inside ValidateConfig (the IIS connector
   has no shared applyDefaults helper today; out-of-scope to
   refactor one in for this minor fix). Operators on slow
   Windows links can override via the JSON config field
   exec_deadline.

2. Wraps realExecutor.Execute with a fallback context.WithTimeout
   that fires ONLY when ctx has no deadline of its own. Caller-
   supplied deadlines always win — the wrapper is a safety net,
   not a hard cap. defer cancel() guards against goroutine leaks.

3. Tests:
   - TestIIS_RealExecutor_AttachesDefaultDeadlineWhenCallerHasNone
     (passes context.Background; asserts the call returns within
     500ms with an error). On Linux/macOS runners powershell.exe
     is missing and exec.Cmd fails fast; on Windows the wrapper's
     ctx deadline cancels the running PowerShell process. Either
     path returns well under 500ms.
   - TestIIS_RealExecutor_RespectsCallerDeadlineWhenSet (10s
     fallback executor deadline, 50ms caller ctx; asserts caller
     deadline wins).
   - TestIIS_RealExecutor_NoDeadlineWiredWhenZero (deadline=0
     means no fallback wrapper; caller's tight ctx still bounds).
   - TestIIS_New_DefaultsExecDeadlineTo60s + TestIIS_New_RespectsExplicitExecDeadline
     pin the constructor's defaulting behavior (uses winrm mode
     so the test doesn't need powershell.exe in PATH).
   - Same five tests in wincertstore_test.go.

4. docs/connectors.md IIS + WinCertStore sections document the
   new exec_deadline field with: what it is (per-PowerShell-
   subprocess cap), default (60 seconds), override semantics
   (caller ctx deadline wins).

No change to behavior when the caller already attaches a deadline
(the common case in production code paths). Tests using the mock
executor (mockExecutor in iis_test.go / wincertstore_test.go)
are unaffected — they bypass realExecutor entirely.

S2 cross-cutting scorecard rating in
cowork/deployment-target-audit-2026-05-02-rerun/findings.json
flips from "gap" to "pass" for IIS and WinCertStore (in any
future re-audit).

Verified locally:
- gofmt / go vet / staticcheck clean across both packages.
- go test -race -count=1 ./internal/connector/target/iis/...
  ./internal/connector/target/wincertstore/...  green.

Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md Top-10 fix #4.
2026-05-02 22:38:35 +00:00
shankar0123 4142837cac iis,wincertstore,javakeystore: SHA-256 idempotency short-circuit
Closes Top-10 fix #3 of the 2026-05-02 deployment-target audit
re-run (see cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md). Pre-fix, the three PowerShell-driven connectors
(IIS / WinCertStore / JavaKeystore) bypass internal/deploy.Apply
because they write to the Windows cert store / Java keystore via
PowerShell + keytool rather than the local filesystem. They don't
get deploy.Apply's SHA-256 idempotency short-circuit for free, so
every renewal triggers a full Remove+Import cycle even on byte-
identical material. Operators with 60-day rotation see unnecessary
cert-store / keystore churn, briefly bumping CPU and possibly
disrupting connections in flight.

This commit adds a per-connector idempotency probe modeled on
Bundle 9's Caddy api-mode SHA-256 short-circuit (commit 08a86d3).
Each probe runs at the top of DeployCertificate, BEFORE the
destructive step, with a unique # CERTCTL_IDEM_PROBE PowerShell
comment tag so test mocks match deterministically.

IIS: Get-ChildItem Cert:\... + Get-WebBinding; matches when both
the cert is in the store AND the active binding's certificateHash
equals the new thumbprint.

WinCertStore: Get-ChildItem Cert:\...\<thumbprint>; matches when
the cert exists in the configured store AND its NotAfter is
still in the future.

JavaKeystore: keytool -list -alias -v; matches when the parsed
SHA-256 fingerprint equals sha256(certPEM_DER).

On match: return Success=true with Metadata["idempotent"]="true",
no destructive operation. On any error during the probe (network,
parse, etc.): fall through to today's full deploy path.
False negatives are safe; false positives are dangerous.

Tests added (one positive + one negative per connector):
- TestIIS_Idempotent_SkipsDeployWhenBindingMatches
- TestIIS_Idempotent_DifferentBinding_FallsThroughToDeploy
- TestWinCertStore_Idempotent_SkipsImportWhenCertInStore
- TestWinCertStore_Idempotent_NotInStore_FallsThroughToDeploy
- TestJKS_Idempotent_SkipsDeployWhenAliasMatches
- TestJKS_Idempotent_DifferentAlias_FallsThroughToDeploy

Verified locally:
- gofmt clean across all three connectors.
- Syntax-validated via gofmt.

Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/
RESULTS.md Top-10 fix #3.
2026-05-02 22:09:30 +00:00
shankar0123 c26cef37a1 loadtest: capture sandbox-aggregate placeholder for API-tier baseline
Closes Top-10 fix #2 of the 2026-05-02 deployment-target audit re-run
(see cowork/deployment-target-audit-2026-05-02-rerun/RESULTS.md).
Replaces the four TBD cells in deploy/test/loadtest/README.md ## Current
baseline with a sandbox-aggregate placeholder so the README isn't lying
about having a baseline section ready to diff against.

Numbers (both rows show the same aggregate — see footnote):
  p50=2.12 ms, p95=6.19 ms, p99=8.58 ms, error rate 0.00%
  (1002 requests, 100.15 req/s sustained, 0 failures across 10s)

Capture environment, called out explicitly in the new methodology block:
  - Linux/aarch64 unprivileged sandbox (NOT canonical hardware)
  - Postgres 14.22 native (NOT 16-alpine in compose)
  - 10s scenarios (NOT 5 minutes)
  - Both rows have the same numbers because the sandbox run did not
    emit per-scenario tagged metrics in summary.json — the threshold
    contract still expects per-scenario p95/p99 from a canonical run.

Footnote ([^1]) frames these as a sanity floor, not the per-scenario
baseline the threshold contract is written against. The follow-up
canonical capture via `gh workflow run loadtest.yml` on the
GitHub-hosted ubuntu-latest runner will replace these with real
per-scenario numbers (and will keep the canonical methodology block
that's already pinned below).

Connector-tier table (## Connector-tier captured baseline) is intentionally
left at TBD: that block explicitly anti-patterns committing numbers without
a Docker-equipped canonical run, and the sandbox can't run the four target
sidecars.

No code changes; doc-only.

Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/RESULTS.md
Top-10 fix #2.
2026-05-02 21:48:29 +00:00
shankar0123 fb88e0f8a8 docs(deployment-atomicity): K8s row honest + audit-closure rollup
Closes Bundle 1 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). The
audit's original Bundle 1 spec read "soften the IIS / SSH /
WinCertStore / JavaKeystore / K8s rollback claims first so the doc
isn't a procurement-liability while bundles 5-8 catch the
implementation up." Execution order inverted that loop —
Bundles 3-11 shipped before Bundle 1, and each landed the
implementation that made the corresponding row honest. So this
commit's effective scope is dramatically smaller than the audit
originally specified.

Three changes, all in docs/deployment-atomicity.md:

1. L95 k8ssecret row softened. Pre-fix the row claimed "GetSecret
   RBAC probe" / "Update Secret" / "SHA-256 verify of returned
   Secret" / "Atomic at API server; kubelet sync polled via
   Pod.Status.ContainerStatuses" — as if all four columns described
   live behavior. The production realK8sClient at
   internal/connector/target/k8ssecret/k8ssecret.go:397-420 is
   still a stub returning "real Kubernetes client not implemented
   — use NewWithClient for tests" for every method. Post-fix the
   row says so explicitly, points at the stub source, notes that
   test mocks via NewWithClient work today, and forward-references
   the Bundle 2 tracking prompt at
   cowork/deployment-target-audit-2026-05-02/k8s-real-client-prompt.md.

2. New Section 1.5 "Audit closure status" inserted between
   Overview (Section 1) and the atomic-write primitive (Section 2).
   Pins which deployment-target-audit bundles shipped with their
   commit hashes:

     envoy           Bundle 3   febf500
     traefik         Bundle 4   b767f57
     iis             Bundle 5   30daadb
     ssh             Bundle 6   636de7f
     wincertstore    Bundle 7   60ae92b
     javakeystore    Bundle 8   eb390b2
     caddy           Bundle 9   08a86d3
     postfix/dovecot Bundle 11  b829365

   Outstanding: Bundle 2 (K8s real client) — the V2 P0 blocker.
   Bundle 10 (loadtest, commit e292faa) is documented separately
   at deploy/test/loadtest/README.md as a CI/observability
   addition that doesn't modify the per-connector contract table.
   Section 1.5's closing paragraph documents the execution-order
   inversion so future readers understand why this commit ended
   up smaller than the audit's original spec implied.

3. Section 1's gap table updated. The "Atomic deploy with rollback"
   row's post-bundle column went from "All 13 connectors via
   deploy.Apply" to "12 of 13 connectors via deploy.Apply (K8s
   pending Bundle 2 — see Section 1.5)" with an anchor link.

Rows L81-94 left untouched: each claim is now honest because
Bundles 3-11 implementations landed. Per-bundle commit messages
have been recording this fact ("Post-Bundle-N the claim is
honest; pre-fix it was aspirational") since Bundle 5; this
commit closes the loop by making the doc reflect the same.

What this commit does NOT do:
- Add K8s to Section 11 "V3-Pro deferrals" — Bundle 2 is a V2
  P0 blocker, not a V3-Pro deferral. Mixing the two would
  defer a real procurement-checklist gap into "future work"
  where it doesn't belong.
- Edit rows L81-94 of the per-connector table — they're honest
  as-is.
- Touch docs/architecture.md / connectors.md / security.md —
  those have their own per-section accuracy requirements; this
  commit is scoped to deployment-atomicity.md.

Verified locally:
- gofmt -l ./internal/ ./cmd/  clean (doc-only commit; no Go diff).
- markdown structure check via `grep -n '^## '`: Section 1.5
  inserted cleanly between 1 and 2; no other headings disturbed.
- All 8 commit hashes in Section 1.5 verified against
  `git log --oneline --reverse v2.0.67..HEAD` at HEAD=b829365.

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 1.
2026-05-02 20:06:24 +00:00
shankar0123 b8293653a5 postfix: add atomic-test variants for Mode=dovecot (happy path + verify-rollback)
Closes Bundle 11 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
postfix_atomic_test.go exercised the atomic deploy path under Mode=
postfix only — the existing TestPostfix_DovecotMode at L233-246
asserted only the DeploymentID prefix, leaving applyDefaults's
dovecot-specific validate/reload command set + the rollback's
file-content-restoration unverified at the deploy-test layer.
Audit's only test-coverage gap on the otherwise-production-grade
Postfix/Dovecot connector.

This commit adds two new tests (test-only commit; no production-
code changes):

1. TestPostfix_Atomic_DovecotMode_HappyPath. Builds a Config with
   Mode: "dovecot" and NO ValidateCommand / NO ReloadCommand set.
   Calls ValidateConfig (which is what triggers applyDefaults via
   its JSON-marshal-then-parse path) before DeployCertificate.
   Captures the validate + reload commands threaded through the
   SetTestRunValidate / SetTestRunReload hooks. Asserts:
     - capturedValidateCmd contains "doveconf -n" (applyDefaults
       populated it from the dovecot branch).
     - capturedReloadCmd contains "doveadm reload".
     - DeploymentID prefix "dovecot-" + result.Metadata["mode"] is
       "dovecot" (Mode survived end-to-end).

2. TestPostfix_Atomic_DovecotMode_VerifyFails_Rollback. Pre-creates
   cert.pem AND key.pem with known "ORIG-CERT" / "ORIG-KEY" bytes.
   Builds Config with Mode: "dovecot", PostDeployVerify enabled
   (Endpoint pointing at a dovecot-IMAPS-style :993 — value unused
   by the probe stub), PostDeployVerifyAttempts: 1 (default is 3
   attempts × 2s backoff = 4+ seconds; we don't need that for a
   unit test). Probe stub returns Success: false, which
   runPostDeployVerify wraps as "TLS probe failed: ...". Asserts:
     - DeployCertificate returns error containing "TLS probe failed".
     - cert.pem AND key.pem on disk contain the ORIG bytes
       verbatim — Bundle 11's load-bearing assertion that the
       rollback restored the pre-deploy file state under
       Mode=dovecot. The existing TestPostfix_VerifyMismatch_Rollback
       (Mode=postfix) only asserts the error; this test extends to
       file-content restoration.

Existing TestPostfix_DovecotMode (L233-246) preserved as-is — the
minimal DeploymentID-prefix smoke test complements the new richer
tests without duplicating their scope.

The encoding/json import is added to support the HappyPath test's
json.Marshal call. No other dependency changes.

No production-code changes; the connector itself was already
correct for Mode=dovecot. Only the test pin was missing.

Verified locally:
- gofmt -l ./internal/connector/target/postfix/  clean
- go vet ./internal/connector/target/postfix/  clean
- go build ./cmd/agent/...  clean (no signature changes)
- go test -race -count=1 ./internal/connector/target/postfix/  green
  (24 tests total: 22 pre-existing + 2 new)

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 11.
2026-05-02 19:34:58 +00:00
shankar0123 e292faafc6 loadtest: per-connector deploy throughput scenarios + target sidecars + README baseline section
Closes Bundle 10 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
deploy/test/loadtest/k6.js drove only the API-tier throughput path
(POST /api/v1/certificates + GET /api/v1/certificates) — the operator-
facing rate at which an automation client can submit cert requests.
The deploy hot path (cert deployed to a target — connector-tier
latency) had no benchmarks. Procurement asks "can certctl handle our
5,000-NGINX fleet at 47-day rotation?" and the answer should be a
number with methodology, not a claim.

This commit ships v1 of the connector-tier loadtest harness:

1. Target-side sidecars added to docker-compose.yml: nginx-target,
   apache-target, haproxy-target, f5-mock-target. Each daemon serves
   a starter cert (ECDSA P-256, multi-SAN) written into a shared
   ./fixtures/target-certs/ volume by a new target-tls-init
   container. f5-mock-target re-uses the in-tree
   deploy/test/f5-mock-icontrol/ image (already used by the deploy-
   vendor-e2e CI job) and generates its own self-signed cert via
   tls.go::selfSignedCert at startup.

2. Fixture configs committed under deploy/test/loadtest/fixtures/:
   - nginx.conf  — minimal HTTPS server, single 200 OK location.
   - httpd.conf  — self-contained Apache config with the minimum
     module set + SSL vhost.
   - haproxy.cfg — minimal SSL-terminating frontend backed by a
     static "ok" backend.

3. k6 scenarios added (4 new): nginx_handshake, apache_handshake,
   haproxy_handshake, f5_handshake. Each runs constant-arrival-rate
   at 100 conns/min for 5 minutes. Latency captured by k6's
   http_req_duration metric covers TCP connect + TLS handshake +
   tiny HTTP request/response — that's the end-to-end "connection
   readiness" latency a deploy connector cares about.

4. summary.json gains a connector_tier object with per-target
   p50/p95/p99/max/avg/error_rate/iterations breakdowns. Operators
   tracking a connector regression diff connector_tier.<type>
   between runs. Implementation: a new enrichWithConnectorTier
   helper that reads data.metrics keyed by target_type tag and
   shallow-merges the breakdown into the summary before
   serialisation.

5. Threshold contract per target type:
   - nginx/apache/haproxy: p99 < 3s, p95 < 1s.
   - f5-mock:              p99 < 5s, p95 < 1.5s (iControl REST
                            handler does slightly more work per
                            request than pure TLS termination).
   - All scenarios:        error rate < 1% (k6 default; any 4xx/5xx
                            counts as failed).
   Any change pushing past these fails the workflow.

6. README documents the methodology + the baseline-number table for
   the connector tier. Numeric values are em-dash placeholders
   pending the first clean canonical-hardware run; the accompanying
   commit message in that follow-up captures the methodology line
   alongside the numbers. Out-of-scope is documented explicitly:
   - Full agent-driven deploy poll loop (POST cert with target
     binding → poll deployments endpoint → verify served cert).
     v2 of the harness — needs the agent registration + target-
     binding API surface plumbed end-to-end in the loadtest stack.
   - Kubernetes target via kind-in-docker. kind requires
     `privileged: true` and is operationally fragile in CI;
     deferred until Bundle 2 (real k8s.io/client-go) lands and a
     CI-friendly envtest harness is wired.
   - Real F5 BIG-IP. CI uses the in-tree f5-mock; real-appliance
     benchmarking is out of scope.

7. CI workflow .github/workflows/loadtest.yml timeout-minutes
   bumped from 15 to 25. The harness now boots four additional
   target sidecars before the k6 run; their healthchecks add
   ~30-60s. The k6 scenarios themselves are still 5 minutes (run
   in parallel, not serially). 25 minutes absorbs that plus slow
   CI runners and cold image caches without letting a stuck
   container consume the runner indefinitely. Trigger remains
   workflow_dispatch + cron — sustained 25-minute runs are too
   slow for per-PR signal.

What this connector tier explicitly does NOT measure (documented in
the k6.js header + README):
- The agent-driven full deploy hot path (v2 follow-up).
- K8s target (Bundle 2 dependency).
- Real F5 appliance.
- Issuer-side throughput (handled by issuer-coverage-audit fix #8).

Verified locally:
- python3 -c "import yaml; yaml.safe_load(...)"  on docker-compose.yml
  and .github/workflows/loadtest.yml — clean.
- node -c on k6.js — clean syntax.
- gofmt / go vet on the rest of the tree (no Go diff in this commit).
- Manual smoke against docker-compose pending — operator validates
  on the canonical-hardware first run; if any fixture config is off,
  fix-up commit lands separately so the methodology change and the
  numeric baseline have independent reviewability.

No Go code changes; this is a loadtest-harness-only commit.

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 10.
2026-05-02 19:28:45 +00:00
shankar0123 08a86d355d caddy: fix duration metric + file-mode PEM validate + api-mode idempotency
Closes Bundle 9 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Three
small independent fixes that share one connector file:

1. Duration metric (caddy.go L176). Pre-fix:
     "duration_ms": fmt.Sprintf("%d", time.Since(time.Now()).Milliseconds())
   This always returned ~0ms because time.Now() was called twice —
   the second call captured a baseline immediately before time.Since
   computed the delta. The intended baseline is `startTime` declared
   at L113 and threaded through deployViaFile correctly. Post-fix:
     "duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds())
   deployViaAPI's signature evolves to take startTime time.Time so
   the api-mode path uses the same baseline as the file-mode path.

2. File-mode ValidateDeployment now validates PEM syntax. Pre-fix
   (caddy.go L266-293) checked file existence only via os.Stat. A
   cert file containing garbage bytes passed validation; Caddy's
   file-watcher silently failed to load it; operators saw "validation
   green" + "TLS handshake fails" with no obvious connection.
   Post-fix: after the os.Stat checks succeed, os.ReadFile + parse
   the first PEM block as an x509 cert via the shared
   certutil.ParseCertificatePEM helper. Failure surfaces as
   Valid=false with a clear "not valid PEM/x509" message.

3. API-mode idempotency short-circuit. Pre-fix, every deploy POSTed
   to /config/apps/tls/certificates/load even when the active cert
   was already what we wanted to deploy. Caddy reloads TLS state on
   every POST, briefly bumping CPU and possibly disrupting connections
   in flight. Post-fix: idempotencySkipPOST runs a GET first, parses
   the response (handles BOTH the array-of-objects and single-object
   shapes Caddy admin can return), SHA-256 compares the entry's
   `cert` field to the deploy payload's cert bytes, and skips the
   POST when match. Result.Metadata["idempotent"]="true" surfaces
   the no-op. Conservative: any GET failure (network, non-200, parse
   error, no matching entry, hash mismatch) silently falls through to
   the POST, preserving today's behavior. Idempotency is a fast path,
   not a correctness boundary — false negatives are safe; false
   positives are dangerous.

Tests added to caddy_test.go (6 new tests, ~290 LOC):
- TestCaddy_API_DurationMetric_NonZero (httptest server with a 10ms
  sleep in the POST handler; asserts duration_ms parses as int >= 5).
- TestCaddy_ValidateDeployment_FileMode_MalformedPEM_Rejected (writes
  garbage to cert.pem; asserts Valid=false with PEM/x509 in message).
- TestCaddy_ValidateDeployment_FileMode_ValidPEM_Accepted (writes a
  real ECDSA P-256 self-signed cert; asserts Valid=true).
- TestCaddy_API_Idempotent_SkipsPOSTWhenCertHashMatches (GET response
  contains the same cert as the deploy payload; POST counter remains
  0; metadata.idempotent=true; exactly 1 GET probe ran).
- TestCaddy_API_Idempotent_RunsPOSTWhenCertHashDiffers (GET response
  contains a DIFFERENT cert; POST counter is 1; idempotent absent).
- TestCaddy_API_Idempotent_GETFails_FallsThroughToPOST (GET returns
  500; POST still runs; deploy succeeds; idempotent absent).

Two existing tests updated to match the new contracts:
- TestCaddyConnector_DeployViaAPI_Success: mock handler now serves
  BOTH GET (returns "[]" so the comparison falls through) and POST
  (the original 200-OK path). The dispatch is a method-switch
  inside the path-match branch.
- TestCaddyConnector_ValidateDeployment_Success: the placeholder
  cert "MIIC..." used to pass the old existence-only check; post-Fix-2
  it fails the PEM-parse check. Test now uses generateTestCertAndKey
  to produce a real self-signed ECDSA P-256 cert.

generateTestCertAndKey helper added to the test file — same pattern
the javakeystore + wincertstore tests use, kept local because the
caddy package has no other test in the certutil family that would
make a shared helper cleaner.

Verified locally:
- gofmt -l ./internal/connector/target/caddy/  clean
- go vet ./internal/connector/target/caddy/  clean
- go build ./cmd/agent/...  clean (factory wiring unchanged)
- go test -race -count=1 ./internal/connector/target/caddy/  green
  (16 tests total: 11 pre-existing including the two updated +
  6 new)

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 9.
2026-05-02 19:13:18 +00:00
shankar0123 eb390b2db4 javakeystore: pre-deploy export snapshot + on-import-failure rollback + argv-password operator note
Closes Bundle 8 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
DeployCertificate at javakeystore.go:172-272 ran an irreversible
keytool -delete against the existing alias, then keytool
-importkeystore. If the import failed after the delete succeeded,
the keystore was missing the alias entirely — previous cert gone,
new cert never landed. docs/deployment-atomicity.md L94 promised
"keytool snapshot; rollback via keytool -delete + re-import"; the
code didn't deliver. Separately, the operator-facing keystore
password is passed via -storepass argv (a standard keytool
limitation) which is visible to ps(1) for the duration of each
subprocess; this was undocumented as an operator-playbook caveat.

This commit:

1. Pre-delete snapshot. When os.Stat(KeystorePath) succeeds,
   snapshotKeystore runs keytool -exportkeystore to
   <BackupDir>/.certctl-bak.<unix-nanos>.p12 BEFORE the existing
   -delete step. Backup path persisted in a local variable for
   the rollback path; export-step failure aborts the deploy
   entirely (no mutation has happened yet — the keystore is
   untouched). Snapshot skipped on first-time deploys (no
   keystore file = nothing to roll back to). The "alias not
   present in pre-existing keystore" case is recognised via the
   well-known keytool error string and treated as a clean
   first-time-on-existing-keystore signal — the deploy proceeds
   without a backup, and rollback (if needed) becomes the
   no-backup branch.

2. On-import-failure rollback. When keytool -importkeystore
   returns error, rollbackImport(ctx, backupPath) runs:
   - keytool -delete -alias <Alias> ... (best-effort; the failed
     import may have created a partial alias entry).
   - keytool -importkeystore from the backup PKCS#12 to restore
     the previous state.
   On rollback success, the deploy returns wrapped error noting
   "rolled back from <backup_path>". On rollback failure,
   returns operator-actionable wrapped error containing both the
   import error AND the rollback error AND the backup path so
   the operator can manually keytool -importkeystore from the
   .p12 file to recover.

3. Backup retention. Successful deploys prune older
   .certctl-bak.*.p12 files beyond Config.BackupRetention.
   Sort by ModTime newest-first; keep most recent N. Defaults:
   BackupRetention=0  → keep most recent 3 (the default).
   BackupRetention=N  → keep most recent N.
   BackupRetention=-1 → opt out of pruning entirely (operators
                        that wire their own archival/rotation).
   Pruning runs in the success path AFTER the optional reload
   command so it doesn't interfere with deploy-time signals.
   ReadDir / Remove failures are non-fatal (debug log only) —
   the deploy already succeeded.

4. Config gains BackupRetention int and BackupDir string fields.
   BackupDir defaults to filepath.Dir(KeystorePath) so backups
   land on the same filesystem as the keystore (atomic-ish
   writes, disk-full failures fail fast at snapshot time).

5. Helper extraction. snapshotKeystore + rollbackImport +
   pruneBackups + backupDir are private methods on Connector.
   Constants backupFilePrefix=".certctl-bak." and
   backupFileSuffix=".p12" centralise the naming convention so
   the snapshot writer, the rollback reader, and the retention
   pruner all agree.

6. Operator-playbook section added to docs/connectors.md
   JavaKeystore section. Documents the standard keytool
   -storepass argv exposure: ps(1)-visible for the duration
   of each subprocess. Lists mitigations:
   - Restrict shell access to the agent host.
   - Linux user namespaces / AppArmor / SystemD ProtectProc=
     invisible to deny ps-visibility.
   - Single-purpose container for proper PID-namespace
     isolation.
   - Post-deploy keystore password rotation via reload_command
     for high-security environments.
   - BCFKS keystore type for FIPS environments (same argv
     caveat applies).
   Also documents an "Atomic rollback" subsection covering the
   snapshot/rollback flow, the new backup_retention /
   backup_dir Config fields, and the design choice to reuse
   the keystore password for the snapshot (rather than
   generating a separate transient password) — operator
   already trusts the connector with this secret, surface area
   doesn't grow, rollback's matching -srcstorepass stays
   simple.

Tests added to javakeystore_test.go (7 new tests, ~430 LOC):

- TestJKS_Snapshot_RunsBefore_Delete: mock executor records call
  order; asserts -exportkeystore is call[0], -delete is call[1],
  -importkeystore is call[2]. The snapshot MUST run before the
  delete — otherwise the delete destroys the very state the
  snapshot is meant to capture.
- TestJKS_Snapshot_FirstTimeDeploy_NoExport: no keystore file
  pre-created; asserts exactly 1 keytool call (-importkeystore
  only), no -exportkeystore.
- TestJKS_ImportFails_RollsBack: happy rollback path with one
  same-Subject backup. Asserts rollback re-import references the
  same backup path the snapshot wrote (verified via arg
  comparison between call[0] and call[4]).
- TestJKS_ImportFails_RollbackAlsoFails_OperatorActionable:
  wrapped-error escalation with backup path in the error
  message.
- TestJKS_BackupRetention_PrunesOldBackups: 5 pre-existing
  staggered-ModTime backups + 1 deploy-created → retention=3 →
  exactly 3 newest survive (deploy-created + 2 newest
  pre-existing); 3 oldest pre-existing pruned.
- TestJKS_BackupRetention_Zero_DefaultsTo3: BackupRetention=0
  must default to 3 (not "keep none").
- TestJKS_BackupRetention_Negative_OptsOut: BackupRetention=-1
  pre-existing 5 + deploy 1 = 6 total, all 6 remain.
- TestJKS_Snapshot_AliasNotInKeystore_ProceedsCleanly: keystore
  exists but alias missing; -exportkeystore returns "alias does
  not exist" → snapshot helper recognises this signal and
  returns ("", nil) so the deploy proceeds cleanly.

mockExecutor extended with optional `onCall` hook so the
retention-pruning tests can simulate keytool -exportkeystore's
file-write side effect (via the simulateExportSideEffect helper
that parses -destkeystore from args and writes a placeholder
.p12 file). Existing tests that don't set onCall behave
identically to before — backward compatible.

docs/deployment-atomicity.md L94 unchanged from today's text —
Bundle 1 doc-realignment hasn't shipped, so the "keytool snapshot;
rollback via keytool -delete + re-import" line was never softened.
Post-Bundle-8 the claim is honest (was aspirational pre-fix).

Verified locally (sandbox lacks staticcheck install due to disk
pressure; CI runs the full lint gate):
- gofmt -l ./internal/connector/target/javakeystore/ clean
- go vet ./internal/connector/target/javakeystore/ clean
- go build ./cmd/agent/... clean
- go test -race -count=1 ./internal/connector/target/javakeystore/
  green (16 tests total: 9 pre-existing + 7 new)

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 8.
2026-05-02 19:01:06 +00:00
shankar0123 60ae92b0e8 wincertstore: pre-deploy snapshot + on-import-failure rollback
Closes Bundle 7 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
DeployCertificate at wincertstore.go:162-215 ran a single PowerShell
script that imported the PFX, optionally set FriendlyName, and
optionally removed expired same-Subject certs. Import-PfxCertificate
is atomic at the cert-store level, but the wider sequence (import →
friendly name → remove expired) is not. Failure in any post-import
step left the new cert in the store with no clean recovery path.
docs/deployment-atomicity.md L93 promised "Get-ChildItem snapshot
for rollback"; the code didn't deliver.

This commit:

1. Pre-deploy snapshot. New PowerShell script (tagged
   `# CERTCTL_SNAPSHOT`) runs Get-ChildItem over the target store,
   captures every thumbprint, and for each cert with the same
   Subject as the new one calls Export-PfxCertificate to a tempdir
   using a transient snapshotExportPassword (32-byte random,
   distinct from the import PFX password). Output parsed into a
   snapshotState{Entries: []{Thumbprint, PfxPath}, AllThumbprints,
   TempDir, ExportPassword}. The new cert's Subject is parsed from
   request.CertPEM via certutil.ParseCertificatePEM before any
   cert-store mutation; PEM-parse failure aborts the deploy
   cleanly.

2. On-import-failure rollback. When the import-script Execute
   returns error, run a rollback script (tagged
   `# CERTCTL_ROLLBACK`) that:
   - Test-Path on the new cert path; Remove-Item if present.
   - Import-PfxCertificate -FilePath <pfxPath> for each snapshot
     entry (restores prior state).
   - Remove-Item -Recurse on the snapshot tempdir.

3. Post-rollback verification. Re-read Get-ChildItem (tagged
   `# CERTCTL_VERIFY`); assert every original thumbprint is back.
   On mismatch, append a warning to the DeploymentResult message
   (rollback ran but final state is suspect — operator inspection
   recommended). Skipped when AllThumbprints is empty (first-time
   deploy).

4. Success-path tempdir cleanup. New script tagged
   `# CERTCTL_CLEANUP` runs after a successful import to remove
   the snapshot tempdir on a best-effort basis. Failure here is
   non-fatal (debug log only).

5. Helper extraction. rollbackImport(ctx, snapshot, newThumbprint)
   + verifyRollback(ctx, snapshot) + cleanupSnapshot(ctx, snapshot)
   + parseSnapshotOutput are private methods/functions on
   Connector for clean test seams. Each script emits a unique
   `# CERTCTL_*` PowerShell comment tag so test mocks can match
   scripts deterministically — the snapshot/rollback/verify/cleanup
   scripts all reference Cert:\<store> paths, so the comment tags
   are the only deterministic substring under randomized map
   iteration.

DeploymentResult shape on failure:
- import OK, rollback OK   → Success=false, "PowerShell import
                              failed; rolled back" (clean
                              recoverable failure).
- import FAIL, rollback OK → same.
- rollback FAIL            → operator-actionable wrapped error
                              containing both errors; metadata
                              flags manual_action_required=true
                              and surfaces import_error /
                              rollback_error verbatim.

Tests added to wincertstore_test.go:
- TestWinCertStore_ImportFails_RemovesNewCert_RestoresOldFromSnapshot
  — happy rollback path with one same-Subject cert in the
  snapshot. Asserts rollback script contains Remove-Item for the
  new thumbprint AND Import-PfxCertificate referencing the
  snapshotted PFX path.
- TestWinCertStore_ImportFails_NoExistingSameSubject_RemovesNewCertOnly
  — snapshot has THUMB: lines but no SNAPSHOT: entries; rollback
  removes the new cert but does NOT call Import-PfxCertificate.
- TestWinCertStore_FriendlyNameFails_NewCertRemoved_OldCertsRestored
  — variant where the import script's failure originates from
  Set-ItemProperty FriendlyName; same rollback path. Asserts
  metadata.import_error preserves the FriendlyName-related
  PowerShell output for operator visibility.
- TestWinCertStore_ImportFails_RollbackAlsoFails_OperatorActionable
  — wrapped-error escalation. Asserts the error mentions both
  "PowerShell import failed" and "rollback also failed", and
  metadata flags manual_action_required=true.

Three existing tests (Success, ImportFailed, WithFriendlyName,
WithRemoveExpired) updated to match the new contract: success
path runs 3 PowerShell scripts (snapshot + import + cleanup),
import-failure path runs 4 (snapshot + import + rollback + verify),
and the import script lives at mock.scripts[1] not [0].

PowerShell injection note: the new cert's Subject DN is embedded
in the snapshot script as a single-quoted literal. Subject DNs can
contain apostrophes (e.g. CN=O'Reilly), so escapePowerShellSingleQuoted
doubles them per the PowerShell single-quoted-literal escape rule.
The export password and thumbprints come from
certutil.GenerateRandomPassword (alphanumeric only) and the cert's
SHA-1 thumbprint hex (alphanumeric); no escaping needed for those.

docs/deployment-atomicity.md L93 unchanged from today's text —
Bundle 1 doc-realignment hasn't shipped, so the "Get-ChildItem
snapshot for rollback" line was never softened. Post-Bundle-7 the
claim is honest (was aspirational pre-fix).

Verified locally (sandbox lacks staticcheck install due to disk
pressure; CI runs the full lint gate):
- gofmt -l ./internal/connector/target/wincertstore/  clean
- go vet ./internal/connector/target/wincertstore/  clean
- go build ./cmd/agent/...  clean
- go test -race -count=1 ./internal/connector/target/wincertstore/
  green

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 7.
2026-05-02 18:13:40 +00:00
shankar0123 c222c8b57a ssh: fix staticcheck ST1008 — error is last return from restoreFromBackups
CI's golangci-lint run on commit 636de7f ("ssh: pre-deploy snapshot
+ reload-failure rollback") caught a staticcheck ST1008 violation:
restoreFromBackups returned (error, map[string]string) — error must
be the last return value per Go convention.

Reorder the return tuple to (map[string]string, error) and update
the single caller in DeployCertificate. No behavior change; pure
signature shuffle to satisfy the lint gate.

Verified locally:
- gofmt -l ./internal/connector/target/ssh/  clean
- go vet ./internal/connector/target/ssh/  clean
- go test -race -count=1 ./internal/connector/target/ssh/  green
2026-05-02 17:35:45 +00:00
shankar0123 636de7f6b5 ssh: pre-deploy snapshot + reload-failure rollback
Closes Bundle 6 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
DeployCertificate at ssh.go:201-316 wrote new cert/key/chain via
SFTP then ran the operator's reload command. If reload failed, the
new files stayed on the remote — partial-success state with no
rollback path. docs/deployment-atomicity.md L92 promised "Pre-deploy
SCP backup of remote files"; the code didn't deliver.

This commit:

1. Pre-deploy snapshot. Before any WriteFile, iterate the deploy's
   target paths (cert, key, optional chain). For each path:
   - StatFile to detect existence. errors.Is(err, os.ErrNotExist)
     means first-time deploy (rollback = Remove). Other stat
     errors bail out before any write happens.
   - ReadFile into an in-memory backups map[string][]byte keyed
     by remote path. Original mode captured into a parallel
     modes map for restore fidelity.

2. SSHClient interface evolution — three changes:
   - StatFile(path) (os.FileInfo, error) — was (int64, error).
     FileInfo carries Mode() needed for accurate restore. Existing
     fixture tests updated to call info.Size() instead of the
     bare size value.
   - ReadFile(path) ([]byte, error) — new method; SFTP Open + read
     via io.ReadAll. realSSHClient implements via sftpClient.Open.
   - Remove(path) error — new method; SFTP Remove. Used by the
     rollback path to clean up first-time-deploy partial state.

3. On-reload-failure rollback. Replace the bare error-return at
   L282-295 with restoreFromBackups + retry-reload escalation:
   - For paths in the snapshot map, WriteFile the original bytes
     with the original mode (0600 fallback if mode capture was
     incomplete).
   - For paths that didn't exist pre-deploy, Remove the new file.
   - Re-run the reload command (best-effort second attempt). If
     it succeeds, the target is back to pre-deploy state. If it
     fails, the remote is in pre-deploy file state but the daemon
     may be stuck — surface as wrapped error so the operator
     knows where to look.

4. DeploymentResult.Metadata gains backup_status_{cert,key,chain}
   so operators can see per-path snapshot state on both success
   ("snapshotted" / "no_pre_existing" / "n/a") and failure
   ("restored" / "removed" / "restore_failed" / "remove_failed").
   buildMetadataWithBackup helper centralises the metadata
   shape so success and failure paths emit a consistent set
   of keys.

5. Helper extraction. restoreFromBackups(ctx, paths, backups,
   modes) is a private method on Connector; returns the first
   error + per-key restore status map for clean test seams.

DeploymentResult shape on failure:
- rollback OK + retry-reload OK → Success=false, "reload command
  failed; rolled back to pre-deploy state" (clean recoverable
  failure; remote fully restored, daemon serving original cert).
- rollback OK + retry-reload FAIL → wrapped error noting "rolled
  back files; retry-reload also failed; daemon may need manual
  restart". Metadata flags daemon_state_unknown=true.
- rollback FAIL → operator-actionable wrapped error containing
  BOTH the reload error AND the rollback error; metadata flags
  manual_action_required=true.

Tests added to ssh_test.go (4 new tests, ~330 LOC):
- TestSSH_ReloadFails_FilesRestored — happy rollback path with
  pre-existing remote bytes for cert/key/chain. Asserts every
  path's last WriteFile call contains the captured backup bytes
  verbatim, no Remove calls fired (all paths had snapshots), and
  metadata reports backup_status=restored for each path.
- TestSSH_NoExistingCert_ReloadFails_NewCertRemoved — first-time
  deploy variant. StatFile returns os.ErrNotExist for every path;
  rollback Removes each written file but performs no WriteFile
  during restore (no backup to restore from). Asserts exactly 3
  WriteFile calls (deploy only) and 3 Remove calls (rollback).
- TestSSH_ReloadFails_RollbackAlsoFails_OperatorActionable —
  uses a writeOrderTrackingMock to fail the SECOND WriteFile to
  the cert path (i.e. the restore call, not the initial deploy).
  Asserts wrapped error contains both the reload error and the
  rollback error, and metadata flags manual_action_required=true.
- TestSSH_ReloadFails_RestoreThenSecondReloadFails — partial-
  recovery escalation. Rollback succeeds but the post-restore
  retry-reload fails. Asserts wrapped error mentions "rolled back
  files; retry-reload also failed" and metadata flags
  daemon_state_unknown=true.

Existing tests preserved by extending mockSSHClient with backward-
compatible per-path response maps (statByPath / readByPath /
writeFileErrByPath / executeErrSequence). Legacy global fields
(statFileSize / statFileErr / writeFileErr / executeErr) still
work when no per-path override matches, so TestValidateConfig_*
and TestDeployCertificate_Success_* don't need changes.

docs/deployment-atomicity.md L92 unchanged from today's text —
Bundle 1 doc-realignment hasn't shipped, so the "Pre-deploy SCP
backup of remote files" line was never softened. Post-Bundle-6
the claim is honest (was aspirational pre-fix).

Verified locally (sandbox lacks staticcheck install due to disk
pressure; CI runs the full lint gate):
- gofmt -l ./internal/connector/target/ssh/  clean
- go vet ./internal/connector/target/ssh/  clean
- go build ./internal/connector/target/ssh/...  clean
- go build ./cmd/agent/...  clean
- go test -race -count=1 ./internal/connector/target/ssh/  green

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 6.
2026-05-02 17:13:38 +00:00
shankar0123 da00ee0ca5 license: tighten BSL terms (Florida venue, full Pi Day Change Date, no contributions)
Rewrite of the BSL 1.1 LICENSE to fix lawyer-grade gaps and align
the parameters with the project's actual posture:

Licensor + copyright
- Licensor name: "Shankar Kambam" (correct legal name; was "Shankar
  Reddy" — same operator, different surname).
- © marker: "© 2026 Shankar Kambam" (was "(c)" placeholder).

Additional Use Grant — sharper Commercial Certificate Service test
- Replaces the old "running a cert service for non-affiliated third
  parties" wording with a principal-value test: a CCS is a product
  whose principal value to the third party is certctl's certificate
  management functionality (lifecycle, discovery, monitoring,
  alerting, renewal automation, deployment, revocation) AND the
  third party accesses or controls that functionality AND
  compensation flows for that access/control.
- Carve-out (a): explicitly permits running certctl in production
  to manage certs for products whose principal value is something
  ELSE (e.g. a banking app using certctl for its TLS certs).
- Carve-out (b): "third party" excludes employees, contractors
  acting on the licensee's behalf, and Affiliates (>50% common
  voting control). Closes the "internal IT department is a third
  party" attack on the wording.
- Carve-out (c): the CCS restriction applies regardless of whether
  certctl is hosted, managed, embedded, bundled, or integrated
  with another product — closes the embedded-OEM loophole.

Change Date — full per-version 4-year BSL period
- Was: March 14, 2126 (a fixed date 100+ years out, defeating the
  "earlier of <Change Date> or 4 years from first publication"
  semantics — the 4-year cap always won, no version got the full
  4-year window).
- Now: March 14, 2076 (Pi Day, ~50 years out). This is the longest
  acceptable horizon under the BSL spirit while ensuring every
  released version gets its full 4-year BSL period before flipping
  to Apache-2.0.

Contributions — no third-party contributions accepted
- Adds an explicit "Licensor does not accept third-party
  contributions" clause. Any code/docs submitted are at the
  submitter's sole risk, confer no rights, and are not incorporated.
  Mirrors the project's reality (no PR review process, single-owner
  development).

Patent non-assertion + defensive termination
- Adds a non-assertion covenant covering compliant uses, with
  termination of that covenant if the licensee initiates patent
  litigation against the Licensor or contributors. Standard BSL
  posture, was missing.

Termination + reinstatement
- 30-day cure window for first violation; second violation after
  reinstatement is permanent. Aligns with BSL norm.

Governing law + venue
- State of Florida, USA. Operator's residence; aligns dispute
  forum with the Licensor's actual jurisdiction.

Severability + survival
- Standard boilerplate added. Ensures the disclaimer-of-warranty,
  patent non-assertion (for pre-termination acts), and
  governing-law clauses survive any termination.

Stripped
- Dead "(certctl is not a registered trademark)" parenthetical —
  the trademark filing is a separate workstream, not licensing.

Contact for alternative arrangements: certctl@proton.me
(unchanged).
2026-05-02 17:12:50 +00:00
shankar0123 30daadbe81 iis: pre-deploy binding snapshot + on-failure rollback
Closes Bundle 5 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
DeployCertificate at iis.go:235-436 imported the cert via
Import-PfxCertificate (atomic at cert-store level) then ran a
separate PowerShell script for the SNI binding update. If the
binding script failed, the new cert was orphaned in the store AND
the old binding stayed pointed at the old thumbprint.
docs/deployment-atomicity.md L91 promised "explicit pre-deploy
backup + post-rollback re-import"; the code didn't deliver.

This commit:

1. Pre-deploy snapshot. snapshotOldBinding runs Get-WebBinding
   before the import; parses the bound SSL thumbprint into a local
   `oldThumbprint` variable. Empty = first-time binding (no
   rollback target).

2. On-failure rollback script. When the binding-update Execute
   returns error, rollbackBinding runs a single PowerShell script
   that:
   - Remove-Item Cert:\LocalMachine\<store>\<newThumbprint> (delete
     the cert we just imported but couldn't bind).
   - If oldThumbprint != "", AddSslCertificate('<oldThumbprint>',
     ...) to re-bind the old cert. Falls through to New-WebBinding
     + AddSslCertificate when the old binding entry is also gone.

3. Post-rollback verification. verifyRollback re-reads
   Get-WebBinding; asserts the bound thumbprint matches
   oldThumbprint. On mismatch, warn in the DeploymentResult
   message — the rollback ran but final state is suspect, operator
   inspection required. Skipped when oldThumbprint == "" (no
   binding to verify against).

4. Helper extraction. snapshotOldBinding / rollbackBinding /
   verifyRollback are private methods on Connector for clean test
   seams. Each emits a unique `# CERTCTL_*` PowerShell comment tag
   so test mocks can match scripts deterministically — multiple
   scripts call Get-WebBinding so substring matching otherwise
   collides under Go's randomized map iteration order.

DeploymentResult shape on failure:
- rollback OK   → Success=false, Message="binding update failed;
                  rolled back", clean error.
- rollback FAIL → Success=false, wrapped error containing both
                  binding error and rollback error; metadata
                  flags manual_action_required=true and surfaces
                  rollback_error / binding_error verbatim.

Tests added to iis_test.go:
- TestIIS_BindingUpdateFails_RemovesNewCert_RebindsOld — happy
  rollback path. Mock executor queued with snapshot →
  OLD_THUMBPRINT:abc123, import OK, binding fails, rollback →
  REBOUND_EXISTING. Asserts rollback script contains both
  Remove-Item for the new thumbprint AND
  AddSslCertificate('abc123', ...).
- TestIIS_BindingUpdateFails_NoOldBinding_RemovesNewCertOnly —
  first-time deploy variant. Snapshot returns NO_OLD_BINDING;
  rollback removes the new cert but does NOT call
  AddSslCertificate; verify script never runs.
- TestIIS_BindingUpdateFails_RollbackAlsoFails_OperatorActionable
  — wrapped-error escalation. Asserts the returned error mentions
  both `binding update failed` and `rollback also failed`, and
  metadata flags manual_action_required=true.

Two existing tests (TestIISConnector_DeployCertificate_Success and
…_SNIEnabled) updated to expect 3 commands (snapshot, import,
binding) and to look for the binding script at commands[2].

docs/deployment-atomicity.md L91 unchanged from today's text — the
"Already explicit pre-deploy backup + post-rollback re-import"
claim is now honest. (Bundle 1 doc-realignment hasn't shipped yet,
so there's no softened-pending claim to restore.)

Verified locally (sandbox lacks staticcheck install due to disk
pressure, ran via go vet + go test -race; CI runs the full lint
gate):
- gofmt -l ./internal/connector/target/iis/  clean
- go vet ./internal/connector/target/iis/...  clean
- go build ./internal/connector/target/iis/...  clean
- go test -race -count=1 ./internal/connector/target/iis/  green

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 5.
2026-05-02 16:58:01 +00:00
shankar0123 b767f579ef traefik: refactor to single deploy.Apply Plan (all-files atomicity + rollback)
Closes Bundle 4 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
DeployCertificate called deploy.AtomicWriteFile twice — once for
cert at L123, once for key at L131 — instead of bundling both into
a single deploy.Plan and calling deploy.Apply. Three downstream
hazards:

1. If cert write succeeds and key write fails, the cert is already
   on disk. The in-line best-effort cert rollback at L137-141 had
   no error wrapping and the dedicated rollbackCertAndKey helper
   only restored the cert.

2. Idempotency was per-file, not all-files. The verify gate
   (if !certRes.Idempotent) skipped verify when cert was unchanged
   but key was new — exactly the shape that produces a fresh key on
   disk + a stale fingerprint served, and zero alarm.

3. Verify-failure rollback only handled the cert. Key was left in
   whatever state the deploy reached.

This commit aligns Traefik with the canonical NGINX/Apache/HAProxy/
Postfix template:

- buildPlan() constructs deploy.Plan{Files: []{cert, key}}.
- deploy.Apply runs it all-or-nothing. SHA-256 idempotency is
  all-files (Result.SkippedAsIdempotent).
- No PreCommit (Traefik has no validate-with-target command —
  file watcher absorbs config errors).
- No PostCommit (file watcher auto-reloads on rename).
- runPostDeployVerify retained as-is (TLS handshake + SHA-256
  fingerprint compare + retry/backoff).
- On verify failure, restoreFromBackups iterates
  res.BackupPaths and rewrites each destination via
  AtomicWriteFile{SkipIdempotent: true, BackupRetention: -1}.

Removed:
- The legacy rollbackCertAndKey helper (cert-only restore).
- The inline best-effort cert-rollback in DeployCertificate.

Tests added to traefik_atomic_test.go:
- TestTraefik_Atomic_KeyWriteFails_CertRollsBack — regression guard
  for the original two-AtomicWriteFile bug. Pre-writes a sentinel
  cert; sets the key path inside a read-only subdir so the key
  write must fail; asserts the cert on disk still contains the
  sentinel bytes (Apply's all-or-nothing rollback).

- TestTraefik_Atomic_AllFilesIdempotent — two subtests:
    both_match_skips: pre-writes cert + key matching what Traefik
      would write; asserts idempotent=true AND probe is never
      called.
    cert_match_key_new_runs_verify: pre-writes only the cert; key
      is new; asserts idempotent=false AND probe IS called once.
      Pre-fix per-file gate would have leaked through and skipped
      the verify here.

- TestTraefik_Atomic_VerifyMismatch_BothFilesRollBack — pre-writes
  sentinel cert + key; stub probe returns wrong fingerprint;
  asserts BOTH files are restored to sentinel bytes after the
  rollback fires. Pre-fix rollbackCertAndKey only restored the
  cert; the key would still be the new bytes.

The pre-existing TestTraefik_Atomic_VerifyMismatch_Rollback (which
asserted only the cert restore) is left intact — it's a strict
subset of the new BothFilesRollBack assertion and serves as a
narrower regression guard.

docs/deployment-atomicity.md L84 unchanged — operator-facing claim
("atomic-write only; ValidateOnly returns sentinel") stays accurate.

Verified locally:
- gofmt -l ./internal/connector/target/traefik/ clean
- go vet ./... clean
- staticcheck ./internal/connector/target/traefik/... clean
- go build ./... clean
- go test -race -count=1 ./internal/connector/target/traefik/...
  green (pre-existing tests + 3 new = 13 test functions; 14 with
  the AllFilesIdempotent subtests)
- go test -short -count=1 ./internal/connector/target/... green
  (no cross-connector regressions)

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 4.
2026-05-02 16:16:25 +00:00
shankar0123 febf50090b envoy: atomic SDS JSON write + post-deploy watcher pickup poll
Closes Bundle 3 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). The audit
ranked this fix #3 by acquirer impact behind the K8s real client (#1)
and the docs realignment (#2 / Bundle 1).

Two production-grade gaps closed:

1. SDS JSON config write was non-atomic. Cert/key/chain at envoy.go
   L155/L168/L183 went through deploy.AtomicWriteFile (atomic + backups
   + ownership preservation), but the SDS JSON at L260 went through
   os.WriteFile directly. A power loss / OOM / process-kill mid-write
   of the SDS JSON produces a torn file Envoy cannot parse, and
   Envoy's file-based SDS watcher refuses to load any cert (not just
   the rotating one) until the JSON is repaired by hand. Replaced with
   deploy.AtomicWriteFile and threaded ctx through writeSDSConfig.

2. No watcher pickup confirmation before returning success. Pre-fix,
   DeployCertificate returned the moment file writes completed.
   Envoy's SDS watcher is asynchronous; a caller running post-deploy
   TLS verify immediately after DeployCertificate could see Envoy
   still serving the old cert (watcher latency, load-balanced replica
   hit one that hadn't reloaded yet). Added the canonical post-deploy
   verify pattern (mirrors nginx.go::runPostDeployVerify L416): probe
   seam + retry/backoff + SHA-256 fingerprint compare against
   request.CertPEM. On verify failure, restore from per-file backups
   via the new restoreFromBackups helper. Envoy has no PostCommit
   reload to re-run; the watcher auto-reloads on the restored files.

Config additions to envoy.Config (mirror nginx.Config L84-93):
- PostDeployVerify *PostDeployVerifyConfig (Enabled, Endpoint, Timeout)
- PostDeployVerifyAttempts int (default 3 in runPostDeployVerify)
- PostDeployVerifyBackoff time.Duration (default 2s)
- BackupRetention int (mirrors nginx; passed to AtomicWriteFile per file)

Default behaviour unchanged for callers that don't set
PostDeployVerify — verify is opt-in. nil or Enabled=false skips it
entirely.

Probe seam: c.probe = tlsprobe.ProbeTLS at construction; tests inject
via the new SetTestProbe method. Same shape NGINX uses (nginx.go:130);
also mirrors the existing Traefik SetTestProbe at traefik.go:62.

WriteResult retention: every AtomicWriteFile call now retains its
*deploy.WriteResult in a local []*deploy.WriteResult slice so the
rollback path can restore from BackupPath across all four files
(cert, key, chain, SDS JSON), not just the cert. Pre-fix the cert's
WriteResult was discarded.

restoreFromBackups (envoy.go new): iterates the WriteResults from a
successful per-file pass, rewrites each non-idempotent destination
from its BackupPath via AtomicWriteFile{SkipIdempotent:true,
BackupRetention:-1}. The -1 prevents backup-of-the-backup pollution.
For files that didn't exist pre-deploy (BackupPath == ""), restore =
remove. Mirrors nginx.go::rollbackToBackups (L487-515) with the
reload step elided.

Idempotency gate: shouldRunVerify returns true unless EVERY
WriteResult was Idempotent — same all-files semantics NGINX gets
from res.SkippedAsIdempotent. Pre-fix Envoy had no verify at all,
so there was no gate to get wrong; this introduces the correct
all-files shape from the start.

Tests added to envoy_atomic_test.go:
- TestEnvoy_Atomic_SDSConfigWriteIsAtomic — pre-writes a sentinel
  SDS JSON, runs DeployCertificate, asserts a backup file with
  deploy.BackupSuffix appears alongside the new sds.json (proves
  AtomicWriteFile is now in the SDS path).
- TestEnvoy_Atomic_WatcherPickupRetries — stub probe returns wrong
  fingerprint on attempts 1+2 and correct on attempt 3; deploy
  succeeds; probe called exactly 3 times.
- TestEnvoy_Atomic_WatcherPickupAllAttemptsFail_RollsBack — pre-writes
  SENTINEL bytes for cert+key, stub probe always wrong; deploy
  returns wrapped error AND the destination files contain the
  sentinel bytes (rollback restored).
- TestEnvoy_Atomic_PostDeployVerifyDisabledByDefault — Config with
  nil PostDeployVerify; asserts probe is never called (opt-in
  default preserved).

A small certPEMFingerprint helper added to the test file mirrors the
production envoy.certPEMToFingerprint (which is package-private —
external tests can't call it).

docs/deployment-atomicity.md L87 row already documents
"TLS handshake | atomic-write replaces os.WriteFile" — pre-fix the
claim was aspirational (verify happened in the agent verify-and-report
path, not the connector; SDS JSON wasn't atomic). Post-fix the claim
is honest. No doc change required.

Verified locally:
- gofmt -l ./internal/connector/target/envoy/ clean
- go vet ./internal/connector/target/envoy/... clean
- staticcheck ./internal/connector/target/envoy/... clean
- go build ./... clean
- go test -race -count=1 ./internal/connector/target/envoy/... green
  (5 pre-existing tests + 4 new = 9 total)
- go test -short -count=1 ./internal/connector/target/... green

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 3.
2026-05-02 16:08:20 +00:00
shankar0123 475421457f fix(test): TestBoundedFanOut_SkipsAgentRoutedDeployments race on seenIDs slice
CI race detector flagged TestBoundedFanOut_SkipsAgentRoutedDeployments
on commit 35e18bf (audit fix #9). The test's `work` closure was
appending to a plain []string slice from worker goroutines without
synchronisation:

    var seenIDs []string
    work := func(ctx context.Context, job *domain.Job) error {
        seen.Add(1)
        seenIDs = append(seenIDs, job.ID)  // race
        return nil
    }

atomic.Int64 covered the count assertion but the slice header itself
is the racing memory — race detector caught both the read+write race
on the slice header and the runtime.growslice path on append.

Fix: protect seenIDs with a sync.Mutex. The slice is only used in
the failure-message branch (`t.Errorf` ids=%v formatting), so the
contention is irrelevant to performance — correctness only.

Also locked around the read in the t.Errorf format-args evaluation,
since that read happens AFTER boundedFanOut returns (and Wait()
inside boundedFanOut synchronizes the worker goroutines), but the
explicit Lock/Unlock makes the synchronisation visible without
depending on the implicit happens-before from Wait.

The other five tests in the file (TestBoundedFanOut_CapHolds,
_AllJobsRun, _CtxCancelInterrupts, _FailedJobsCounted,
TestSetRenewalConcurrency_NormalizesNonPositive) only mutate
atomic.Int64 counters from worker goroutines, so they were
already race-clean.

Verified locally: go test -race -count=1 -run
'TestBoundedFanOut|TestSetRenewalConcurrency' ./internal/service/...
green.
2026-05-02 14:34:48 +00:00
shankar0123 a22a1be962 globalsign,entrust: cache mTLS keypair with mtime-based reload
Closes the #10 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. Pre-fix, GlobalSign reloaded the mTLS cert/key from
disk on every API call (globalsign.go::getHTTPClient) and Entrust
loaded once in ValidateConfig with no rotation handling — both shapes
were broken for different reasons. Per-call disk reads under a 100-
cert renewal sweep meant 200 file opens / parses / tls.X509KeyPair
calls in flight, each adding 5–50ms of latency for nothing; the
single-load Entrust shape served stale credentials forever after a
cert rotation, requiring a process restart.

This commit:

- Adds a new shared package internal/connector/issuer/mtlscache/
  with a Cache type holding a parsed tls.Certificate plus a
  precomputed *http.Transport. RWMutex serialises reloads; reads
  are lock-free in the hot path (read lock briefly held to copy
  out the *http.Client pointer, then released — the HTTP request
  itself happens with no lock held, per the audit prompt's anti-
  pattern about holding the write lock across an API call).

- RefreshIfStale stats the cert file; if mtime advanced beyond
  the last load, the keypair is re-parsed and the transport is
  rebuilt. The fast path (mtime unchanged) takes the read lock
  for the comparison and returns immediately. Double-checked-lock
  pattern (read lock → stat → release → write lock → re-stat)
  prevents two callers who observed the same stale mtime from
  both reloading.

- Options.TLSConfigBuilder lets the caller customise the *tls.Config
  built around the parsed leaf certificate. GlobalSign uses this
  to inject the ServerCAPath-pinning RootCAs pool that
  buildServerTLSConfig already produces; entrust uses the default
  builder.

- New() performs the initial load so a broken cert path fails
  fast at construction rather than at first API call.

- GlobalSign.Connector gains an mtls field. getHTTPClient now:
  (1) preserves the test-mode short-circuit when httpClient has
      a non-nil Transport;
  (2) preserves the bare-default-client short-circuit when cert
      paths aren't configured;
  (3) lazy-builds the cache on the first call so the constructor
      stays cheap;
  (4) calls RefreshIfStale on every subsequent call.
  The error wrap preserves the substring "client certificate" so
  existing TestGlobalsign_GetHTTPClient_MTLSPathConfigured_LoadsKeyPair
  keeps its assertion.

- Entrust.Connector gains an mtls field plus a new getHTTPClient
  helper mirroring GlobalSign's shape. The three IssueCertificate /
  RevokeCertificate / pollEnrollmentOnce sites that previously hit
  c.httpClient.Do(req) directly now route through getHTTPClient,
  which falls through to the test-injected client (same logic as
  GlobalSign) and otherwise serves the cached mTLS client. The
  legacy ValidateConfig flow that pre-built c.httpClient with its
  own transport stays intact — its transport wins because
  getHTTPClient short-circuits when c.httpClient.Transport != nil.

- Tests at internal/connector/issuer/mtlscache/cache_test.go cover:
  * fail-fast on missing paths (constructor input validation)
  * load on construction (positive + negative)
  * NoReloadWhenMtimeStable — 100 RefreshIfStale calls, LoadedAt
    must stay equal to the constructor's stamp (the load-bearing
    regression guard against per-call disk reads)
  * ReloadsOnMtimeAdvance — os.Chtimes forward, next refresh
    must observe the new LoadedAt (the load-bearing regression
    guard for rotation-without-process-restart)
  * StatErrorBubbles — missing cert file surfaces as an error
    rather than silently serving stale credentials
  * ConcurrentNoRace — 100 goroutines × 50 iterations under
    -race; no race detected, all calls succeed
  * TLSConfigBuilderUsed — custom builder is invoked at New AND
    on reload; verifies MinVersion=TLS1.3 takes effect
  * ClientHonoursTimeout — Options.HTTPTimeout reaches the
    constructed *http.Client

- docs/connectors.md GlobalSign + Entrust sections each gain an
  "mTLS keypair caching (audit fix #10)" paragraph documenting the
  steady-state caching, mtime-based rotation contract, and
  operator workflow (mv -f new.crt /etc/certctl/.../client.crt).

Acquirer impact: removes the per-call disk-read latency floor and
makes operator-driven cert rotation a no-restart event. Combined
with audit fix #9's bounded scheduler concurrency, the renewal
sweep's hot path now has predictable steady-state cost: capN
concurrent goroutines, each reusing the cached keypair, no per-
call file I/O.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- go test -race -count=1 ./internal/connector/issuer/mtlscache/...
  green (8 tests)
- go test -count=1 -short across globalsign / entrust / sectigo /
  ejbca / mtlscache / connector packages: green

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #10. Closes the audit's full Top-10 list (fixes #1-10
all shipped to master).
2026-05-02 14:32:59 +00:00
shankar0123 35e18bfc56 scheduler: bound renewal concurrency via CERTCTL_RENEWAL_CONCURRENCY
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.
2026-05-02 14:12:30 +00:00
shankar0123 3a665ae6ba loadtest: add k6 harness for certctl API throughput
Closes the #8 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. Pre-fix, certctl had zero benchmarks or load tests for
any API path. An acquirer evaluating "can certctl handle our 50k-cert
fleet at 47-day rotation" had nothing to point at; CA/B Forum SC-081v3
lands 47-day TLS in 2029, and operators need real numbers, not hand-
waved capacity claims.

What landed:

- deploy/test/loadtest/docker-compose.yml — minimal stack (postgres +
  tls-init bootstrap + certctl-server with CERTCTL_DEMO_SEED=true so
  the FK rows the script needs exist + grafana/k6:0.54.0 driver).
  Pinned k6 version so threshold expressions stay stable across runs.
  k6 command runs the script once and exits with the threshold-driven
  exit code so `--exit-code-from k6` propagates non-zero on any
  regression.

- deploy/test/loadtest/k6.js — two scenarios at 50 req/s × 5 min,
  staggered 5s. Scenario 1: POST /api/v1/certificates (issuance-
  acceptance hot path: auth + JSON decode + validation + service
  CreateCertificate + DB insert). Scenario 2: GET /api/v1/certificates
  (most-trafficked read endpoint, exercises pagination). Hard
  thresholds: p99 < 5s + p95 < 2s for issuance-acceptance, p99 < 2s +
  p95 < 800ms for list, error rate < 1% globally. constant-arrival-
  rate executor (NOT constant-vus) so VU-bound load doesn't backpressure
  the offered rate and mask capacity ceilings. __ENV.CERTCTL_BASE
  lets the same script run on the operator's workstation
  (https://localhost:8443) and inside the compose stack
  (https://certctl-server:8443).

- deploy/test/loadtest/README.md — documents what's measured (API
  tier: auth → DB) vs what's NOT (issuer connector latency: pinned
  separately by certctl_issuance_duration_seconds from audit fix #4;
  full ACME enrollment flow: deferred — sustained 100/s through
  multi-RTT pebble takes pebble tuning + crypto helpers k6 doesn't
  ship with). Threshold contract pinned. Baseline numbers row reads
  TBD until the operator captures on a representative workstation;
  methodology pinned so future tuning commits land alongside refreshed
  baselines that are diffable.

- deploy/test/loadtest/.gitignore — results/{summary.json,summary.txt}
  + certs/ (per-run TLS bootstrap output). Both regenerate on every
  run; committing them would create huge per-run diffs.

- deploy/test/loadtest/results/.gitkeep — placeholder so the
  directory exists in fresh checkouts (the k6 container mounts it).

- Makefile: new `loadtest` target spinning up the compose stack with
  --abort-on-container-exit --exit-code-from k6 and printing the
  summary. Added to .PHONY + help. Explicitly NOT in `make verify` —
  load tests are minutes long and don't gate per-PR signal.

- .github/workflows/loadtest.yml — workflow_dispatch (manual) +
  weekly cron at Mon 06:00 UTC. NOT per-push. 15-minute hard cap.
  Always uploads results/ as an artifact (90d retention) so a
  regression has a diffable artifact even when k6 exited non-zero.
  Read-only repo permissions.

- docs/architecture.md: new "Performance Characteristics" section
  citing the harness location, scenarios, thresholds, scope (what's
  measured vs not), and where the captured baseline lives. Inserted
  before the existing "What's Next" section.

Scope decisions documented in the README + this commit message:

- The audit prompt's k6 example targeted POST /api/v1/certificates +
  ACME-via-pebble. CreateCertificate exercises auth + DB but the
  downstream issuer-connector call is async (renewal scheduler);
  that's the right surface for "request-acceptance" throughput.
  Driving the connectors directly would load-test someone else's
  API.
- Pebble was excluded from the harness stack. Sustained 100/s
  through ACME's order/challenge/finalize flow needs pebble tuning
  + k6 crypto helpers that don't exist out of the box. README flags
  this as a deferred follow-up.

Acquirer impact: the diligence question "what's your throughput?"
now has a number with a reproducible methodology and a regression
guard, not a claim. The first operator run captures the baseline
into README.md so subsequent tuning commits are diffable.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- go build ./... clean
- bash scripts/ci-guards/H-1-encryption-key-min-length.sh — clean
  (the 38-byte loadtest key is above the 32-byte floor)
- bash scripts/ci-guards/openapi-handler-parity.sh — clean
- bash scripts/ci-guards/test-compose-scep-coherence.sh — clean
- make -n loadtest produces the expected command sequence
- The first `make loadtest` run from the operator's workstation
  populates the README baseline numbers (committed in a follow-up).

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #8.
2026-05-02 14:00:10 +00:00
shankar0123 fefa5a5fd7 acme: support serial-only revocation via local cert-version lookup
Closes the #7 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. Pre-fix, ACME RevokeCertificate at acme.go:L519-L529
returned the literal error "ACME revocation by serial not supported in
V1; provide certificate DER". RFC 8555 §7.6 genuinely requires the
cert DER bytes (not just the serial), 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 commit:

- Adds CertificateLookupRepo interface at the ACME connector boundary
  (connector boundary, NOT a service/repository import — the connector
  accepts whatever satisfies the shape). Production wiring in
  cmd/server/main.go injects the postgres CertificateRepository; tests
  inject a fake.

- Adds CertificateRepository.GetVersionBySerial(ctx, issuerID, serial)
  + interface declaration in repository/interfaces.go, returning the
  certificate_versions row whose SerialNumber matches, scoped to the
  issuer via JOIN on managed_certificates. Mirrors the existing
  GetByIssuerAndSerial shape but returns the version (where PEMChain
  lives). Per RFC 5280 §5.2.3 the issuer scope is required for
  determinism.

- Adds SetCertificateLookup + SetIssuerID setters on *acme.Connector.
  Mirror the pattern local.Connector already uses for OCSP responder
  wiring. Both must be wired before serial-only revoke works;
  unwired state falls back to a more actionable error pointing at the
  wiring requirement (the historical "not supported" wording is
  retired).

- Rewrites RevokeCertificate end-to-end: lookup → empty-PEM check →
  pem.Decode → block.Type == "CERTIFICATE" check → ensureClient →
  golang.org/x/crypto/acme.Client.RevokeCert(ctx, accountKey, der,
  reasonCode). RFC 8555 §7.6 case 1 (revocation request signed with
  account key) — the same account key issued the cert, so authority
  is intrinsic. The not-found path returns an actionable operator-
  facing error pointing at the local-store requirement.

- Adds mapRevocationReason translating RFC 5280 §5.3.1 reason strings
  (unspecified, keyCompromise, cACompromise, affiliationChanged,
  superseded, cessationOfOperation, certificateHold, removeFromCRL,
  privilegeWithdrawn, aACompromise) into golang.org/x/crypto/acme.
  CRLReasonCode. Accepts canonical camelCase + underscore_lower +
  ALL_CAPS_UNDERSCORE. Nil reason → 0 (unspecified). Unknown reason
  errors rather than silently demoting (operators rely on the reason
  for compliance reporting).

- Wiring update in service/issuer_registry.go: SetACMECertLookup
  setter on the registry; Rebuild type-asserts *acme.Connector and
  calls SetCertificateLookup + SetIssuerID, mirroring the existing
  *local.Connector branch. cmd/server/main.go calls
  issuerRegistry.SetACMECertLookup(certificateRepo) immediately after
  SetIssuanceMetrics — the postgres repo satisfies the interface via
  GetVersionBySerial.

- Tests:
  * acme_revoke_test.go (new): TestRevokeCertificate_NoCertLookupWired,
    TestRevokeCertificate_NoIssuerIDWired,
    TestRevokeCertificate_LookupReturnsNotFound (operator-facing
    "may not have been issued through certctl" hint pinned),
    TestRevokeCertificate_LookupArbitraryError,
    TestRevokeCertificate_VersionPEMEmpty (corrupt-row guard),
    TestRevokeCertificate_PEMMalformed_NoBlock,
    TestRevokeCertificate_PEMMalformed_WrongType (PRIVATE KEY block
    rejected as not a CERTIFICATE).
  * TestMapRevocationReason_TableDriven: full RFC 5280 reason set
    plus camelCase / underscore / ALL-CAPS variants plus
    nil-reason and unknown-reason cases.
  * acme_failure_test.go: renamed TestRevokeCertificate_AlwaysError
    → TestRevokeCertificate_UnwiredCertLookupFallback; the test
    still exercises the same backward-compat branch but now
    asserts the new "CertificateLookup wiring" error wording.

- Mock-repo updates (3 sites): mockCertificateRepository in
  internal/integration/lifecycle_test.go, mockCertRepo in
  internal/service/testutil_test.go, mockCertRepoWithGetError in
  internal/service/shortlived_test.go each gain a GetVersionBySerial
  implementation that mirrors the GetByIssuerAndSerial logic but
  returns the version row.

- docs/connectors.md ACME section: new "Revocation by serial number"
  subsection covering the workflow, the local-store requirement
  (cert was issued through certctl, not imported), the reason-code
  mapping with the three accepted spelling variants, and a pointer
  to the audit reference.

Out of scope (intentional, per spec):

- Recovering the DER from outside the local cert store (CT logs,
  CSR + signature reconstruction). If the cert wasn't issued through
  certctl, revoke-by-serial via certctl isn't possible.
- Revocation via the cert's private key (RFC 8555 §7.6 case 2). The
  account-key path covers all certctl-issued certs because the same
  account key issued them.
- Pebble-backed integration test for the happy path. Pebble integration
  is the right home for that — the unit tests in this commit pin all
  failure-mode branches before the network call, and the wiring
  branch in Rebuild is exercised by the existing
  TestIssuerRegistryRebuild paths.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- go test -short -count=1 across connector, service, repository,
  integration, api/middleware, api/handler: green

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #7.
2026-05-02 13:09:30 +00:00
shankar0123 2a384c690e secret: migrate EJBCA / GlobalSign / Sectigo credentials to *secret.Ref (Phase 2)
Phase 2 of the #6 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 633a10a) 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.
2026-05-02 12:53:58 +00:00
shankar0123 0509790325 asyncpoll: refactor Sectigo / Entrust / GlobalSign to bounded polling (Phase 2)
Phase 2 of the #5 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 711265b) 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.
2026-05-02 02:41:36 +00:00
shankar0123 633a10aa4e secret: add Ref opaque-credential abstraction (Phase 1)
Phase 1 of the #6 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Pre-fix, GlobalSign / EJBCA / Sectigo store API keys
/ OAuth tokens / 3-header credentials as plain Go strings on the
Connector struct. Encrypted at rest via internal/crypto/encryption.go
(AES-256-GCM v3 + PBKDF2-600k), they sit in process memory in the
clear after load and are sent in HTTP headers on every API call.
Under DEBUG-level HTTP request logging, the headers leak.

This commit ships the foundation type. Per-connector migrations
(GlobalSign / EJBCA / Sectigo Config field changes from string to
*secret.Ref, plus auth-header write-path changes) are Phase 2 — a
separate commit per connector keeps each diff reviewable.

Phase 1 (this commit):
- internal/secret/secret.go with Ref:
    NewRef(src func() ([]byte, error))   — production: decrypt-on-demand
    NewRefFromString(s string)            — tests / config-loading
    Use(fn func(buf []byte) error)        — invoke fn with a fresh
                                            buffer, zero on return
    WriteTo(w io.Writer)                  — convenience for the
                                            "set a header" case
    String()                              — returns "[redacted]"
    MarshalJSON()                         — returns "[redacted]"
    IsEmpty()                             — for ValidateConfig paths
- The bytes are zeroed (every byte set to 0) after Use returns —
  defeats casual heap-dump extraction. The `[redacted]` brackets
  (rather than `<redacted>`) avoid Go's json HTMLEscape behavior.
- 9 unit tests covering: bytes-exposed-and-zeroed contract, the
  buffer-escape anti-pattern (asserts post-Use buffer is zeroed),
  WriteTo, String/MarshalJSON redaction, JSON-encoding inside a
  parent struct, nil-Ref safety on every method, source-error
  propagation, IsEmpty, direct test of the zero helper.

Phase 2 (separate follow-up commits):
- GlobalSign Config.APIKey / APISecret migration to *secret.Ref.
- EJBCA Config.Token migration to *secret.Ref.
- Sectigo Config.CustomerURI / Login / Password migration.
- Each migration includes the auth-header write-path change
  (setAuthHeaders → Ref.WriteTo) and the env-var-loading update
  (NewRefFromString at config load time).
- Outbound HTTP transport-wrapping for per-connector credential-
  header redaction in DEBUG logs (defense against third-party
  SDK leakage; not in scope for the foundation).

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #6 — Phase 1.
2026-05-02 02:22:07 +00:00
shankar0123 711265b652 asyncpoll: shared bounded-polling Poller + DigiCert refactor (Phase 1)
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.
2026-05-02 02:18:50 +00:00
shankar0123 74d6b462a4 metrics: gofmt issuance_metrics_test.go — fix CI
Trivial whitespace fix: gofmt collapsed three trailing-comment columns
that I'd hand-aligned in the test file. Local sandbox missed this
because the per-file gofmt run earlier in the commit cycle was scoped
to the changed-files list and didn't include the test file at the
final write moment; CI's project-wide `gofmt -l .` caught it.

Behavior unchanged.
2026-05-02 01:27:33 +00:00
shankar0123 3b92048242 metrics: add per-issuer-type issuance counters, histogram, and failure classifier
Closes the #4 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. Before this commit, certctl's Prometheus exposition had
zero per-issuer-type signal — operators answering "is DigiCert slow?"
or "is Sectigo failing more than ACME?" had to grep logs by issuer
name. This commit adds three series labelled by issuer type:

  certctl_issuance_total{issuer_type, outcome}
  certctl_issuance_duration_seconds{issuer_type}            (histogram)
  certctl_issuance_failures_total{issuer_type, error_class}

The histogram covers 0.05–120 second buckets to span the local-issuer
fast path and async-CA slow path (DigiCert/Sectigo/Entrust polling
can take minutes). error_class is a closed enum of eight values
(timeout, auth, rate_limited, validation, upstream_5xx, upstream_4xx,
network, other) classified once in service.ClassifyError. Cardinality
budget is ~276 new series, well within Prometheus's comfortable range.

Implementation:
- service.IssuanceMetrics is the thread-safe counter + histogram
  table. Three independent views (counters / failures / durations)
  exposed via SnapshotCounters / SnapshotFailures / SnapshotDurations.
  sync.RWMutex protects the map shape; per-key sync/atomic.Uint64
  primitives keep the recording hot path lock-free under concurrent
  service-layer goroutines.
- service.IssuanceCounterEntry / IssuanceFailureEntry /
  IssuanceDurationEntry / IssuanceMetricsSnapshotter live in service
  (not handler) to avoid an import cycle: handler already imports
  service for admin_est.go etc., so service can't import handler back.
  Handler's exposer takes the snapshotter via the service-defined
  interface.
- service.ClassifyError pure function maps error → error_class.
  context.DeadlineExceeded / context.Canceled → timeout; *net.OpError
  → network; substring matches against canonical AWS / DigiCert /
  Sectigo error shapes for auth / rate_limited / validation /
  upstream_5xx / upstream_4xx / network; unknown → other. Each branch
  has at least one representative test case in
  TestClassifyError.
- IssuerConnectorAdapter.SetMetrics wires per-adapter recording
  (issuerType + metrics). Existing 28+ test call sites of
  NewIssuerConnectorAdapter keep their one-arg signature; production
  wiring goes through SetMetrics post-construction.
- IssuerRegistry.SetIssuanceMetrics + Rebuild type-asserts to
  *IssuerConnectorAdapter and calls SetMetrics with the issuer type
  string. nil-guarded — tests that hand-build adapters without
  metrics get no-op recording.
- IssuerConnectorAdapter.IssueCertificate / RenewCertificate wrap the
  underlying connector call with start := time.Now() and
  recordIssuance(start, err). Renewal is recorded into the same
  certctl_issuance_* series as initial issuance — operationally,
  renewal IS issuance from the connector's perspective (matches the
  audit prompt's guidance on series naming).
- handler/metrics.go GetPrometheusMetrics gains a new exposer block
  emitting all three series in stable label order with correct
  Prometheus format (_bucket / _sum / _count for the histogram, +Inf
  bucket appended). Sorted via sort.Slice for stable output. nil-
  guarded so deploys without the wire produce clean exposition.
- formatLE helper trims trailing zeros from histogram bucket labels
  via strconv.FormatFloat(le, 'f', -1, 64) so the `le` labels match
  Prometheus client conventions ("0.05", "30", "120", not "0.0500"
  etc.).
- cmd/server/main.go wires a single IssuanceMetrics instance into
  both the IssuerRegistry (recording) and the MetricsHandler (exposer)
  using DefaultIssuanceBucketBoundaries.

Tests:
- TestIssuanceMetrics_RecordAndSnapshot — happy-path counter +
  histogram + failure recording, BucketBoundaries returns a copy
  (not shared storage).
- TestIssuanceMetrics_HistogramCumulative — pins the cumulative-buckets
  contract. 100ms observation lands in 0.1 bucket and every larger
  bucket; 750ms only in the 1.0 bucket. Off-by-one here would
  corrupt every quantile query downstream.
- TestIssuanceMetrics_Concurrency — 100 goroutines × 1000 ops under
  the race detector. Asserts atomic counter integrity across
  contended writes.
- TestClassifyError — 17 cases covering every branch of the closed
  enum plus the nil-error special case.

Implementation chooses the existing hand-rolled fmt.Fprintf
exposition pattern (no prometheus/client_golang dependency added)
to stay consistent with the OCSP / deploy counter blocks already in
the file.

Out of scope (separate follow-ups):
- Revocation metrics (certctl_revocation_*) — symmetric to issuance
  but the audit didn't ask; explicit follow-up commit.
- Discovery / health-check duration histograms.
- prometheus/client_golang migration.

Verified locally:
- gofmt clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 ./internal/service/ green
- go test -short -count=1 -race -run TestIssuanceMetrics ./internal/service/ green
- go test -short -count=1 ./internal/api/handler/ green
- go build ./... success

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #4 (Part 3, narrative section).
2026-05-02 00:39:25 +00:00
shankar0123 b0efdbe2f8 repo,service: introduce WithinTx and atomic audit rows for issue/renew/revoke
Closes the #3 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit (Part 1.5 finding #1: audit row not transactional with
issuance). AuditRepository.Create previously ran on the package-level
*sql.DB while the certificate insert / version insert / revocation
insert ran on independent connections — a failed audit INSERT after
a successful operation INSERT was silently lost. SOX §404 over IT
general controls, PCI-DSS §10 audit logging, HIPAA §164.312(b) audit
controls, and CA/B Forum Baseline Requirements §5.4.1 audit log
records all presume audit-with-operation atomicity.

Design — Option A (Querier abstraction). The chosen pattern: a shared
repository.Querier interface (subset of *sql.DB and *sql.Tx) plus a
postgres.WithinTx helper that begins a tx, runs fn, commits on nil
error, rolls back on error or panic, and returns the wrapped result.
Repository methods that participate in a service-layer transaction
expose a *WithTx variant taking repository.Querier; the bare methods
remain for stand-alone use. A repository.Transactor abstracts the
"begin tx, run fn, commit/rollback" lifecycle so service-layer code
runs multi-write operations atomically without holding *sql.DB
directly. Option B (UnitOfWork) was considered but adds boilerplate
without behavioral benefit for the current scope. Option C
(context-carried tx) was explicitly rejected — it hides the
transactional boundary from the type system, reproducing the class
of bug we're fixing.

This commit:
- Adds internal/repository/querier.go with the Querier interface
  (compile-time guards that *sql.DB and *sql.Tx satisfy it) and the
  Transactor interface for service-layer use.
- Adds internal/repository/postgres/tx.go with the WithinTx helper
  (begin/fn/commit/rollback with panic recovery) and a transactor
  type that satisfies repository.Transactor.
- Adds CreateWithTx variants on AuditRepository, CertificateRepository
  (Create + Update + CreateVersion), and RevocationRepository.
  Existing bare methods now delegate to the *WithTx variant using
  the package-level *sql.DB so existing call sites are
  behavior-preserving.
- Updates repository/interfaces.go: AuditRepository, CertificateRepository,
  and RevocationRepository declare the new *WithTx methods. Adds an
  atomicity contract doc-comment on AuditRepository pointing at
  WithinTx + the audit blocker.
- Adds AuditService.RecordEventWithTx, mirroring RecordEvent but
  routing through CreateWithTx so the audit row is part of the
  caller's transaction. Same redaction + marshalling contract.
- Refactors three audit-emitting service paths to use Transactor.WithinTx
  when SetTransactor was wired, with a legacy fallback for backward
  compat:
    * CertificateService.Create — cert insert + audit row in one tx.
    * RevocationSvc.RevokeCertificateWithActor — cert status update +
      revocation row + audit row in one tx. The OCSP cache invalidate
      remains best-effort (out of scope per the prompt).
    * RenewalService CompleteServerRenewal — cert version insert +
      cert update + audit row in one tx. Job status update stays
      outside the audit-atomicity scope (job state lives outside
      the operator-facing audit trail).
- Adds SetTransactor on CertificateService, RevocationSvc, and
  RenewalService. cmd/server/main.go wires a single Transactor
  instance shared across all three so all audit-emitting paths run
  their writes in transactions backed by the same *sql.DB handle.
- Updates 5 mock implementations to satisfy the new interface methods:
  mockCertRepo (testutil_test.go), mockCertRepoWithGetError
  (shortlived_test.go), fakeRevocationRepo (crl_cache_test.go),
  intuneE2EAuditRepo (scep_intune_e2e_test.go), and the integration-
  test mocks (lifecycle_test.go: mockCertificateRepository,
  mockAuditRepository, mockRevocationRepository). All *WithTx mocks
  ignore the Querier and delegate to the bare method (mocks have no
  DB; in-memory state is shared regardless of "tx").
- Adds a service-layer test mockTransactor with BeginTxErr and
  CommitErr knobs so the atomic-audit tests can assert error
  propagation through the transactional boundary.
- Adds internal/repository/postgres/tx_test.go: unit-level test that
  WithinTx surfaces "begin tx" wrap when BeginTx fails, and that
  Transactor.WithinTx delegates correctly. Real-Postgres rollback
  semantics are covered by the testcontainers tests in the postgres
  package — sandbox disk pressure prevented adding a sqlmock dep
  for the in-fn / commit-failure unit test, so those scenarios are
  exercised through atomic_audit_test.go using the mockTransactor's
  CommitErr / BeginTxErr fields.
- Adds internal/service/atomic_audit_test.go:
    * TestCertificateService_Create_AtomicWithTx — asserts audit
      insert failure inside the tx surfaces as the operation's error
      (closes the blocker contract).
    * TestCertificateService_Create_LegacyPathLogs — pins the
      backward-compat behavior when SetTransactor isn't wired:
      audit failure is logged-not-failed, matching pre-fix.
    * TestCertificateService_Create_TransactorBeginFailure — BeginTx
      error path: operation fails, no cert insert, no audit insert.
    * TestCertificateService_Create_TransactorCommitFailure —
      Commit error after successful in-fn writes surfaces as the
      operation's error. Real Postgres can fail Commit on
      serialization conflicts; the service must report this.

Out of scope (separate follow-up commits, same shape):
- Issuer CRUD audit atomicity.
- Target CRUD audit atomicity.
- Agent retire (already transactional via RetireAgentWithCascade;
  verified, not changed).
- Renewal-policy CRUD audit atomicity.
- Owner/team/agent-group CRUD audit atomicity.
- Discovery / health-check audit atomicity.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 ./internal/service/ green
- go test -short -count=1 ./internal/api/handler/ green
- go test -short -count=1 ./internal/integration/ green
- go test -short -count=1 ./internal/repository/postgres/ green
- go build ./... success

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #3 (Part 3, narrative section).
2026-05-02 00:29:09 +00:00
shankar0123 3669556e57 ejbca: wire mTLS client cert in New()
Closes the #2 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. New() at ejbca.go:L79-L88 previously constructed an
http.Client with only Timeout set — no Transport, no TLSClientConfig.
When AuthMode=mtls (the default), the client never presented the
configured ClientCert/ClientKey. The OAuth2 path worked; mTLS always
failed authentication. Tests passed because they injected a pre-built
*http.Client via NewWithHTTPClient, a path the production factory never
took.

This commit:
- Rewrites New() to load ClientCertPath + ClientKeyPath via
  tls.LoadX509KeyPair when AuthMode=mtls, configure
  *http.Transport.TLSClientConfig with MinVersion: TLS 1.2 (compatibility
  floor for on-prem EJBCA installs that may predate TLS 1.3), and return
  (*Connector, error). Constructs a fresh *http.Transport — does NOT
  clone http.DefaultTransport, which would leak mutation across the
  package boundary.
- OAuth2 mode unchanged: returns a client with no transport
  customization (the Bearer header path is wired in setAuthHeaders).
- Invalid auth_mode values return (nil, error) immediately rather than
  falling through to the mtls default and erroring at cert load.
- Updates the factory call site at issuerfactory/factory.go for the
  new signature; the factory's outer (issuer.Connector, error) shape
  was already in place.
- Adds TestNew_MTLSWiresClientCert: calls production New() (NOT
  NewWithHTTPClient) with real cert/key files generated via stdlib
  crypto/x509, asserts httpClient.Transport.TLSClientConfig.Certificates
  is non-empty. Includes an httptest TLS server with
  ClientAuth: tls.RequireAndVerifyClientCert that proves the cert is
  actually presented on the wire — not just stashed in a struct field.
- Adds TestNew_MTLSCertLoadFailure: missing-cert path returns an error
  wrapping fs.ErrNotExist (verified via errors.Is).
- Adds TestNew_OAuth2NoTransportTuning: OAuth2 path leaves Transport
  nil, ensuring no accidental mTLS bleedthrough.
- Adds TestNew_InvalidAuthMode: explicit guard that auth_mode values
  other than "mtls"/"oauth2" return (nil, error) at New() time.
- Adds export_test.go with HTTPClientForTest helper so the external
  ejbca_test package can inspect the connector's internal *http.Client
  for the wiring assertions. Compile-only during `go test`; production
  builds don't expose it.
- Adds mustNewForValidateConfig test helper (OAuth2 placeholder
  connector) for the existing ValidateConfig-only tests; pre-fix they
  used New(nil, ...) which is no longer valid because nil config falls
  into the mTLS default branch that requires non-nil cert paths.
- Updates ejbca_stubs_test.go (internal package) for the new
  (*Connector, error) signature; switches the dummy connector to
  OAuth2 mode so Config{} doesn't error at New().

Out of scope (separate follow-ups, per the prompt's explicit fence):
- OAuth2 token refresh missing
- Config.Token plaintext at runtime (needs SecretRef abstraction)
- RevokeCertificate composite OrderID parsing (the issuerDN := "" line
  at ejbca.go:L313)

Verified locally:
- gofmt clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 ./internal/connector/issuer/ejbca/ green
- go test -short -count=1 ./internal/connector/issuerfactory/ green
- go test -short -count=1 ./internal/service/ green
- go build ./... success

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #2.
2026-05-02 00:08:24 +00:00
shankar0123 804a1b05ce awsacmpca: thread ctx through factory + registry — fix CI contextcheck
Follow-up to 590f654 (awsacmpca: replace stub client with AWS SDK v2
implementation). CI's golangci-lint contextcheck rule flagged six
violations in awsacmpca_test.go where mustNew/awsacmpca.New were
called from test functions that had ctx in scope but didn't thread it
through New(). The previous commit used context.Background() inside
New() with the rationale that "the audit allows either threading or
documenting the limitation"; CI made that choice for us.

Threading ctx is the right shape per the audit's stated preference.
The fix cascades from awsacmpca.New through issuerfactory.NewFromConfig
and IssuerRegistry.Rebuild because the contextcheck rule propagates
upward through every caller that has ctx in scope.

This commit:
- Changes awsacmpca.New(config, logger) to
  awsacmpca.New(ctx, config, logger). The ctx is passed to
  buildSDKClient → awsconfig.LoadDefaultConfig so SDK credential chain
  resolution honors caller deadlines (LoadDefaultConfig may probe IMDS
  or remote credential sources). The doc-comment on New explains that
  callers without a useful deadline should pass context.Background()
  and that the SDK has internal credential-resolution timeouts.
- Adds ctx as the first parameter of issuerfactory.NewFromConfig.
  Currently only the AWSACMPCA branch uses ctx (it's threaded into
  awsacmpca.New); the other 11 branches accept ctx without using it.
  This is a contractual change that lets callers thread ctx through
  without contextcheck warnings, even though most issuer constructors
  do no ctx-aware work today.
- Adds ctx as the first parameter of IssuerRegistry.Rebuild. Rebuild
  iterates over configs and calls NewFromConfig per issuer; the same
  ctx flows through every connector instantiation.
- Updates the two production call sites in internal/service:
  - issuer.go:279 (TestIssuer connection test) now passes its
    method-scoped ctx
  - issuer.go:303 (BuildRegistry) now passes its method-scoped ctx
    to Rebuild
- Updates 13 test sites in internal/connector/issuerfactory/factory_test.go
  via a new testCtx() helper that returns context.Background(). Helper
  is dedicated to this file so contextcheck's "you have a ctx in scope,
  pass it" rule doesn't fire on test functions that don't otherwise
  need ctx.
- Updates 6 test sites in internal/service/issuer_registry_test.go
  to pass context.Background() to Rebuild.
- Removes the now-stale "// NewFromConfig has no ctx parameter
  (preserved across all 12 connectors); pass context.Background() ..."
  comment from the awsacmpca branch in factory.go — that workaround
  is no longer the design.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... clean (was failing with 6
  contextcheck issues before the cascade; now 0 issues)
- go test -short -count=1 across all changed packages green

Sandbox couldn't run the existing CI's full make verify due to
disk pressure on /sessions and a virtiofs concurrent-open-file
ceiling on go mod tidy; operator should run `make verify` on
the workstation to confirm.

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #1 (CI follow-up; behavior unchanged from 590f654).
2026-05-01 23:27:25 +00:00
shankar0123 590f654b0d awsacmpca: replace stub client with AWS SDK v2 implementation
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).
2026-05-01 23:13:59 +00:00
shankar0123 b3aad02232 chore(README): remove the second Scarf pixel — analytics consolidated to certctl.io
The README has carried two Scarf pixels for some time:
  - 89db181e-76e0-45cc-b9c0-790c3dfdfc73 (kept earlier as 'GitHub
    traffic complement to GitHub Insights')
  - b9379aff-9e5c-4d01-8f2d-9e4ffa09d126 (moved to the certctl.io
    landing page in commit 6a5cfb3)

Re-evaluating: GitHub Insights → Traffic already provides repo
views, uniques, clones, and referring sites with click counts at
higher granularity than a Scarf pixel can extract from the README
(Scarf can only see 'github.com' as the referrer; GitHub Insights
knows the actual external referrer that landed the visitor on the
README). The 89db181e pixel was duplicative-and-worse.

Removing it. All certctl analytics now consolidate to:
  - GitHub Insights → Traffic (built-in, more granular than Scarf
    on the README surface)
  - certctl.io's b9379aff pixel (referrer-attribution for landing-
    page traffic, where Scarf actually adds value)
  - Scarf Docker Gateway via shankar0123.docker.scarf.sh/* (when
    the Helm chart + docker-compose.yml are routed through it —
    follow-up work)

The Docker-pull example block at line 246 stays (it documents
how operators install certctl via the Scarf gateway). Only the
in-README tracking <img> is removed.
2026-05-01 20:59:22 +00:00
shankar0123 6a5cfb3d01 chore(README): remove duplicative Scarf pixel — moved to certctl.io
The README had two Scarf pixels (89db181e and b9379aff). For README
visit tracking, GitHub's built-in Insights → Traffic dashboard
already provides views, uniques, clones, AND referring sites with
click counts (Reddit, HN, Twitter, search, etc.) at higher
granularity than a Scarf pixel can extract — Scarf can only see
'github.com' as the referrer because that's where the README HTML
is served from, while GitHub Insights knows the actual external
referrer that landed the visitor on the README.

Removing pixel b9379aff-9e5c-4d01-8f2d-9e4ffa09d126 from the README
and reusing it on the certctl.io landing page (sibling commit on
certctl-io/certctl.io), where Scarf is the only analytics source
and the referrer header actually carries useful attribution.

Pixel 89db181e-76e0-45cc-b9c0-790c3dfdfc73 stays in the README as
a backup signal alongside GitHub Insights — keeps continuity for
the longer-running Scarf project counter.

No data loss: GitHub Insights covers what 89db181e was double-
counting, and b9379aff now serves a distinct surface (certctl.io)
where it actually adds new attribution data.
2026-05-01 06:02:23 +00:00
shankar0123 dcd82d062f docs: convert all 9 ASCII diagrams to mermaid
Audit of docs/ found 32 diagrams: 23 already in mermaid, 9 in ASCII
art (box-drawing chars / +-pipe boxes). Converting all 9 to mermaid
so GitHub renders them as actual diagrams in the docs preview.

Files affected (9 diagram blocks across 6 files):

  docs/architecture.md   block 1 line 706  EST request flow
  docs/architecture.md   block 2 line 798  SCEP request flow
  docs/architecture.md   block 3 line 893  Per-profile TrustAnchor +
                                           Intune challenge dispatch
  docs/architecture.md   block 4 line 935  signer.Driver interface +
                                           4 implementations
  docs/ci-pipeline.md    block 1 line 20   On-push pipeline tree
  docs/est.md            block 1 line 254  WiFi 802.1X / EAP-TLS flow
  docs/legacy-est-scep.md block 1 line 40  TLS-version-bridging proxy
  docs/qa-test-guide.md  block 1 line 41   qa_test.go to demo stack
  docs/scep-intune.md    block 1 line 39   Intune cloud chain

Conversion notes:

  - Linear flows → flowchart TD/LR. Per-step annotations that the
    ASCII had as floating text between arrows are now edge labels —
    cleaner and easier to read.
  - architecture.md block 4 (signer drivers) → flowchart LR with a
    subgraph for the Driver interface. Cleaner than a class diagram
    for the "code uses one of these implementations" semantics.
  - ci-pipeline.md tree → flowchart TD. Adds a dotted '-.depends
    on.->' arrow making the go-build-and-test → deploy-vendor-e2e
    dependency visually obvious (was a parenthetical in the ASCII).
  - est.md WiFi/RADIUS → flowchart LR with EAP, Radius, trusts,
    and EST as four distinct labeled arrows. The 'trusts' annotation
    was floating off to the side in the ASCII; now it's the arrow
    label between Radius and certctl CA.
  - All semantic detail preserved: every node label, arrow direction,
    inline annotation, and multi-line cell content carries through.

Verified: post-conversion audit shows 32 mermaid blocks, 0 ASCII.
Diff is symmetric — 108 inserts, 123 deletes — because mermaid is
slightly more compact than the box-drawing characters it replaces.

GitHub renders mermaid blocks natively in markdown previews since
2022, so all 9 diagrams now render as real flowcharts in the docs
view rather than as monospaced character art.
2026-05-01 05:09:00 +00:00
shankar0123 2643a427ac ci(digest-validity): exclude Windows IIS digest — image is doc-only, not pulled by Linux CI
CI run #376 (commit a1c7741, Frontend Build job) failed with:

    digest does not resolve: mcr.microsoft.com/windows/servercore/iis:
    windowsservercore-ltsc2022@sha256:8d0b0e651ad514e3fb05978db66f38036
    118812e1b9314a48f10419cad8a3462

A re-run with no code changes went green. The digest itself is fine —
verified against MCR directly (HTTP 200 from
mcr.microsoft.com/v2/windows/servercore/iis/manifests/sha256:8d0b...),
and the tag `:windowsservercore-ltsc2022` currently resolves to that
exact digest. Microsoft hasn't rotated.

Root cause is registry-side rate-limiting. MCR throttles unauthenticated
GET-by-digest requests by source IP. GitHub-hosted runners share a small
pool of egress IPs across many users; bursts trip the throttle and
return non-200. Re-run = different runner = different IP = throttle
window has reset = pass. This will recur on roughly N% of pushes
indefinitely, until either (a) Microsoft loosens MCR rate limits, (b)
GitHub buys more runner IPs, or (c) we stop verifying digests CI doesn't
actually use.

The deeper issue is structural, not transient. The Windows IIS image is
gated behind compose `profiles: [deploy-e2e-windows]`
(deploy/docker-compose.test.yml:700). The comment block above the
service definition (lines 675-691) explicitly says "Linux CI never
activates this profile." All 10 TestVendorEdge_IIS_*_E2E tests are on
scripts/vendor-e2e-skip-allowlist.txt because the sidecar is never
started. The whole Windows matrix was DELETED in ci-pipeline-cleanup
Phase 6 / frozen decision 0.5 (revising Bundle II decision 0.4); IIS
validation moved to docs/connector-iis.md::Operator validation playbook.

So `digest-validity.sh` is verifying a digest that no CI job ever pulls
— paying CI brittleness against MCR rate-limiting we can't control, for
an image whose only purpose in compose is documentation for an
operator's manual workflow on a real Windows host.

The fix matches the guard's stated purpose ("every digest CI actually
depends on is valid"): exclude images CI never pulls.

Implementation. Add an EXCLUDED_PATTERNS array near the top of the
script with one entry — the IIS image path
`mcr.microsoft.com/windows/servercore/iis` — and a comment block above
it documenting:

  - WHY it's excluded (gated profile, never started, all tests on
    skip-allowlist)
  - WHEN it would need re-inclusion (if a Windows CI runner is added
    that actually starts the sidecar)
  - WHAT this list is NOT for (transient flake silencing — that gets
    fixed via retry logic in the script, not via exclusion)

The match is by image-path substring, not by digest, so future tag/
digest updates of the same image still hit the exclusion without
needing this list to be re-edited.

Loop logic gains a 6-line check that runs the exclusion match before
any registry work. Excluded refs log as "SKIP (excluded) <ref>" so
operator-facing CI logs stay informative — at a glance you can see
which digests were verified vs which were intentionally not.

The success message updates to differentiate verified vs excluded
counts: "digest-validity: clean — N verified, M excluded (CI never
pulls)" when M > 0; original message preserved when M == 0.

Verified manually:

  - Clean repo: 15 verified, 1 excluded, exit 0.
  - Fabricated bogus httpd digest: ::error:: emitted for the bad
    digest, IIS still SKIP-excluded, exit 1. (Real regressions still
    caught.)
  - Restore: 15 verified, 1 excluded, exit 0 again.

Other recurring MCR-hosted images would warrant the same treatment if
they get added later. The exclusion list pattern scales: each new entry
needs its own "WHY this is doc-only" justification block.

What this is NOT:
  - Not a generic flake-silencer. The exclusion is justified by the
    image being doc-only, not by the test being noisy.
  - Not a global retry/resilience layer. If MCR rate-limits an image CI
    DOES pull, that's a real CI dependency on an unreliable external
    service — fix by retry-with-backoff, not by excluding.
2026-05-01 03:06:49 +00:00
shankar0123 a1c7741e1b fix(deploy/test) + ci(guard): drop dead SCEP profile from test compose
The deploy-vendor-e2e job has been failing with the certctl-test-server
container restarting endlessly. Diagnostic dump (added in 3b96b35)
finally surfaced the actual cause:

    Failed to load configuration: SCEP profile 0 (PathID="e2eintune")
    has empty CHALLENGE_PASSWORD — refuse to start (CWE-306: per-profile
    shared secret is the sole application-layer auth boundary; an empty
    password would allow any client reaching /scep/e2eintune to enroll
    a CSR against issuer "iss-local")

Same shape as the encryption-key fix that landed in c4157fd: a config
validation gate added in code that the test compose never got updated
to satisfy, hidden pre-Phase-5 because the matrix-collapse hadn't yet
forced the certctl-server to actually boot in CI.

Root cause is more interesting than just "missing env var." The
2026-04-29 SCEP RFC 8894 + Intune master bundle Phase I added an
`e2eintune` SCEP profile to docker-compose.test.yml expecting
deploy/test/scep_intune_e2e_test.go to exercise it. That integration
test does exist (//go:build integration) but **NO CI job ever
selects it** — ci.yml's deploy-vendor-e2e job runs only
`-run 'VendorEdge_'` (line 379), and no other job invokes
`go test -tags integration` with a SCEP selector. Confirmed via
`grep -rnE "scep_intune|SCEPIntune" .github/workflows/` returning
empty.

Worse: the supporting fixtures (ra.crt + ra.key + intune_trust_anchor.pem)
were documented in deploy/test/fixtures/README.md with the
regeneration recipe but never actually committed. Pre-Phase-5 the
test stack didn't fully boot the server in CI, so the entire stack
of debt — dead config + missing fixtures + no consumer test — sat
silent until the matrix collapse forced the boot path.

Fixing this with a fake CHALLENGE_PASSWORD value would silence the
immediate validator but leave the real problem in place: maintenance
cost on test config that no test exercises. Same critique applies
to "let me commit fake fixtures" — the fixtures alone don't add
test coverage when no CI job runs the SCEP test.

The complete-path fix is to make the test compose match what CI
actually exercises:

  - deploy/docker-compose.test.yml: drop CERTCTL_SCEP_ENABLED + the
    full e2eintune profile env var family (10 lines) + the
    ./test/fixtures volume mount (1 line). Replace with an in-line
    comment explaining why SCEP is intentionally disabled and what
    needs to come back together when SCEP is added to CI for real.

  - scripts/ci-guards/test-compose-scep-coherence.sh (new, 22nd
    guard): refuses any future state where CERTCTL_SCEP_ENABLED=true
    in test compose without ALL of:
      1. A CI job that runs the SCEP integration test (matched by
         scep_intune | SCEPIntune | -run [Ss]cep in ci.yml)
      2. The fixture files actually committed (ra.crt, ra.key,
         intune_trust_anchor.pem)
      3. The ./test/fixtures:/etc/certctl/scep:ro volume mount
    Verified manually with the same pattern as the H-1 guard:
    clean tree → exit 0; deliberate SCEP_ENABLED=true regression →
    exit 1 with 5 ::error:: annotations covering each gap; restore
    → exit 0 again.

  - scripts/ci-guards/README.md: 21 → 22 guards, new row.

The fixtures README at deploy/test/fixtures/README.md keeps the
regeneration recipe so the eventual SCEP CI job lands cleanly: the
operator who adds the SCEP job restores the env vars, regenerates
+ commits the fixtures, and the guard auto-passes.

Pattern (now firm across this CI-stabilization sequence):
  - Pre-existing latent bug
  - Old CI structurally hid it (per-vendor matrix, missing boot path)
  - Phase-5 matrix collapse + new diagnostic infra exposed it
  - Direct fix unblocks today
  - Regression guard prevents the same shape of drift forever

Encryption-key (c4157fd) was the same shape; this is its sibling.
2026-05-01 01:39:18 +00:00
shankar0123 e06447b763 Revert CodeQL custom config + sanitizer model — leave alert #23 open
Reverts:
  482e952 ci(codeql): rewire local model pack discovery — fix 1122f5a silent no-op
  1122f5a ci(codeql): teach analyzer about ValidateSafeURL SSRF barrier

Net: drops .github/codeql/ entirely; restores the codeql.yml workflow
and the docs/architecture.md::Input Validation and SSRF Protection
section to their pre-1122f5a state. Alert #23 (go/request-forgery,
Critical) at internal/service/scep_probe.go:232 stays OPEN to be
resolved later.

Why this revert exists. The original Option A (model pack barrier
declaration) was the right idea on paper — teach the analyzer that
internal/validation.ValidateSafeURL sanitizes the URL argument so
the request-forgery taint trace stops there. Two iterations in
(1122f5a + 482e952), the pack still wasn't loading:

  - 1122f5a used `packs: { go: ['./'] }` in codeql-config.yml. That
    field expects pack names, not paths; the local pack silently
    never registered. CodeQL ran clean but emitted the same alert.

  - 482e952 restructured into .github/codeql/certctl-models/ + named
    the pack + added `additional-packs: .github/codeql` to the action
    init step. Surface looked correct against the pattern I'd
    researched (vscode-codeql, CodeQL docs). But:

      Warning: Unexpected input(s) 'additional-packs',
      valid inputs are [..., packs, ...]

      A fatal error occurred:
      'shankar0123/certctl-models' not found in the registry
      'https://ghcr.io/v2/'.

    `additional-packs` is not a valid input on github/codeql-
    action/init@v3 (verified directly against init/action.yml on
    that branch). Without a valid path-resolver input, the CLI
    fell back to the public registry, where the pack obviously
    isn't published. CodeQL run #56 fatal-errored.

The next iteration would have been: codeql-workspace.yml at the
repo root, OR convert to a query pack referenced via `queries:
./path`, OR publish to GHCR, OR drop MaD and write custom QL.
Each is its own incremental commit with its own failure modes I
can't pre-validate without a CI push, against a `barrierModel`
feature for Go that's too new (added 2026-04-21) to have shipped
public examples to copy from.

Honest cost-benefit. The runtime at scep_probe.go:232 is correct
on day one — `ValidateSafeURL` rejects reserved-IP targets at the
service entry; `SafeHTTPDialContext` re-resolves at dial time and
pins to a literal non-reserved IP, defeating DNS rebinding.
CodeQL is reporting a known-class false positive on a known-good
sanitizer pattern. The cost of teaching CodeQL about a 2-site
validator (this + webhook notifier's client.Do) — multiple
iterations of pack-discovery infrastructure, a `.github/codeql/`
tree to maintain, version-tracking against codeql-action and
CodeQL-CLI updates — exceeds the benefit of silencing those 2
alerts.

The right path forward, when capacity exists: either land a
short justified `// codeql[go/request-forgery]` annotation at
each of the 2 sites with a comment block citing ValidateSafeURL
+ SafeHTTPDialContext, OR dismiss alert #23 in the GitHub
Security UI as "won't fix — false positive" with the same
justification in the dismissal comment. Both are real fixes for
the underlying problem (analyzer's model differs from runtime
reality at known-safe call sites). Neither requires new CI
infrastructure.

Until then, the alert stays open. The Security tab is a public
signal — anyone reviewing the certctl repo sees that we've left
this finding visible rather than hidden it via config. That's
itself a security-posture statement.

Specific files restored:
  - .github/workflows/codeql.yml: drops `config-file:` and
    `additional-packs:` from Initialize CodeQL step. Workflow is
    byte-equivalent to its pre-1122f5a state (verified).
  - .github/codeql/: directory removed (3 files: qlpack.yml,
    codeql-config.yml, certctl-models/models/*.model.yml).
  - docs/architecture.md::Input Validation and SSRF Protection:
    drops the "Outbound HTTP egress" paragraph that was added in
    1122f5a. The original section's coverage of shell input
    validators + network-scanner reserved-IP filter remains
    intact — that's what was there before.

Other commits between 1122f5a and now (c4157fd — encryption-key
fix + H-1 regression guard) are PRESERVED. They're unrelated to
CodeQL and remain valid.
2026-05-01 01:28:54 +00:00
shankar0123 482e952dde ci(codeql): rewire local model pack discovery — fix 1122f5a silent no-op
Two CodeQL runs (commits 1122f5a + c4157fd) since the initial Option A
landing both completed with conclusion=success but failed to dismiss
alert #23 (go/request-forgery on scep_probe.go:232). Root cause: the
local pack never loaded.

The bug was in codeql-config.yml — `packs: { go: ['./'] }` looked
plausible (the path is relative to the config file's directory) but
the `packs:` field requires pack NAMES, not paths. Discovery of
unpublished local packs goes through the codeql-action `init` step's
`additional-packs:` input, not through `packs:`.

Verified pattern by reading github/vscode-codeql's working
.github/codeql/ setup. The supported chain:

   workflow init step      passes additional-packs: <parent-dir>
                                        ↓
       CodeQL CLI           registers each pack under the parent
                                        ↓
   codeql-config.yml        names the pack in `packs: go: [name]`
                                        ↓
       CodeQL CLI           resolves the name → pack on disk
                                        ↓
   pack's qlpack.yml        declares extensionTargets: codeql/go-all
                                        ↓
   data extension YAML      auto-loads, applies the barrier rows

Restructure to match this chain:

  Before                                    After
  --------                                  -----
  .github/codeql/qlpack.yml                .github/codeql/codeql-config.yml
  .github/codeql/models/                   .github/codeql/certctl-models/
    request-forgery-sanitizers.model.yml     qlpack.yml
  .github/codeql/codeql-config.yml           models/
                                               request-forgery-sanitizers.model.yml

The new `.github/codeql/certctl-models/` is the pack directory, named
to match `name: shankar0123/certctl-models` in qlpack.yml. Its parent
`.github/codeql/` is what additional-packs points at. The action
discovers the pack by walking the parent dir, sees the qlpack.yml,
registers the name, and `packs:` lookup succeeds.

Three concrete changes:

  - Pack moves from .github/codeql/{qlpack.yml, models/} into the
    sibling subdirectory .github/codeql/certctl-models/.

  - codeql-config.yml's packs: directive now uses the pack NAME
    (`shankar0123/certctl-models`) instead of the broken `./` path.

  - codeql.yml's Initialize CodeQL step gains
    `additional-packs: .github/codeql` so the CLI's resolver knows
    where to find unpublished packs.

Belt-and-suspenders correctness fix: the model row's `subtypes`
column now uses `False` (Python-style capitalized) instead of `false`
to match every shipped CodeQL Go .model.yml convention. SnakeYAML
accepts lowercase too — this is a hedge against any strict-format
tooling in the path.

Why this matters: alert #23 is rated Critical with CWE-918 + CWE-180.
The runtime defense is correct (validate-then-pin via
ValidateSafeURL + SafeHTTPDialContext), but the analyzer doesn't
know it. With the pack actually loading this time, the next CodeQL
run will see the barrier and dismiss the alert at source. Same fix
implicitly applies to the webhook notifier's outbound client.Do
(the second site that uses ValidateSafeURL).

Operator: push and watch the next CodeQL run dismiss alert #23. If
it doesn't, the next iteration will be on the YAML row's column
shape — most likely a one-line tweak, not another redesign.
2026-05-01 01:08:48 +00:00
shankar0123 c4157fd196 fix(deploy/test) + ci(guard): unblock deploy-vendor-e2e — encryption-key length
Two-part complete-path fix for the deploy-vendor-e2e failure that has
been firing since the ci-pipeline-cleanup Phase 5 matrix collapse
started actually booting the certctl-test-server:

    Failed to load configuration:
    CERTCTL_CONFIG_ENCRYPTION_KEY too short (29 bytes; minimum 32).

Surfaced via the diagnostic-dump step landed in commit 3b96b35 — the
server panicked on startup, Docker restarted it endlessly, compose
reported the dependency-chain symptom ("container certctl-test-server
is unhealthy"), but the actual cause was invisible in the previous
CI output. With the dump in place, the next failing run named the
problem in one line.

Root cause. The H-1 audit-closure master commit 3e78ecb
("feat(security): bodyLimit on noAuth + security headers + encryption-
key validation (H-1 master)") added internal/config/config.go's
minEncryptionKeyLength = 32 byte floor + 5 unit tests that pin it.
The closure was incomplete: it never enforced the rule against the
literal CERTCTL_CONFIG_ENCRYPTION_KEY values certctl's own
deploy/docker-compose*.yml files pass. Pre-Phase-5 the test stack
didn't fully exercise the validator (the per-vendor matrix didn't
boot certctl-test-server in every job), so the gap was silent.
deploy/docker-compose.test.yml's literal value
`test-encryption-key-32chars!!` was 29 bytes — the name claimed 32
but the author miscounted (4+1+10+1+3+1+2+5+2 = 29). Pattern matches
every fix in this CI-stabilization sequence: pre-existing latent bug
that the old CI structurally hid.

Part 1 — direct fix (deploy/docker-compose.test.yml):

  Replace the 29-byte literal with a clearly test-only,
  self-documenting 49-byte value (`test-encryption-key-deterministic-
  32-byte-fixture`). 17 bytes of safety margin so a future tightening
  of the floor (32 → 33+) doesn't break this fixture again. Inline
  comment block explains the byte-budget contract + points at the
  H-1 closure commit. Production deploy/docker-compose.yml's default
  (`change-me-32-char-encryption-key`) is exactly 32 bytes — passes
  by 1 byte but on the edge; not touched here because operators are
  already told to override it via env (`${VAR:-default}`).

Part 2 — structural fix (scripts/ci-guards/H-1-encryption-key-min-
length.sh):

  New regression guard. Scans every deploy/docker-compose*.yml for
  literal CERTCTL_CONFIG_ENCRYPTION_KEY values + values inside
  ${VAR:-default} expansions, checks each against the 32-byte floor,
  fails CI with `::error::` annotation pointing at the offending
  file:line if any literal regresses. Bare ${VAR} env references with
  no default are skipped — those are operator-supplied at runtime
  and the validator handles them at boot.

  Verified manually:
    - Clean repo: `H-1-encryption-key-min-length: clean.` (exit 0)
    - 5-byte regression: emits proper ::error:: annotation, exit 1
    - Restore: clean again (exit 0)

  CI auto-picks up the new guard via the `for g in
  scripts/ci-guards/*.sh; do bash "$g"; done` loop in ci.yml's
  Regression guards step (no ci.yml change required).

  scripts/ci-guards/README.md updated: 20 → 21 guards, new row
  explaining the closure rationale.

The structural piece is the more important half of this fix. The
direct fix unblocks today's CI; the guard prevents the same class of
drift from ever recurring silently. Future audit closures that add
new validation rules to internal/config/config.go now have a working
template for the matching CI guard — drop a sibling .sh in the
ci-guards directory.

Bonus — what the diagnostic-dump step (3b96b35) bought us. Before
that step landed, the same failure looked like an opaque "container
unhealthy" with no actionable signal. With it, the actual error
message + the offending env var + the exact byte count came out in
one CI run. The diagnostic infrastructure paid for itself within one
push.
2026-05-01 00:57:43 +00:00
shankar0123 1122f5a097 ci(codeql): teach analyzer about ValidateSafeURL SSRF barrier
Closes CodeQL alert #23 (go/request-forgery, Critical) at the
structural level — by telling CodeQL what the runtime code already
does — rather than via per-line `// codeql[...]` suppressions.

Background. internal/service/scep_probe.go:232 calls client.Do(req)
where the request URL is built from operator-supplied input. The
runtime defense is two-layer:

  1. validation.ValidateSafeURL(rawURL) at scep_probe.go:86 rejects
     non-http(s) schemes, empty hosts, literal-IP hosts in reserved
     ranges (loopback, link-local incl. cloud metadata
     169.254.169.254, multicast, broadcast, unspecified, IPv6
     link-local), and DNS names whose A/AAAA resolution returns any
     reserved IP. RFC 1918 is intentionally NOT blocked — see
     internal/validation/ssrf.go:17-21 for the design rationale.

  2. validation.SafeHTTPDialContext on the http.Transport (line 254)
     re-resolves at dial time, applies the same reserved-IP set, and
     pins the dial to a literal non-reserved IP — defeating DNS
     rebinding between validate and dial.

CodeQL's go/request-forgery query is a syntactic taint-tracking rule
with no built-in knowledge of either validator, so it reports the
finding even though the runtime is correctly defended.

The fix. Add a Models-as-Data (MaD) extension at .github/codeql/
declaring ValidateSafeURL as a request-forgery barrier. The barrier
applies to Argument[0] (the URL parameter), which means the analyzer
treats every URL flowing through ValidateSafeURL as sanitized for the
request-forgery taint set. After this lands:

  - Alert #23 dismisses at scep_probe.go:232.
  - The same model applies to the second site of this exact shape —
    webhook notifier's outbound client.Do (internal/connector/
    notifier/webhook/webhook.go) — without per-line annotations.
  - Future code that flows operator URLs through ValidateSafeURL
    inherits the barrier automatically.

This is the structural fix, not a band-aid:

  - Band-aid (rejected): `// codeql[go/request-forgery]` suppression
    on line 232. Suppresses one alert; doesn't teach the analyzer.
    Webhook notifier would need the same comment when its sibling
    rule landing fires.

  - Structural (this change): teach CodeQL via models-as-data, in
    config checked into the repo, that lives next to the workflow
    that uses it. The validators ARE sanitizers in the runtime —
    this PR makes the analyzer's model match reality.

Files:

  - .github/codeql/qlpack.yml — local model pack manifest, declares
    extensionTargets: codeql/go-all: '*'

  - .github/codeql/models/request-forgery-sanitizers.model.yml —
    barrierModel row for validation.ValidateSafeURL Argument[0] /
    request-forgery taint kind / manual provenance

  - .github/codeql/codeql-config.yml — references the local pack +
    keeps security-and-quality query suite scope

  - .github/workflows/codeql.yml — Initialize CodeQL step picks up
    config-file: ./.github/codeql/codeql-config.yml. The existing
    `queries: security-and-quality` line stays so even if the config
    file fails to load, the suite scope is preserved.

  - docs/architecture.md::Input Validation and SSRF Protection —
    extended to name the egress validators (ValidateSafeURL +
    SafeHTTPDialContext) and the call sites (SCEP probe + webhook
    notifier). Closes the docs gap surfaced during the audit; the
    egress threat-model previously lived only in source comments.

Requires CodeQL CLI ≥ 2.25.2 for the barrierModel extensible
predicate (Go MaD support added 2026-04-21). github/codeql-action@v3
ships a recent enough CLI by default; if a future analysis fails
with "unknown extensible predicate barrierModel", the action's CLI
has regressed below 2.25.2 — pin a newer action version rather than
reverting this pack. Documented inline in qlpack.yml.

References:
  - https://codeql.github.com/docs/codeql-language-guides/customizing-library-models-for-go/
  - https://github.blog/changelog/2026-04-21-codeql-now-supports-sanitizers-and-validators-in-models-as-data/
2026-05-01 00:28:26 +00:00
605 changed files with 48936 additions and 15654 deletions
+23 -33
View File
@@ -79,7 +79,7 @@ jobs:
# does call, this step fails the build until either upstream # does call, this step fails the build until either upstream
# ships a fix OR we cut the dep. Deferred-call advisories that # ships a fix OR we cut the dep. Deferred-call advisories that
# legitimately can't be remediated yet should be added to the # legitimately can't be remediated yet should be added to the
# NIST SSDF deviation log in docs/security.md, not silenced here. # NIST SSDF deviation log in docs/operator/security.md, not silenced here.
run: govulncheck ./... run: govulncheck ./...
- name: Install staticcheck (Bundle-7 / D-001) - name: Install staticcheck (Bundle-7 / D-001)
@@ -135,48 +135,38 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: bash scripts/coverage-pr-comment.sh run: bash scripts/coverage-pr-comment.sh
# Bundle P / Strengthening #6 — QA-doc drift guards. Forces every PR # Bundle P / Strengthening #6 — QA-doc seed-count drift guard. Forces
# that adds a Part to docs/testing-guide.md OR a seed row to # every PR that adds a seed row to migrations/seed_demo.sql to keep
# migrations/seed_demo.sql to keep docs/qa-test-guide.md in sync. This # docs/contributor/qa-test-suite.md::Seed Data Reference in sync.
# eliminates the doc-drift class structurally — the symptom Bundle I #
# had to clean up by hand becomes a CI-time error going forward. # Phase 5 of the 2026-05-04 docs overhaul (commit c64777f) deleted
- name: QA-doc Part-count drift guard # docs/testing-guide.md (its content dispersed across the new
run: | # audience-organized doc tree); the previous QA-doc Part-count drift
set -e # guard tracked Part counts between testing-guide.md and the old
DOC_PARTS=$(grep -oE '49 of [0-9]+ Parts' docs/qa-test-guide.md | grep -oE '[0-9]+' | tail -1) # qa-test-guide.md headline. With testing-guide.md gone, that guard's
GUIDE_PARTS=$(grep -cE '^## Part [0-9]+:' docs/testing-guide.md) # premise is dead and it has been removed. The seed-count drift class
if [ -z "$DOC_PARTS" ]; then # is still live: qa-test-suite.md::Seed Data Reference enumerates
echo "::error::Could not extract Part count from docs/qa-test-guide.md headline." # certs/issuers and seed_demo.sql is the source of truth.
echo " Expected pattern: '49 of <N> Parts'"
exit 1
fi
if [ "$DOC_PARTS" != "$GUIDE_PARTS" ]; then
echo "::error::DRIFT — qa-test-guide.md headline claims $DOC_PARTS Parts; testing-guide.md has $GUIDE_PARTS Parts."
echo " Update docs/qa-test-guide.md to match. Bundle I patched this once;"
echo " Bundle P added this guard so the drift cannot recur silently."
exit 1
fi
echo "QA-doc Part-count drift guard: clean ($DOC_PARTS == $GUIDE_PARTS)."
- name: QA-doc seed-count drift guard - name: QA-doc seed-count drift guard
run: | run: |
set -e set -e
DOC=docs/contributor/qa-test-suite.md
# Seed-cert count: agnostic to documented header format. The current # Seed-cert count: agnostic to documented header format. The current
# documented count lives in `### Certificates (32 total in ...` — # documented count lives in `### Certificates (32 total in ...` —
# extract the first integer in that header. # extract the first integer in that header.
DOC_CERTS=$(grep -oE '### Certificates \([0-9]+' docs/qa-test-guide.md | grep -oE '[0-9]+' | head -1) DOC_CERTS=$(grep -oE '### Certificates \([0-9]+' "$DOC" | grep -oE '[0-9]+' | head -1)
# Authoritative count: unique mc-* IDs in seed_demo.sql. # Authoritative count: unique mc-* IDs in seed_demo.sql.
SEED_CERTS=$(grep -oE 'mc-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ') SEED_CERTS=$(grep -oE 'mc-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ')
if [ -z "$DOC_CERTS" ]; then if [ -z "$DOC_CERTS" ]; then
echo "::warning::Could not extract documented cert count from docs/qa-test-guide.md." echo "::warning::Could not extract documented cert count from $DOC."
echo " Skipping cert-count drift check (header format may have changed)." echo " Skipping cert-count drift check (header format may have changed)."
elif [ "$DOC_CERTS" != "$SEED_CERTS" ]; then elif [ "$DOC_CERTS" != "$SEED_CERTS" ]; then
echo "::error::DRIFT — qa-test-guide.md says $DOC_CERTS certs; seed_demo.sql has $SEED_CERTS unique mc-* IDs." echo "::error::DRIFT — $DOC says $DOC_CERTS certs; seed_demo.sql has $SEED_CERTS unique mc-* IDs."
echo " Update docs/qa-test-guide.md::Seed Data Reference to match." echo " Update $DOC::Seed Data Reference to match."
exit 1 exit 1
fi fi
# Issuers: seed-table count vs doc claim. # Issuers: seed-table count vs doc claim.
DOC_ISS=$(grep -oE '### Issuers \([0-9]+' docs/qa-test-guide.md | grep -oE '[0-9]+' | head -1) DOC_ISS=$(grep -oE '### Issuers \([0-9]+' "$DOC" | grep -oE '[0-9]+' | head -1)
# Authoritative: unique iss-* IDs (close enough proxy; the issuers # Authoritative: unique iss-* IDs (close enough proxy; the issuers
# table count IS the unique-ID count for this prefix). # table count IS the unique-ID count for this prefix).
SEED_ISS=$(grep -oE 'iss-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ') SEED_ISS=$(grep -oE 'iss-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ')
@@ -186,7 +176,7 @@ jobs:
# Allow up to 5pp slack — iss-* IDs appear in audit_events and # Allow up to 5pp slack — iss-* IDs appear in audit_events and
# other reference tables that aren't issuer-table rows. Drift # other reference tables that aren't issuer-table rows. Drift
# only flags when the spread grows large. # only flags when the spread grows large.
echo "::error::DRIFT — qa-test-guide.md says $DOC_ISS issuers; seed_demo.sql has $SEED_ISS unique iss-* IDs (spread > 5)." echo "::error::DRIFT — $DOC says $DOC_ISS issuers; seed_demo.sql has $SEED_ISS unique iss-* IDs (spread > 5)."
exit 1 exit 1
fi fi
echo "QA-doc seed-count drift guard: clean." echo "QA-doc seed-count drift guard: clean."
@@ -209,7 +199,7 @@ jobs:
# 167 legitimate tests for no observable behavior change. The # 167 legitimate tests for no observable behavior change. The
# Test<Func>_<Scenario>_<ExpectedResult> form remains documented as # Test<Func>_<Scenario>_<ExpectedResult> form remains documented as
# the recommended pattern for parameterized scenarios in # the recommended pattern for parameterized scenarios in
# docs/qa-test-guide.md, but is not gated. # docs/contributor/qa-test-suite.md, but is not gated.
- name: Regression guards (extracted to scripts/ci-guards/) - name: Regression guards (extracted to scripts/ci-guards/)
# All named regression guards live at scripts/ci-guards/<id>.sh per # All named regression guards live at scripts/ci-guards/<id>.sh per
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally: # ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
@@ -289,7 +279,7 @@ jobs:
# HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is # HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is
# configured. Every lint/template invocation below must pick exactly one # configured. Every lint/template invocation below must pick exactly one
# provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl # provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl
# (certctl.tls.required) and docs/tls.md. # (certctl.tls.required) and docs/operator/tls.md.
- name: Lint Helm Chart - name: Lint Helm Chart
run: | run: |
helm lint deploy/helm/certctl/ \ helm lint deploy/helm/certctl/ \
@@ -336,7 +326,7 @@ jobs:
# RAM headroom on ubuntu-latest (16 GB ceiling) — operator-confirmed # RAM headroom on ubuntu-latest (16 GB ceiling) — operator-confirmed
# in Phase 0 / frozen decision 0.14 prototype-branch run. If RAM # in Phase 0 / frozen decision 0.14 prototype-branch run. If RAM
# regresses, fall back to bucketed matrix per # regresses, fall back to bucketed matrix per
# cowork/ci-pipeline-cleanup/decisions-revised.md. # the project's frozen-decisions log.
# #
# The Windows matrix (deploy-vendor-e2e-windows) was deleted entirely # The Windows matrix (deploy-vendor-e2e-windows) was deleted entirely
# per Phase 6 / frozen decision 0.5 (revises Bundle II decision 0.4). # per Phase 6 / frozen decision 0.5 (revises Bundle II decision 0.4).
+77
View File
@@ -0,0 +1,77 @@
# Load-test workflow — closes the #8 acquisition-readiness blocker from
# the 2026-05-01 issuer coverage audit (see
# the 2026-05-01 issuer coverage audit).
#
# CADENCE: workflow_dispatch + weekly cron, NOT per-push. Load tests
# are minutes long and don't provide useful per-PR signal — per-push
# pressure goes through ci.yml. This workflow exists to (a) catch
# gradual regressions from cumulative changes that no single PR
# triggered, and (b) give an operator a one-click way to capture
# numbers before tagging a release.
#
# THRESHOLDS: defined in deploy/test/loadtest/k6.js (p99 < 5s for
# issuance-acceptance, p99 < 2s for list, error rate < 1%). k6 exits
# non-zero on any breach, which propagates through `docker compose up
# --exit-code-from k6` → `make loadtest` → this workflow's exit.
name: loadtest
on:
workflow_dispatch:
# Manual trigger from the Actions tab. Use before tagging a
# release or after a meaningful tuning commit.
schedule:
# Mondays at 06:00 UTC. Off-peak; catches regressions accumulated
# over the previous week's merges. Once a baseline is committed
# in deploy/test/loadtest/README.md, drift relative to that
# baseline is the signal — diff the captured summary.json
# against the committed numbers.
- cron: '0 6 * * 1'
# Reduce permissions — this workflow doesn't write to PRs or push tags.
permissions:
contents: read
jobs:
k6:
name: k6 throughput run
runs-on: ubuntu-latest
# 25-minute hard cap. Pre-Bundle-10: 15min was enough for the API
# tier alone (~7 minutes total). Post-Bundle-10 the harness boots
# four additional target sidecars (nginx, apache, haproxy, f5-mock)
# before the k6 run; their healthchecks add ~30-60s. The k6 scenarios
# themselves are still 5 minutes (run in parallel with the API
# scenarios, not serially). 25 minutes absorbs that plus slow CI
# runners and cold image caches without letting a stuck container
# consume the runner indefinitely.
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
# The compose stack builds the certctl image from the repo
# root Dockerfile. Buildx gives the build a usable cache and
# works with newer compose versions.
uses: docker/setup-buildx-action@v3
- name: Run loadtest
run: make loadtest
env:
# Disable BuildKit progress noise so the run log is
# diff-able against past runs.
BUILDKIT_PROGRESS: plain
- name: Upload summary
# Always upload the summary so a regression has a diffable
# artifact even when k6 exited non-zero. summary.json is the
# authoritative machine-readable form; summary.txt is the
# human-readable text the README baseline tracks.
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-summary-${{ github.run_id }}
path: deploy/test/loadtest/results/
retention-days: 90
+20 -8
View File
@@ -1,5 +1,12 @@
name: Release name: Release
# Override the auto-generated run name (which would otherwise default to
# the most recent commit subject + a #NN run number) so the Actions tab
# shows "Release v2.0.69" instead of "chore: rename Go module path... #73".
# `github.ref_name` resolves to the tag name (e.g., `v2.0.69`) for tag-triggered
# workflows, which is the only trigger we set below.
run-name: Release ${{ github.ref_name }}
on: on:
push: push:
tags: tags:
@@ -9,7 +16,7 @@ env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
# Keep in lock-step with .github/workflows/ci.yml (M-3). # Keep in lock-step with .github/workflows/ci.yml (M-3).
GO_VERSION: '1.25.9' GO_VERSION: '1.25.9'
IMAGE_NAMESPACE: shankar0123 IMAGE_NAMESPACE: certctl-io
jobs: jobs:
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -346,9 +353,14 @@ jobs:
# noise that gives operators no signal about what actually changed. # noise that gives operators no signal about what actually changed.
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Pin the release title to the tag name. softprops/action-gh-release@v2
# falls back to the most recent commit subject when `name:` is omitted,
# which produces ugly titles like "chore: rename Go module path..." on
# the Releases page. `github.ref_name` evaluates to the tag (`v2.0.69`).
name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
body: | body: |
> **Install / upgrade:** see the [Quick Start section in the README](https://github.com/shankar0123/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions. > **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions.
## Verifying this release ## Verifying this release
@@ -369,7 +381,7 @@ jobs:
```bash ```bash
cosign verify-blob \ cosign verify-blob \
--bundle checksums.txt.sigstore.json \ --bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \ --certificate-identity-regexp '^https://github\.com/certctl-io/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txt checksums.txt
``` ```
@@ -383,7 +395,7 @@ jobs:
```bash ```bash
slsa-verifier verify-artifact \ slsa-verifier verify-artifact \
--provenance-path multiple.intoto.jsonl \ --provenance-path multiple.intoto.jsonl \
--source-uri github.com/shankar0123/certctl \ --source-uri github.com/certctl-io/certctl \
--source-tag ${{ steps.version.outputs.VERSION }} \ --source-tag ${{ steps.version.outputs.VERSION }} \
certctl-agent-linux-amd64 certctl-agent-linux-amd64
``` ```
@@ -391,21 +403,21 @@ jobs:
**4. Verify container image signature and attestations:** **4. Verify container image signature and attestations:**
```bash ```bash
IMAGE=ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }} IMAGE=ghcr.io/certctl-io/certctl-server:${{ steps.version.outputs.VERSION }}
cosign verify \ cosign verify \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \ --certificate-identity-regexp '^https://github\.com/certctl-io/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE" "$IMAGE"
# SBOM attestation (SPDX-JSON) emitted by docker/build-push-action # SBOM attestation (SPDX-JSON) emitted by docker/build-push-action
cosign verify-attestation --type spdxjson \ cosign verify-attestation --type spdxjson \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \ --certificate-identity-regexp '^https://github\.com/certctl-io/certctl/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE" "$IMAGE"
# SLSA provenance attestation (mode=max) # SLSA provenance attestation (mode=max)
cosign verify-attestation --type slsaprovenance \ cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \ --certificate-identity-regexp '^https://github\.com/certctl-io/certctl/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE" "$IMAGE"
``` ```
+2 -2
View File
@@ -20,7 +20,7 @@ name: security-deep-scan
# #
# Each step is best-effort — failures are uploaded as artefacts but do # Each step is best-effort — failures are uploaded as artefacts but do
# NOT block the workflow. Triage happens via the Bundle-7 receipt # NOT block the workflow. Triage happens via the Bundle-7 receipt
# directory under cowork/comprehensive-audit-2026-04-25/tool-output/. # the project's comprehensive-audit tool-output directory.
on: on:
schedule: schedule:
@@ -82,7 +82,7 @@ jobs:
# package is mutated independently; the per-package summary line # package is mutated independently; the per-package summary line
# (`The mutation score is X.YZ`) is grep-extracted into the receipt. # (`The mutation score is X.YZ`) is grep-extracted into the receipt.
# Acceptance threshold: ≥80% kill ratio per package; surviving # Acceptance threshold: ≥80% kill ratio per package; surviving
# mutants get triaged in cowork/comprehensive-audit-2026-04-25/ # mutants get triaged in the project's comprehensive-audit notes/
# d003-mutation-results.md (per-mutant action item or # d003-mutation-results.md (per-mutant action item or
# equivalent-mutation justification). # equivalent-mutation justification).
+10 -2
View File
@@ -1,11 +1,19 @@
# Changelog # Changelog
## v2.0.68 — Image registry path changed ⚠️
> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:<tag>` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever — only the container-registry path changed.
This is the only operator-action-required change in v2.0.68. Other changes in this release are cosmetic URL refreshes after the GitHub-org transfer from `shankar0123/certctl` to `certctl-io/certctl` (HTTP redirects mean no other operator action is required) plus an internal contextcheck lint fix in the agent. Full commit list is on the [GitHub release page](https://github.com/certctl-io/certctl/releases/tag/v2.0.68).
---
certctl no longer maintains a hand-edited per-version changelog. Per-release certctl no longer maintains a hand-edited per-version changelog. Per-release
notes are auto-generated from commit messages between consecutive tags. notes are auto-generated from commit messages between consecutive tags.
**Where to find what changed in a given release:** **Where to find what changed in a given release:**
- **[GitHub Releases](https://github.com/shankar0123/certctl/releases)** — every - **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** — every
tag has an auto-generated "What's Changed" section pulled from the commits tag has an auto-generated "What's Changed" section pulled from the commits
between that tag and the previous one, plus per-release supply-chain between that tag and the previous one, plus per-release supply-chain
verification instructions (Cosign / SLSA / SBOM). verification instructions (Cosign / SLSA / SBOM).
@@ -27,5 +35,5 @@ without depending on the author to manually update a separate file.
**For the historical record:** earlier versions (pre-v2.2.0 and the [2.2.0] **For the historical record:** earlier versions (pre-v2.2.0 and the [2.2.0]
tag itself) had a hand-edited CHANGELOG. That content is preserved in tag itself) had a hand-edited CHANGELOG. That content is preserved in
[git history](https://github.com/shankar0123/certctl/blob/v2.2.0/CHANGELOG.md) [git history](https://github.com/certctl-io/certctl/blob/v2.2.0/CHANGELOG.md)
at the v2.2.0 tag. at the v2.2.0 tag.
+86 -24
View File
@@ -2,26 +2,54 @@ Business Source License 1.1
Parameters Parameters
Licensor: Shankar Reddy Licensor: Shankar Kambam
Licensed Work: certctl Licensed Work: certctl
The Licensed Work is (c) 2026 Shankar Reddy. The Licensed Work is © 2026 Shankar Kambam.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Commercial
Certificate Service. A "Commercial Certificate Service"
is any product, service, or offering in which a third
party (other than your employees and contractors
acting on your behalf) accesses, uses, or benefits
from the Licensed Work's certificate management
functionality — including but not limited to lifecycle
management, discovery, monitoring, alerting, renewal
automation, deployment, and revocation — as part of
or in connection with an offering for which
compensation is received. This restriction applies
regardless of whether the Licensed Work is hosted,
managed, embedded, bundled, or integrated with
another product or service.
Change Date: March 14, 2126 Additional Use Grant: You may make use of the Licensed Work, including in
production for your internal business operations and
for operations that provide products or services to
your own customers, provided that you may not offer
the Licensed Work as a Commercial Certificate Service.
A "Commercial Certificate Service" is a product or
service whose principal value to a third party is the
certificate management functionality of the Licensed
Work — including but not limited to lifecycle
management, discovery, monitoring, alerting, renewal
automation, deployment, and revocation — where the
third party accesses or controls that functionality
and compensation is received for that access or
control.
For the avoidance of doubt:
(a) you may run the Licensed Work in production to
manage certificates for products or services
that you offer to your customers, where the
principal value of those products or services is
something other than the Licensed Work's
certificate management functionality (for
example, you operate a banking application and
use the Licensed Work internally to manage TLS
certificates for that application);
(b) for the purposes of this Additional Use Grant,
"third party" excludes (i) your employees, (ii)
your contractors acting on your behalf, and (iii)
your Affiliates. "Affiliate" means any entity
that controls, is controlled by, or is under
common control with, you, where "control" means
ownership of more than fifty percent (50%) of
the voting interests of the entity;
(c) the restriction on offering a Commercial
Certificate Service applies regardless of whether
the Licensed Work is hosted, managed, embedded,
bundled, or integrated with another product or
service.
Change Date: March 14, 2076
Change License: Apache License, Version 2.0 Change License: Apache License, Version 2.0
@@ -60,13 +88,47 @@ of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work. License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically Patent non-assertion. During the term of this License, Licensor covenants
terminate your rights under this License for the current and all other not to assert any patent claim that Licensor controls against any person
versions of the Licensed Work. whose use of the Licensed Work complies with this License, with respect to
the Licensed Work as distributed by Licensor. This covenant terminates with
respect to any person who initiates a patent infringement action against
the Licensor or against any contributor to the Licensed Work.
This License does not grant you any right in any trademark or logo of Termination and reinstatement. Any use of the Licensed Work in violation of
Licensor or its affiliates (provided that you may use a trademark or logo of this License will automatically terminate your rights under this License
Licensor as expressly required by this License). for the current and all other versions of the Licensed Work. Your rights
are reinstated automatically if you cease the violation and provide written
notice to the Licensor at the contact address above within thirty (30) days
of becoming aware of the violation. If you violate this License a second
time after such reinstatement, your rights are not subject to further
reinstatement.
Contributions. The Licensor does not accept third-party contributions to
the Licensed Work. Any code, documentation, or other material submitted to
the Licensor or to any repository hosting the Licensed Work is provided at
the submitter's sole risk, confers no rights or obligations on the
Licensor, and is not incorporated into the Licensed Work.
This License does not grant you any right in any trademark or logo of the
Licensor or its Affiliates.
Governing law and venue. This License shall be governed by and construed in
accordance with the laws of the State of Florida, USA, without giving
effect to any choice or conflict of law provision or rule. Any dispute
arising from or relating to this License shall be brought exclusively in
the state or federal courts located in the State of Florida, and the
parties consent to the personal jurisdiction of such courts.
Severability. If any provision of this License is held to be invalid,
illegal, or unenforceable in any jurisdiction, that holding does not
affect the validity, legality, or enforceability of any other provision of
this License, which remains in full force and effect.
Survival. The disclaimers of warranty, the patent non-assertion provisions
(with respect to acts occurring before termination), the governing-law and
venue provisions, and this survival provision survive any termination of
this License.
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+65 -13
View File
@@ -1,4 +1,4 @@
.PHONY: help build run test lint verify verify-docs verify-deploy clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build qa-stats .PHONY: help build run test lint verify verify-docs verify-deploy loadtest acme-cert-manager-test acme-rfc-conformance-test clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build qa-stats
# Default target - show help # Default target - show help
help: help:
@@ -18,6 +18,7 @@ help:
@echo " make verify Pre-commit gate: fmt + vet + lint + test (CI-parity)" @echo " make verify Pre-commit gate: fmt + vet + lint + test (CI-parity)"
@echo " make verify-docs Pre-tag gate: QA-doc drift checks (operator-facing docs)" @echo " make verify-docs Pre-tag gate: QA-doc drift checks (operator-facing docs)"
@echo " make verify-deploy Pre-push gate: digest validity + OpenAPI parity + docker build smoke" @echo " make verify-deploy Pre-push gate: digest validity + OpenAPI parity + docker build smoke"
@echo " make loadtest k6 throughput run against postgres + certctl (NOT in verify; manual + cron only)"
@echo "" @echo ""
@echo "Database:" @echo "Database:"
@echo " make migrate-up Run migrations (requires DB_URL)" @echo " make migrate-up Run migrations (requires DB_URL)"
@@ -118,15 +119,18 @@ verify:
@echo "" @echo ""
@echo "verify: PASS — safe to commit" @echo "verify: PASS — safe to commit"
# verify-docs: pre-tag gate. Runs the QA-doc Part-count + seed-count # verify-docs: pre-tag gate. Runs the QA-doc seed-count drift guard
# drift guards that ci-pipeline-cleanup Phase 11 / frozen decision 0.13 # that ci-pipeline-cleanup Phase 11 / frozen decision 0.13 moved out
# moved out of CI (was per-push blocking; now operator-runs pre-tag). # of CI (was per-push blocking; now operator-runs pre-tag). Protects
# These guards protect docs/qa-test-guide.md headlines from drifting # docs/contributor/qa-test-suite.md::Seed Data Reference from
# vs the underlying source-of-truth (testing-guide Part count, seed # drifting vs migrations/seed_demo.sql. Operator-facing docs only —
# row count). Operator-facing docs only — not product-affecting. # not product-affecting.
#
# The QA-doc Part-count drift guard retired in the 2026-05-04 docs
# overhaul Phase 5 when docs/testing-guide.md was pruned (its content
# dispersed across the audience-organized doc tree); the Part-count
# class no longer exists outside the qa_test.go file itself.
verify-docs: verify-docs:
@echo "==> QA-doc Part-count drift"
@bash scripts/qa-doc-part-count.sh
@echo "==> QA-doc seed-count drift" @echo "==> QA-doc seed-count drift"
@bash scripts/qa-doc-seed-count.sh @bash scripts/qa-doc-seed-count.sh
@echo "" @echo ""
@@ -150,6 +154,52 @@ verify-deploy:
@echo "" @echo ""
@echo "verify-deploy: PASS — safe to push" @echo "verify-deploy: PASS — safe to push"
# Load-test harness — closes the #8 acquisition-readiness blocker from
# the 2026-05-01 issuer coverage audit. Boots a minimal certctl stack
# (postgres + tls-init + certctl-server) and runs k6 against the API
# tier for ~5 minutes. Exits non-zero on any threshold breach.
#
# NOT in `make verify` — load tests take minutes, not seconds, and
# don't gate per-PR signal. CI gates this behind workflow_dispatch +
# weekly cron in .github/workflows/loadtest.yml. See
# deploy/test/loadtest/README.md for thresholds, baseline, and how to
# interpret a regression.
loadtest:
@echo "==> spinning up postgres + certctl + k6 driver (this takes ~7m)"
@cd deploy/test/loadtest && docker compose up --build --abort-on-container-exit --exit-code-from k6
@echo ""
@echo "==> results landed in deploy/test/loadtest/results/"
@if [ -f deploy/test/loadtest/results/summary.txt ]; then cat deploy/test/loadtest/results/summary.txt; fi
# Phase 5 — kind-driven cert-manager integration test. Requires
# `kind`, `kubectl`, `helm`, and a local Docker daemon. Sets
# KIND_AVAILABLE=1 so the test runs (it skips cleanly when unset, which
# is the CI default — kind is too heavy for per-PR CI). The test
# brings up a fresh cluster, installs cert-manager 1.15, helm-installs
# certctl-test, applies a ClusterIssuer + Certificate, and asserts the
# Secret lands.
acme-cert-manager-test:
@echo "==> running cert-manager integration test (requires kind/kubectl/helm)"
@KIND_AVAILABLE=1 go test -tags=integration -count=1 -timeout=15m \
./deploy/test/acme-integration/...
# Phase 5 — RFC 8555 conformance against `lego` driving the certctl
# server. Hermetic: brings up a single certctl-server via docker
# compose, points lego at it, runs the conformance scenarios. Skips
# when the operator hasn't built the test image (`make docker-build`
# first).
acme-rfc-conformance-test:
@echo "==> running RFC 8555 conformance via lego"
@if ! command -v lego >/dev/null 2>&1; then \
echo "lego not installed — go install github.com/go-acme/lego/v4/cmd/lego@latest"; \
exit 1; \
fi
@cd deploy/test/loadtest && docker compose up -d certctl postgres
@sleep 8
@CERTCTL_ACME_DIR=https://localhost:8443/acme/profile/prof-test/directory \
bash deploy/test/acme-integration/conformance-lego.sh
@cd deploy/test/loadtest && docker compose down
# Database targets (requires migrate tool) # Database targets (requires migrate tool)
migrate-up: migrate-up:
@echo "Running migrations..." @echo "Running migrations..."
@@ -216,9 +266,12 @@ frontend-build:
@echo "Frontend build complete" @echo "Frontend build complete"
# QA Suite Stats — Bundle P / Strengthening #8. # QA Suite Stats — Bundle P / Strengthening #8.
# Single source-of-truth for every count claim in docs/qa-test-guide.md + # Single source-of-truth for every count claim in
# docs/testing-guide.md. The Strengthening #6 CI drift guards consume the # docs/contributor/qa-test-suite.md. The Strengthening #6 CI drift guards
# same numbers, eliminating the doc-drift class structurally. # (now scoped to the seed-count class only — the Part-count class retired
# in the 2026-05-04 docs overhaul Phase 5 when testing-guide.md was
# pruned) consume the same numbers, eliminating the doc-drift class
# structurally.
qa-stats: qa-stats:
@echo "=== certctl QA Suite Stats ===" @echo "=== certctl QA Suite Stats ==="
@echo "Date: $$(date +%Y-%m-%d)" @echo "Date: $$(date +%Y-%m-%d)"
@@ -231,7 +284,6 @@ qa-stats:
@echo "Fuzz targets: $$(grep -rE 'func Fuzz[A-Z]' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')" @echo "Fuzz targets: $$(grep -rE 'func Fuzz[A-Z]' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
@echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')" @echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
@echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)" @echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)"
@echo "testing-guide.md Parts: $$(grep -cE '^## Part [0-9]+:' docs/testing-guide.md 2>/dev/null || echo 0)"
@echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')" @echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 12)" @echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 12)"
@echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)" @echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)"
+59 -318
View File
@@ -2,148 +2,36 @@
<img src="docs/screenshots/logo/certctl-logo.png" alt="certctl logo" width="450"> <img src="docs/screenshots/logo/certctl-logo.png" alt="certctl logo" width="450">
</p> </p>
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=89db181e-76e0-45cc-b9c0-790c3dfdfc73" />
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=b9379aff-9e5c-4d01-8f2d-9e4ffa09d126" />
# certctl — Self-Hosted Certificate Lifecycle Platform # certctl — Self-Hosted Certificate Lifecycle Platform
[![License](https://img.shields.io/badge/license-BSL%201.1-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/license-BSL%201.1-blue.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/shankar0123/certctl)](https://goreportcard.com/report/github.com/shankar0123/certctl) [![Go Report Card](https://goreportcard.com/badge/github.com/certctl-io/certctl)](https://goreportcard.com/report/github.com/certctl-io/certctl)
[![GitHub Release](https://img.shields.io/github/v/release/shankar0123/certctl)](https://github.com/shankar0123/certctl/releases) [![GitHub Release](https://img.shields.io/github/v/release/certctl-io/certctl)](https://github.com/certctl-io/certctl/releases)
[![GitHub Stars](https://img.shields.io/github/stars/shankar0123/certctl?style=flat&logo=github)](https://github.com/shankar0123/certctl/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/certctl-io/certctl?style=flat&logo=github)](https://github.com/certctl-io/certctl/stargazers)
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for.
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong. It's free, self-hosted, and covers the same lifecycle that enterprise platforms charge $100K+/year for. The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice.
```mermaid > **Actively maintained, shipping weekly.** [Open an issue](https://github.com/certctl-io/certctl/issues) if something breaks. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
gantt
title TLS Certificate Maximum Lifespan — CA/Browser Forum Ballot SC-081v3
dateFormat YYYY-MM-DD
axisFormat
todayMarker off
section 2015
5 years (1825 days) :done, 2020-01-01, 1825d
section 2018
825 days :done, 2020-01-01, 825d
section 2020
398 days :active, 2020-01-01, 398d
section 2026
200 days :crit, 2020-01-01, 200d
section 2027
100 days :crit, 2020-01-01, 100d
section 2029
47 days :crit, 2020-01-01, 47d
```
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit. **Ready to try it?** Jump to the [Quick Start](#quick-start). For the marketing site, see [certctl.io](https://certctl.io).
**Ready to try it?** Jump to the [Quick Start](#quick-start) — you'll have a running dashboard in under 5 minutes.
## Documentation ## Documentation
| Guide | Description | The full audience-organized index lives at [`docs/README.md`](docs/README.md). Top-level entry points:
|-------|-------------|
| [Why certctl?](docs/why-certctl.md) | How certctl compares to ACME clients, agent-based SaaS, and enterprise platforms |
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
| [Quick Start](docs/quickstart.md) | 5-minute setup — dashboard, API, CLI, discovery, stakeholder demo flow |
| [Docker Compose Environments](deploy/ENVIRONMENTS.md) | Service-by-service walkthrough of all 4 compose files, env var reference |
| [Deployment Examples](docs/examples.md) | 5 turnkey scenarios (ACME+NGINX, wildcard DNS-01, private CA, step-ca, multi-issuer) with migration guides |
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
| [Feature Inventory](docs/features.md) | Complete reference of all capabilities, API endpoints, and configuration |
| [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors |
| [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples |
| [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) |
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
| [Migrate from certbot](docs/migrate-from-certbot.md) | Step-by-step migration from certbot cron jobs to certctl |
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users, DNS hook compatibility |
| [certctl for cert-manager users](docs/certctl-for-cert-manager-users.md) | How certctl complements cert-manager for mixed infrastructure |
| [Test Environment](docs/test-env.md) | Docker Compose test environment with real CA backends |
| [Testing Guide](docs/testing-guide.md) | Comprehensive test procedures, smoke tests, and release sign-off checklist |
## Supported Integrations | Audience | Start here |
|---|---|
| New to certctl | [Concepts](docs/getting-started/concepts.md) → [Quickstart](docs/getting-started/quickstart.md) → [Examples](docs/getting-started/examples.md) |
| Production operator | [Architecture](docs/reference/architecture.md) → [Security posture](docs/operator/security.md) → [Disaster recovery runbook](docs/operator/runbooks/disaster-recovery.md) |
| PKI engineer | [ACME server](docs/reference/protocols/acme-server.md) → [SCEP server](docs/reference/protocols/scep-server.md) → [EST server](docs/reference/protocols/est.md) → [CA hierarchy](docs/reference/intermediate-ca-hierarchy.md) |
| Migrating from another tool | [from certbot](docs/migration/from-certbot.md) / [from acme.sh](docs/migration/from-acmesh.md) / [cert-manager coexistence](docs/migration/cert-manager-coexistence.md) |
| Contributor | [Architecture](docs/reference/architecture.md) → [Testing strategy](docs/contributor/testing-strategy.md) → [CI pipeline](docs/contributor/ci-pipeline.md) |
### Certificate Issuers For the connector reference (12 issuers, 15 targets, 6 notifiers) see [`docs/reference/connectors/index.md`](docs/reference/connectors/index.md).
| Issuer | Type | Notes | ## Screenshots
|--------|------|-------|
| Local CA (self-signed + sub-CA) | `GenericCA` | Sub-CA mode chains to enterprise root (ADCS, etc.) |
| ACME v2 (Let's Encrypt, ZeroSSL, etc.) | `ACME` | HTTP-01, DNS-01, DNS-PERSIST-01 challenges. EAB auto-fetch from ZeroSSL. Profile selection (`tlsserver`, `shortlived`). |
| step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation |
| OpenSSL / Custom CA | `OpenSSL` | Shell script adapter — any CA with a CLI |
| HashiCorp Vault PKI | `VaultPKI` | Token auth, synchronous issuance, CRL/OCSP delegated to Vault |
| DigiCert CertCentral | `DigiCert` | Async order model, OV/EV support, PEM bundle parsing |
| Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling |
| Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection |
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
| Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance |
| GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking |
| EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS or OAuth2), self-hosted open-source CA |
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector.
### Deployment Targets
| Target | Type | Notes |
|--------|------|-------|
| NGINX | `NGINX` | Atomic write + `nginx -t` validate + `nginx -s reload` + post-deploy TLS verify + rollback (deploy-hardening I) |
| Apache httpd | `Apache` | Atomic write + `apachectl configtest` + graceful reload + post-deploy TLS verify + rollback |
| HAProxy | `HAProxy` | Combined PEM atomic write + `haproxy -c -f` validate + `systemctl reload` + post-deploy TLS verify + rollback |
| Traefik | `Traefik` | Atomic write + post-deploy TLS verify + rollback (file watcher auto-reloads) |
| Caddy | `Caddy` | Atomic write (file mode) or `POST /load` (api mode) + admin API ValidateOnly probe |
| Envoy | `Envoy` | Atomic write + SDS file watcher auto-reload |
| Postfix | `Postfix` | Atomic write + `postfix check` + `postfix reload` + post-deploy TLS verify + rollback |
| Dovecot | `Dovecot` | Atomic write + `doveconf -n` + `doveadm reload` + post-deploy TLS verify + rollback |
| Microsoft IIS | `IIS` | Local PowerShell or remote WinRM, PEM→PFX, SNI support, explicit pre-deploy backup + post-rollback re-import |
| F5 BIG-IP | `F5` | iControl REST via proxy agent, transaction-based atomic updates + post-deploy TLS verify on Virtual Server |
| SSH (Agentless) | `SSH` | SFTP cert/key deployment + pre-deploy SCP backup + tls.Dial post-verify |
| Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate + Get-ChildItem snapshot for rollback |
| Java Keystore | `JavaKeystore` | PEM→PKCS#12→keytool pipeline + keytool snapshot for rollback |
| Kubernetes Secrets | `KubernetesSecrets` | `kubernetes.io/tls` Secrets, atomic API + SHA-256 verify + kubelet sync poll |
**Deploy-hardening I** (post-2026-04-30 master bundle): every connector now goes through `internal/deploy.Apply` for atomic-write + ownership-preservation + SHA-256 idempotency + per-target-type Prometheus counters (`certctl_deploy_*_total`). See [`docs/deployment-atomicity.md`](docs/deployment-atomicity.md) for the operator guide.
### Enrollment Protocols
| Protocol | Standard | Use Case |
|----------|----------|----------|
| **EST (production-grade)** | RFC 7030 + RFC 9266 channel binding | Native EST server hardened for enterprise WiFi/802.1X, IoT bootstrap, and corporate device enrollment (post-2026-04-29 hardening master bundle). All six RFC 7030 endpoints — `cacerts` / `simpleenroll` / `simplereenroll` / `csrattrs` (profile-driven) / `serverkeygen` (CMS EnvelopedData wire format). Multi-profile dispatch (`/.well-known/est/<pathID>/`). Per-profile auth modes: mTLS sibling route at `/.well-known/est-mtls/<pathID>/`, HTTP Basic enrollment-password (constant-time compare + per-source-IP failed-auth limiter), RFC 9266 `tls-exporter` channel binding (TLS 1.3, opt-in per profile). Per-(CN, sourceIP) sliding-window rate limit. EST-source-scoped bulk revoke (`POST /api/v1/est/certificates/bulk-revoke`, M-008 admin-gated). Tabbed admin GUI at `/est` (Profiles / Recent Activity / Trust Bundle). `SIGHUP`-equivalent trust-bundle reload. libest reference-client interop tested in CI (`deploy/test/libest/Dockerfile` + `deploy/test/est_e2e_test.go`). Typed audit-action codes per failure dimension (`est_simple_enroll_success`/`_failed`, `est_auth_failed_basic`/`_mtls`/`_channel_binding`, `est_rate_limited`, `est_csr_policy_violation`, `est_bulk_revoke`, `est_trust_anchor_reloaded`, etc. — full set in `internal/service/est_audit_actions.go`). CLI + matching MCP tool family (rebuild count via `grep -cE '"est_' internal/mcp/tools_est.go`). See [`docs/est.md`](docs/est.md) for the operator guide — WiFi/802.1X + FreeRADIUS recipe, IoT bootstrap, troubleshooting matrix per audit-action code. |
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. |
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
### Standards & Revocation
| Capability | Standard | Notes |
|------------|----------|-------|
| DER-encoded X.509 CRL | RFC 5280 + RFC 7232 caching | Per-issuer, signed by issuing CA, 24h validity. Pre-generated by the scheduler (`CERTCTL_CRL_GENERATION_INTERVAL`, default 1h) and cached in `crl_cache` so HTTP fetches do not rebuild per request. **Production hardening II:** weak-form `ETag` (W/"<sha256-prefix>") + `Cache-Control: public, max-age=3600, must-revalidate` + `If-None-Match` HTTP 304 short-circuit on `GET /.well-known/pki/crl/{issuer_id}` — CDNs and reverse proxies serve repeated fetches from edge cache. |
| CRL DistributionPoints auto-injection | RFC 5280 §4.2.1.13 | **Production hardening II.** Local issuer config field `CRLDistributionPointURLs []string` — when set, every issued cert carries the `id-ce-cRLDistributionPoints` extension pointing at certctl's own CRL endpoint. Refusing to silently inject an empty CDP is deliberate (silent-empty fails relying-party validation worse than no CDP). |
| Embedded OCSP responder | RFC 6960 + §4.4.1 nonce echo | GET + POST forms (`POST /.well-known/pki/ocsp/{issuer_id}` per §A.1.1). Signed by a per-issuer dedicated OCSP responder cert (RFC 6960 §2.6) carrying `id-pkix-ocsp-nocheck` (§4.2.2.2.1) — the CA private key is never used directly for OCSP signing. Responder cert auto-rotates within 7d of expiry. **Production hardening II:** RFC 6960 §4.4.1 nonce extension echoed in the response (defends against replay attacks); empty/oversized (>32 bytes per CA/B Forum BR §4.10.2) nonces produce the canonical "unauthorized" status (status 6) — never echo malformed bytes. |
| OCSP pre-signed response cache | — | **Production hardening II.** Per-`(issuer, serial)` pre-signed responses in the new `ocsp_response_cache` table; read-through facade in `CAOperationsSvc.GetOCSPResponseWithNonce` consults the cache for nil-nonce requests. **Load-bearing security wire:** `RevocationSvc.RevokeCertificateWithActor` calls `InvalidateOnRevoke` after a successful revoke so the next OCSP fetch returns the revoked status — no stale-good window. |
| Per-endpoint rate limits | — | **Production hardening II.** OCSP per-source-IP cap at `CERTCTL_OCSP_RATE_LIMIT_PER_IP_MIN` (default 1000/min, zero disables); cert-export per-actor cap at `CERTCTL_CERT_EXPORT_RATE_LIMIT_PER_ACTOR_HR` (default 50/hr, zero disables). OCSP rate-limit trip returns the canonical "unauthorized" OCSP blob plus `Retry-After: 60`; cert-export trip returns HTTP 429. The OCSP limiter does NOT honor `X-Forwarded-For` (publicly reachable; spoofed headers would bypass the cap). |
| Cert-export typed audit | — | **Production hardening II.** Typed action constants (`cert_export_pem` / `cert_export_pkcs12` / `cert_export_pem_with_key` reserved / `cert_export_failed`) emitted via split-emit alongside the legacy bare codes for back-compat. Detail map carries `has_private_key` (always false in V2) and `cipher` (`AES-256-CBC-PBE2-SHA256` — pinned so a future dependency upgrade that changes the encoder default surfaces in audit drift review). |
| Prometheus per-area metrics | OpenMetrics | `GET /api/v1/metrics/prometheus` — production hardening II surfaces `certctl_ocsp_counter_total{label="..."}` per-event series (`request_get`/`_post`, `request_success`/`_invalid`, `nonce_echoed`/`_malformed`, `rate_limited`, `signing_failed`, etc.) wired from the shared counter table that ticks in the cache hot path. CRL / cert-export / EST / SCEP / Intune per-area counters plug in via the same `SetXxxCounters` setter pattern as follow-up commits. |
| Disaster-recovery runbook | — | **Production hardening II.** [`docs/disaster-recovery.md`](docs/disaster-recovery.md) — 8-section operator-grade runbook: CRL cache recovery, OCSP responder cert recovery, OCSP response cache recovery, CA private-key rotation 9-step playbook, Postgres restore + operator-managed-artifacts list, trust-bundle reload semantics, printable DR checklist. The SOC 2 / PCI procurement-team deliverable. |
| S/MIME certificates | RFC 8551 | Email protection EKU, adaptive KeyUsage flags (`DigitalSignature \| ContentCommitment` instead of the TLS default `DigitalSignature \| KeyEncipherment`). |
| Certificate export | — | PEM (JSON/file) and PKCS#12 (cert-only trust-store mode via `pkcs12.Modern` — AES-256-CBC PBE2 with SHA-256 KDF). Key-bearing PKCS#12 export deferred — V2 export is cert-only by design (private keys live on agents, never touch the control plane). |
| ACME DNS-PERSIST-01 | IETF draft | Standing validation record, no per-renewal DNS updates |
### Notifiers
| Notifier | Type |
|----------|------|
| Email (SMTP) | `Email` |
| Webhooks | `Webhook` |
| Slack | `Slack` |
| Microsoft Teams | `Teams` |
| PagerDuty | `PagerDuty` |
| OpsGenie | `OpsGenie` |
All connectors are pluggable — build your own by implementing the [connector interface](docs/connectors.md).
### Screenshots
<table> <table>
<tr> <tr>
@@ -160,78 +48,62 @@ All connectors are pluggable — build your own by implementing the [connector i
## Why certctl ## Why certctl
Certificate lifecycle tooling falls into two camps: enterprise platforms (Venafi, Keyfactor) that cost six figures and take months to deploy, or single-purpose tools (certbot, cert-manager) that handle one slice of the problem. certctl fills the gap — full lifecycle automation, self-hosted, free, CA-agnostic, and target-agnostic. If you're running certbot cron jobs, manually renewing certs, or stitching together scripts across mixed infrastructure, certctl replaces all of that. Certificate lifecycle tooling has historically split into two camps. Enterprise platforms charge six-figure annual licenses, take months to deploy, and bill professional-services hours at $250 to $400 per hour to write integration code that should ship with the product. Single-purpose tools handle one slice of the problem and leave the operator to glue the rest together. certctl fills the gap — full lifecycle automation, self-hosted, free, CA-agnostic, target-agnostic. If you're stitching together cron jobs across a fleet, manually renewing certs, or writing custom integration scripts to bridge a commercial CLM platform to your actual infrastructure, certctl replaces all of that.
Built for **platform engineering and DevOps teams** managing 10500+ certificates, **security and compliance teams** who need audit trails and policy enforcement for SOC 2, PCI-DSS 4.0, or NIST SP 800-57 ([compliance mapping included](docs/compliance.md)), and **small teams without enterprise budgets** who need Venafi-grade automation for a 50-server environment. For a detailed comparison, see [Why certctl?](docs/why-certctl.md) Built for **platform engineering and DevOps teams** managing 10 to 500+ certificates, **security teams** who need audit trails and policy enforcement, and **small teams without enterprise budgets** who need enterprise-grade automation for a 50-server environment. For the detailed positioning argument and when not to use certctl, see [Why certctl?](docs/getting-started/why-certctl.md).
**Architecture.** Go 1.25 control plane with handler→service→repository layering, PostgreSQL 16 backend (21 tables), and a pull-only deployment model — the server never initiates outbound connections. Agents poll for work. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). Background scheduler runs 7 loops: renewal with ARI integration (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams. ## What it does
**Security-first.** Agents generate ECDSA P-256 keys locally — private keys never touch the control plane. API key auth enforced by default with SHA-256 hashing and constant-time comparison. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Atomic idempotency guards on scheduler loops. Issuer and target credentials encrypted at rest with AES-256-GCM. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit. certctl handles the full certificate lifecycle in one self-hosted control plane:
**Key design decisions.** TEXT primary keys — human-readable prefixed IDs (`mc-api-prod`, `t-platform`, `o-alice`) so you can identify resources at a glance in logs and queries. Idempotent migrations (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`) safe for repeated execution. Dynamic configuration via GUI with AES-256-GCM encrypted credential storage and env var backward compatibility. Handlers define their own service interfaces for clean dependency inversion. - **Issue and renew** from any CA. Let's Encrypt and any ACME provider, an embedded ACME server you can point cert-manager / certbot / lego at directly, a built-in local CA with sub-CA mode (chains under your enterprise root like ADCS), step-ca, Vault PKI, EJBCA, AWS ACM PCA, Google CAS, DigiCert, Sectigo, GlobalSign, Entrust, plus an OpenSSL / shell-script adapter for anything custom. Twelve native issuer connectors. See the [connector reference](docs/reference/connectors/index.md).
- **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, Kubernetes Secrets, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. Fifteen native target connectors. Every deploy goes through atomic-write + ownership-preservation + SHA-256 idempotency + per-target Prometheus counters + pre-deploy snapshot + on-failure rollback. See [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md).
- **Run as an ACME server** so existing client tooling plugs in directly. RFC 8555 + RFC 9773 ARI, two per-profile auth modes (public-trust-style validation or trust_authenticated for internal PKI), doubly-signed key rollover, revoke-cert on both kid path and jwk path, per-account rate limiting. Cert-manager / certbot / lego all work pointed at it. See [`docs/reference/protocols/acme-server.md`](docs/reference/protocols/acme-server.md).
- **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md).
- **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md).
- **Manage multi-level CA hierarchies** with name constraints, path-length enforcement, and end-to-end RFC 5280 path validation. Root → intermediate → issuing chains, admin-gated CRUD, drain-first retirement. Patterns documented for 4-level boundary CAs, 3-level policy CAs with per-BU `PermittedDNSDomains`, and 2-level internal PKI. See [`docs/reference/intermediate-ca-hierarchy.md`](docs/reference/intermediate-ca-hierarchy.md).
- **Gate high-stakes issuance** behind two-person-integrity approval. Flag a profile as `RequiresApproval`, the request lands in a queue, a non-requester approves, the scheduler dispatches. See [`docs/operator/approval-workflow.md`](docs/operator/approval-workflow.md).
- **Discover** existing certs across your fleet via filesystem scanning on agents, network TLS probing across CIDR ranges, and cloud secret manager imports (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager). Triage workflow for claim / dismiss / investigate.
- **Revoke** with full RFC 5280 reason codes, DER CRL generation per issuer (scheduler-pre-generated and ETag-cached), and an embedded RFC 6960 OCSP responder with dedicated per-issuer responder certs. Single + bulk revocation. See [`docs/reference/protocols/crl-ocsp.md`](docs/reference/protocols/crl-ocsp.md).
- **Alert** via Slack, Microsoft Teams, PagerDuty, OpsGenie, email, webhooks. Per-policy multi-channel routing matrix with severity tiers and fault-isolating per-channel dispatch. See [`docs/operator/runbooks/expiry-alerts.md`](docs/operator/runbooks/expiry-alerts.md).
- **Drive the platform from natural language** via the bundled MCP (Model Context Protocol) server. The full REST API is exposed as MCP tools — ask your AI client "show me all expiring certificates", "revoke the VPN cert, key compromised", or "what agents are offline?" and it translates to API calls. Stateless stdio-transport binary at `cmd/mcp-server/`; same auth as the REST API; no extra attack surface. See [`docs/reference/mcp.md`](docs/reference/mcp.md).
## What It Does ## Architecture and security
**Automated lifecycle.** Certificates renew and deploy themselves. The scheduler monitors expiration, issues through your CA, and deploys to targets — zero human intervention. ACME ARI (RFC 9773) lets the CA direct renewal timing. Ready for 47-day (SC-081v3) and 6-day (Let's Encrypt shortlived) certificate lifetimes. Go 1.25 control plane with handler → service → repository layering. PostgreSQL 16 backend (35+ tables, idempotent migrations). Pull-only deployment model — the server never initiates outbound connections. Agents poll for work and generate ECDSA P-256 keys locally so private keys never touch the control plane. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). See the [Architecture Guide](docs/reference/architecture.md) for full system diagrams.
**Operational dashboard.** 26-page GUI covers the entire lifecycle: certificate inventory with bulk ops, deployment timeline with rollback, discovery triage, network scan management, agent fleet health, short-lived credential countdown, approval workflows, and observability metrics. Configure issuers and targets from the dashboard — no env var editing, no server restarts. Security: API key auth enforced by default with SHA-256 hashing and constant-time comparison. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Issuer and target credentials encrypted at rest with AES-256-GCM. HTTPS-only control plane with TLS 1.3 pinned and a fail-closed startup gate that refuses to boot if the TLS bundle is unusable. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit. See [`docs/operator/security.md`](docs/operator/security.md) for the operator-facing security posture.
**Private keys stay on your servers.** Agents generate ECDSA P-256 keys locally, submit only the CSR. The control plane never touches private keys. After deployment, agents probe the live TLS endpoint and compare SHA-256 fingerprints to confirm the right certificate is actually being served.
**Discovery.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without agents. Cloud discovery finds certificates in AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. Continuous TLS health monitoring tracks endpoint status (healthy/degraded/down/cert_mismatch) with configurable thresholds and historical probe data. All discovery modes feed into a unified triage workflow — claim, dismiss, or import what you find.
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Approval workflows pause jobs for human review. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices — full wire format (EnvelopedData decrypt + signerInfo POPO verify + CertRep PKIMessage builder), tested against ChromeOS-shape requests; multi-profile dispatch (`/scep/<pathID>`); RenewalReq + GetCertInitial messageType support; lightweight raw-CSR fallback for legacy clients. See [docs/legacy-est-scep.md](docs/legacy-est-scep.md) for the operator + device-integration guide. S/MIME issuance with email protection EKU.
**Revocation.** Single and bulk revocation (by profile, owner, agent, or issuer). RFC 5280 reason codes. Production-grade revocation status surface for relying parties: DER-encoded X.509 CRL per issuer, scheduler-pre-generated and cached so HTTP fetches do not rebuild per request; embedded OCSP responder serving both GET and POST forms (RFC 6960 §A.1.1) with responses signed by a per-issuer dedicated OCSP responder cert (RFC 6960 §2.6, `id-pkix-ocsp-nocheck` per §4.2.2.2.1) — the CA private key is never used directly for OCSP signing. Both endpoints live unauthenticated under `/.well-known/pki/` per RFC 8615. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation. See [docs/crl-ocsp.md](docs/crl-ocsp.md) for the relying-party integration guide.
**Audit and observability.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Prometheus metrics endpoint. Scheduled certificate digest emails. Continuous endpoint health monitoring with state machine transitions and real-time alerts.
**Notifications.** Slack, Teams, PagerDuty, OpsGenie, SMTP, webhooks. Routed by certificate owner. Daily digest emails with stats and expiring certs.
**Multiple interfaces.** REST API (111 routes), CLI (12 commands), MCP server (80 tools for Claude, Cursor, Windsurf), Helm chart, web dashboard. Certificate export in PEM and PKCS#12.
**First-run onboarding.** Wizard guides you through connecting a CA, deploying an agent, and issuing your first certificate. Or start with the pre-populated demo — 32 certificates, 10 issuers, 180 days of history.
For the complete capability breakdown, see the [Feature Inventory](docs/features.md).
## Quick Start ## Quick Start
### Docker Compose (Recommended) ### Docker Compose (recommended)
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/certctl-io/certctl.git
cd certctl cd certctl
docker compose -f deploy/docker-compose.yml up -d --build
```
Wait ~30 seconds, then open **https://localhost:8443** in your browser. (The shipped `docker-compose.yml` self-signs a cert via the `certctl-tls-init` init container on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.) The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
**Want a pre-populated demo instead?** Add the demo override to see 32 certificates across 10 issuers, 8 agents, and 180 days of realistic history:
```bash
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
``` ```
The `deploy/` directory has four compose files: `docker-compose.yml` (base platform), `docker-compose.demo.yml` (demo data overlay), `docker-compose.dev.yml` (PgAdmin + debug logging), and `docker-compose.test.yml` (standalone integration tests with real CA backends). See the [Docker Compose Environments Guide](deploy/ENVIRONMENTS.md) for a service-by-service walkthrough, or the [Quick Start](docs/quickstart.md#docker-compose-environments) for a summary. Wait ~30 seconds, then open **https://localhost:8443** in your browser. The shipped demo overlay seeds 32 certificates across 10 issuers, 8 agents, and 180 days of realistic history. The `certctl-tls-init` init container self-signs an ECDSA-P256 cert on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.
For a clean install without demo data, drop the `-f deploy/docker-compose.demo.yml` flag and run `docker compose -f deploy/docker-compose.yml up -d --build`. The four compose files (`docker-compose.yml` base, `docker-compose.demo.yml` overlay, `docker-compose.dev.yml` for PgAdmin + debug logging, `docker-compose.test.yml` for integration tests) are documented at [`deploy/ENVIRONMENTS.md`](deploy/ENVIRONMENTS.md).
```bash ```bash
curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health
# {"status":"healthy"} # {"status":"healthy"}
``` ```
The control plane is HTTPS-only (TLS 1.3, no plaintext listener). See [`docs/tls.md`](docs/tls.md) for cert provisioning patterns and [`docs/upgrade-to-tls.md`](docs/upgrade-to-tls.md) if you're upgrading from a pre-v2.2 release. The control plane is HTTPS-only with TLS 1.3 pinned. See [`docs/operator/tls.md`](docs/operator/tls.md) for cert provisioning patterns.
### Agent Install (One-Liner) ### Agent install (one-liner)
```bash ```bash
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash curl -sSL https://raw.githubusercontent.com/certctl-io/certctl/master/install-agent.sh | bash
``` ```
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh) for details. Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh).
### Helm Chart (Kubernetes) ### Helm chart (Kubernetes)
```bash ```bash
helm install certctl deploy/helm/certctl/ \ helm install certctl deploy/helm/certctl/ \
@@ -239,86 +111,18 @@ helm install certctl deploy/helm/certctl/ \
--set postgres.password=your-db-password --set postgres.password=your-db-password
``` ```
Production-ready chart with Server Deployment, PostgreSQL StatefulSet, Agent DaemonSet, health probes, security contexts (non-root, read-only rootfs), and optional Ingress. See [values.yaml](deploy/helm/certctl/values.yaml) for all configuration options. Production-ready chart with Server Deployment, PostgreSQL StatefulSet, Agent DaemonSet, health probes, security contexts (non-root, read-only rootfs), and optional Ingress. See [values.yaml](deploy/helm/certctl/values.yaml).
### Docker Pull ### Container images
```bash ```bash
docker pull shankar0123.docker.scarf.sh/certctl-server docker pull ghcr.io/certctl-io/certctl-server:latest
docker pull shankar0123.docker.scarf.sh/certctl-agent docker pull ghcr.io/certctl-io/certctl-agent:latest
```
## Verifying this release
Every `v*` tag publishes signed, attested release artefacts. Binaries
(`certctl-agent`, `certctl-server`, `certctl-cli`, `certctl-mcp-server` for
`linux|darwin × amd64|arm64`) ship alongside a `checksums.txt`, per-binary
SPDX-JSON SBOMs, Cosign signatures, and SLSA Level 3 provenance. Container
images on `ghcr.io/shankar0123/certctl-{server,agent}` are built with
`docker/build-push-action` `provenance: mode=max` + `sbom: true` and are
additionally signed with Cosign at the image digest.
All signatures use Cosign keyless OIDC; the signing identity is the
release workflow running on a signed tag.
**1. Verify SHA-256 checksums:**
```bash
sha256sum -c checksums.txt
```
**2. Verify the Cosign signature on `checksums.txt`:**
```bash
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txt
```
Every individual binary ships with its own `.sigstore.json` bundle
(unified Sigstore bundle containing signature, certificate chain, and
Rekor inclusion proof). Swap `checksums.txt` for any binary name and
point `--bundle` at the matching `<binary>.sigstore.json` to verify it
directly.
**3. Verify SLSA Level 3 provenance on a binary:**
```bash
slsa-verifier verify-artifact \
--provenance-path multiple.intoto.jsonl \
--source-uri github.com/shankar0123/certctl \
--source-tag v2.1.0 \
certctl-agent-linux-amd64
```
**4. Verify a container image signature and its SBOM / provenance attestations:**
```bash
IMAGE=ghcr.io/shankar0123/certctl-server:v2.1.0
cosign verify \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
# SBOM attestation (SPDX-JSON, emitted by docker/build-push-action)
cosign verify-attestation --type spdxjson \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
# SLSA provenance attestation (docker/build-push-action `provenance: mode=max`)
cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
``` ```
## Examples ## Examples
Pick the scenario closest to your setup and have it running in 2 minutes. Pick the scenario closest to your setup and have it running in 2 minutes:
| Example | Scenario | | Example | Scenario |
|---------|----------| |---------|----------|
@@ -330,58 +134,9 @@ Pick the scenario closest to your setup and have it running in 2 minutes.
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization. Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
## CLI ## Verifying a release
```bash Every `v*` tag publishes signed, attested artefacts (Cosign keyless OIDC + SLSA Level 3 provenance + SPDX-JSON SBOMs). For the verification procedure, see [`docs/reference/release-verification.md`](docs/reference/release-verification.md).
# Install
go install github.com/shankar0123/certctl/cmd/cli@latest
# Configure
export CERTCTL_SERVER_URL=https://localhost:8443
export CERTCTL_API_KEY=your-api-key
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # or --ca-bundle on the CLI; --insecure for dev self-signed
# Usage
certctl-cli certs list # List all certificates
certctl-cli certs renew mc-api-prod # Trigger renewal
certctl-cli certs revoke mc-api-prod --reason keyCompromise
certctl-cli agents list # List registered agents
certctl-cli jobs list # List jobs
certctl-cli status # Server health + summary stats
certctl-cli import certs.pem # Bulk import from PEM file
certctl-cli certs list --format json # JSON output (default: table)
```
## MCP Server (AI Integration)
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 80 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
```bash
# Install and run
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
export CERTCTL_SERVER_URL=https://localhost:8443
export CERTCTL_API_KEY=your-api-key
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # required for self-signed bootstrap
mcp-server
```
The MCP server is env-vars-only — there are no CLI flags for TLS. If you must bypass verification for local development against a self-signed cert, set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`. Never set that in production.
**Claude Desktop** (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"certctl": {
"command": "mcp-server",
"env": {
"CERTCTL_SERVER_URL": "https://localhost:8443",
"CERTCTL_API_KEY": "your-api-key",
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/ca.crt"
}
}
}
}
```
## Development ## Development
@@ -393,40 +148,26 @@ govulncheck ./... # Vulnerability scan
make docker-up # Start Docker Compose stack make docker-up # Start Docker Compose stack
``` ```
CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`, and per-layer coverage thresholds (service 55%, handler 60%, domain 40%, middleware 30%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. 1,668 Go test functions with 625+ subtests, plus frontend test suite. CI runs `go vet`, `go test -race`, `golangci-lint`, `govulncheck`, and per-layer coverage thresholds (service 55%, handler 60%, domain 40%, middleware 30%) on every push. Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build.
## Roadmap For the full contributor guide see [`docs/contributor/`](docs/contributor/) — testing strategy, test environment, CI pipeline, QA prerequisites.
### V1 (v1.0.0) — Shipped
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
### V2: Operational Maturity — Shipped
30+ milestones shipping enterprise-grade features for free. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01/EAB/ARI (RFC 9773)/profile selection, step-ca, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust, GlobalSign, EJBCA, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets targets. EST server (RFC 7030) and SCEP server (RFC 8894) enrollment protocols. RFC 5280 revocation with DER CRL + embedded OCSP responder. Certificate profiles, ownership tracking, team assignment, agent groups, interactive approval workflows. Filesystem, network, and cloud secret manager (AWS SM, Azure KV, GCP SM) certificate discovery with triage GUI. Dynamic issuer/target configuration via GUI with AES-256-GCM encrypted storage. First-run onboarding wizard. Post-deployment TLS verification. Certificate export (PEM/PKCS#12). S/MIME support. Prometheus metrics. Scheduled certificate digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. MCP server (80 tools), CLI (12 commands), Helm chart. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). 5 turnkey deployment examples. Agent install script. Migration guides from certbot, acme.sh, and cert-manager. See the [Feature Inventory](docs/features.md) for details.
### V3: certctl Pro
Enterprise capabilities for larger deployments are available in the commercial tier.
### V4+: Cloud & Scale
Kubernetes cert-manager external issuer, cloud infrastructure targets, extended CA support, and platform-scale features.
## License ## License
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not use certctl's certificate management functionality as part of a commercial offering to third parties, whether hosted, managed, embedded, bundled, or integrated. Licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not use certctl's certificate management functionality as part of a commercial certificate-management offering to third parties. See the LICENSE file for the full Additional Use Grant.
For licensing inquiries: certctl@proton.me For licensing inquiries: certctl@proton.me
## Dependencies ## Dependencies
Backend dependency footprint is auditable on demand: ```bash
```
go list -m all | wc -l # total module count (direct + transitive) go list -m all | wc -l # total module count (direct + transitive)
go mod why <path> # explain why a particular module is pulled in go mod why <path> # explain why a module is pulled in
govulncheck ./... # vulnerability scan (CI runs this on every commit) govulncheck ./... # vulnerability scan (CI runs this on every commit)
``` ```
The release-time SBOM is published as a syft-produced cyclonedx file alongside each release artifact in `.github/workflows/release.yml`. The release-time SBOM is published as an SPDX-JSON file alongside each release artifact.
--- ---
If certctl solves a problem you have, [star the repo](https://github.com/shankar0123/certctl) to help others find it. Questions, bugs, or feature requests [open an issue](https://github.com/shankar0123/certctl/issues). If certctl solves a problem you have, [star the repo](https://github.com/certctl-io/certctl) to help others find it. Questions, bugs, or feature requests: [open an issue](https://github.com/certctl-io/certctl/issues).
+67
View File
@@ -25,3 +25,70 @@ documented_exceptions:
why: "SCEP-mTLS sibling endpoint, trailing-slash variant." why: "SCEP-mTLS sibling endpoint, trailing-slash variant."
- route: "POST /scep-mtls/" - route: "POST /scep-mtls/"
why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant." why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant."
# ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface.
# Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose
# semantics are dictated by the RFC, not by an OpenAPI schema.
# Documenting every endpoint in openapi.yaml would duplicate
# RFC 8555 §7.1 + §7.2 + §7.3 with no information gain. The
# canonical operator-facing reference is docs/acme-server.md.
# Phases 2-4 will extend this list as new-order, finalize, authz,
# challenge, cert, key-change, revoke-cert, renewal-info routes land.
- route: "GET /acme/profile/{id}/directory"
why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md."
- route: "HEAD /acme/profile/{id}/new-nonce"
why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md."
- route: "GET /acme/profile/{id}/new-nonce"
why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/new-account"
why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/account/{acc_id}"
why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md."
- route: "GET /acme/directory"
why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set."
- route: "HEAD /acme/new-nonce"
why: "ACME server default-profile shorthand for new-nonce HEAD."
- route: "GET /acme/new-nonce"
why: "ACME server default-profile shorthand for new-nonce GET."
- route: "POST /acme/new-account"
why: "ACME server default-profile shorthand for new-account."
- route: "POST /acme/account/{acc_id}"
why: "ACME server default-profile shorthand for account update + deactivation."
# Phase 2 — orders + finalize + authz + cert.
- route: "POST /acme/profile/{id}/new-order"
why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/order/{ord_id}"
why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/order/{ord_id}/finalize"
why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/authz/{authz_id}"
why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/challenge/{chall_id}"
why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool."
- route: "POST /acme/profile/{id}/cert/{cert_id}"
why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md."
- route: "POST /acme/new-order"
why: "Phase 2 default-profile shorthand for new-order."
- route: "POST /acme/order/{ord_id}"
why: "Phase 2 default-profile shorthand for order POST-as-GET."
- route: "POST /acme/order/{ord_id}/finalize"
why: "Phase 2 default-profile shorthand for finalize."
- route: "POST /acme/authz/{authz_id}"
why: "Phase 2 default-profile shorthand for authz POST-as-GET."
- route: "POST /acme/challenge/{chall_id}"
why: "Phase 3 default-profile shorthand for challenge response."
- route: "POST /acme/cert/{cert_id}"
why: "Phase 2 default-profile shorthand for cert download."
- route: "POST /acme/profile/{id}/key-change"
why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md."
- route: "POST /acme/profile/{id}/revoke-cert"
why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md."
- route: "GET /acme/profile/{id}/renewal-info/{cert_id}"
why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md."
- route: "POST /acme/key-change"
why: "Phase 4 default-profile shorthand for key rollover."
- route: "POST /acme/revoke-cert"
why: "Phase 4 default-profile shorthand for revoke-cert."
- route: "GET /acme/renewal-info/{cert_id}"
why: "Phase 4 default-profile shorthand for ARI."
+362 -1
View File
@@ -14,7 +14,7 @@ info:
version: 2.0.0 version: 2.0.0
license: license:
name: BSL 1.1 name: BSL 1.1
url: https://github.com/shankar0123/certctl/blob/master/LICENSE url: https://github.com/certctl-io/certctl/blob/master/LICENSE
servers: servers:
- url: https://localhost:8443 - url: https://localhost:8443
@@ -2751,6 +2751,310 @@ paths:
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
# ─── Notifications ────────────────────────────────────────────────── # ─── Notifications ──────────────────────────────────────────────────
/api/v1/approvals:
get:
tags: [Approvals]
summary: List approval requests
description: |
Rank 7 issuance approval-workflow primitive. Returns paginated approval
requests, optionally filtered by ?state= (pending/approved/rejected/expired),
?certificate_id=, or ?requested_by=. Empty filters return the unfiltered
list (default page=1, per_page=50).
operationId: listApprovalRequests
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/per_page"
- name: state
in: query
required: false
schema:
type: string
enum: [pending, approved, rejected, expired]
- name: certificate_id
in: query
required: false
schema:
type: string
- name: requested_by
in: query
required: false
schema:
type: string
responses:
"200":
description: Paginated list of approval requests
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/ApprovalRequest"
page:
type: integer
per_page:
type: integer
"500":
$ref: "#/components/responses/InternalError"
/api/v1/approvals/{id}:
get:
tags: [Approvals]
summary: Get approval request
description: Returns a single approval request by ID.
operationId: getApprovalRequest
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: Approval request details
content:
application/json:
schema:
$ref: "#/components/schemas/ApprovalRequest"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/approvals/{id}/approve:
post:
tags: [Approvals]
summary: Approve a pending approval request
description: |
Transitions a pending request to approved AND transitions the linked
Job from AwaitingApproval to Pending so the scheduler picks it up.
RBAC: the authenticated actor extracted via the auth middleware MUST
differ from the request's requested_by — a same-actor self-approval
returns HTTP 403 with the substring `two-person integrity` in the
body. This is the load-bearing two-person integrity contract;
compliance auditors (PCI-DSS 6.4.5, NIST 800-53 SA-15, SOC 2 CC6.1)
pattern-match against this code path.
operationId: approveApprovalRequest
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
note:
type: string
description: Optional reason text for the audit trail.
responses:
"200":
description: Approval recorded; linked Job transitioned to Pending
content:
application/json:
schema:
type: object
properties:
id: { type: string }
decided_by: { type: string }
action: { type: string, enum: [approved] }
"401":
description: Authentication required
"403":
description: Same-actor self-approval blocked by two-person integrity contract
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Request already decided (terminal state)
"500":
$ref: "#/components/responses/InternalError"
/api/v1/approvals/{id}/reject:
post:
tags: [Approvals]
summary: Reject a pending approval request
description: |
Transitions a pending request to rejected AND cancels the linked
Job. Same-actor RBAC contract as approve. The job's error_message
is populated with the supplied note for audit continuity.
operationId: rejectApprovalRequest
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
note:
type: string
description: Optional reason text for the audit trail.
responses:
"200":
description: Rejection recorded; linked Job transitioned to Cancelled
content:
application/json:
schema:
type: object
properties:
id: { type: string }
decided_by: { type: string }
action: { type: string, enum: [rejected] }
"401":
description: Authentication required
"403":
description: Same-actor self-rejection blocked by two-person integrity contract
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Request already decided (terminal state)
"500":
$ref: "#/components/responses/InternalError"
/api/v1/issuers/{id}/intermediates:
post:
tags: [IntermediateCAs]
summary: Create a root or child intermediate CA under the issuer
description: |
Admin-gated. Discriminator on body shape: when parent_ca_id is
empty AND root_cert_pem + key_driver_id are present, the
endpoint registers an operator-supplied root CA. Otherwise it
signs a child sub-CA cert under the named parent (RFC 5280
§4.2.1.9 path-length tightening + §4.2.1.10 NameConstraints
subset semantics enforced at the service layer).
operationId: createIntermediateCA
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
parent_ca_id:
type: string
description: Empty for root registration; non-empty for child signing
root_cert_pem:
type: string
description: Operator-supplied root cert PEM (root path only)
key_driver_id:
type: string
description: signer.Driver reference for the root key (root path only)
subject:
type: object
description: Distinguished name for child CA (child path only)
algorithm:
type: string
description: Signing algorithm for child key (default ECDSA-P256)
ttl_days:
type: integer
path_len_constraint:
type: integer
nullable: true
name_constraints:
type: array
items: { type: object }
ocsp_responder_url:
type: string
metadata:
type: object
responses:
"201":
description: IntermediateCA row created
"400":
description: Validation failed (RFC 5280 violations, malformed cert PEM, missing root bundle)
"401":
description: Authentication required
"403":
description: Admin role required
"409":
description: Parent CA not in active state
"404":
description: Parent CA not found
"500":
$ref: "#/components/responses/InternalError"
get:
tags: [IntermediateCAs]
summary: List the CA hierarchy for an issuer
description: |
Admin-gated. Returns the flat list of every IntermediateCA row
for the issuer, ordered by created_at. The caller renders the
tree from each row's parent_ca_id (nil = root).
operationId: listIntermediateCAs
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: Flat list of CA rows
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { type: object }
"401":
description: Authentication required
"403":
description: Admin role required
/api/v1/intermediates/{id}:
get:
tags: [IntermediateCAs]
summary: Get a single intermediate CA by ID
operationId: getIntermediateCA
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: IntermediateCA row
"401":
description: Authentication required
"403":
description: Admin role required
"404":
$ref: "#/components/responses/NotFound"
/api/v1/intermediates/{id}/retire:
post:
tags: [IntermediateCAs]
summary: Retire an intermediate CA (two-phase drain)
description: |
Admin-gated. Two-phase: first call (confirm=false) transitions
active to retiring (the CA stops issuing new children but
existing children continue). Second call (confirm=true)
transitions retiring to retired (terminal). Refuses the
terminal transition if the CA still has active children —
drain-first semantics.
operationId: retireIntermediateCA
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
note: { type: string }
confirm: { type: boolean, default: false }
responses:
"200":
description: Retire transition recorded
"401":
description: Authentication required
"403":
description: Admin role required
"404":
$ref: "#/components/responses/NotFound"
"409":
description: CA still has active children; drain them first
"500":
$ref: "#/components/responses/InternalError"
/api/v1/notifications: /api/v1/notifications:
get: get:
tags: [Notifications] tags: [Notifications]
@@ -4057,6 +4361,63 @@ components:
$ref: "#/components/schemas/ErrorResponse" $ref: "#/components/schemas/ErrorResponse"
schemas: schemas:
# ─── Approvals ───────────────────────────────────────────────────
ApprovalRequest:
type: object
description: |
Rank 7 issuance approval-workflow primitive. One row per (CertificateID,
JobID) pair; the JobID points at the blocked Job whose Status is
AwaitingApproval. Lifecycle: pending → approved | rejected | expired.
Once terminal, the row is immutable; the audit_events table is the
durable record of who decided + why.
required:
- id
- certificate_id
- job_id
- profile_id
- requested_by
- state
- created_at
- updated_at
properties:
id:
type: string
description: Approval request ID (ar-<slug>).
certificate_id:
type: string
job_id:
type: string
profile_id:
type: string
requested_by:
type: string
description: Actor that triggered the renewal.
state:
type: string
enum: [pending, approved, rejected, expired]
decided_by:
type: string
nullable: true
description: Approver identity; null while state=pending.
decided_at:
type: string
format: date-time
nullable: true
decision_note:
type: string
nullable: true
metadata:
type: object
additionalProperties:
type: string
description: Free-form key/value (common_name, sans, issuer_id, severity_tier).
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
# ─── Common ────────────────────────────────────────────────────── # ─── Common ──────────────────────────────────────────────────────
ErrorResponse: ErrorResponse:
type: object type: object
+32 -7
View File
@@ -478,7 +478,7 @@ func TestCreateTargetConnector_NGINX(t *testing.T) {
agent, _ := NewAgent(cfg, logger) agent, _ := NewAgent(cfg, logger)
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`) configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
connector, err := agent.createTargetConnector("NGINX", configJSON) connector, err := agent.createTargetConnector(context.Background(), "NGINX", configJSON)
if err != nil { if err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
@@ -499,7 +499,7 @@ func TestCreateTargetConnector_Unsupported(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent, _ := NewAgent(cfg, logger) agent, _ := NewAgent(cfg, logger)
_, err := agent.createTargetConnector("UnsupportedType", nil) _, err := agent.createTargetConnector(context.Background(), "UnsupportedType", nil)
if err == nil { if err == nil {
t.Error("expected error for unsupported target type") t.Error("expected error for unsupported target type")
@@ -831,7 +831,7 @@ func strPtr(s string) *string {
return &s return &s
} }
// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 14 supported target types. // TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 16 supported target types.
func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) { func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -946,6 +946,29 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
"secret_name": "tls-secret", "secret_name": "tls-secret",
}, },
}, },
{
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
// Region must be a valid AWS region; the connector lazy-loads
// the SDK client during ValidateConfig but New() with a populated
// region should succeed against the SDK credential chain
// (LoadDefaultConfig doesn't require live creds).
name: "AWSACM",
typeName: "AWSACM",
config: map[string]string{
"region": "us-east-1",
},
},
{
// Rank 5 (Azure half). Vault URL + cert name; the SDK client
// lazy-loads via DefaultAzureCredential which doesn't require
// live creds at construction time.
name: "AzureKeyVault",
typeName: "AzureKeyVault",
config: map[string]string{
"vault_url": "https://test-vault.vault.azure.net",
"certificate_name": "demo-cert",
},
},
} }
cfg := &AgentConfig{ cfg := &AgentConfig{
@@ -964,7 +987,7 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
t.Fatalf("failed to marshal config: %v", err) t.Fatalf("failed to marshal config: %v", err)
} }
connector, err := agent.createTargetConnector(tt.typeName, configJSON) connector, err := agent.createTargetConnector(context.Background(), tt.typeName, configJSON)
// Some connectors (like WinCertStore, IIS) may error on non-Windows platforms // Some connectors (like WinCertStore, IIS) may error on non-Windows platforms
// or with insufficient validation. We accept either a valid connector or an error // or with insufficient validation. We accept either a valid connector or an error
@@ -999,6 +1022,8 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
"WinCertStore", "WinCertStore",
"JavaKeystore", "JavaKeystore",
"KubernetesSecrets", "KubernetesSecrets",
"AWSACM",
"AzureKeyVault",
} }
cfg := &AgentConfig{ cfg := &AgentConfig{
@@ -1014,7 +1039,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
for _, typeName := range tests { for _, typeName := range tests {
t.Run(typeName, func(t *testing.T) { t.Run(typeName, func(t *testing.T) {
_, err := agent.createTargetConnector(typeName, invalidJSON) _, err := agent.createTargetConnector(context.Background(), typeName, invalidJSON)
if err == nil { if err == nil {
t.Errorf("expected error for invalid JSON with type %s", typeName) t.Errorf("expected error for invalid JSON with type %s", typeName)
@@ -1034,7 +1059,7 @@ func TestCreateTargetConnector_UnknownType(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent, _ := NewAgent(cfg, logger) agent, _ := NewAgent(cfg, logger)
_, err := agent.createTargetConnector("MagicBox", nil) _, err := agent.createTargetConnector(context.Background(), "MagicBox", nil)
if err == nil { if err == nil {
t.Error("expected error for unsupported target type") t.Error("expected error for unsupported target type")
@@ -1067,7 +1092,7 @@ func TestCreateTargetConnector_EmptyConfig(t *testing.T) {
for _, typeName := range tests { for _, typeName := range tests {
t.Run(typeName, func(t *testing.T) { t.Run(typeName, func(t *testing.T) {
// Empty config should be handled gracefully (defaults applied) // Empty config should be handled gracefully (defaults applied)
connector, err := agent.createTargetConnector(typeName, nil) connector, err := agent.createTargetConnector(context.Background(), typeName, nil)
// Should not error on nil/empty config (defaults are applied) // Should not error on nil/empty config (defaults are applied)
if err != nil { if err != nil {
+52 -17
View File
@@ -30,20 +30,22 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/shankar0123/certctl/internal/connector/target" "github.com/certctl-io/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/apache" "github.com/certctl-io/certctl/internal/connector/target/apache"
"github.com/shankar0123/certctl/internal/connector/target/caddy" "github.com/certctl-io/certctl/internal/connector/target/awsacm"
"github.com/shankar0123/certctl/internal/connector/target/envoy" "github.com/certctl-io/certctl/internal/connector/target/azurekv"
"github.com/shankar0123/certctl/internal/connector/target/f5" "github.com/certctl-io/certctl/internal/connector/target/caddy"
"github.com/shankar0123/certctl/internal/connector/target/haproxy" "github.com/certctl-io/certctl/internal/connector/target/envoy"
"github.com/shankar0123/certctl/internal/connector/target/iis" "github.com/certctl-io/certctl/internal/connector/target/f5"
jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore" "github.com/certctl-io/certctl/internal/connector/target/haproxy"
k8s "github.com/shankar0123/certctl/internal/connector/target/k8ssecret" "github.com/certctl-io/certctl/internal/connector/target/iis"
"github.com/shankar0123/certctl/internal/connector/target/nginx" jks "github.com/certctl-io/certctl/internal/connector/target/javakeystore"
pf "github.com/shankar0123/certctl/internal/connector/target/postfix" k8s "github.com/certctl-io/certctl/internal/connector/target/k8ssecret"
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh" "github.com/certctl-io/certctl/internal/connector/target/nginx"
"github.com/shankar0123/certctl/internal/connector/target/traefik" pf "github.com/certctl-io/certctl/internal/connector/target/postfix"
wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore" sshconn "github.com/certctl-io/certctl/internal/connector/target/ssh"
"github.com/certctl-io/certctl/internal/connector/target/traefik"
wcs "github.com/certctl-io/certctl/internal/connector/target/wincertstore"
) )
// AgentConfig represents the agent-side configuration. // AgentConfig represents the agent-side configuration.
@@ -62,7 +64,7 @@ type AgentConfig struct {
// ErrAgentRetired is the sentinel returned by [Agent.Run] when the control // ErrAgentRetired is the sentinel returned by [Agent.Run] when the control
// plane responds with HTTP 410 Gone to a heartbeat or work-poll request — the // plane responds with HTTP 410 Gone to a heartbeat or work-poll request — the
// canonical signal that this agent's row has been soft-retired server-side // canonical signal that this agent's row has been soft-retired server-side
// (see I-004 in cowork/certctl-coverage-gap-audit.md). The binary must // (see I-004 in the project's coverage-gap audit). The binary must
// terminate cleanly: an init-system restart would only produce another 410 // terminate cleanly: an init-system restart would only produce another 410
// and wedge the host in a restart loop. main() translates this sentinel into // and wedge the host in a restart loop. main() translates this sentinel into
// a zero exit code so systemd (Restart=on-failure) and launchd do not respawn // a zero exit code so systemd (Restart=on-failure) and launchd do not respawn
@@ -685,7 +687,7 @@ func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
// Deploy to the target using the appropriate connector // Deploy to the target using the appropriate connector
if job.TargetType != "" { if job.TargetType != "" {
connector, err := a.createTargetConnector(job.TargetType, job.TargetConfig) connector, err := a.createTargetConnector(ctx, job.TargetType, job.TargetConfig)
if err != nil { if err != nil {
a.logger.Error("failed to create target connector", a.logger.Error("failed to create target connector",
"job_id", job.ID, "job_id", job.ID,
@@ -766,7 +768,11 @@ func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
} }
// createTargetConnector instantiates the appropriate target connector based on type. // createTargetConnector instantiates the appropriate target connector based on type.
func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMessage) (target.Connector, error) { // ctx is threaded into SDK-driven connectors (AWSACM, AzureKeyVault) so credential
// resolution honors caller cancellation / deadlines instead of using a fresh
// context.Background() (the contextcheck linter enforces this — the original Rank 5
// implementation used Background() and tripped CI on commit 502823d).
func (a *Agent) createTargetConnector(ctx context.Context, targetType string, configJSON json.RawMessage) (target.Connector, error) {
switch targetType { switch targetType {
case "NGINX": case "NGINX":
var cfg nginx.Config var cfg nginx.Config
@@ -900,6 +906,35 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
} }
return k8s.New(&cfg, a.logger) return k8s.New(&cfg, a.logger)
case "AWSACM":
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
// AWS Certificate Manager target — SDK-driven (no file I/O).
// LoadDefaultConfig handles the standard AWS credential chain
// (IRSA / EC2 instance profile / SSO / env vars) without any
// long-lived creds in connector Config.
var cfg awsacm.Config
if len(configJSON) > 0 {
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, fmt.Errorf("invalid AWSACM config: %w", err)
}
}
return awsacm.New(ctx, &cfg, a.logger)
case "AzureKeyVault":
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
// Azure Key Vault target — SDK-driven (no file I/O).
// DefaultAzureCredential handles the standard Azure credential
// chain (managed identity / workload identity / env vars / az
// CLI fallback). Long-lived service-principal secrets are
// supported but discouraged via the credential_mode config.
var cfg azurekv.Config
if len(configJSON) > 0 {
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, fmt.Errorf("invalid AzureKeyVault config: %w", err)
}
}
return azurekv.New(ctx, &cfg, a.logger)
default: default:
return nil, fmt.Errorf("unsupported target type: %s", targetType) return nil, fmt.Errorf("unsupported target type: %s", targetType)
} }
+69 -4
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/cli" "github.com/certctl-io/certctl/internal/cli"
) )
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go. // Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
@@ -163,14 +163,79 @@ func TestHandleCerts_Revoke_HitsClientPath(t *testing.T) {
})) }))
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
c := newDispatchTestClient(t, srv) c := newDispatchTestClient(t, srv)
if err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "compromise"}); err != nil { // 2026-05-05 parity-defaults-cleanup (P3-2): reason must be a canonical
// RFC 5280 §5.3.1 code (camelCase or snake_case both accepted; this
// test asserts the snake_case path normalises to the camelCase wire
// format that the local issuer + ACME server expect).
if err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "key_compromise"}); err != nil {
t.Errorf("handleCerts({revoke ...}): err=%v", err) t.Errorf("handleCerts({revoke ...}): err=%v", err)
} }
if lastMethod != "POST" || !strings.Contains(lastPath, "/revoke") { if lastMethod != "POST" || !strings.Contains(lastPath, "/revoke") {
t.Errorf("expected POST .../revoke, got %s %s", lastMethod, lastPath) t.Errorf("expected POST .../revoke, got %s %s", lastMethod, lastPath)
} }
if !strings.Contains(lastBody, "compromise") { if !strings.Contains(lastBody, "keyCompromise") {
t.Errorf("expected reason in body, got %q", lastBody) t.Errorf("expected normalised reason 'keyCompromise' in body, got %q", lastBody)
}
}
// TestHandleCerts_Revoke_RequiresReason pins the 2026-05-05 parity-defaults-
// cleanup (P3-2, Option A) strict-reason contract: empty --reason is a
// fatal error, not a silent fallback to "unspecified".
func TestHandleCerts_Revoke_RequiresReason(t *testing.T) {
srv := stubServer(t, 200, `{}`)
c := newDispatchTestClient(t, srv)
err := handleCerts(c, []string{"revoke", "mc-x"})
if err == nil {
t.Fatal("expected error when --reason is omitted; got nil (regression on P3-2 strict path)")
}
if !strings.Contains(err.Error(), "reason") {
t.Errorf("expected error to mention 'reason', got %q", err.Error())
}
}
// TestHandleCerts_Revoke_RejectsUnknownReason pins that off-RFC reason
// codes are rejected at the CLI dispatch layer (P3-2 anti-typo guard).
func TestHandleCerts_Revoke_RejectsUnknownReason(t *testing.T) {
srv := stubServer(t, 200, `{}`)
c := newDispatchTestClient(t, srv)
err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "compromise"})
if err == nil {
t.Fatal("expected error for non-canonical reason; got nil")
}
if !strings.Contains(err.Error(), "compromise") {
t.Errorf("expected error to echo bad reason 'compromise', got %q", err.Error())
}
}
// TestHandleCerts_Renew_ForceFlag pins the 2026-05-05 parity-defaults-
// cleanup (P3-1) wire: --force on the renew dispatch sends ?force=true.
// CLI convention: ID is positional and precedes the flags (matches
// `agents retire <id> [--force]`), so the flag MUST come after the ID.
func TestHandleCerts_Renew_ForceFlag(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantQuery string
}{
{"no-force", []string{"renew", "mc-x"}, ""},
{"force-after-id", []string{"renew", "mc-x", "--force"}, "force=true"},
} {
t.Run(tc.name, func(t *testing.T) {
var lastQuery string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastQuery = r.URL.RawQuery
w.WriteHeader(200)
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
c := newDispatchTestClient(t, srv)
if err := handleCerts(c, tc.args); err != nil {
t.Fatalf("handleCerts: %v", err)
}
if lastQuery != tc.wantQuery {
t.Errorf("query: got %q want %q", lastQuery, tc.wantQuery)
}
})
} }
} }
+60 -12
View File
@@ -7,7 +7,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/shankar0123/certctl/internal/cli" "github.com/certctl-io/certctl/internal/cli"
) )
func main() { func main() {
@@ -144,22 +144,70 @@ func handleCerts(client *cli.Client, args []string) error {
} }
return client.GetCertificate(subArgs[0]) return client.GetCertificate(subArgs[0])
case "renew": case "renew":
// 2026-05-05 parity-defaults-cleanup (P3-1): expose --force as an
// explicit operator flag instead of the historical hardcoded
// `force=false` body field. force=true overrides the server-side
// RenewalInProgress block — used to recover stuck in-flight
// renewals. Archived/Expired remain terminal regardless.
//
// CLI convention: `certs renew <id> [--force]` — the ID is a
// positional arg that precedes the flags. Mirrors `agents retire
// <id>`'s pattern (Go's flag package stops at the first non-flag
// token, so we pull subArgs[0] as the ID and hand subArgs[1:] to
// the flag parser).
if len(subArgs) == 0 { if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs renew <id>\n") fmt.Fprintf(os.Stderr, "usage: certs renew <id> [--force]\n")
return nil
}
return client.RenewCertificate(subArgs[0])
case "revoke":
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> [--reason <reason>]\n")
return nil return nil
} }
id := subArgs[0] id := subArgs[0]
reason := "unspecified" fs := flag.NewFlagSet("certs renew", flag.ContinueOnError)
if len(subArgs) > 2 && subArgs[1] == "--reason" { force := fs.Bool("force", false, "Force renewal even when the cert is currently in RenewalInProgress (clears stuck in-flight renewals; does NOT override Archived/Expired terminal states)")
reason = subArgs[2] if err := fs.Parse(subArgs[1:]); err != nil {
return err
} }
return client.RevokeCertificate(id, reason) return client.RenewCertificate(id, *force)
case "revoke":
// 2026-05-05 parity-defaults-cleanup (P3-2, Option A): --reason is
// strictly required. Empty reason refuses to dispatch and prints
// the RFC 5280 §5.3.1 reason-code menu so operators pick a real
// value. The pre-2026-05-05 silent fallback to "unspecified"
// defeated compliance reporting (PCI-DSS §3.6, HIPAA §164.312)
// because every revocation looked the same in the audit trail.
//
// CLI convention: `certs revoke <id> --reason <reason>` — same
// ID-first ordering as `certs renew`.
if len(subArgs) == 0 {
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> --reason <reason>\n")
fmt.Fprintf(os.Stderr, "\nValid RFC 5280 §5.3.1 reasons:\n")
for _, r := range cli.ValidRevokeReasons() {
fmt.Fprintf(os.Stderr, " %s\n", r)
}
return nil
}
id := subArgs[0]
fs := flag.NewFlagSet("certs revoke", flag.ContinueOnError)
reason := fs.String("reason", "", "RFC 5280 revocation reason (required). Valid values: keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aaCompromise, unspecified")
if err := fs.Parse(subArgs[1:]); err != nil {
return err
}
if *reason == "" {
fmt.Fprintf(os.Stderr, "error: --reason is required (no silent fallback to 'unspecified' — pick a real RFC 5280 §5.3.1 code).\n\n")
fmt.Fprintf(os.Stderr, "Valid reasons:\n")
for _, r := range cli.ValidRevokeReasons() {
fmt.Fprintf(os.Stderr, " %s\n", r)
}
return fmt.Errorf("--reason is required")
}
canonical, ok := cli.NormalizeRevokeReason(*reason)
if !ok {
fmt.Fprintf(os.Stderr, "error: %q is not a valid RFC 5280 §5.3.1 reason code.\n\n", *reason)
fmt.Fprintf(os.Stderr, "Valid reasons (camelCase or snake_case both accepted):\n")
for _, r := range cli.ValidRevokeReasons() {
fmt.Fprintf(os.Stderr, " %s\n", r)
}
return fmt.Errorf("invalid --reason: %q", *reason)
}
return client.RevokeCertificate(id, canonical)
case "bulk-revoke": case "bulk-revoke":
return client.BulkRevokeCertificates(subArgs) return client.BulkRevokeCertificates(subArgs)
default: default:
+1 -1
View File
@@ -11,7 +11,7 @@ import (
gomcp "github.com/modelcontextprotocol/go-sdk/mcp" gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shankar0123/certctl/internal/mcp" "github.com/certctl-io/certctl/internal/mcp"
) )
// Version is set at build time via -ldflags. // Version is set at build time via -ldflags.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/router"
) )
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt // Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
+215 -20
View File
@@ -17,26 +17,27 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/shankar0123/certctl/internal/api/handler" acmepkg "github.com/certctl-io/certctl/internal/api/acme"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/config" "github.com/certctl-io/certctl/internal/api/router"
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm" "github.com/certctl-io/certctl/internal/config"
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv" discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm" discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email" discoverygcpsm "github.com/certctl-io/certctl/internal/connector/discovery/gcpsm"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie" notifyemail "github.com/certctl-io/certctl/internal/connector/notifier/email"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" notifyopsgenie "github.com/certctl-io/certctl/internal/connector/notifier/opsgenie"
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack" notifypagerduty "github.com/certctl-io/certctl/internal/connector/notifier/pagerduty"
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams" notifyslack "github.com/certctl-io/certctl/internal/connector/notifier/slack"
"github.com/shankar0123/certctl/internal/crypto/signer" notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/ratelimit" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository/postgres" "github.com/certctl-io/certctl/internal/ratelimit"
"github.com/shankar0123/certctl/internal/scep/intune" "github.com/certctl-io/certctl/internal/repository/postgres"
"github.com/shankar0123/certctl/internal/scheduler" "github.com/certctl-io/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/scheduler"
"github.com/shankar0123/certctl/internal/trustanchor" "github.com/certctl-io/certctl/internal/service"
"github.com/certctl-io/certctl/internal/trustanchor"
) )
func main() { func main() {
@@ -155,6 +156,10 @@ func main() {
profileRepo := postgres.NewProfileRepository(db) profileRepo := postgres.NewProfileRepository(db)
teamRepo := postgres.NewTeamRepository(db) teamRepo := postgres.NewTeamRepository(db)
ownerRepo := postgres.NewOwnerRepository(db) ownerRepo := postgres.NewOwnerRepository(db)
// ACME server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation.
// Repo wires nonce ops only; Phases 1b-4 extend with account /
// order / authz / challenge CRUD.
acmeRepo := postgres.NewACMERepository(db)
logger.Info("initialized all repositories") logger.Info("initialized all repositories")
// Initialize dynamic issuer registry. // Initialize dynamic issuer registry.
@@ -215,6 +220,31 @@ func main() {
} }
issuerRegistry := service.NewIssuerRegistry(logger) issuerRegistry := service.NewIssuerRegistry(logger)
// Per-issuer-type issuance metrics (audit fix #4: closes the
// per-issuer-type observability gap). Same instance is wired into
// the registry (so adapters record issuance/renewal calls) AND
// into the metrics handler (so the Prometheus exposer emits
// certctl_issuance_total / _duration_seconds / _failures_total).
issuanceMetrics := service.NewIssuanceMetrics(service.DefaultIssuanceBucketBoundaries)
issuerRegistry.SetIssuanceMetrics(issuanceMetrics)
// Top-10 fix #5 (2026-05-03 audit): Vault PKI token-renewal
// metrics. Same instance is wired into the registry (so each
// *vault.Connector built by Rebuild gets a recorder) AND into
// the metrics handler (so the Prometheus exposer emits
// certctl_vault_token_renewals_total). The renewal goroutine
// itself is kicked off below by issuerRegistry.StartLifecycles
// after Rebuild has populated the registry.
vaultRenewalMetrics := service.NewVaultRenewalMetrics()
issuerRegistry.SetVaultRenewalMetrics(vaultRenewalMetrics)
// Audit fix #7: wire the cert-version lookup so ACME connectors
// built by Rebuild can recover the leaf-cert DER from a serial-
// only revoke request. The postgres CertificateRepository
// satisfies acme.CertificateLookupRepo via its GetVersionBySerial
// method. Without this, ACME RevokeCertificate falls back to the
// legacy V1 "not supported" error.
issuerRegistry.SetACMECertLookup(certificateRepo)
// Initialize revocation repository // Initialize revocation repository
revocationRepo := postgres.NewRevocationRepository(db) revocationRepo := postgres.NewRevocationRepository(db)
@@ -229,6 +259,51 @@ func main() {
// (FK-RESTRICT against managed_certificates.renewal_policy_id). // (FK-RESTRICT against managed_certificates.renewal_policy_id).
renewalPolicyService := service.NewRenewalPolicyService(renewalPolicyRepo) renewalPolicyService := service.NewRenewalPolicyService(renewalPolicyRepo)
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService) certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
// Atomic audit-row plumbing (closes the #3 acquisition-readiness
// blocker from the 2026-05-01 issuer coverage audit). The same
// transactor instance is shared across CertificateService /
// RevocationSvc / RenewalService so all three audit-emitting
// service paths run their writes in transactions backed by the
// same *sql.DB handle.
transactor := postgres.NewTransactor(db)
certificateService.SetTransactor(transactor)
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable —
// issuance approval-workflow primitive. ApprovalRepository +
// ApprovalMetrics + ApprovalService construct here; the gate is
// activated on CertificateService via SetApprovalService +
// SetProfileRepo. Inactive when CertificateProfile.RequiresApproval
// is false (the default), preserving the historical unattended
// renewal path. See docs/approval-workflow.md.
approvalRepo := postgres.NewApprovalRepository(db)
approvalMetrics := service.NewApprovalMetrics()
approvalService := service.NewApprovalService(approvalRepo, jobRepo, auditService,
approvalMetrics, cfg.Approval.BypassEnabled)
if cfg.Approval.BypassEnabled {
logger.Warn("CERTCTL_APPROVAL_BYPASS=true — every approval auto-approves with actor=system-bypass; production deploys must leave this unset")
}
certificateService.SetApprovalService(approvalService)
certificateService.SetProfileRepo(profileRepo)
approvalHandler := handler.NewApprovalHandler(approvalService)
// Rank 8 of the 2026-05-03 deep-research deliverable — first-class
// CA hierarchy management (intermediate_cas table + admin-gated
// hierarchy endpoints). The service receives the issuerRepo so
// future surface area (issuer-row hierarchy_mode validation) can
// query the issuer config; for the commit-4 wiring it carries
// only the fields used today. The signer.FileDriver shared with
// the OCSP responder bootstrap path is reused here — operators
// can plug in PKCS#11 / cloud-KMS drivers via the same Driver
// interface without touching the service. See
// docs/intermediate-ca-hierarchy.md.
intermediateCARepo := postgres.NewIntermediateCARepository(db)
intermediateCAMetrics := service.NewIntermediateCAMetrics()
// Defer wiring the service + handler — signerDriver is constructed
// further down in this function alongside the OCSP responder
// bootstrap path. The service holds a reference to issuerRepo for
// future hierarchy_mode validation surface area.
_ = intermediateCAMetrics // service constructed below alongside signerDriver
notifierRegistry := make(map[string]service.Notifier) notifierRegistry := make(map[string]service.Notifier)
// Wire notifier connectors from config // Wire notifier connectors from config
@@ -287,8 +362,21 @@ func main() {
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry) notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
notificationService.SetOwnerRepo(ownerRepo) notificationService.SetOwnerRepo(ownerRepo)
// Rank 4 of the 2026-05-03 Infisical deep-research deliverable
// (per the project's deep-research deliverable, Part 5). Per-policy
// multi-channel expiry-alert metrics. Same instance is wired into
// the notification service (recording side, every
// SendThresholdAlertOnChannel call reports its outcome) AND into
// the metrics handler below (exposing side, Prometheus emitter
// reads the counters). Mirrors the VaultRenewalMetrics wiring
// pattern from the 2026-05-03 audit fix #5 — single instance,
// shared between recorder and exposer.
expiryAlertMetrics := service.NewExpiryAlertMetrics()
notificationService.SetExpiryAlertMetrics(expiryAlertMetrics)
// Create RevocationSvc with its dependencies // Create RevocationSvc with its dependencies
revocationSvc := service.NewRevocationSvc(certificateRepo, revocationRepo, auditService) revocationSvc := service.NewRevocationSvc(certificateRepo, revocationRepo, auditService)
revocationSvc.SetTransactor(transactor)
revocationSvc.SetIssuerRegistry(issuerRegistry) revocationSvc.SetIssuerRegistry(issuerRegistry)
revocationSvc.SetNotificationService(notificationService) revocationSvc.SetNotificationService(notificationService)
@@ -320,6 +408,15 @@ func main() {
RotationGrace: cfg.OCSPResponder.RotationGrace, RotationGrace: cfg.OCSPResponder.RotationGrace,
Validity: cfg.OCSPResponder.Validity, Validity: cfg.OCSPResponder.Validity,
}) })
// Rank 8 service + handler — wired here so signerDriver is in
// scope. The same FileDriver instance feeds both the OCSP
// responder bootstrap path and the intermediate-CA hierarchy.
// Operators that swap to PKCS#11 / cloud-KMS drivers reuse the
// single Driver instance across both surfaces.
intermediateCAService := service.NewIntermediateCAService(
intermediateCARepo, issuerRepo, signerDriver, auditService, intermediateCAMetrics)
intermediateCAHandler := handler.NewIntermediateCAHandler(intermediateCAService)
crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger) crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger)
// Production hardening II Phase 2: OCSP response cache. Mirrors the // Production hardening II Phase 2: OCSP response cache. Mirrors the
@@ -352,12 +449,18 @@ func main() {
certificateService.SetJobRepo(jobRepo) certificateService.SetJobRepo(jobRepo)
certificateService.SetKeygenMode(cfg.Keygen.Mode) certificateService.SetKeygenMode(cfg.Keygen.Mode)
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode) renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
renewalService.SetTransactor(transactor)
renewalService.SetTargetRepo(targetRepo) renewalService.SetTargetRepo(targetRepo)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, certificateRepo, ownerRepo, renewalService, deploymentService, logger) jobService := service.NewJobService(jobRepo, certificateRepo, ownerRepo, renewalService, deploymentService, logger)
// I-001: emit "job_retry" audit events when the scheduler resets Failed→Pending. // I-001: emit "job_retry" audit events when the scheduler resets Failed→Pending.
// SetAuditService is optional — JobService falls back to nil-guarded no-op if unwired. // SetAuditService is optional — JobService falls back to nil-guarded no-op if unwired.
jobService.SetAuditService(auditService) jobService.SetAuditService(auditService)
// Audit fix #9: bound the per-tick goroutine fan-out so a 5k-cert
// sweep doesn't trip upstream-CA rate limits. Default 25 from
// CERTCTL_RENEWAL_CONCURRENCY; ≤0 normalised to 1 (sequential)
// inside the setter.
jobService.SetRenewalConcurrency(cfg.Scheduler.RenewalConcurrency)
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
agentService.SetProfileRepo(profileRepo) agentService.SetProfileRepo(profileRepo)
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger) issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
@@ -368,6 +471,16 @@ func main() {
logger.Error("failed to build issuer registry from database", "error", err) logger.Error("failed to build issuer registry from database", "error", err)
} }
logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len()) logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len())
// Top-10 fix #5 (2026-05-03 audit): kick off any optional
// long-running background work bound to issuer connectors. Today
// only Vault PKI implements issuer.Lifecycle (renew-self loop);
// other connectors are silently skipped. Per-connector Start
// failures are logged, not fatal — a misconfigured Vault doesn't
// block server startup. Stop is wired to the deferred shutdown
// path below so the goroutines exit cleanly on signal.
issuerRegistry.StartLifecycles(context.Background())
defer issuerRegistry.StopLifecycles()
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger) targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
profileService := service.NewProfileService(profileRepo, auditService) profileService := service.NewProfileService(profileRepo, auditService)
teamService := service.NewTeamService(teamRepo, auditService) teamService := service.NewTeamService(teamRepo, auditService)
@@ -534,6 +647,17 @@ func main() {
// alert on certctl_ocsp_counter_total{label="rate_limited"}, // alert on certctl_ocsp_counter_total{label="rate_limited"},
// {label="nonce_malformed"}, etc. // {label="nonce_malformed"}, etc.
metricsHandler.SetOCSPCounters(ocspCounters) metricsHandler.SetOCSPCounters(ocspCounters)
// Audit fix #4: wire the per-issuer-type issuance metrics so the
// /api/v1/metrics/prometheus exposer emits the new series.
metricsHandler.SetIssuanceCounters(issuanceMetrics)
// Top-10 fix #5 (2026-05-03 audit): Vault PKI token-renewal counter.
// Same instance the registry uses to record per-tick results.
metricsHandler.SetVaultRenewals(vaultRenewalMetrics)
// Rank 4 of the 2026-05-03 Infisical deep-research deliverable:
// per-policy multi-channel expiry-alert counter. Same instance the
// notification service uses to record per-(channel, threshold,
// result) outcomes.
metricsHandler.SetExpiryAlerts(expiryAlertMetrics)
// Bundle-5 / H-006: pass the *sql.DB pool so /ready can probe DB // Bundle-5 / H-006: pass the *sql.DB pool so /ready can probe DB
// connectivity via PingContext. /health stays shallow (liveness signal). // connectivity via PingContext. /health stays shallow (liveness signal).
healthHandler := handler.NewHealthHandler(cfg.Auth.Type, db) healthHandler := handler.NewHealthHandler(cfg.Auth.Type, db)
@@ -711,6 +835,63 @@ func main() {
// by PathID; the AdminEST handler reads it at request time. // by PathID; the AdminEST handler reads it at request time.
estServices := map[string]*service.ESTService{} estServices := map[string]*service.ESTService{}
// ACME server (RFC 8555 + RFC 9773 ARI). Phase 1a wired the
// directory + new-nonce surface against acmeRepo + profileRepo;
// Phase 1b adds the JWS-authenticated POST surface (new-account +
// account/<id>), which requires the transactor + audit service
// for per-op atomic-audit rows. SetTransactor mirrors the
// CertificateService.SetTransactor wiring at line 254 — same
// transactor instance shared across services.
acmeService := service.NewACMEService(acmeRepo, profileRepo, cfg.ACMEServer)
acmeService.SetTransactor(transactor)
acmeService.SetAuditService(auditService)
// Phase 2 — finalize plumbing. The finalize handler routes
// through CertificateService.Create + certRepo.CreateVersionWithTx
// + IssuerRegistry.Get for the bound profile's issuer. Same
// pipeline EST/SCEP/agent/renewal use, so policy + audit + per-
// issuer-type metrics apply uniformly to ACME-issued certs.
acmeService.SetIssuancePipeline(certificateService, certificateRepo, issuerRegistry)
// Phase 3 — challenge validator pool. The 3 per-type semaphores
// (HTTP-01 / DNS-01 / TLS-ALPN-01) bound concurrent validations
// so a flood of pending authorizations can't fan out unboundedly.
// Defaults: 10 weight per type, 30s per-challenge timeout,
// 8.8.8.8:53 DNS resolver. Operators tune via
// CERTCTL_ACME_SERVER_*_CONCURRENCY + DNS01_RESOLVER.
acmeValidatorPool := acmepkg.NewPool(acmepkg.PoolConfig{
HTTP01Weight: int64(cfg.ACMEServer.HTTP01ConcurrencyMax),
DNS01Weight: int64(cfg.ACMEServer.DNS01ConcurrencyMax),
TLSALPN01Weight: int64(cfg.ACMEServer.TLSALPN01ConcurrencyMax),
DNS01Resolver: cfg.ACMEServer.DNS01Resolver,
})
acmeService.SetValidatorPool(acmeValidatorPool)
// Phase 4 — revocation pipeline + renewal-policy lookup. The same
// revocationSvc instance shared across the rest of the platform
// covers ACME revoke-cert; the renewalPolicyRepo backs ARI window
// math (when present, ComputeRenewalWindow uses RenewalWindowDays;
// when absent, falls back to last-33%-of-validity).
acmeService.SetRevocationDelegate(revocationSvc)
acmeService.SetRenewalPolicyLookup(renewalPolicyRepo)
// Phase 5 — per-account rate limiter. In-memory token-buckets,
// shared across all entry points (CreateOrder / RotateAccountKey /
// RespondToChallenge). Restart wipes counters; orders/hour caps are
// eventual-consistency anyway. Persistent rate limiting is a
// follow-up if production telemetry shows abuse patterns we can't
// catch in a single restart cycle.
acmeRateLimiter := acmepkg.NewRateLimiter()
acmeService.SetRateLimiter(acmeRateLimiter)
// Phase 5 — ACME GC sweeper. Disabled when GCInterval <= 0; the
// scheduler.SetACMEGarbageCollector(nil) leg short-circuits in
// scheduler.Start (the loopCount + go-routine launch are gated on
// non-nil acmeGC). Wired here (not earlier with the other scheduler
// loops) because the GC service needs a fully-constructed acmeService.
if cfg.ACMEServer.Enabled && cfg.ACMEServer.GCInterval > 0 {
sched.SetACMEGarbageCollector(acmeService)
sched.SetACMEGCInterval(cfg.ACMEServer.GCInterval)
logger.Info("ACME GC scheduler enabled",
"interval", cfg.ACMEServer.GCInterval.String())
}
acmeHandler := handler.NewACMEHandler(acmeService)
// Build the API router with all handlers // Build the API router with all handlers
apiRouter := router.New() apiRouter := router.New()
apiRouter.RegisterHandlers(router.HandlerRegistry{ apiRouter.RegisterHandlers(router.HandlerRegistry{
@@ -766,6 +947,20 @@ func main() {
AdminEST: handler.NewAdminESTHandler( AdminEST: handler.NewAdminESTHandler(
handler.NewAdminESTServiceImpl(estServices), handler.NewAdminESTServiceImpl(estServices),
), ),
// ACME server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation.
// Phase 1a wires directory + new-nonce; subsequent phases extend
// with the JWS-authenticated POST surface (new-account,
// new-order, finalize, challenges, revoke, ARI). See
// docs/acme-server.md for the operator-facing reference.
ACME: acmeHandler,
// Approvals — issuance approval-workflow primitive. Rank 7 of
// the 2026-05-03 Infisical deep-research deliverable. See
// docs/approval-workflow.md.
Approvals: approvalHandler,
// IntermediateCAs — first-class CA hierarchy management.
// Rank 8 of the 2026-05-03 deep-research deliverable. See
// docs/intermediate-ca-hierarchy.md.
IntermediateCAs: intermediateCAHandler,
}) })
// Register EST (RFC 7030) handlers if enabled. // Register EST (RFC 7030) handlers if enabled.
// //
+4 -4
View File
@@ -10,10 +10,10 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/router"
"github.com/shankar0123/certctl/internal/config" "github.com/certctl-io/certctl/internal/config"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// TestMain_HealthEndpointBypassesAuth verifies that health check endpoints // TestMain_HealthEndpointBypassesAuth verifies that health check endpoints
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests. // fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
-159
View File
@@ -1,159 +0,0 @@
# CI Pipeline Cleanup — Phase 0 Baseline
> Captured against repo HEAD `1de61e91cf07449356d9046a76499c86efe413b1` (operator tag `v2.0.66`) on 2026-04-30.
> Each subsequent Phase that changes a number references this baseline.
## Repo state
**HEAD SHA:** `1de61e91cf07449356d9046a76499c86efe413b1`
**Operator-stamped tag:** `v2.0.66`
## ci.yml shape
- Total lines: `1488`
- Total named steps: `53`
- Named regression-guard steps: 22 (enumerated below)
### The 22 regression-guard steps
```
81: - name: Forbidden auth-type literal regression guard (G-1)
144: - name: Forbidden bare InsecureSkipVerify regression guard (L-001)
180: - name: Forbidden bare FROM regression guard (H-001)
201: - name: Forbidden missing USER regression guard (M-012)
228: - name: Forbidden README JWT advertising regression guard (H-009)
254: - name: Forbidden api_key_hash JSON-shape regression guard (G-2)
311: - name: Forbidden plaintext HEALTHCHECK regression guard (U-2)
360: - name: Forbidden migration mount in compose initdb (U-3)
417: - name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)
569: - name: Forbidden client-side bulk-action loop regression guard (L-1)
613: - name: Forbidden orphan-CRUD client function regression guard (B-1)
665: - name: Forbidden strings.Contains(err.Error()) regression guard (S-2)
868: - name: QA-doc Part-count drift guard
886: - name: QA-doc seed-count drift guard
938: - name: Test-naming convention guard (hard-fail)
982: - name: Forbidden hardcoded source-count prose regression guard (S-1)
1027: - name: Documented orphan client fns sync guard (P-1)
1063: - name: Frontend page-coverage regression guard (T-1)
1118: - name: Bundle-8 / L-015 target=_blank rel=noopener regression guard
1147: - name: Bundle-8 / L-019 dangerouslySetInnerHTML regression guard
1176: - name: Bundle-8 / M-009 + M-029 Pass 1 mutation contract guard (hard zero)
1220: - name: Forbidden env-var docs drift regression guard (G-3)
```
## SA1019 site count
- **Operator-on-workstation deliverable** — sandbox cannot run `staticcheck`.
- ci.yml inline comment claims "6 sites" (`middleware.NewAuth × 3`, `csr.Attributes`, `elliptic.Marshal`).
- Source-grep at HEAD shows:
- `internal/api/handler/scep.go`: `csr.Attributes` references present
- `internal/connector/issuer/local/local.go`: `elliptic.Marshal` historic refs (already migrated per bundle9_coverage_test.go byte-equivalence test)
- `cmd/server/main_test.go`: `middleware.NewAuth` references TBD
- Operator must run `staticcheck ./... 2>&1 | grep SA1019` on workstation and update Phase 3 plan with the actual site list.
## Dockerfile inventory (verified 4)
```
./Dockerfile.agent
./Dockerfile
./deploy/test/f5-mock-icontrol/Dockerfile
./deploy/test/libest/Dockerfile
```
## Migration up/down balance
- ups: `24`
- downs: `24`
- missing downs: `0`
## OpenAPI ↔ handler parity gap (verified)
- operationIds in api/openapi.yaml: `136`
- r.Register calls in router.go: `149`
- Gap to root-cause in Phase 9: 13 routes
## docker-compose.test.yml sidecars
```
52: certctl-tls-init:
107: postgres:
135: pebble-challtestsrv:
150: pebble:
178: step-ca:
213: certctl-server:
363: nginx:
391: certctl-agent:
449: libest-client:
488: apache-test:
502: haproxy-test:
515: traefik-test:
533: caddy-test:
548: envoy-test:
562: postfix-test:
577: dovecot-test:
591: openssh-test:
613: f5-mock-icontrol:
631: k8s-kind-test:
648: windows-iis-test:
666: certctl-test:
```
## Makefile::verify body (existing)
```
verify:
@echo "==> fmt"
@go fmt ./... | { ! grep -q '.'; } || (echo "gofmt produced changes — commit them" && exit 1)
@echo "==> go vet ./..."
@go vet ./...
@echo "==> golangci-lint run ./... (incl. staticcheck ST*)"
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
@golangci-lint run ./... --timeout 5m
@echo "==> go test -short ./..."
@go test -short -count=1 ./...
@echo ""
@echo "verify: PASS — safe to commit"
```
## RAM headroom for collapsed vendor-e2e job
- **Operator-on-workstation deliverable** — requires a prototype branch with the collapsed job + `docker stats` polling.
- Per Phase 0 frozen decision 0.14: if peak RSS ≤ 12 GB on ubuntu-latest (16 GB ceiling), single-job collapse is approved.
- If > 12 GB, fall back to bucketed-matrix design documented in `cowork/ci-pipeline-cleanup/decisions-revised.md`.
## Coverage thresholds at HEAD
```
778: if [ "$(echo "$SERVICE_COV < 70" | bc -l)" -eq 1 ]; then
779: echo "::error::Service layer coverage ${SERVICE_COV}% is below 70% (Bundle R-CI-extended floor — add tests, do not lower the gate)"
782: if [ "$(echo "$HANDLER_COV < 75" | bc -l)" -eq 1 ]; then
783: echo "::error::Handler layer coverage ${HANDLER_COV}% is below 75% (Bundle R-CI-extended floor — add tests, do not lower the gate)"
786: if [ "$(echo "$DOMAIN_COV < 40" | bc -l)" -eq 1 ]; then
787: echo "::error::Domain layer coverage ${DOMAIN_COV}% is below 40% threshold"
790: if [ "$(echo "$MIDDLEWARE_COV < 30" | bc -l)" -eq 1 ]; then
791: echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold"
802: if [ "$(echo "$CRYPTO_COV < 88" | bc -l)" -eq 1 ]; then
803: echo "::error::Crypto package coverage ${CRYPTO_COV}% is below 88% (Bundle R closure floor — add tests, do not lower the gate)"
832: if [ "$(echo "$LOCAL_ISSUER_COV < 86" | bc -l)" -eq 1 ]; then
833: echo "::error::Local-issuer coverage ${LOCAL_ISSUER_COV}% is below 86% (Bundle R closure floor — add tests, do not lower the gate)"
842: if [ "$(echo "$ACME_COV < 80" | bc -l)" -eq 1 ]; then
843: echo "::error::ACME issuer coverage ${ACME_COV}% is below 80% (Bundle R-CI-extended floor — add tests, do not lower the gate)"
846: if [ "$(echo "$STEPCA_COV < 80" | bc -l)" -eq 1 ]; then
847: echo "::error::StepCA issuer coverage ${STEPCA_COV}% is below 80% (Bundle L.B closure floor — add tests, do not lower the gate)"
850: if [ "$(echo "$MCP_COV < 85" | bc -l)" -eq 1 ]; then
851: echo "::error::MCP coverage ${MCP_COV}% is below 85% (Bundle K closure floor — add tests, do not lower the gate)"
```
## CodeQL workflow (no changes)
- File: `.github/workflows/codeql.yml` (`81` lines)
- Matrix: `[go, javascript-typescript]` — 2 status checks per push
- Trigger: push to master, PR to master, weekly Sunday cron
## Status check accounting (verified)
Today: 1 `go-build-and-test` + 1 `frontend-build` + 1 `helm-lint` + 12 `deploy-vendor-e2e (<vendor>)` + 2 `deploy-vendor-e2e-windows (<vendor>)` + 2 `CodeQL Analyze (<lang>)` = **19 status checks per push**.
After cleanup: 1 `go-build-and-test` + 1 `frontend-build` + 1 `helm-lint` + 1 `deploy-vendor-e2e` + 1 `image-and-supply-chain` + 2 `CodeQL Analyze (<lang>)` = **7 status checks per push**.
@@ -1,53 +0,0 @@
# CI Pipeline Cleanup — Deliberate Revisions of Bundle II Decisions
This bundle deliberately revises two Bundle II frozen decisions. Both revisions are recorded here for audit trail and acknowledged in the per-Phase commits that implement them.
## Bundle II decision 0.4 → revised by ci-pipeline-cleanup decision 0.5
**Bundle II 0.4 (original):** "IIS e2e strategy — `mcr.microsoft.com/windows/servercore:ltsc2022` Windows containers via Docker Desktop on Windows hosts. Linux CI runners CAN'T run Windows containers, so the IIS e2e suite runs on a separate Windows-runner CI matrix job (or operator's local Windows host for development). Documented limitation."
**ci-pipeline-cleanup 0.5 (revision):** Delete the Windows-runner CI matrix entirely.
**Rationale for revision:**
1. The matrix can't physically work on `windows-latest` GitHub-hosted runners today. Verified via the failure logs from CI run `25183374742` (commit `1de61e9`):
- `wincertstore` job: `error during connect: ... open //./pipe/docker_engine: The system cannot find the file specified` — Docker daemon not started in Windows-containers mode.
- `iis` job: image pulled successfully (so the new digest is correct), then died at `failed to create network deploy_certctl-test: could not find plugin bridge in v1 plugin registry: plugin not found``bridge` network driver doesn't exist on Windows Docker (uses `nat`).
2. Even if both Docker-daemon and network-driver issues were fixed, the matrix would validate nothing of substance. Verified by source-grep: all 16 functions matching `TestVendorEdge_(IIS|WinCertStore)_*` in `deploy/test/vendor_e2e_phase3_to_13_test.go` are `t.Log` placeholders that exercise no IIS-specific behavior. The real IIS connector validation lives in `internal/connector/target/iis/` unit tests (run on Linux in `go-build-and-test` — already green per push).
3. Bundle II decision 0.14 explicitly required operator manual smoke against a real instance for "verified" status in the vendor matrix. Moving IIS + WinCertStore validation to a documented operator playbook in `docs/connector-iis.md` satisfies that criterion better than a fake CI matrix that passes by skipping.
**Preservation:** the `windows-iis-test` sidecar stays in `deploy/docker-compose.test.yml` under `profiles: [deploy-e2e-windows]` — operators on a Windows host can opt in via `docker compose --profile deploy-e2e-windows up -d windows-iis-test`. Linux CI never activates this profile.
## Bundle II decision 0.9 → revised by ci-pipeline-cleanup decision 0.4
**Bundle II 0.9 (original):** "CI parallelism — Each vendor e2e gets its own GitHub Actions matrix job. Vendor failures surface independently in the CI status check (operator sees 'K8s 1.31 vendor-edge fail' as a discrete check, not a generic 'integration tests failed')."
**ci-pipeline-cleanup 0.4 (revision):** Single `deploy-vendor-e2e` job replaces the 12-job matrix; per-vendor visibility partially restored via skip-detection guard messages.
**Rationale for revision:**
1. The per-vendor granularity Bundle II decision 0.9 was designed to provide is fake signal. Verified by source-analysis at HEAD:
```
$ grep -cE 't\.Log\(' deploy/test/{vendor_e2e_phase3_to_13,nginx_vendor_e2e}_test.go
deploy/test/nginx_vendor_e2e_test.go:9
deploy/test/vendor_e2e_phase3_to_13_test.go:106
$ awk '/^func TestVendorEdge_/{in_test=1; name=$2; has_assert=0; next}
in_test && /^}$/ {if (has_assert) print name; in_test=0}
in_test && /t\.(Fatal|Error|Errorf|Fatalf|Fail|Failf)/ {has_assert=1}' \
deploy/test/vendor_e2e_phase3_to_13_test.go deploy/test/nginx_vendor_e2e_test.go
TestVendorEdge_NGINX_HighConcurrencyDeployUnderLoad_E2E
```
115 of 116 vendor-edge test functions are `t.Log`-only — they spin up a sidecar, log a one-line description of the vendor quirk, and return. Only 1 has a real assertion.
2. Per-vendor status-check granularity costs ~9 sec setup overhead × 12 jobs = ~108 sec of pure runner waste per push (verified from CI run `25183374742` job timings).
3. The single-job version partially restores per-vendor visibility via the skip-detection guard (decision 0.6): if a sidecar fails to start, the affected tests' SKIP names print in the CI output and the build fails. Operators see "TestVendorEdge_K8s_KubeletSyncWaitContract_DefaultTimeout60s_E2E SKIPPED: vendor sidecar 'k8s-kind' not reachable" — same per-vendor signal, just no longer rendered as a separate status-check row.
**Preservation:** the per-test discoverability via `go test -run 'VendorEdge_<vendor>'` (Bundle II frozen decision 0.6) is unchanged. Only the matrix-jobs-per-vendor part of decision 0.9 is revised; the per-test naming convention stays.
## Forward-looking note
Both revisions are limited in scope to CI execution shape — they do NOT delete the test files, the sidecar definitions, or the documentation that Bundle II shipped. Future work could re-introduce per-vendor matrix jobs if test bodies are filled in with real assertions (transforming the t.Log placeholders into actual contract pins). At that point, decision 0.4 + 0.9 should be re-evaluated.
@@ -1,64 +0,0 @@
# CI Pipeline Cleanup — Frozen Decisions
> 14 frozen decisions confirmed at Phase 0. Each subsequent Phase references the decision number it implements.
## 0.1 — Trigger model
Three-tier split, no mixing:
- **On push/PR to master:** blocking, fast, every check earns its keep, target <10 min wall-clock.
- **Daily cron + workflow_dispatch:** `security-deep-scan.yml` as-is; slow scans, best-effort, never blocks.
- **On tag push (`v*`):** `release.yml` as-is; cross-platform binaries, ghcr.io push, SLSA provenance.
## 0.2 — Extracted-script location
`scripts/ci-guards/` at repo root. Operator runs `bash scripts/ci-guards/<id>.sh` locally. Contract documented in `scripts/ci-guards/README.md`.
## 0.3 — Coverage threshold YAML format
`.github/coverage-thresholds.yml`. Top-level keys are package paths; each entry has `floor:` (integer pct) + `why:` (multi-line string for load-bearing context). Bash step uses Python (already on the runner) to read the YAML — no `yq` dependency.
## 0.4 — Vendor matrix collapse policy (REVISES Bundle II decision 0.9)
Single `deploy-vendor-e2e` job replaces 12-job matrix. Bundle II decision 0.9 said "Each vendor e2e gets its own GitHub Actions matrix job" — this revision recognizes that 115/116 vendor-edge tests are `t.Log` placeholders, so per-vendor status-check granularity is fake signal. Skip-detection guard partially restores per-vendor visibility (SKIP messages name the vendor). Documented as deliberate revision in `cowork/ci-pipeline-cleanup/decisions-revised.md`.
## 0.5 — Windows IIS validation deletion (REVISES Bundle II decision 0.4)
Delete `deploy-vendor-e2e-windows` matrix entirely. Bundle II decision 0.4 said "the IIS e2e suite runs on a separate Windows-runner CI matrix job" — this revision recognizes that (a) the matrix can't physically work on `windows-latest` (Docker not started in Windows-containers mode; `bridge` driver missing on Windows Docker), and (b) all 16 IIS + WinCertStore tests are `t.Log` placeholders. Move validation to `docs/connector-iis.md::Operator validation playbook` per Bundle II decision 0.14's third criterion. The `windows-iis-test` sidecar stays in `deploy/docker-compose.test.yml` for operator local use.
## 0.6 — Skip-detection guard semantics + EXPECTED_SKIPS allowlist
After `go test -tags integration -run 'VendorEdge_'`, count `^--- SKIP:` lines. Allowlist: 6 JavaKeystore tests in `vendor_e2e_phase3_to_13_test.go` that legitimately t.Log without sidecar. Allowlist file at `scripts/ci-guards/vendor-e2e-skip-allowlist.txt`, one test name per line.
## 0.7 — SA1019 closure approach
Close each site individually with byte-equivalence tests where the deprecated API was load-bearing. Then flip `continue-on-error: true``false` in the SAME commit. Do NOT split — shipping the gate without closing sites would fail CI on master. Live verification: `staticcheck ./... 2>&1 | grep -c SA1019` returns 0 BEFORE flipping the gate.
## 0.8 — Image-and-supply-chain placement
Separate top-level job (not steps in `go-build-and-test`). Two reasons: (a) digest-validity needs network egress to multiple registries (Docker Hub, ghcr.io, mcr.microsoft.com), bundling into go-build blocks Go tests on registry latency. (b) `docker build` is parallel to Go tests; isolating lets it run concurrently.
## 0.9 — Coverage PR-comment provider
Default: lightweight self-hosted action that posts a per-PR comment via `gh pr comment`. Avoids paid SaaS. Operator can swap to Codecov/Coveralls later.
## 0.10 — Docker build smoke scope
Build all 4 Dockerfiles in the repo: `Dockerfile`, `Dockerfile.agent`, `deploy/test/f5-mock-icontrol/Dockerfile`, `deploy/test/libest/Dockerfile`. The test-sidecar Dockerfiles are load-bearing for vendor-e2e — a syntax error there silently breaks the e2e suite. Tagged `:smoke` and discarded.
## 0.11 — OpenAPI ↔ handler parity exception YAML
NEW `api/openapi-handler-exceptions.yaml`. Schema: `documented_exceptions:` list of `{route, why}` entries. The 13-route gap at HEAD is root-caused in Phase 9; most are likely health probes / metrics / SCEP-EST-OCSP wire endpoints that legitimately have no operationId.
## 0.12 — Branch-protection-rule update timing
Operator updates GitHub branch-protection rules in Phase 13 AFTER the new pipeline ships and runs green on a feature branch + on the first push to master. Required-checks list changes from 19 → 7 entries. Operator action only — agent cannot do this.
## 0.13 — Make-target naming for new operator-side scripts
- `make verify` (existing) — required pre-commit; gofmt + vet + lint + tests
- `make verify-deploy` (new) — optional pre-push; digest-validity + OpenAPI parity + docker build smoke (server + agent only — fast subset for local)
- `make verify-docs` (new) — required pre-tag; QA-doc Part-count + seed-count drift
## 0.14 — RAM headroom verification methodology
Phase 0 deliverable. Operator creates `prototype/ci-pipeline-cleanup-vendor-collapse` branch, runs the collapsed `deploy-vendor-e2e` job once, captures peak RSS via `docker stats --no-stream` snapshots every 30 sec, records max in this baseline doc. If max > 12 GB (75% of 16 GB ceiling), fall back to bucketed matrix (3 jobs × ~4 sidecars). If max ≤ 12 GB, single-job collapse is approved.
@@ -1,100 +0,0 @@
# Phase 13 Verification Log
> Captured against repo HEAD post-Phase-12 commit `453ba78` on 2026-04-30.
## All 22 ci-guards run on HEAD
```
PASS B-1-orphan-crud.sh
PASS D-1-D-2-statusbadge-phantom.sh
PASS G-1-jwt-auth-literal.sh
PASS G-2-api-key-hash-json.sh
PASS G-3-env-docs-drift.sh
PASS H-001-bare-from.sh
PASS H-009-readme-jwt.sh
PASS L-001-insecure-skip-verify.sh
PASS L-1-bulk-action-loop.sh
PASS M-012-no-root-user.sh
PASS P-1-documented-orphan-fns.sh
PASS S-1-hardcoded-source-counts.sh
PASS S-2-strings-contains-err.sh
PASS T-1-frontend-page-coverage.sh
PASS U-2-plaintext-healthcheck.sh
PASS U-3-migration-mount.sh
PASS bundle-8-L-015-target-blank-rel-noopener.sh
PASS bundle-8-L-019-dangerously-set-inner-html.sh
PASS bundle-8-M-009-bare-usemutation.sh
PASS digest-validity.sh
PASS openapi-handler-parity.sh
PASS test-naming-convention.sh
```
The two "intentionally-fail-on-bare-invocation" helper scripts:
- `vendor-e2e-skip-check.sh` — needs `test-output.log` argument (CI provides it); naked invocation correctly errors
- `coverage-pr-comment.sh` — no-ops gracefully when `PR_NUMBER` env var is unset
## Make targets pre-tag
```
make verify-docs:
qa-doc-part-count: clean (56 == 56).
qa-doc-seed-count: clean.
verify-docs: PASS — safe to tag
```
`make verify` and `make verify-deploy` require Go + docker; sandbox can't run them. Operator pre-tag verification:
```bash
make verify # required pre-commit
make verify-deploy # optional pre-push
make verify-docs # required pre-tag (verified above)
```
## ci.yml final shape
- Line count: **439** (down from baseline **1488** = -71%)
- Job boundaries verified at lines 13, 232, 278, 345, 409:
- `go-build-and-test`
- `frontend-build`
- `helm-lint`
- `deploy-vendor-e2e` (single job, was 12-job matrix)
- `image-and-supply-chain` (NEW)
- Total status checks per push: **7** (5 CI + 2 CodeQL), down from baseline **19**.
## Phase commits (master ahead of v2.0.66)
```
453ba78 ci-pipeline-cleanup Phase 12: docs/ci-pipeline.md + bundle artefacts
ce987cc ci-pipeline-cleanup Phase 11: make verify-docs + verify-deploy targets
3a69600 ci-pipeline-cleanup Phase 10: coverage PR-comment action
19a5e43 ci-pipeline-cleanup Phases 7-9: image-and-supply-chain job
d0bc53b ci-pipeline-cleanup Phase 6 follow-up: IIS operator playbook + matrix doc
6f6de63 ci-pipeline-cleanup Phase 5+6: collapse vendor matrix; delete Windows matrix
71b2245 ci-pipeline-cleanup Phase 4: gofmt parity + go mod tidy drift
af72630 ci-pipeline-cleanup Phase 3: staticcheck hard-fail (SA1019 sites verified closed)
60f368e ci-pipeline-cleanup Phase 2: coverage thresholds → YAML manifest
5b7a022 ci-pipeline-cleanup Phase 1: extract 20 regression guards to scripts/ci-guards/
d57910c ci-pipeline-cleanup Phase 0: baseline + frozen decisions + Bundle II revisions
```
## Operator action items post-merge
1. **GitHub branch protection rule update** — required-checks list changes 19 → 7:
```
Go Build & Test
Frontend Build
Helm Chart Validation
deploy-vendor-e2e
image-and-supply-chain
Analyze (go)
Analyze (javascript-typescript)
```
Old-name checks (`deploy-vendor-e2e (<vendor>)` × 12, `deploy-vendor-e2e-windows (<vendor>)` × 2) won't appear on new PRs after the workflow change. Operator removes them from the required list.
2. **RAM-headroom verification** (frozen decision 0.14) — operator runs the collapsed `deploy-vendor-e2e` job on a one-off branch with `docker stats --no-stream` polling. If peak RSS > 12 GB, fall back to bucketed matrix per `cowork/ci-pipeline-cleanup/decisions-revised.md`. If ≤ 12 GB, current single-job design is the final shape.
3. **Tag** — operator picks the exact `v2.X.0` value (recommended: increment from `v2.0.66`). 11 phase commits land on master after the prior bundle's closing commit.
## Acceptance gate verified
All 19 ☐ items from the prompt's "Final acceptance gate" pass except the operator-only items (3 above). Bundle is shippable pending the operator action.
-73
View File
@@ -1,73 +0,0 @@
# Reddit / HN announce — ci-pipeline-cleanup
> Don't auto-post. Operator times manually after the tag lands.
## r/devops / r/golang
> **certctl 2.X.0 — CI pipeline cleanup: 19 status checks → 7, ci.yml -71%**
>
> Open-source Go cert lifecycle tool. v2.X.0 ships a CI-only refactor
> that drops status checks per push from 19 → 7, shrinks ci.yml from
> 1488 lines to ~430 (-71%), closes three lying-field patterns, and
> adds five new gates that catch bug classes the prior pipeline missed.
>
> The 20 named regression guards (G-1 JWT auth, L-001 InsecureSkipVerify,
> H-001 bare FROM, G-3 env-docs drift, etc.) extracted from inline
> ci.yml bash to sibling scripts/ci-guards/<id>.sh — each callable
> locally as `bash scripts/ci-guards/<id>.sh`. Adding a new guard:
> drop a new script; CI loop auto-picks it up.
>
> Coverage thresholds moved to a YAML manifest with per-package `floor:`
> + `why:` (load-bearing context — Bundle reference, HEAD measurement,
> gap rationale).
>
> Three lying fields closed:
> - staticcheck `continue-on-error: true` (the M-028 work was
> effectively done in earlier bundles, just nobody flipped the gate)
> - H-001 bare-FROM guard verifies digest *presence* but not
> *resolution* (Bundle II shipped 11 fabricated digests that passed
> H-001 and failed `docker pull` in CI). New `digest-validity` step
> in the new image-and-supply-chain job resolves every @sha256 ref
> against its registry.
> - Windows IIS matrix that couldn't physically run on windows-latest
> (bridge network driver missing on Windows Docker) AND validated
> nothing (16 t.Log placeholders). Deleted; moved to operator
> playbook for manual Windows-host validation pre-release.
>
> Five new gates: digest validity, `go mod tidy` drift, gofmt parity
> with Makefile::verify, OpenAPI ↔ handler operationId parity (with
> documented exceptions YAML), Docker build smoke for all 4 Dockerfiles.
>
> Repo: <github>/certctl. Operator guide: docs/ci-pipeline.md.
## Hacker News
> **certctl: CI pipeline cleanup — 19 status checks → 7, ci.yml -71%**
>
> Open-source cert lifecycle tool. v2.X.0 ships a CI refactor that
> tightens the on-push pipeline without changing any product behavior.
>
> The interesting bits: collapsed a 12-job per-vendor matrix to one
> job + a skip-count enforcement guard (the per-vendor granularity
> was fake signal because 115/116 vendor-edge tests are t.Log
> placeholders); deleted a Windows IIS CI matrix that couldn't
> physically run on windows-latest (Docker not in Windows-containers
> mode by default; bridge network driver missing) AND validated
> nothing; flipped staticcheck from soft-gate to hard-fail; added
> a digest-validity check that closes the lying-field gap H-001's
> regex-only check left open.
>
> Coverage thresholds in a YAML manifest with per-package `why:`
> context. 20 regression guards as standalone scripts, each
> callable locally. New 3-tier make convention: verify (pre-commit),
> verify-deploy (optional pre-push), verify-docs (pre-tag).
## Discord (announcement channel template)
> 🚀 v2.X.0 ships ci-pipeline-cleanup — 19 status checks → 7,
> ci.yml -71%, 3 lying fields closed, 5 new gates.
>
> docs/ci-pipeline.md is the new operator guide. scripts/ci-guards/
> hosts the 20 named regression guards extracted from inline ci.yml
> bash. .github/coverage-thresholds.yml is the per-package floor
> manifest. cowork/ci-pipeline-cleanup/ has the bundle artefacts.
@@ -1,191 +0,0 @@
# certctl v2.X.0 — CI Pipeline Cleanup
> Operator-facing release notes for the ci-pipeline-cleanup master bundle.
> Operator picks the exact `v2.X.0` from the increment-from-the-last-tag rule.
## TL;DR
Restructured the on-push CI pipeline. Status checks per push drop from
**19 → 7**. `ci.yml` shrinks **1488 → ~430 lines** (-71%). Three lying
fields closed (staticcheck soft-gate; Bundle II's fabricated digest
regex-only check; Windows matrix that validated nothing). Five new
gates added (digest validity, `go mod tidy` drift, gofmt parity,
OpenAPI ↔ handler parity, Docker build smoke).
**Zero product behavior changes.** No migrations, no API changes, no
connector behavior changes. CI-only refactor.
## What's new
### `scripts/ci-guards/` — extracted regression guards (Phase 1)
20 named regression guards moved from inline `ci.yml` bash to sibling
scripts:
- `G-1-jwt-auth-literal.sh`, `L-001-insecure-skip-verify.sh`,
`H-001-bare-from.sh`, `M-012-no-root-user.sh`, `H-009-readme-jwt.sh`,
`G-2-api-key-hash-json.sh`, `U-2-plaintext-healthcheck.sh`,
`U-3-migration-mount.sh`, `D-1-D-2-statusbadge-phantom.sh`,
`L-1-bulk-action-loop.sh`, `B-1-orphan-crud.sh`,
`S-2-strings-contains-err.sh`, `G-3-env-docs-drift.sh`,
`test-naming-convention.sh`, `S-1-hardcoded-source-counts.sh`,
`P-1-documented-orphan-fns.sh`, `T-1-frontend-page-coverage.sh`,
`bundle-8-L-015-target-blank-rel-noopener.sh`,
`bundle-8-L-019-dangerously-set-inner-html.sh`,
`bundle-8-M-009-bare-usemutation.sh`
Each script is callable locally:
```bash
bash scripts/ci-guards/G-3-env-docs-drift.sh
```
CI step is a single loop that auto-picks up new scripts. Adding a new
guard: drop a new `<id>.sh`; no `ci.yml` change required.
The 2 QA-doc guards (Part-count + seed-count) moved to `make verify-docs`
instead — they protect docs-the-operator-reads, not anything the
product depends on.
### `.github/coverage-thresholds.yml` (Phase 2)
Per-package coverage floors moved out of inline bash into a YAML
manifest. Each entry has `floor:` (integer percentage) + `why:`
(load-bearing context — Bundle reference, HEAD measurement, gap
rationale). Adding a new gated package: one YAML entry instead of
~30 lines of bash. Floors unchanged from HEAD.
### `staticcheck` hard gate (Phase 3)
The old `continue-on-error: true` lying field with the "M-028 will
close 6 SA1019 sites" comment is gone. Verified at HEAD: all live
SA1019 sites either migrated (`middleware.NewAuth``NewAuthWithNamedKeys`)
or suppressed inline with load-bearing rationale (`csr.Attributes` for
RFC 2985 challengePassword; `elliptic.Marshal` only in byte-equivalence
test). Gate now hard.
### `make verify` parity + `go mod tidy` drift (Phase 4)
Two new steps in `go-build-and-test`:
- **gofmt drift** — closes the parity gap with `Makefile::verify`
(CI was running vet + lint + test but not gofmt)
- **go mod tidy drift** — `go mod tidy && git diff --exit-code go.mod go.sum`
### `deploy-vendor-e2e` collapsed: 12 jobs → 1 job (Phase 5)
Per-vendor matrix granularity was fake signal — verified that 115/116
vendor-edge tests are `t.Log` placeholders. Single job brings up all
11 sidecars at once + runs the full `VendorEdge_` suite + enforces
skip-count (no sidecar may silently fail to come up).
NEW `scripts/ci-guards/vendor-e2e-skip-check.sh` + allowlist file at
`scripts/ci-guards/vendor-e2e-skip-allowlist.txt` (15 windows-iis-
requiring tests legitimately skip on Linux per Phase 6).
**Revises Bundle II frozen decision 0.9.** Documented in
`cowork/ci-pipeline-cleanup/decisions-revised.md`.
### `deploy-vendor-e2e-windows` deleted entirely (Phase 6)
The Windows matrix can't physically work on `windows-latest` GitHub
runners (Docker not started in Windows-containers mode by default;
`bridge` network driver missing on Windows Docker — uses `nat`).
Even if fixed, all 16 IIS + WinCertStore tests are `t.Log` placeholders.
NEW `docs/connector-iis.md::Operator validation playbook` documents
the manual-on-Windows-host procedure operators run pre-release. The
`windows-iis-test` sidecar stays in `deploy/docker-compose.test.yml`
under `profiles: [deploy-e2e-windows]` for operator local use.
`docs/deployment-vendor-matrix.md` IIS + WinCertStore rows status
updated `pending``operator-playbook`.
**Revises Bundle II frozen decision 0.4.** Documented in
`cowork/ci-pipeline-cleanup/decisions-revised.md`.
### NEW `image-and-supply-chain` job (Phases 7-9)
Top-level Ubuntu job (~3 min, parallel to `go-build-and-test`). Three
steps:
1. **Digest validity** — every `@sha256:<digest>` ref in
`deploy/**/*.{yml,Dockerfile*}` must resolve on its registry.
Closes the H-001 lying-field gap (H-001 verifies digest *presence*
only — Bundle II shipped 11 fabricated digests that passed H-001
and failed `docker pull` in CI).
2. **Docker build smoke** — all 4 Dockerfiles in the repo must build
(`Dockerfile`, `Dockerfile.agent`,
`deploy/test/f5-mock-icontrol/Dockerfile`,
`deploy/test/libest/Dockerfile`).
3. **OpenAPI ↔ handler operationId parity** — every router route has
a matching `operationId` in `api/openapi.yaml` or is documented in
the new `api/openapi-handler-exceptions.yaml` (8 documented
exceptions at HEAD: SCEP + SCEP-mTLS wire-protocol endpoints).
### Coverage PR-comment action (Phase 10)
Self-hosted alternative to Codecov / Coveralls. Posts per-package
coverage table as a PR comment; updates in place on subsequent
pushes. No paid SaaS dependency.
### `make verify-docs` + `make verify-deploy` (Phase 11)
Three-tier convention now:
- `make verify` — required pre-commit (gofmt + vet + lint + test)
- `make verify-deploy` — optional pre-push (digest validity + OpenAPI
parity + Docker build smoke for server + agent)
- `make verify-docs` — required pre-tag (QA-doc Part-count + seed-count)
### NEW `docs/ci-pipeline.md` (Phase 12)
Operator-facing guide to the on-push pipeline. Per-job deep-dive,
guard inventory, threshold management, troubleshooting matrix, branch
protection list to update.
## Operator action required
After merge:
1. **Update GitHub branch protection rule** for `master` branch.
Required-checks list changes from 19 entries → 7:
- `Go Build & Test`
- `Frontend Build`
- `Helm Chart Validation`
- `deploy-vendor-e2e`
- `image-and-supply-chain`
- `Analyze (go)`
- `Analyze (javascript-typescript)`
2. **(Optional)** RAM-headroom verification on a test branch with the
collapsed `deploy-vendor-e2e` job. If peak RSS > 12 GB on
ubuntu-latest, fall back to bucketed matrix per
`cowork/ci-pipeline-cleanup/decisions-revised.md`.
## Rollback
If RAM headroom proves insufficient or a guard misbehaves:
- Vendor matrix collapse (Phase 5): revert that one commit; fall back
to the bucketed-matrix design (3 jobs × ~4 sidecars).
- staticcheck hard gate (Phase 3): revert that one commit; flip
`continue-on-error: true` back temporarily until the new SA1019
site is closed.
- All other phases are pure-additive or pure-extraction; reverting
any single Phase commit restores the prior behavior.
## Verification
```
make verify # pre-commit gate (existing)
make verify-deploy # optional pre-push (new)
make verify-docs # pre-tag (new)
bash scripts/ci-guards/*.sh # all 20 guards locally
bash scripts/check-coverage-thresholds.sh # only after coverage.out exists
```
All passing on HEAD.
## Tag
Operator picks the exact `v2.X.0` value. Bundle ships ~13 commits
on master after the prior bundle's closing commit (HEAD `1de61e91`).
+4 -2
View File
@@ -77,7 +77,7 @@ Three services on a private bridge network:
### Starting it ### Starting it
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/certctl-io/certctl.git
cd certctl cd certctl
docker compose -f deploy/docker-compose.yml up -d --build docker compose -f deploy/docker-compose.yml up -d --build
``` ```
@@ -198,7 +198,9 @@ docker compose -f deploy/docker-compose.yml down -v
### What it adds ### What it adds
One line: mounts `seed_demo.sql` into PostgreSQL's init directory. This 667-line SQL file inserts 180 days of simulated operational history: teams, owners, certificates across multiple issuers, agents on different platforms, jobs with realistic timestamps, discovery scan results, audit events, policies, and profiles. One env var: `CERTCTL_DEMO_SEED=true` on the `certctl-server` service. The server applies `migrations/seed_demo.sql` at boot via `postgres.RunDemoSeed` AFTER the baseline migrations + `seed.sql` are in place. The demo seed file inserts 180 days of simulated operational history: teams, owners, certificates across multiple issuers, agents on different platforms, jobs with realistic timestamps, discovery scan results, audit events, policies, and profiles.
Pre-U-3 the overlay used to mount `seed_demo.sql` into PostgreSQL's `/docker-entrypoint-initdb.d/` and rely on initdb-time application. That worked only because the production stack also mounted the migrations there, so the schema existed when initdb ran. Once U-3 dropped the production initdb mounts (single source of truth: server runs `RunMigrations` + `RunSeed` at boot), the demo seed could no longer be applied at initdb time — the tables it references wouldn't exist yet. Post-U-3 the overlay is a 27-line override file with no `image:` / `build:` of its own; it MUST be passed alongside the base, or compose errors with `service "certctl-server" has neither an image nor a build context specified`.
### Starting it ### Starting it
+54 -30
View File
@@ -284,29 +284,57 @@ services:
CERTCTL_EST_ENABLED: "true" CERTCTL_EST_ENABLED: "true"
CERTCTL_EST_ISSUER_ID: iss-local CERTCTL_EST_ISSUER_ID: iss-local
# SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance # SCEP intentionally NOT configured in this stack.
# (deploy/test/scep_intune_e2e_test.go integration variant).
# Closed in the 2026-04-29 audit-closure bundle (Phase I).
# #
# Publishes /scep/e2eintune?operation=... with the Intune # The 2026-04-29 master bundle Phase I added an `e2eintune` SCEP
# dispatcher enabled. The deterministic Connector signing cert # profile to this compose file with the intent that
# is bind-mounted at the path below; the matching private key # deploy/test/scep_intune_e2e_test.go would exercise it. That
# lives ONLY on the test side (see # integration test exists (//go:build integration) but no CI job
# deploy/test/scep_intune_e2e_test.go::generateE2EIntuneTrustAnchor). # actually selects it — ci.yml's deploy-vendor-e2e job runs only
CERTCTL_SCEP_ENABLED: "true" # `-run 'VendorEdge_'` (line 379), and no other job ever invokes
CERTCTL_SCEP_PROFILES: "e2eintune" # `go test -tags integration` with a SCEP selector.
CERTCTL_SCEP_PROFILE_E2EINTUNE_ISSUER_ID: iss-local #
CERTCTL_SCEP_PROFILE_E2EINTUNE_RA_CERT_PATH: /etc/certctl/scep/ra.crt # The result was dead config: SCEP_ENABLED=true triggered the
CERTCTL_SCEP_PROFILE_E2EINTUNE_RA_KEY_PATH: /etc/certctl/scep/ra.key # per-profile validator chain at server boot, but the supporting
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_ENABLED: "true" # fixtures (ra.crt + ra.key + intune_trust_anchor.pem) were never
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH: /etc/certctl/scep/intune_trust_anchor.pem # committed to deploy/test/fixtures/ — only the README documenting
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE: https://localhost:8443/scep/e2eintune # how to regenerate them. Pre-Phase-5 (ci-pipeline-cleanup matrix
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CHALLENGE_VALIDITY: 60m # collapse) the test stack didn't fully boot the certctl-server in
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CLOCK_SKEW_TOLERANCE: 60s # CI, so the gap was hidden. Once the matrix collapsed and the
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_PER_DEVICE_RATE_LIMIT_24H: 3 # collapsed deploy-vendor-e2e job started actually booting the
# server, the fail-loud gate at config.go:2069 (CWE-306, empty
# CHALLENGE_PASSWORD) fired and blocked CI.
#
# CERTCTL_SCEP_ENABLED is unset → default false → the validator
# skips the entire SCEP block. Coherence guard at
# scripts/ci-guards/test-compose-scep-coherence.sh refuses any
# future edit that re-enables SCEP without ALSO (a) adding a CI
# job that runs the SCEP integration test and (b) committing the
# required fixtures. The README at deploy/test/fixtures/README.md
# keeps the regen recipe so the eventual SCEP CI job lands cleanly.
# Dynamic issuer/target config encryption (M34/M35) # Dynamic issuer/target config encryption (M34/M35).
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!! #
# MUST be ≥ 32 bytes. The H-1 closure (commit 6cb4414, "feat(security):
# encryption-key validation") added internal/config/config.go's
# minEncryptionKeyLength = 32 byte floor; values shorter than that are
# rejected at server boot with `Failed to load configuration:
# CERTCTL_CONFIG_ENCRYPTION_KEY too short`. The previous test value
# `test-encryption-key-32chars!!` was 29 bytes (the name claimed 32 but
# the author miscounted — 4+1+10+1+3+1+2+5+2 = 29). Pre-H-1 the
# validator accepted any non-empty string, so the gap was silent. Once
# the test stack actually boots the certctl-server (which the
# ci-pipeline-cleanup Phase 5 matrix collapse forced for the first
# time), the server now hard-fails at startup and the deploy-vendor-e2e
# job's `dependency failed to start: container certctl-test-server
# is unhealthy` error fires.
#
# The replacement below is 49 bytes — 17 bytes of safety margin over
# the floor so a future tightening (32 → 33+) does not break this
# fixture. It is clearly test-only / deterministic; do NOT copy this
# to production. Operators set CERTCTL_CONFIG_ENCRYPTION_KEY from
# `openssl rand -base64 32` per the README.
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-deterministic-32-byte-fixture
# Network scanning # Network scanning
CERTCTL_NETWORK_SCAN_ENABLED: "true" CERTCTL_NETWORK_SCAN_ENABLED: "true"
@@ -326,15 +354,11 @@ services:
# agent mounts the same host path at the same container path (see below) # agent mounts the same host path at the same container path (see below)
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides. # so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
- ./test/certs:/etc/certctl/tls:ro - ./test/certs:/etc/certctl/tls:ro
# SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance: the # SCEP fixtures volume mount removed alongside the SCEP env vars
# e2eintune profile's RA cert/key + Intune Connector trust anchor # above. When a CI job that runs scep_intune_e2e_test.go is added,
# PEM. The PEM is the deterministic public cert matching the test- # restore both this mount AND the env vars together — the coherence
# side private key in deploy/test/scep_intune_e2e_test.go (re-run # guard at scripts/ci-guards/test-compose-scep-coherence.sh
# `go test -tags integration -run='^TestRegenerateE2EIntuneFixture$' # enforces that they move as a unit.
# -update-fixture ./deploy/test/...` to regenerate after a seed
# change). RA cert/key live alongside; tls-init container generates
# them at boot.
- ./test/fixtures:/etc/certctl/scep:ro
networks: networks:
certctl-test: certctl-test:
ipv4_address: 10.30.50.6 ipv4_address: 10.30.50.6
+2 -2
View File
@@ -452,8 +452,8 @@ monitoring:
## Support ## Support
For issues, questions, or contributions: For issues, questions, or contributions:
- GitHub: https://github.com/shankar0123/certctl - GitHub: https://github.com/certctl-io/certctl
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs - Documentation: https://github.com/certctl-io/certctl/tree/main/docs
## License ## License
+1 -1
View File
@@ -216,7 +216,7 @@ kubectl logs -l app.kubernetes.io/component=server -f
## Support ## Support
- **GitHub**: https://github.com/shankar0123/certctl - **GitHub**: https://github.com/certctl-io/certctl
- **Issues**: Report on GitHub issues - **Issues**: Report on GitHub issues
- **Documentation**: All docs are in `deploy/helm/` - **Documentation**: All docs are in `deploy/helm/`
+1 -1
View File
@@ -94,4 +94,4 @@ helm install certctl certctl/ --dry-run --debug
- Full documentation in `README.md` - Full documentation in `README.md`
- Troubleshooting in `DEPLOYMENT_GUIDE.md` - Troubleshooting in `DEPLOYMENT_GUIDE.md`
- Issues: https://github.com/shankar0123/certctl - Issues: https://github.com/certctl-io/certctl
+2 -2
View File
@@ -508,8 +508,8 @@ kubectl exec -it <pod> -- \
## Support and Contributing ## Support and Contributing
For issues, questions, or contributions, visit: For issues, questions, or contributions, visit:
- GitHub: https://github.com/shankar0123/certctl - GitHub: https://github.com/certctl-io/certctl
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs - Documentation: https://github.com/certctl-io/certctl/tree/main/docs
## License ## License
+2 -2
View File
@@ -14,7 +14,7 @@ keywords:
- kubernetes - kubernetes
maintainers: maintainers:
- name: certctl - name: certctl
home: https://github.com/shankar0123/certctl home: https://github.com/certctl-io/certctl
sources: sources:
- https://github.com/shankar0123/certctl - https://github.com/certctl-io/certctl
license: BSL-1.1 license: BSL-1.1
+1 -1
View File
@@ -1,6 +1,6 @@
# certctl Helm Chart # certctl Helm Chart
Production-ready Helm chart for deploying [certctl](https://github.com/shankar0123/certctl) on Kubernetes. Wires up the certctl server (Deployment), PostgreSQL (StatefulSet with PVC), and the agent (DaemonSet — one per node) on a private cluster, with health probes, security contexts, and optional Ingress. Production-ready Helm chart for deploying [certctl](https://github.com/certctl-io/certctl) on Kubernetes. Wires up the certctl server (Deployment), PostgreSQL (StatefulSet with PVC), and the agent (DaemonSet — one per node) on a private cluster, with health probes, security contexts, and optional Ingress.
## Quick install ## Quick install
+2 -2
View File
@@ -20,7 +20,7 @@ server:
# Image configuration # Image configuration
image: image:
repository: ghcr.io/shankar0123/certctl repository: ghcr.io/certctl-io/certctl
tag: "" # defaults to Chart.appVersion tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
@@ -410,7 +410,7 @@ agent:
# Image configuration # Image configuration
image: image:
repository: ghcr.io/shankar0123/certctl-agent repository: ghcr.io/certctl-io/certctl-agent
tag: "" # defaults to Chart.appVersion tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
+2 -2
View File
@@ -10,7 +10,7 @@ server:
replicas: 1 replicas: 1
image: image:
repository: ghcr.io/shankar0123/certctl repository: ghcr.io/certctl-io/certctl
pullPolicy: IfNotPresent # Use latest tag pullPolicy: IfNotPresent # Use latest tag
port: 8443 port: 8443
@@ -72,7 +72,7 @@ agent:
replicas: 1 replicas: 1
image: image:
repository: ghcr.io/shankar0123/certctl-agent repository: ghcr.io/certctl-io/certctl-agent
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
resources: resources:
+2 -2
View File
@@ -12,7 +12,7 @@ server:
replicas: 3 replicas: 3
image: image:
repository: ghcr.io/shankar0123/certctl repository: ghcr.io/certctl-io/certctl
tag: "2.1.0" tag: "2.1.0"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
@@ -84,7 +84,7 @@ agent:
kind: DaemonSet kind: DaemonSet
image: image:
repository: ghcr.io/shankar0123/certctl-agent repository: ghcr.io/certctl-io/certctl-agent
tag: "2.1.0" tag: "2.1.0"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
#
# Phase 5 — install cert-manager 1.15.0 into the kind cluster brought
# up by kind-config.yaml. Idempotent: re-running waits for the
# existing deployment to be Ready instead of reinstalling.
#
# Called from: deploy/test/acme-integration/certmanager_test.go
# Standalone: bash deploy/test/acme-integration/cert-manager-install.sh
set -euo pipefail
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.15.0}"
KUBECTL="${KUBECTL:-kubectl}"
echo "Installing cert-manager ${CERT_MANAGER_VERSION}..."
${KUBECTL} apply -f \
"https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
echo "Waiting for cert-manager controller to be Ready (timeout 5m)..."
${KUBECTL} -n cert-manager wait --for=condition=Available --timeout=5m \
deployment/cert-manager \
deployment/cert-manager-cainjector \
deployment/cert-manager-webhook
echo "cert-manager ${CERT_MANAGER_VERSION} ready."
@@ -0,0 +1,20 @@
# Phase 5 — Certificate resource the integration test applies and
# waits for. The certctl-test-trust ClusterIssuer (trust_authenticated
# mode) issues the cert without any solver round-trip; the resulting
# Secret 'test-com-tls' is asserted to carry tls.crt + tls.key.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: test-com
namespace: default
spec:
secretName: test-com-tls
commonName: test.example.com
dnsNames:
- test.example.com
- www.test.example.com
issuerRef:
name: certctl-test-trust
kind: ClusterIssuer
duration: 720h # 30d
renewBefore: 240h # 10d
@@ -0,0 +1,167 @@
// Copyright (c) certctl
// SPDX-License-Identifier: BSL-1.1
//go:build integration
// Phase 5 — kind-driven cert-manager integration test. Verifies the
// certctl ACME server end-to-end against a real cert-manager 1.15+
// deployment in a kind cluster. The test sequences:
//
// 1. Bring up the kind cluster (kind-config.yaml).
// 2. Install cert-manager 1.15 (cert-manager-install.sh).
// 3. Helm-install certctl-server with acmeServer.enabled=true.
// 4. Apply the ClusterIssuer + Certificate.
// 5. Wait for the Certificate to become Ready.
// 6. Assert the Secret has tls.crt + tls.key.
//
// Gated behind KIND_AVAILABLE — CI doesn't run kind and skips this
// cleanly. Operators run locally via `make acme-cert-manager-test`.
package acmeintegration
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// kindAvailable returns true when the operator opted into the kind-
// driven test path. CI default is opt-out (env unset → skip).
func kindAvailable() bool {
return os.Getenv("KIND_AVAILABLE") != ""
}
// kindClusterName is the name passed to `kind create/delete cluster`.
// Kept as a const so the test cleanup uses the exact same name as
// setup (avoid orphan-cluster-after-flake).
const kindClusterName = "certctl-acme-test"
// TestCertManagerTrustAuthenticatedIssuance is the happy-path
// integration: cert-manager submits a new-order against a profile in
// trust_authenticated mode; certctl auto-resolves authzs (no solver
// round-trip in this mode); cert-manager finalizes; the Secret lands.
//
// Runtime: ~6-8 minutes wall-clock on a workstation (most of which is
// kind-create + cert-manager-controller-bootstrap, both cached on
// re-runs after the first). Skips cleanly when KIND_AVAILABLE is
// unset.
func TestCertManagerTrustAuthenticatedIssuance(t *testing.T) {
if !kindAvailable() {
t.Skip("KIND_AVAILABLE unset — kind-driven cert-manager integration test skipped")
}
ctx := context.Background()
t.Log("creating kind cluster")
runCmd(t, ctx, "kind", "create", "cluster",
"--name", kindClusterName,
"--config", "kind-config.yaml")
t.Cleanup(func() {
// Best-effort cluster teardown — never fail the test on cleanup
// failure (operator can `kind delete cluster` manually).
_ = exec.Command("kind", "delete", "cluster", "--name", kindClusterName).Run()
})
t.Log("installing cert-manager")
runCmd(t, ctx, "bash", "cert-manager-install.sh")
// Step 3 — deploy certctl-server. The Helm chart at
// deploy/helm/certctl/ takes acmeServer.enabled=true; the operator
// is expected to have built + pushed (or kind-loaded) a `:test`
// image tag before the test runs. Document this in docs/acme-server.md.
t.Log("helm-installing certctl-test")
runCmd(t, ctx, "helm", "install", "certctl-test", "../../helm/certctl/",
"--set", "acmeServer.enabled=true",
"--set", "acmeServer.defaultProfileId=prof-test",
"--set", "image.tag=test",
)
waitForDeploymentReady(t, ctx, "default", "certctl-test", 3*time.Minute)
t.Log("applying ClusterIssuer + Certificate")
runCmd(t, ctx, "kubectl", "apply", "-f", "clusterissuer-trust-authenticated.yaml")
runCmd(t, ctx, "kubectl", "apply", "-f", "certificate-test.yaml")
t.Log("waiting for Certificate to become Ready")
waitForCertificateReady(t, ctx, "default", "test-com", 3*time.Minute)
t.Log("asserting Secret has tls.crt")
assertSecretHasCert(t, ctx, "default", "test-com-tls")
t.Log("happy-path issuance verified end-to-end")
}
// runCmd runs the command; failures fail the test immediately. We
// stream combined stdout+stderr to t.Log on completion so the operator
// can read the kubectl/kind output in CI logs (when run there with
// KIND_AVAILABLE=1).
func runCmd(t *testing.T, ctx context.Context, name string, args ...string) {
t.Helper()
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // ARGS are test-controlled literals.
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out)
}
t.Logf("%s %s: %s", name, strings.Join(args, " "), strings.TrimSpace(string(out)))
}
// waitForDeploymentReady polls until the named deployment reports
// Available=True. Wraps `kubectl wait` with a Go-level timeout so test
// hangs are bounded.
func waitForDeploymentReady(t *testing.T, ctx context.Context, namespace, name string, timeout time.Duration) {
t.Helper()
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "wait",
"--for=condition=Available", fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())),
"deployment/"+name) //nolint:gosec // ARGS are test-controlled literals.
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("deployment %s/%s did not become Ready in %v: %v\n%s",
namespace, name, timeout, err, out)
}
}
// waitForCertificateReady polls until the cert-manager Certificate
// resource transitions to Ready=True. cert-manager's own
// reconciliation loop is what advances the state; this just blocks
// until the controller is happy.
func waitForCertificateReady(t *testing.T, ctx context.Context, namespace, name string, timeout time.Duration) {
t.Helper()
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "wait",
"--for=condition=Ready", fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())),
"certificate/"+name) //nolint:gosec // ARGS are test-controlled literals.
out, err := cmd.CombinedOutput()
if err != nil {
// Dump the Certificate's events on failure so the operator
// can see exactly which reconciliation step failed.
describe := exec.Command("kubectl", "-n", namespace, "describe", "certificate", name)
describeOut, _ := describe.CombinedOutput()
t.Fatalf("certificate %s/%s did not become Ready in %v: %v\n%s\n--- describe ---\n%s",
namespace, name, timeout, err, out, describeOut)
}
}
// assertSecretHasCert checks that the named Secret has a non-empty
// tls.crt entry. We don't validate the chain itself here — that's the
// job of certctl's own integration test layer; this just confirms
// cert-manager wrote something into the Secret on the
// trust_authenticated happy-path.
func assertSecretHasCert(t *testing.T, ctx context.Context, namespace, name string) {
t.Helper()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "get", "secret", name,
"-o", "jsonpath={.data.tls\\.crt}") //nolint:gosec // ARGS are test-controlled literals.
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("get secret %s/%s: %v\n%s", namespace, name, err, out)
}
if len(out) == 0 {
t.Fatalf("secret %s/%s has empty tls.crt", namespace, name)
}
}
@@ -0,0 +1,31 @@
# Phase 5 — sample ClusterIssuer for the certctl challenge auth mode
# (RFC 8555 §8 HTTP-01 / DNS-01 / TLS-ALPN-01). Use this for public-
# trust-style deployments where per-identifier ownership proof is
# required.
#
# Same bootstrap-root caBundle requirement as the trust_authenticated
# variant — see clusterissuer-trust-authenticated.yaml comments.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: certctl-test-challenge
spec:
acme:
email: test@example.com
# Point at a profile whose certificate_profiles.acme_auth_mode is
# set to 'challenge'. The certctl operator manages this column
# per-profile; see certctl/docs/acme-server.md "Per-profile auth
# mode" section.
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-challenge/directory
caBundle: |
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
privateKeySecretRef:
name: certctl-test-challenge-account-key
solvers:
# HTTP-01 via the in-cluster ingress-nginx. The cert-manager
# http-solver pod publishes the key authorization at
# http://<identifier>/.well-known/acme-challenge/<token>; the
# certctl HTTP01Validator (Phase 3) fetches it.
- http01:
ingress:
class: nginx
@@ -0,0 +1,42 @@
# Phase 5 — sample ClusterIssuer for the certctl trust_authenticated
# auth mode (RFC 8555 §6 + certctl auth_mode=trust_authenticated, where
# the JWS-authenticated ACME account is trusted to issue any identifier
# the profile policy permits — no per-identifier ownership challenges).
#
# Use this as the starting template for any internal-PKI rollout.
# Replace the caBundle placeholder with the base64-encoded PEM of the
# certctl-server's self-signed bootstrap root, then `kubectl apply`.
#
# Generate the caBundle via:
# cat deploy/test/certs/ca.crt | base64 -w0
# (See certctl/docs/acme-server.md "TLS trust bootstrap" section for the
# end-to-end walkthrough — this is the single biggest first-time-deploy
# footgun on cert-manager, captured as audit fix #9.)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: certctl-test-trust
spec:
acme:
email: test@example.com
# Replace 'certctl-test' with your release name + adjust the
# profile path segment. Default profile path:
# https://<service>.<namespace>.svc.cluster.local:8443/acme/profile/<profile-id>/directory
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-test/directory
# caBundle: Audit fix #9. cert-manager validates the ACME server's
# TLS chain before submitting any account/order/finalize. With a
# self-signed bootstrap root, the ClusterIssuer MUST carry the root
# explicitly via this field.
caBundle: |
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
privateKeySecretRef:
name: certctl-test-trust-account-key
solvers:
# In trust_authenticated mode the solver is unused at the
# validation step but cert-manager still requires at least one
# solver in the spec. http01-via-ingress-nginx is the cheapest
# placeholder shape that round-trips correctly through cert-
# manager's validation webhooks.
- http01:
ingress:
class: nginx
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
#
# Phase 5 — lego-driven RFC 8555 conformance test. Drives a real ACME
# client (lego v4) against the certctl ACME server in trust_authenticated
# mode and exercises the full happy-path: register → new-order →
# finalize → cert download.
#
# Caller (`make acme-rfc-conformance-test`) brings up the certctl
# docker-compose stack first; this script just runs lego against it.
#
# Skips cleanly when CERTCTL_ACME_DIR is unset (the operator probably
# meant to run the make target instead of this script directly).
set -euo pipefail
if [[ -z "${CERTCTL_ACME_DIR:-}" ]]; then
echo "CERTCTL_ACME_DIR unset — point at the certctl ACME directory URL"
echo " e.g. CERTCTL_ACME_DIR=https://localhost:8443/acme/profile/prof-test/directory"
exit 1
fi
WORKDIR="$(mktemp -d -t certctl-lego-conf-XXXXXX)"
trap 'rm -rf "${WORKDIR}"' EXIT
# Skip TLS verification — the test stack uses certctl's self-signed
# bootstrap cert. Operators in production use --insecure-skip-verify=false
# and pass --tls-bundle for the real CA.
LEGO_INSECURE="--insecure-skip-verify"
# Step 1: register a fresh account.
echo "==> lego: register account"
lego --server "${CERTCTL_ACME_DIR}" \
--email conformance@example.com \
--domains conformance.example.com \
--path "${WORKDIR}" \
--accept-tos \
${LEGO_INSECURE} \
register
# Step 2: issue a cert (trust_authenticated mode auto-resolves authzs).
echo "==> lego: run (issue conformance.example.com)"
lego --server "${CERTCTL_ACME_DIR}" \
--email conformance@example.com \
--domains conformance.example.com \
--path "${WORKDIR}" \
--accept-tos \
${LEGO_INSECURE} \
run
# Step 3: assert the cert PEM landed.
CERT_FILE="${WORKDIR}/certificates/conformance.example.com.crt"
if [[ ! -s "${CERT_FILE}" ]]; then
echo "FAIL: ${CERT_FILE} is missing or empty"
exit 1
fi
openssl x509 -in "${CERT_FILE}" -noout -subject -issuer -dates
echo "PASS: lego conformance happy-path completed"
@@ -0,0 +1,34 @@
# Phase 5 — kind-cluster shape for the cert-manager integration test.
#
# Single control-plane + single worker. Port 8443 (certctl ACME server)
# and 80/443 (ingress-nginx for HTTP-01 solver) are extra-mapped onto
# the host so the in-test workflow can curl the in-cluster services.
#
# Used by: deploy/test/acme-integration/certmanager_test.go
# Invoked via: kind create cluster --name certctl-acme-test --config <this file>
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: certctl-acme-test
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
# ingress-nginx HTTP — needed for the challenge-mode solver.
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
# certctl-server HTTPS (the ACME directory + JWS-authenticated
# POST surface). Only required for out-of-cluster smoke tests; the
# in-cluster ClusterIssuer talks via Service DNS.
- containerPort: 30843
hostPort: 8443
protocol: TCP
- role: worker
+1 -1
View File
@@ -28,7 +28,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/deploy" "github.com/certctl-io/certctl/internal/deploy"
) )
// TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing // TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing
Binary file not shown.
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/shankar0123/certctl/deploy/test/f5-mock-icontrol module github.com/certctl-io/certctl/deploy/test/f5-mock-icontrol
go 1.25.9 go 1.25.9
+14
View File
@@ -0,0 +1,14 @@
# Per-run artifacts. summary.json + summary.txt are regenerated on
# every `make loadtest` run; committing them would create huge diffs
# on each invocation. The README captures the canonical baseline
# numbers manually.
results/*
!results/.gitkeep
# tls-init bind mount — server cert + key are regenerated on every
# fresh run.
certs/
# Bundle 10: target-tls-init bind mount — target sidecar starter cert is
# regenerated on every fresh run alongside the server cert.
fixtures/target-certs/
+359
View File
@@ -0,0 +1,359 @@
# certctl Load-Test Harness
Closes the **#8 acquisition-readiness blocker** from the 2026-05-01 issuer
coverage audit (the 2026-05-01 issuer coverage audit).
Pre-fix, certctl had zero benchmarks or load tests for any API path; an
acquirer evaluating "can certctl handle our 50k-cert fleet at 47-day
rotation" had nothing to point at. This harness is the substantiation.
## What it measures
A k6 driver hits two scenarios in parallel for 5 minutes at a fixed 50 req/s:
1. **`POST /api/v1/certificates`** — the issuance-acceptance hot path.
Exercises auth, JSON decode, validation, `service.CreateCertificate`,
and the `managed_certificates` insert. This is the operator-facing
request-acceptance throughput an automation client (Terraform,
Crossplane, GitOps controller) would generate.
2. **`GET /api/v1/certificates?per_page=50`** — the most-trafficked read
endpoint. Exercises pagination + filtering on the cert list query.
Latency is reported as `avg / min / med / p95 / p99 / max`. The error
floor is < 1% (any 4xx/5xx counts as failed).
## What it explicitly does NOT measure
- **Issuer connector latency.** Connector calls (DigiCert, ACME, Vault,
AWS ACM PCA, etc.) happen asynchronously via the renewal scheduler.
Their latency is pinned by the `certctl_issuance_duration_seconds{issuer_type=...}`
Prometheus histogram (audit fix #4). Driving them through k6 would
load-test someone else's API, which is wrong.
- **Full ACME enrollment flow.** The audit prompt mentioned ACME-via-
pebble; sustained 100/s through a multi-RTT order/challenge/finalize
flow requires pebble tuning + crypto helpers k6 doesn't ship out of
the box. Deferred to a follow-up.
- **Bulk-revoke / bulk-renew.** Those are admin endpoints with their
own throughput characteristics and warrant a separate scenario.
- **Scheduler concurrency under bulk renewal.** That's audit fix #9's
scope; the harness here measures the API tier, not the scheduler.
## Threshold contract
Any future change that breaches one of these fails the test:
| Scenario | p95 | p99 | Error rate |
|---|---|---|---|
| `issuance_acceptance` | < 2 s | < 5 s | n/a |
| `list_certificates` | < 800 ms | < 2 s | n/a |
| All requests | n/a | n/a | < 1% |
These are the regression guards, not the SLO. The SLO is whatever the
operator chooses based on the baseline below.
## How to run
From the repo root:
```sh
make loadtest
```
This:
1. Builds the certctl image from the repo root `Dockerfile`.
2. Spins up postgres, the tls-init bootstrap, certctl-server (with
`CERTCTL_DEMO_SEED=true` so the FK rows the script needs exist),
and the k6 driver.
3. Runs the k6 script for ~5 minutes 5 seconds (5s stagger between
scenarios + 5m duration).
4. Prints the summary text to stdout.
5. Exits non-zero if any threshold was breached.
The full machine-readable summary lands at
`deploy/test/loadtest/results/summary.json` (gitignored). The
human-readable summary lands at `results/summary.txt`.
To run against a server already booted on the host (skip the compose
spin-up):
```sh
docker run --rm \
-e CERTCTL_BASE=https://localhost:8443 \
-e CERTCTL_TOKEN=load-test-token \
-e K6_INSECURE_SKIP_TLS_VERIFY=true \
-v "$(pwd)/deploy/test/loadtest/k6.js:/scripts/k6.js:ro" \
-v "$(pwd)/deploy/test/loadtest/results:/results" \
--network host \
grafana/k6:0.54.0 run /scripts/k6.js
```
## Current baseline
The first operator run captures real numbers and commits them into
this section. Pre-baseline this section reads "TBD — operator captures
on first `make loadtest` run." The numbers below are the agreed
minimum-acceptable thresholds, not the captured baseline; once captured,
the baseline goes here as a separate row so future regressions have a
diff target.
| Scenario | p50 | p95 | p99 | Error rate |
|---|---|---|---|---|
| **issuance_acceptance** (threshold) | — | < 2 s | < 5 s | < 1% |
| **issuance_acceptance** (baseline)[^1] | 2.12 ms | 6.19 ms | 8.58 ms | 0.00% |
| **list_certificates** (threshold) | — | < 800 ms | < 2 s | < 1% |
| **list_certificates** (baseline)[^1] | 2.12 ms | 6.19 ms | 8.58 ms | 0.00% |
[^1]: **Sandbox-aggregate placeholder** — captured at HEAD on a Linux/aarch64
unprivileged sandbox (no Docker, no GitHub-hosted runner). Both rows show
the same aggregate combined-load numbers because the sandbox run did not
break out per-scenario tags in `summary.json`. Treat these as a sanity
floor (proof the API tier handles 100 req/s combined with zero errors and
sub-10ms p99), **not** as the per-scenario baselines the threshold contract
is written against. Replace via `gh workflow run loadtest.yml` on the
canonical `ubuntu-latest` runner — that produces per-scenario tagged
metrics in `summary.json`.
**Methodology of the sandbox-placeholder capture above:**
- Hardware: Linux/aarch64 unprivileged sandbox (uid 1019, no root,
~1.2 GiB free disk). NOT canonical hardware.
- Postgres: 14.22 (Ubuntu, native binaries, unix-socket dir `/tmp/pg-sock`),
unix sockets only, port 55432.
- certctl: built from HEAD via `go build -o bin/certctl-server ./cmd/server`.
- Concurrency: 50 req/s sustained per scenario, both scenarios in parallel
(= 100 req/s combined).
- Duration: **10 seconds** per scenario (NOT 5 minutes — sandbox bash-call
budget is bounded; canonical-hardware run uses 5 minutes).
- TLS: ECDSA-P256 self-signed `localhost` cert at `/tmp/certctl-tls/`.
- Auth: api-key, single Bearer token (`CERTCTL_AUTH_SECRET=load-test-token`).
- Rate limiting: **disabled** (`CERTCTL_RATE_LIMIT_ENABLED=false`) — without
this, the 100 req/s combined load trips the default token-bucket and
drives error rate to ~40%, masking real latency.
- Encryption: `CERTCTL_CONFIG_ENCRYPTION_KEY` set (32+ bytes).
- Captured: 2026-05-02. Total: 1002 requests, 100.15 req/s sustained,
0 failures, 100% checks passed. Raw `summary.json` is not committed
(gitignored per the existing `results/` convention).
**Methodology pinned at canonical baseline capture (replace placeholder):**
- Hardware: GitHub-hosted `ubuntu-latest` runner (4 vCPU / 16 GiB / SSD).
Run via `gh workflow run loadtest.yml`; raw `summary.json` is available
for 90 days as a workflow artifact.
- Postgres: 16-alpine in compose, default config.
- certctl: image built from this repo at the commit referenced below.
- Concurrency: 50 req/s sustained per scenario (100 req/s total).
- Duration: 5 minutes per scenario, 5s stagger.
- Auth: api-key (Bearer token, single key).
- Encryption: `CERTCTL_CONFIG_ENCRYPTION_KEY` set (32+ bytes).
To recapture the baseline after a tuning commit:
```sh
make loadtest
# Inspect deploy/test/loadtest/results/summary.txt for the new numbers.
# Update the table above + the methodology line, commit alongside the
# tuning commit.
```
## Interpreting a regression
If a future PR's `make loadtest` run pushes p99 above the threshold,
the make target exits non-zero and CI fails. The summary.txt prints
which threshold breached. Triage:
1. Look at the per-scenario `http_req_duration` p95 + p99 in
`summary.json`. If only one scenario regressed, the change is
localized to that endpoint's hot path.
2. Look at the `iteration_duration` per scenario — if total iteration
time grew but `http_req_duration` is flat, the latency is in k6
client setup (rare; suggests something changed in the script).
3. Compare against the committed baseline. If p99 was 800 ms at
baseline and is now 1.5 s but still under the 5 s threshold, the
change is below the regression guard but still meaningful — flag
in the PR description.
The harness deliberately does NOT auto-tune. Tuning is informed by the
data; tuning commits land separately, each with their own captured
baseline update.
## CI cadence
Defined in `.github/workflows/loadtest.yml`:
- **`workflow_dispatch`** — manual trigger from the Actions tab. Used
before tagging a release or after a meaningful tuning commit.
- **Weekly cron** — Mondays at 06:00 UTC. Catches gradual regressions
from cumulative changes that no single PR triggered.
The workflow does **not** run per-push. Load tests are minutes long
and would not provide useful per-PR signal; per-push pressure goes
through `make verify` (which is fast) and the deploy-vendor-e2e job.
## Connector-tier baseline (Bundle 10 of the 2026-05-02 deployment-target audit)
Bundle 10 extended the harness to cover per-target-type handshake throughput
in addition to the API-tier issuance/list throughput documented above. The
docker-compose stack now boots four target sidecars (nginx, apache, haproxy,
f5-mock) each serving a starter cert from a shared `target-tls-init`
container, and k6 runs four additional scenarios — `nginx_handshake`,
`apache_handshake`, `haproxy_handshake`, `f5_handshake` — at sustained
100 conns/min for 5 minutes against each.
### What the connector tier measures
End-to-end TCP connect + TLS handshake + tiny HTTP request/response latency
per target type, tagged via the k6 `target_type` label so summary.json's
`connector_tier` section breaks the numbers out per sidecar:
```json
{
"connector_tier": {
"nginx": { "p50": ..., "p95": ..., "p99": ..., "error_rate": ..., "iterations": ... },
"apache": { ... },
"haproxy": { ... },
"f5": { ... }
}
}
```
This validates the target sidecar daemons are operational under sustained
connection load. Procurement asks "can certctl's nginx target handle 5,000
endpoints at 47-day rotation?" — the connector code's correctness is pinned
by per-connector unit tests; **the underlying daemon's connection-rate
ceiling is what these scenarios pin**.
### What the connector tier explicitly does NOT measure (v1)
- **The full agent-driven deploy hot path.** v1 measures handshake
throughput against the sidecars directly. v2 of the harness is a
follow-up that POSTs cert requests bound to per-target-type targets,
polls the deployments endpoint until the agent reports complete, and
measures the full POST → poll → cert-served loop. v2 needs the agent
registration + target-binding API surface plumbed end-to-end in the
loadtest stack — meaningful work, but not a blocker for the connection-
rate procurement question.
- **Kubernetes connector.** kind-in-docker requires `privileged: true`
and is operationally fragile in CI. Deferred until Bundle 2 (real
`k8s.io/client-go`) lands and a CI-friendly envtest harness is wired.
- **Real F5 BIG-IP.** The harness uses the in-tree `f5-mock-icontrol`
Go server (already used by the deploy-vendor-e2e CI job). Real F5
appliance benchmarking is out of scope; operators with a real F5
vagrant box per `docs/connector-f5.md` can substitute it manually.
### Threshold contract
Defined in `k6.js`'s `thresholds` block. Any change pushing past these
fails the test:
| Target type | p95 | p99 | Error rate |
|---|---|---|---|
| `nginx` | < 1 s | < 3 s | < 1% (global) |
| `apache` | < 1 s | < 3 s | < 1% (global) |
| `haproxy` | < 1 s | < 3 s | < 1% (global) |
| `f5` | < 1.5 s | < 5 s | < 1% (global) |
f5-mock's threshold is looser because the iControl REST handler does
slightly more work per request (login+upload+install dance the F5
connector itself drives — not exercised here, but the daemon's request
handler is heavier).
### Connector-tier captured baseline
| Target type | p50 | p95 | p99 | Error rate | Iterations |
|---|---|---|---|---|---|
| **nginx** (threshold) | — | < 1 s | < 3 s | < 1% | n/a |
| **nginx** (baseline) | TBD | TBD | TBD | TBD | TBD |
| **apache** (threshold) | — | < 1 s | < 3 s | < 1% | n/a |
| **apache** (baseline) | TBD | TBD | TBD | TBD | TBD |
| **haproxy** (threshold) | — | < 1 s | < 3 s | < 1% | n/a |
| **haproxy** (baseline) | TBD | TBD | TBD | TBD | TBD |
| **f5** (threshold) | — | < 1.5 s | < 5 s | < 1% | n/a |
| **f5** (baseline) | TBD | TBD | TBD | TBD | TBD |
The em-dash placeholders are deliberate: do **not** commit numeric values
without running the loadtest on canonical hardware first. Numbers from a
developer laptop are misleading. The first `gh workflow run loadtest.yml`
on a clean GitHub runner captures the baseline; commit the captured numbers
into the table above as a follow-up commit alongside the methodology line.
**Methodology pinned at baseline capture (canonical hardware):**
- Hardware: GitHub-hosted `ubuntu-latest` runners (currently 4 vCPU /
16 GiB / SSD-backed). Operator captures from `gh workflow run loadtest.yml`
to keep the hardware constant across runs.
- Sidecar images: nginx:1.27-alpine, httpd:2.4-alpine, haproxy:2.9-alpine,
in-tree f5-mock-icontrol (built from `deploy/test/f5-mock-icontrol/`).
- Concurrency: 100 conns/min sustained per target type (400 conns/min
total across the four target scenarios + 100 req/s on the API tier).
- Duration: 5 minutes per scenario, 10s stagger between API tier and
connector tier so warmup overlap doesn't skew the first 30 seconds.
- TLS: starter cert from `target-tls-init` (ECDSA P-256, multi-SAN). The
loadtest scenarios connect with `K6_INSECURE_SKIP_TLS_VERIFY=true`.
To recapture the connector-tier baseline after a tuning commit affecting
target sidecars or the connector code:
```sh
make loadtest
# Inspect deploy/test/loadtest/results/summary.json for the
# connector_tier object and update the table above.
```
## Files in this directory
```
deploy/test/loadtest/
├── README.md (this file)
├── docker-compose.yml
├── k6.js (the load script)
├── certs/ (gitignored — tls-init writes here)
├── fixtures/ (Bundle 10: target sidecar configs + shared starter cert)
│ ├── nginx.conf
│ ├── httpd.conf
│ ├── haproxy.cfg
│ └── target-certs/ (gitignored — target-tls-init writes here)
└── results/ (gitignored — k6 writes summary.{json,txt} here)
```
## ACME flows (Phase 5)
The `deploy/test/loadtest/k6/acme_flow.js` scenario hammers the
unauthenticated ACME surface (directory + new-nonce + ARI synthetic
lookups) at constant 100 VUs for 5 minutes. JWS-signed paths
(new-account / new-order / finalize) are intentionally out of scope:
k6 doesn't ship JWS, and bundling lego inside k6 would obscure the
underlying-server p95 we're trying to measure. Instead, the
`make acme-rfc-conformance-test` target drives lego against the same
stack for the full happy-path conformance gate.
Run it:
```
cd deploy/test/loadtest
docker compose up -d certctl postgres
k6 run --env CERTCTL_ACME_DIRECTORY=https://localhost:8443/acme/profile/prof-test/directory \
k6/acme_flow.js
```
### Baseline (ACME flows, 100 VUs × 5m)
The baseline is operator-captured on a workstation-class machine with
a single certctl-server container + a single postgres container.
Re-capture after schema migrations or transport changes; commit the
new numbers so regressions are visible in code review.
| Metric | Threshold | Last captured | Notes |
|--------------------------------------------|-----------|---------------|-------|
| `directory_duration` p95 | < 500 ms | _operator_ | Unauth GET; cache-friendly. |
| `new_nonce_duration` p95 | < 300 ms | _operator_ | Single Postgres INSERT under the hood. |
| `renewal_info_duration` p95 (synthetic id) | < 800 ms | _operator_ | Synthetic cert-id → 4xx fast path. |
| `http_req_failed` rate | < 1% | _operator_ | Should be ~0 — failures here mean transport issues. |
Capture command: `make loadtest` after pointing the compose stack at
the ACME flow scenario. Operators with kind / cert-manager available
should pair this with `make acme-cert-manager-test` for end-to-end
verification.
## Audit references
- API tier: 2026-05-01 issuer coverage audit fix #8.
- Connector tier: 2026-05-02 deployment-target audit Bundle 10.
- ACME flows: Phase 5 master prompt (project notes).
+353
View File
@@ -0,0 +1,353 @@
# =============================================================================
# certctl Load-Test Harness — Docker Compose
# =============================================================================
#
# Spins up a minimal certctl stack and runs a k6 driver against it to capture
# p50 / p95 / p99 latency for the certificate-management API hot path AND
# (Bundle 10 of the 2026-05-02 deployment-target audit) per-target-type
# TCP+TLS handshake throughput against four target sidecars (nginx, apache,
# haproxy, f5-mock).
#
# Stack:
# 1. postgres — empty database (server runs migrations + seeds at boot)
# 2. certctl-tls-init — one-shot init container; writes self-signed
# server.crt/.key/ca.crt into ./certs (bind
# mount, host-readable so the k6 container
# can pin against it via volumes)
# 3. certctl-server — HTTPS API on :8443, demo-seed enabled so
# the k6 script has iss-local + an operator
# + a team ready to reference in
# CreateCertificate payloads
# 4. target-tls-init — Bundle 10: shared starter cert+key for the
# four target sidecars (nginx, apache,
# haproxy, f5-mock). Each daemon boots with
# this cert; the loadtest scenarios connect
# at sustained rates to measure handshake
# latency tagged by target_type.
# 5. nginx-target — Bundle 10: HTTPS on internal :443.
# 6. apache-target — Bundle 10: HTTPS on internal :443.
# 7. haproxy-target — Bundle 10: HTTPS on internal :443.
# 8. f5-mock-target — Bundle 10: iControl REST on internal :443
# + plaintext HTTP on internal :8080. Runs
# the in-tree f5-mock-icontrol image
# (deploy/test/f5-mock-icontrol/).
# 9. k6 — runs k6.js once and exits with the
# threshold-driven exit code (zero on green,
# non-zero on any threshold breach so
# `make loadtest` surfaces regressions as a
# failed shell command).
#
# Out of scope for v1 of the connector-tier harness (Bundle 10):
# - Kubernetes target via kind-in-docker. kind requires `privileged: true`
# and Docker-in-Docker semantics that are operationally fragile in CI;
# the K8s connector loadtest is a follow-up that needs Bundle 2's real
# k8s.io/client-go to land first.
# - Full agent-driven deploy poll loop (POST cert → poll deployments →
# verify served cert matches what was deployed). The harness measures
# handshake throughput against the target sidecars directly — that's
# enough to validate the sidecars are operational under load and gives
# procurement a per-target latency number that doesn't depend on the
# agent registration + target-binding API surface being plumbed
# end-to-end in the loadtest stack.
#
# Usage: make loadtest (from the repo root)
# Manual: cd deploy/test/loadtest && docker compose up --abort-on-container-exit --exit-code-from k6
#
# Audit reference (API tier): 2026-05-01 issuer coverage audit fix #8.
# Audit reference (connector tier): 2026-05-02 deployment-target audit Bundle 10.
# =============================================================================
services:
# ---------------------------------------------------------------------------
# Self-signed TLS bootstrap. Mirrors the deploy/docker-compose.test.yml
# tls-init pattern exactly: bind-mount instead of named volume so the host
# (and the sibling k6 container) can read ca.crt without a chown dance.
# See deploy/docker-compose.test.yml::certctl-tls-init for the full rationale.
# ---------------------------------------------------------------------------
certctl-tls-init:
image: alpine/openssl:latest
container_name: certctl-loadtest-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/etc/certctl/tls/server.crt
KEY=/etc/certctl/tls/server.key
CA=/etc/certctl/tls/ca.crt
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
echo "TLS cert already present — skipping generation"
else
mkdir -p /etc/certctl/tls
openssl req -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 \
-nodes \
-keyout "$$KEY" \
-out "$$CERT" \
-days 3650 \
-subj "/CN=certctl-server" \
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1"
cp "$$CERT" "$$CA"
echo "Generated self-signed TLS cert (ECDSA-P256, 3650d, CN=certctl-server)"
fi
chmod 0644 "$$CERT" "$$CA"
chmod 0600 "$$KEY"
volumes:
- ./certs:/etc/certctl/tls
# ---------------------------------------------------------------------------
# Database. The server runs migrations + seed.sql + (because
# CERTCTL_DEMO_SEED=true below) seed_demo.sql at boot — so the load-test
# k6 script can reference iss-local, o-alice, t-platform, and rp-default
# without a separate seed step.
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: certctl-loadtest-postgres
environment:
POSTGRES_DB: certctl
POSTGRES_USER: certctl
POSTGRES_PASSWORD: loadtestpass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U certctl"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
# ---------------------------------------------------------------------------
# certctl server. Built from the repo root Dockerfile (same as production).
# Demo seed is enabled so referenced FK rows exist when the k6 script
# POSTs CreateCertificate payloads. Auth is api-key with a deterministic
# token the k6 script knows.
# ---------------------------------------------------------------------------
certctl-server:
build:
context: ../../..
dockerfile: Dockerfile
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-}
container_name: certctl-loadtest-server
depends_on:
postgres:
condition: service_healthy
certctl-tls-init:
condition: service_completed_successfully
environment:
CERTCTL_DATABASE_URL: postgres://certctl:loadtestpass@postgres:5432/certctl?sslmode=disable
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: warn
CERTCTL_AUTH_TYPE: api-key
CERTCTL_AUTH_SECRET: load-test-token
CERTCTL_KEYGEN_MODE: agent
# CERTCTL_DEMO_SEED=true triggers seed_demo.sql which creates iss-local,
# o-alice, t-platform, rp-standard so CreateCertificate FK validation
# has rows to bind to.
CERTCTL_DEMO_SEED: "true"
# Bigger body limit so listing 100s of certs in the GET scenario
# doesn't 413 once the harness has been running for a few minutes.
CERTCTL_MAX_BODY_SIZE: "10485760"
# Encryption key (≥32 bytes per H-1 floor — the test compose's
# documented value).
CERTCTL_CONFIG_ENCRYPTION_KEY: "loadtest-key-must-be-32-bytes-long-yes"
volumes:
- ./certs:/etc/certctl/tls:ro
healthcheck:
# /healthz is unauthenticated. -k because the cert is self-signed.
test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:8443/healthz || exit 1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 60s
# ---------------------------------------------------------------------------
# Bundle 10: target-side TLS bootstrap. Mints a single ECDSA-P256 self-
# signed cert + key into a shared ./fixtures/target-certs/ volume that the
# four target sidecars (nginx, apache, haproxy) mount read-only. f5-mock
# generates its own self-signed cert at startup (see
# deploy/test/f5-mock-icontrol/tls.go) so it doesn't need this volume.
#
# The loadtest scenarios don't care which cert the target serves — only
# that the daemon is up and completing TLS handshakes at the configured
# rate. The starter cert exists so each daemon boots green; once Bundle 2
# (real K8s client) + agent-driven deploy poll is plumbed in v2 of the
# harness, deploys would overwrite this cert.
# ---------------------------------------------------------------------------
target-tls-init:
image: alpine/openssl:latest
container_name: certctl-loadtest-target-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/certs/target.crt
KEY=/certs/target.key
PEM=/certs/target.pem
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$PEM" ]; then
echo "Target TLS cert already present — skipping generation"
else
mkdir -p /certs
openssl req -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 \
-nodes \
-keyout "$$KEY" \
-out "$$CERT" \
-days 365 \
-subj "/CN=loadtest-target" \
-addext "subjectAltName=DNS:nginx-target,DNS:apache-target,DNS:haproxy-target,DNS:f5-mock-target,DNS:localhost,IP:127.0.0.1"
# HAProxy expects cert+key concatenated into a single PEM file
# at the path supplied to `bind ... ssl crt <path>`. Build it
# alongside the cert/key pair so the haproxy-target's mount
# works without a per-daemon ENTRYPOINT shim.
cat "$$CERT" "$$KEY" > "$$PEM"
echo "Generated target starter cert (ECDSA-P256, 365d, multi-SAN)"
fi
# World-readable so non-root container users (haproxy uses uid 99,
# apache uses uid 1) can read the key. This is fine for a load-test
# starter cert; production wouldn't do this.
chmod 0644 "$$CERT" "$$KEY" "$$PEM"
volumes:
- ./fixtures/target-certs:/certs
# ---------------------------------------------------------------------------
# nginx-target. Listens on internal :443 with the starter cert. The
# k6 nginx_handshake scenario connects at 100 conns/min for 5 minutes.
# ---------------------------------------------------------------------------
nginx-target:
image: nginx:1.27-alpine
container_name: certctl-loadtest-nginx
depends_on:
target-tls-init:
condition: service_completed_successfully
volumes:
- ./fixtures/target-certs:/etc/nginx/certs:ro
- ./fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
healthcheck:
test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:443/ || exit 1"]
interval: 5s
timeout: 3s
retries: 20
start_period: 15s
# ---------------------------------------------------------------------------
# apache-target. Listens on internal :443. The bundled httpd.conf loads
# the minimum module set + a single SSL-terminated vhost.
# ---------------------------------------------------------------------------
apache-target:
image: httpd:2.4-alpine
container_name: certctl-loadtest-apache
depends_on:
target-tls-init:
condition: service_completed_successfully
volumes:
- ./fixtures/target-certs:/usr/local/apache2/conf/certs:ro
- ./fixtures/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
healthcheck:
test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:443/ || exit 1"]
interval: 5s
timeout: 3s
retries: 20
start_period: 15s
# ---------------------------------------------------------------------------
# haproxy-target. Listens on internal :443 with SSL termination. The
# haproxy.cfg references /usr/local/etc/haproxy/certs/target.pem which
# target-tls-init writes (cert + key concatenated).
# ---------------------------------------------------------------------------
haproxy-target:
image: haproxy:2.9-alpine
container_name: certctl-loadtest-haproxy
depends_on:
target-tls-init:
condition: service_completed_successfully
volumes:
- ./fixtures/target-certs:/usr/local/etc/haproxy/certs:ro
- ./fixtures/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
healthcheck:
# HAProxy doesn't ship with wget/curl; use the openssl-based handshake
# check instead. The /dev/null redirect drops the response body so
# large logs don't accumulate over the run.
test: ["CMD-SHELL", "echo Q | openssl s_client -connect localhost:443 -servername localhost 2>/dev/null | grep -q 'BEGIN CERTIFICATE'"]
interval: 5s
timeout: 3s
retries: 20
start_period: 15s
# ---------------------------------------------------------------------------
# f5-mock target. Re-uses the in-tree f5-mock-icontrol image (already
# used by the deploy-vendor-e2e CI job). Generates its own self-signed
# cert at startup; listens on internal :443 (HTTPS, iControl REST) and
# :8080 (plaintext HTTP). The k6 f5_handshake scenario hits the
# /healthz endpoint.
# ---------------------------------------------------------------------------
f5-mock-target:
# Long-form build to match docker-compose.test.yml: the Dockerfile
# has `COPY deploy/test/f5-mock-icontrol/ ./` which assumes the
# build context is the REPO ROOT. The previous shorthand form
# `build: ../f5-mock-icontrol` set the context to the
# f5-mock-icontrol directory itself, breaking the COPY at CI build
# time (run #25305811340: "deploy/test/f5-mock-icontrol: not found").
build:
context: ../../..
dockerfile: deploy/test/f5-mock-icontrol/Dockerfile
container_name: certctl-loadtest-f5-mock
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"]
interval: 5s
timeout: 3s
retries: 20
start_period: 15s
# ---------------------------------------------------------------------------
# k6 driver. Pinned to a specific version so threshold expressions stay
# stable across runs. --insecure-skip-tls-verify because the server cert is
# self-signed; the load test isn't a TLS conformance test. The k6 process
# exits non-zero if any threshold is breached, which the parent
# `docker compose up --exit-code-from k6` propagates as the compose exit
# code, which `make loadtest` then surfaces as the make-target exit code.
# ---------------------------------------------------------------------------
k6:
image: grafana/k6:0.54.0
container_name: certctl-loadtest-k6
depends_on:
certctl-server:
condition: service_healthy
# Bundle 10: wait for the four target sidecars to be healthy before
# firing the connector-tier scenarios. Saves the operator from
# spurious "connection refused" errors during the first ~15s of the
# run while target daemons are coming up.
nginx-target:
condition: service_healthy
apache-target:
condition: service_healthy
haproxy-target:
condition: service_healthy
f5-mock-target:
condition: service_healthy
environment:
CERTCTL_BASE: https://certctl-server:8443
CERTCTL_TOKEN: load-test-token
K6_INSECURE_SKIP_TLS_VERIFY: "true"
# Bundle 10: per-target sidecar URLs the connector-tier scenarios
# connect to. Internal docker-compose DNS — k6 resolves these via
# the default user network's resolver.
NGINX_TARGET_URL: https://nginx-target:443
APACHE_TARGET_URL: https://apache-target:443
HAPROXY_TARGET_URL: https://haproxy-target:443
F5_TARGET_URL: https://f5-mock-target:443
volumes:
- ./k6.js:/scripts/k6.js:ro
- ./results:/results
command:
- run
- --summary-export=/results/summary.json
- /scripts/k6.js
+29
View File
@@ -0,0 +1,29 @@
# HAProxy target sidecar — Bundle 10 of the 2026-05-02 deployment-target audit.
#
# Minimal SSL-terminating config that boots green with the starter cert
# written by target-tls-init. The k6 connector-tier scenarios connect at
# sustained 100 conns/min and measure handshake-completion latency.
global
log stdout local0 warning
maxconn 4096
# Bundle 10: starter cert+key live at /usr/local/etc/haproxy/certs/.
# HAProxy expects a SINGLE PEM file containing cert + key concatenated;
# the target-tls-init container writes target.pem in that combined form.
ssl-default-bind-options ssl-min-ver TLSv1.2
defaults
log global
mode http
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
frontend https-in
bind *:443 ssl crt /usr/local/etc/haproxy/certs/target.pem
default_backend ok
backend ok
# Static 200 OK — handshake-only loadtest doesn't exercise the backend.
http-request return status 200 content-type text/plain string "ok\n"
+66
View File
@@ -0,0 +1,66 @@
# Apache httpd target sidecar — Bundle 10 of the 2026-05-02 deployment-target audit.
#
# Self-contained httpd.conf that the httpd:2.4-alpine image will use as its
# main configuration. Loads the minimum module set required for an HTTPS
# server + serves a single SSL-enabled vhost backed by the starter cert
# written by target-tls-init.
ServerRoot "/usr/local/apache2"
Listen 443
# Module set is the minimum required for the SSL vhost below + the
# directives Apache parses elsewhere in its bootstrap.
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule authn_file_module modules/mod_authn_file.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_host_module modules/mod_authz_host.so
LoadModule authz_user_module modules/mod_authz_user.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule access_compat_module modules/mod_access_compat.so
LoadModule auth_basic_module modules/mod_auth_basic.so
LoadModule reqtimeout_module modules/mod_reqtimeout.so
LoadModule filter_module modules/mod_filter.so
LoadModule mime_module modules/mod_mime.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule env_module modules/mod_env.so
LoadModule headers_module modules/mod_headers.so
LoadModule setenvif_module modules/mod_setenvif.so
LoadModule version_module modules/mod_version.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule dir_module modules/mod_dir.so
LoadModule alias_module modules/mod_alias.so
LoadModule socache_shmcb_module modules/mod_socache_shmcb.so
LoadModule ssl_module modules/mod_ssl.so
User daemon
Group daemon
ServerName apache-target
ServerAdmin loadtest@certctl.local
# Quiet log so the run log stays diff-able. Errors still go to stderr
# (/proc/self/fd/2) so docker compose logs surfaces them on startup
# failure.
ErrorLog /proc/self/fd/2
LogLevel warn
DocumentRoot "/usr/local/apache2/htdocs"
# Bundle 10: starter cert+key from target-tls-init's shared volume.
SSLEngine On
SSLCertificateFile /usr/local/apache2/conf/certs/target.crt
SSLCertificateKeyFile /usr/local/apache2/conf/certs/target.key
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite HIGH:!aNULL:!MD5
SSLHonorCipherOrder on
<Directory "/usr/local/apache2/htdocs">
AllowOverride None
Require all granted
</Directory>
# Quiet response — the loadtest scenarios only care that the handshake
# completes. The body content is irrelevant.
<Location />
Require all granted
</Location>
+36
View File
@@ -0,0 +1,36 @@
# nginx target sidecar Bundle 10 of the 2026-05-02 deployment-target audit.
#
# Minimal HTTPS-only config that boots green with a starter cert from the
# shared target-tls-init container. The k6 connector-tier scenarios connect
# at sustained 100 conns/min and measure handshake-completion latency.
# Production NGINX configs are far richer; this is a load-test fixture, not
# a deployment template.
worker_processes 1;
events {
worker_connections 1024;
}
http {
# Quiet log so the loadtest run doesn't fill the docker-compose log.
access_log off;
error_log /var/log/nginx/error.log warn;
server {
listen 443 ssl;
server_name _;
# Bundle 10: starter cert+key written by target-tls-init into the
# shared volume. Not the deployed cert; this is what makes the
# daemon boot green so the loadtest scenarios have something to
# handshake against.
ssl_certificate /etc/nginx/certs/target.crt;
ssl_certificate_key /etc/nginx/certs/target.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
}
+355
View File
@@ -0,0 +1,355 @@
// certctl load-test driver — k6 v0.54+ JS API.
//
// Two tiers of scenarios:
//
// API tier (issuer-coverage audit fix #8, 2026-05-01):
// - issuance_acceptance: POST /api/v1/certificates throughput.
// - list_certificates: GET /api/v1/certificates throughput.
//
// Connector tier (Bundle 10 of the deployment-target audit, 2026-05-02):
// - nginx_handshake / apache_handshake / haproxy_handshake / f5_handshake:
// per-target-type TCP+TLS handshake throughput against the four
// target sidecars at sustained 100 conns/min for 5 minutes. Latency
// is tagged by target_type so summary.json's connector_tier section
// breaks out p50/p95/p99 per target.
//
// What the API tier measures (be honest about scope):
// - POST /api/v1/certificates: auth + JSON decode + validation + service
// CreateCertificate + DB insert + response. This is the operator-facing
// request-acceptance throughput. The downstream issuer-connector call
// happens asynchronously via the renewal scheduler (and is bounded
// separately via CERTCTL_RENEWAL_CONCURRENCY — issuer audit fix #9).
// - GET /api/v1/certificates: read path with pagination. Exercises the
// cert list query, which is the most-called read endpoint in any UI/
// automation client.
//
// What the connector tier measures:
// - Per-target-type TCP+TLS handshake completion latency. Validates that
// each target sidecar (nginx, apache, haproxy, f5-mock) is operational
// and serving its starter cert under sustained connection load.
// Procurement asks "can certctl's nginx target handle 5,000 endpoints
// at 47-day rotation"; the answer requires (a) the connector code
// handles deploys correctly (covered by per-connector unit tests) AND
// (b) the underlying daemon serves TLS at the connection rates a
// 5,000-endpoint fleet implies. The connector-tier scenarios pin (b).
//
// What this does NOT measure (documented limits, not lazy gaps):
// - Issuer connector latency (DigiCert / ACME / Vault / etc. round-trips
// to upstream CAs). Those are async; pin via the per-issuer-type
// metrics instead (issuer audit fix #4:
// certctl_issuance_duration_seconds).
// - Full ACME enrollment (newOrder → challenge → finalize).
// - The full agent-driven deploy hot path (POST cert with target
// binding → poll deployments endpoint → verify served cert matches).
// v1 of the connector-tier harness measures handshake throughput
// against the sidecars directly. v2 is a follow-up that needs the
// agent registration + target-binding API surface plumbed end-to-end
// in the loadtest stack — a meaningful addition but not a blocker
// for the Bundle 10 procurement question.
// - Kubernetes connector. kind-in-docker requires `privileged: true`
// and is operationally fragile in CI. Deferred until Bundle 2 (real
// k8s.io/client-go) lands.
//
// Threshold contract:
// - API tier: p99 < 5s for issuance, < 2s for list, error rate < 1%.
// - Connector tier: p99 < 3s per handshake target (5s for f5-mock,
// iControl REST is slower), error rate < 1%.
// Any change pushing past these fails the workflow.
//
// CI gates the run behind workflow_dispatch + cron (NOT per-push — load
// tests are too slow to gate per-PR signal).
//
// Audit references:
// - API tier: 2026-05-01 issuer coverage audit fix #8.
// - Connector tier: 2026-05-02 deployment-target audit Bundle 10.
import http from 'k6/http';
import { check } from 'k6';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
// __ENV.* lets the same script run unchanged on the operator's
// workstation (CERTCTL_BASE=https://localhost:8443) and inside the
// docker-compose stack (CERTCTL_BASE=https://certctl-server:8443).
const BASE = __ENV.CERTCTL_BASE || 'https://localhost:8443';
const TOKEN = __ENV.CERTCTL_TOKEN || 'load-test-token';
// Bundle 10: per-target sidecar URLs. Defaults match the docker-compose
// stack's internal DNS; operators running k6 manually against a different
// stack override these via env. Empty default → the corresponding
// scenario is skipped (the scenarioFor* helper guards).
const NGINX_TARGET_URL = __ENV.NGINX_TARGET_URL || 'https://nginx-target:443';
const APACHE_TARGET_URL = __ENV.APACHE_TARGET_URL || 'https://apache-target:443';
const HAPROXY_TARGET_URL = __ENV.HAPROXY_TARGET_URL || 'https://haproxy-target:443';
// f5-mock's iControl REST `/healthz` endpoint is the CI-friendly
// per-handshake probe — hits the path the F5 connector itself uses for
// reachability. Real F5 BIG-IP also exposes /healthz under /mgmt/.
const F5_TARGET_URL = __ENV.F5_TARGET_URL || 'https://f5-mock-target:443';
// Demo seed (CERTCTL_DEMO_SEED=true) creates these rows; CreateCertificate
// requires all four FKs to exist. Pre-baked here so the script has zero
// dependency on test fixtures beyond the seed.
const ISSUER_ID = 'iss-local';
const OWNER_ID = 'o-alice';
const TEAM_ID = 't-platform';
const RENEWAL_POLICY = 'rp-standard';
export const options = {
scenarios: {
// Issuance-acceptance throughput. constant-arrival-rate fires
// requests at a fixed rate regardless of latency, which is the
// right shape for capacity testing — VU-bound load (constant-vus)
// would let slow responses backpressure the offered load and
// mask actual capacity ceilings.
issuance_acceptance: {
executor: 'constant-arrival-rate',
rate: 50,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50,
maxVUs: 200,
exec: 'createCertificate',
tags: { scenario: 'issuance_acceptance' },
},
// Read path. Same rate as issuance so the DB sees a balanced
// mix; staggered start so warmup overlap doesn't skew the
// first 30 seconds of either scenario.
list_certificates: {
executor: 'constant-arrival-rate',
rate: 50,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50,
maxVUs: 200,
exec: 'listCertificates',
startTime: '5s',
tags: { scenario: 'list_certificates' },
},
// Bundle 10: connector-tier per-target-type handshake scenarios.
// 100 conns/min sustained for 5 minutes against each sidecar.
// The handshake measurement captures TCP connect + TLS
// handshake + tiny HTTP GET (`/` for nginx/apache/haproxy,
// `/healthz` for f5-mock); k6's http_req_duration aggregates
// all three so the numbers are end-to-end "respond to the
// operator's connection" latency, not isolated TLS-handshake
// microseconds.
nginx_handshake: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1m',
duration: '5m',
preAllocatedVUs: 10,
maxVUs: 50,
exec: 'nginxHandshake',
startTime: '10s',
tags: { scenario: 'nginx_handshake', target_type: 'nginx' },
},
apache_handshake: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1m',
duration: '5m',
preAllocatedVUs: 10,
maxVUs: 50,
exec: 'apacheHandshake',
startTime: '10s',
tags: { scenario: 'apache_handshake', target_type: 'apache' },
},
haproxy_handshake: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1m',
duration: '5m',
preAllocatedVUs: 10,
maxVUs: 50,
exec: 'haproxyHandshake',
startTime: '10s',
tags: { scenario: 'haproxy_handshake', target_type: 'haproxy' },
},
f5_handshake: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1m',
duration: '5m',
preAllocatedVUs: 10,
maxVUs: 50,
exec: 'f5Handshake',
startTime: '10s',
tags: { scenario: 'f5_handshake', target_type: 'f5' },
},
},
thresholds: {
// API tier — issuer audit fix #8.
'http_req_duration{scenario:issuance_acceptance}': ['p(99)<5000', 'p(95)<2000'],
'http_req_duration{scenario:list_certificates}': ['p(99)<2000', 'p(95)<800'],
// Bundle 10 connector tier. nginx/apache/haproxy are pure TLS
// termination → tight thresholds. f5-mock includes a tiny Go
// server response on top of the handshake → slightly looser.
'http_req_duration{target_type:nginx}': ['p(99)<3000', 'p(95)<1000'],
'http_req_duration{target_type:apache}': ['p(99)<3000', 'p(95)<1000'],
'http_req_duration{target_type:haproxy}': ['p(99)<3000', 'p(95)<1000'],
'http_req_duration{target_type:f5}': ['p(99)<5000', 'p(95)<1500'],
// < 1% error rate across ALL scenarios. Auth failures, validation
// failures, server errors, connection refused all count.
'http_req_failed': ['rate<0.01'],
},
// Smaller summary payload — strip per-VU metrics we don't read.
summaryTrendStats: ['avg', 'min', 'med', 'p(95)', 'p(99)', 'max'],
};
// uniqueCN returns a deterministic-but-unique CommonName per
// (VU, iter). This avoids unique-constraint violations on the
// managed_certificates row (the table has a unique index on
// (issuer_id, name) so two parallel POSTs with the same Name 409
// rather than 201).
function uniqueCN() {
return `loadtest-${__VU}-${__ITER}-${Date.now()}.example.test`;
}
export function createCertificate() {
const cn = uniqueCN();
const payload = JSON.stringify({
name: cn,
common_name: cn,
issuer_id: ISSUER_ID,
owner_id: OWNER_ID,
team_id: TEAM_ID,
renewal_policy_id: RENEWAL_POLICY,
environment: 'production',
sans: [cn],
});
const res = http.post(`${BASE}/api/v1/certificates`, payload, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TOKEN}`,
},
tags: { scenario: 'issuance_acceptance' },
});
check(res, {
'create status 201': (r) => r.status === 201,
});
}
export function listCertificates() {
const res = http.get(`${BASE}/api/v1/certificates?per_page=50`, {
headers: {
'Authorization': `Bearer ${TOKEN}`,
},
tags: { scenario: 'list_certificates' },
});
check(res, {
'list status 200': (r) => r.status === 200,
});
}
// --- Bundle 10: connector-tier handshake scenarios ---
//
// Each per-target function does a single HTTPS GET against its target
// sidecar. k6's http_req_duration metric captures TCP connect + TLS
// handshake + HTTP request/response — that's the end-to-end "connection
// readiness" latency a deploy connector cares about. The target_type
// tag groups results in summary.json's connector_tier section.
//
// Status-check threshold: any 4xx/5xx counts as failed (k6 default
// behaviour for http_req_failed). f5-mock's /healthz returns 200; the
// other three nginx/apache/haproxy default vhost configs all return
// 200 on `/`.
//
// Bundle 10 of the 2026-05-02 deployment-target audit.
export function nginxHandshake() {
const res = http.get(`${NGINX_TARGET_URL}/`, {
tags: { scenario: 'nginx_handshake', target_type: 'nginx' },
});
check(res, {
'nginx 2xx': (r) => r.status >= 200 && r.status < 300,
});
}
export function apacheHandshake() {
const res = http.get(`${APACHE_TARGET_URL}/`, {
tags: { scenario: 'apache_handshake', target_type: 'apache' },
});
check(res, {
'apache 2xx': (r) => r.status >= 200 && r.status < 300,
});
}
export function haproxyHandshake() {
const res = http.get(`${HAPROXY_TARGET_URL}/`, {
tags: { scenario: 'haproxy_handshake', target_type: 'haproxy' },
});
check(res, {
'haproxy 2xx': (r) => r.status >= 200 && r.status < 300,
});
}
export function f5Handshake() {
const res = http.get(`${F5_TARGET_URL}/healthz`, {
tags: { scenario: 'f5_handshake', target_type: 'f5' },
});
check(res, {
'f5 2xx': (r) => r.status >= 200 && r.status < 300,
});
}
// handleSummary writes the full results to /results/summary.{json,txt}
// so the operator can commit the baseline numbers into README.md after
// each run and so CI can ingest the JSON for diffing.
//
// Bundle 10 added a `connector_tier` aggregation alongside the API tier
// — same source data (data.metrics), grouped by target_type tag for
// per-connector-type p50/p95/p99/error breakdowns. Operators tracking a
// connector regression diff `connector_tier.<type>` between runs.
//
// stdout reproduces the textSummary so the docker compose log shows
// the same numbers an operator running it manually would see.
export function handleSummary(data) {
const enriched = enrichWithConnectorTier(data);
return {
'/results/summary.json': JSON.stringify(enriched, null, 2),
'/results/summary.txt': textSummary(data, { indent: ' ', enableColors: false }),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}
// enrichWithConnectorTier appends a connector_tier object to the k6
// summary data. Each target_type entry contains:
// { p50, p95, p99, max, avg, error_rate, iterations }
// Missing tags (e.g. an operator runs only the API tier scenarios) are
// reported as null so callers can detect them without a separate scan.
function enrichWithConnectorTier(data) {
const targetTypes = ['nginx', 'apache', 'haproxy', 'f5'];
const connectorTier = {};
for (const t of targetTypes) {
const reqDurKey = `http_req_duration{target_type:${t}}`;
const reqFailKey = `http_req_failed{target_type:${t}}`;
const iterKey = `iterations{target_type:${t}}`;
const dur = data.metrics[reqDurKey];
const fail = data.metrics[reqFailKey];
const iters = data.metrics[iterKey];
if (!dur || !dur.values) {
connectorTier[t] = null;
continue;
}
connectorTier[t] = {
p50: dur.values['med'] ?? null,
p95: dur.values['p(95)'] ?? null,
p99: dur.values['p(99)'] ?? null,
max: dur.values['max'] ?? null,
avg: dur.values['avg'] ?? null,
error_rate: fail && fail.values ? (fail.values['rate'] ?? null) : null,
iterations: iters && iters.values ? (iters.values['count'] ?? null) : null,
};
}
// Shallow-merge so existing summary fields (data.metrics, data.options,
// etc.) stay untouched. The connector_tier key is additive.
return Object.assign({}, data, { connector_tier: connectorTier });
}
+80
View File
@@ -0,0 +1,80 @@
// Phase 5 — k6 scenario for the ACME issuance loop. Each VU executes
// directory + new-nonce + new-account + new-order + finalize + cert
// download against an operator-provided certctl-server. Per-step
// duration histograms feed the baseline numbers in
// deploy/test/loadtest/README.md (ACME flows section).
//
// Default scenario: 100 concurrent VUs for 5 minutes. Override via
// K6_VUS / K6_DURATION env vars.
//
// Note on signing: this scenario runs as a *load* generator, not as a
// JWS-signing client. It exercises the unauthenticated surface
// (directory + new-nonce + GET renewal-info) and validates that the
// server holds throughput under concurrency. JWS-signed flow load is
// a follow-up that requires bundling lego or a dedicated Go driver
// inside the k6 binary — k6 itself doesn't ship JWS.
import http from "k6/http";
import { check, sleep } from "k6";
import { Trend } from "k6/metrics";
const directoryURL =
__ENV.CERTCTL_ACME_DIRECTORY ||
"https://certctl:8443/acme/profile/prof-test/directory";
export const options = {
scenarios: {
acme_directory_and_nonce: {
executor: "constant-vus",
vus: parseInt(__ENV.K6_VUS || "100", 10),
duration: __ENV.K6_DURATION || "5m",
gracefulStop: "30s",
},
},
insecureSkipTLSVerify: true, // self-signed bootstrap cert
thresholds: {
"directory_duration": ["p(95)<500"],
"new_nonce_duration": ["p(95)<300"],
"renewal_info_duration": ["p(95)<800"],
"http_req_failed": ["rate<0.01"],
},
};
const directoryDuration = new Trend("directory_duration", true);
const newNonceDuration = new Trend("new_nonce_duration", true);
const renewalInfoDuration = new Trend("renewal_info_duration", true);
export default function () {
// Step 1 — directory.
let res = http.get(directoryURL);
directoryDuration.add(res.timings.duration);
check(res, { "directory 200": (r) => r.status === 200 });
if (res.status !== 200) return;
const dir = res.json();
// Step 2 — new-nonce.
if (dir.newNonce) {
res = http.head(dir.newNonce);
newNonceDuration.add(res.timings.duration);
check(res, {
"new-nonce 200 + Replay-Nonce": (r) =>
r.status === 200 && !!r.headers["Replay-Nonce"],
});
}
// Step 3 — ARI smoke (with a deliberately-malformed cert-id to
// exercise the error path; full happy-path needs a real cert which
// requires JWS signing — out of scope for this baseline scenario).
if (dir.renewalInfo) {
res = http.get(dir.renewalInfo + "/" + "aaaa.bbbb");
renewalInfoDuration.add(res.timings.duration);
// 400 (malformed cert-id, expected) OR 404 (cert not found).
check(res, {
"renewal-info 4xx for synthetic cert-id": (r) =>
r.status === 400 || r.status === 404,
});
}
sleep(1);
}
+3
View File
@@ -0,0 +1,3 @@
# Placeholder so `results/` exists in a fresh checkout. The k6
# container mounts this directory and writes summary.{json,txt} into
# it on every run; both outputs are gitignored.
+127
View File
@@ -0,0 +1,127 @@
# certctl Documentation
> Last reviewed: 2026-05-05
The full docs index, organized by audience. Pick the section that matches what you need to do; each link below opens a focused doc rather than a wall of text.
For the elevator pitch and quickstart commands, see the repo `README.md` at the root. For the marketing site, see [certctl.io](https://certctl.io).
---
## Getting Started
You're new to certctl, just cloned the repo, or want to understand what it does before installing.
| Doc | What it covers |
|---|---|
| [Concepts](getting-started/concepts.md) | TLS certificates explained for beginners — CAs, ACME, EST, private keys, the full glossary |
| [Quickstart](getting-started/quickstart.md) | Five-minute setup with Docker Compose, dashboard tour, API tour |
| [Examples](getting-started/examples.md) | Five turnkey scenarios — ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer |
| [Advanced demo](getting-started/advanced-demo.md) | End-to-end certificate lifecycle with technical depth at each step |
| [Why certctl](getting-started/why-certctl.md) | Positioning vs ACME clients, agent-based SaaS, enterprise platforms; when to look elsewhere |
## Reference
You're operating certctl in production or building integrations and need authoritative technical detail.
| Doc | What it covers |
|---|---|
| [Architecture](reference/architecture.md) | System design, data flow, security model, deployment topologies |
| [API](reference/api.md) | OpenAPI 3.1 spec, integration patterns, client SDK generation |
| [CLI](reference/cli.md) | certctl-cli command reference and CI/CD integration patterns |
| [Configuration](reference/configuration.md) | `CERTCTL_*` environment variable reference (scheduler, rate limits, deploy verify, audit, agent) |
| [MCP server](reference/mcp.md) | Model Context Protocol integration for AI assistants |
| [Release verification](reference/release-verification.md) | Cosign / SLSA / SBOM verification procedure |
| [Intermediate CA hierarchy](reference/intermediate-ca-hierarchy.md) | Multi-level CA tree management — RFC 5280 §3.2/§4.2.1.9/§4.2.1.10 enforcement |
| [Deployment model](reference/deployment-model.md) | Atomic write, post-deploy verify, rollback semantics across all targets |
| [Vendor matrix](reference/vendor-matrix.md) | Tested vendor versions per target connector |
### Connectors
The [connector index](reference/connectors/index.md) is the canonical catalog (interfaces, registry, scanners, plus an inline reference per built-in). Per-connector deep-dive siblings cover operator-grade material — vendor edges, troubleshooting, rotation playbooks, when-to-use vs alternatives.
**Issuers** (13 deep-dives): [ACME](reference/connectors/acme.md) · [ADCS](reference/connectors/adcs.md) · [AWS ACM Private CA](reference/connectors/aws-acm-pca.md) · [DigiCert](reference/connectors/digicert.md) · [EJBCA / Keyfactor](reference/connectors/ejbca.md) · [Entrust](reference/connectors/entrust.md) · [GlobalSign Atlas HVCA](reference/connectors/globalsign.md) · [Google CAS](reference/connectors/google-cas.md) · [Local CA](reference/connectors/local-ca.md) · [OpenSSL / Custom CA](reference/connectors/openssl.md) · [Sectigo SCM](reference/connectors/sectigo.md) · [step-ca / Smallstep](reference/connectors/step-ca.md) · [Vault PKI](reference/connectors/vault.md)
**Targets** (15 deep-dives): [Apache](reference/connectors/apache.md) · [AWS Certificate Manager](reference/connectors/aws-acm.md) · [Azure Key Vault](reference/connectors/azure-kv.md) · [Caddy](reference/connectors/caddy.md) · [Envoy](reference/connectors/envoy.md) · [F5 BIG-IP](reference/connectors/f5.md) · [HAProxy](reference/connectors/haproxy.md) · [IIS](reference/connectors/iis.md) · [Java Keystore](reference/connectors/jks.md) · [Kubernetes Secrets](reference/connectors/k8s.md) · [NGINX](reference/connectors/nginx.md) · [Postfix / Dovecot](reference/connectors/postfix.md) · [SSH (agentless)](reference/connectors/ssh.md) · [Traefik](reference/connectors/traefik.md) · [Windows Certificate Store](reference/connectors/wincertstore.md)
### Protocols
| Doc | What it covers |
|---|---|
| [ACME server](reference/protocols/acme-server.md) | Run certctl as an RFC 8555 + RFC 9773 ARI ACME server |
| [ACME server threat model](reference/protocols/acme-server-threat-model.md) | Security posture for the ACME server endpoint |
| [SCEP server](reference/protocols/scep-server.md) | RFC 8894 native SCEP server — RA cert config, multi-profile dispatch, must-staple, mTLS sibling route |
| [SCEP for Microsoft Intune](reference/protocols/scep-intune.md) | Intune-specific deployment guide — NDES replacement playbook |
| [EST server](reference/protocols/est.md) | RFC 7030 EST server — 802.1X / Wi-Fi enrollment, IoT bootstrap, channel binding |
| [CRL & OCSP](reference/protocols/crl-ocsp.md) | RFC 5280 CRL + RFC 6960 OCSP responder for relying parties |
| [Async CA polling](reference/protocols/async-ca-polling.md) | Bounded polling for async-CA issuer connectors |
## Operator
You're running certctl in production and need operational guidance.
| Doc | What it covers |
|---|---|
| [Security posture](operator/security.md) | Auth, rate limits, encryption at rest, key rotation |
| [Control plane TLS](operator/tls.md) | Self-signed bootstrap, operator-supplied Secret, cert-manager Certificate CR |
| [Database TLS](operator/database-tls.md) | PostgreSQL transport encryption |
| [Approval workflow](operator/approval-workflow.md) | Two-person integrity gate for high-stakes issuance |
| [Helm deployment](operator/helm-deployment.md) | Kubernetes installation via the bundled chart |
| [Performance baselines](operator/performance-baselines.md) | Operator-runnable benchmarks for regression spot checks |
| [Legacy clients (TLS 1.2)](operator/legacy-clients-tls-1.2.md) | Reverse-proxy runbook for embedded EST/SCEP clients on TLS 1.2 |
### Runbooks
| Runbook | When |
|---|---|
| [Cloud targets](operator/runbooks/cloud-targets.md) | AWS ACM + Azure Key Vault deployment, debugging, rollback |
| [Expiry alerts](operator/runbooks/expiry-alerts.md) | Per-policy multi-channel routing matrix, severity tiers |
| [Disaster recovery](operator/runbooks/disaster-recovery.md) | CRL cache, OCSP responder cert, CA private-key rotation, Postgres restore |
## Migration
You're moving from another cert-management tool to certctl, or running both in parallel.
| From | Doc |
|---|---|
| Certbot | [migration/from-certbot.md](migration/from-certbot.md) |
| acme.sh | [migration/from-acmesh.md](migration/from-acmesh.md) |
| cert-manager (coexistence, not replacement) | [migration/cert-manager-coexistence.md](migration/cert-manager-coexistence.md) |
| Caddy ACME (point Caddy at certctl) | [migration/acme-from-caddy.md](migration/acme-from-caddy.md) |
| cert-manager ACME (point cert-manager at certctl) | [migration/acme-from-cert-manager.md](migration/acme-from-cert-manager.md) |
| Traefik ACME (point Traefik at certctl) | [migration/acme-from-traefik.md](migration/acme-from-traefik.md) |
## Contributor
You're contributing to certctl, running tests locally, or trying to understand the CI pipeline.
| Doc | What it covers |
|---|---|
| [Testing strategy](contributor/testing-strategy.md) | What we test and why; per-PR fast gates vs daily deep-scan |
| [Test environment](contributor/test-environment.md) | Local environment with real CAs (Pebble, step-ca, etc.) |
| [QA prerequisites](contributor/qa-prerequisites.md) | Before running QA: stack boot, demo data baseline, env vars |
| [QA test suite](contributor/qa-test-suite.md) | qa_test.go reference for release QA |
| [GUI QA checklist](contributor/gui-qa-checklist.md) | Manual GUI verification pass for release |
| [Release sign-off](contributor/release-sign-off.md) | Release-day checklist — code state, automated gates, manual QA, artefact verification |
| [CI pipeline](contributor/ci-pipeline.md) | CI shape, regression guards, adding new checks |
## Archive
Historical docs preserved for reference. Most operators don't need these.
| Doc | Why archived |
|---|---|
| [Upgrade to TLS (v2.2)](archive/upgrades/to-tls-v2.2.md) | Pre-v2.2 HTTPS-everywhere upgrade procedure |
| [Upgrade past v2 JWT removal](archive/upgrades/to-v2-jwt-removal.md) | G-1 milestone JWT auth removal procedure |
---
## Reading order by role
**First-time operator:** [Concepts](getting-started/concepts.md) → [Quickstart](getting-started/quickstart.md) → [Examples](getting-started/examples.md). About 90 minutes end to end.
**Production operator:** [Architecture](reference/architecture.md) → [Security posture](operator/security.md) → [Control plane TLS](operator/tls.md) → [Disaster recovery runbook](operator/runbooks/disaster-recovery.md). About 4 hours end to end.
**PKI engineer:** [ACME server](reference/protocols/acme-server.md) → [SCEP server](reference/protocols/scep-server.md) → [EST server](reference/protocols/est.md) → [Intermediate CA hierarchy](reference/intermediate-ca-hierarchy.md). About 6 hours end to end.
**Contributor:** [Architecture](reference/architecture.md) → [Testing strategy](contributor/testing-strategy.md) → [Test environment](contributor/test-environment.md) → [CI pipeline](contributor/ci-pipeline.md). About 3 hours end to end.
@@ -1,10 +1,18 @@
# Upgrading to HTTPS-Everywhere (v2.2) # Upgrading to HTTPS-Everywhere (v2.2)
> Last reviewed: 2026-05-05
> **Archived 2026-05-05.** This upgrade guide applies to certctl < v2.2.
> Current operators on v2.2+ already have HTTPS-only control planes and
> don't need this procedure. For the steady-state TLS reference, see
> [`docs/operator/tls.md`](../../operator/tls.md). Preserved here for
> late upgraders coming off pre-v2.2 releases.
certctl's control plane is HTTPS-only as of v2.2. There is no `http` mode, no `auto` mode, no dual-listener bind, no N-release migration window. The cutover is a single step. Out-of-date agents that still point at `http://…` fail at the TCP/TLS handshake layer on first connect after the upgrade and stay `Offline` in the dashboard until their env block is updated and the fleet is rolled. certctl's control plane is HTTPS-only as of v2.2. There is no `http` mode, no `auto` mode, no dual-listener bind, no N-release migration window. The cutover is a single step. Out-of-date agents that still point at `http://…` fail at the TCP/TLS handshake layer on first connect after the upgrade and stay `Offline` in the dashboard until their env block is updated and the fleet is rolled.
This doc walks operators through the cutover for the two shipped deployment topologies — docker-compose and Helm — and documents the failure modes and rollback posture explicitly. This doc walks operators through the cutover for the two shipped deployment topologies — docker-compose and Helm — and documents the failure modes and rollback posture explicitly.
For the deep-dive on cert provisioning patterns, SIGHUP cert reload, and client-side CA-trust configuration, read [`tls.md`](tls.md). This doc is the narrow "how do I upgrade" procedure. For the deep-dive on cert provisioning patterns, SIGHUP cert reload, and client-side CA-trust configuration, read [`tls.md`](../../operator/tls.md). This doc is the narrow "how do I upgrade" procedure.
## Preconditions ## Preconditions
@@ -22,7 +30,7 @@ There is no schema migration tied to this release; the only at-rest state that c
## Procedure — docker-compose operators ## Procedure — docker-compose operators
The shipped `deploy/docker-compose.yml` includes a `certctl-tls-init` init container that self-signs an ECDSA-P256 (SHA-256 signature) cert on first boot and drops `server.crt`, `server.key`, and `ca.crt` into a named volume mounted read-only at `/etc/certctl/tls/` on the server and agent containers. No manual cert provisioning is required for the default stack. (Pre-v2.0.48 this was an ed25519 cert; see [`tls.md`](tls.md) Pattern 1 for the rationale and the `down -v && up --build` migration note.) The shipped `deploy/docker-compose.yml` includes a `certctl-tls-init` init container that self-signs an ECDSA-P256 (SHA-256 signature) cert on first boot and drops `server.crt`, `server.key`, and `ca.crt` into a named volume mounted read-only at `/etc/certctl/tls/` on the server and agent containers. No manual cert provisioning is required for the default stack. (Pre-v2.0.48 this was an ed25519 cert; see [`tls.md`](../../operator/tls.md) Pattern 1 for the rationale and the `down -v && up --build` migration note.)
1. **Pull the HTTPS-everywhere release.** From the repo root: 1. **Pull the HTTPS-everywhere release.** From the repo root:
@@ -68,7 +76,7 @@ The shipped `deploy/docker-compose.yml` includes a `certctl-tls-init` init conta
## Procedure — Helm operators ## Procedure — Helm operators
The Helm chart does not self-sign. It refuses to render (`helm template` exits non-zero) unless you configure one of two cert sources: an operator-supplied Secret, or a cert-manager `Certificate` CR. See [`tls.md`](tls.md) for the full pattern catalog. The Helm chart does not self-sign. It refuses to render (`helm template` exits non-zero) unless you configure one of two cert sources: an operator-supplied Secret, or a cert-manager `Certificate` CR. See [`tls.md`](../../operator/tls.md) for the full pattern catalog.
1. **Provision cert material.** Pick one of: 1. **Provision cert material.** Pick one of:
@@ -182,13 +190,13 @@ Once every agent is `Online`, confirm a few invariants:
- `curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8443/health` returns `000` with `Connection refused` (no HTTP listener). Plaintext is gone. - `curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8443/health` returns `000` with `Connection refused` (no HTTP listener). Plaintext is gone.
- `openssl s_client -connect localhost:8443 -tls1_2 </dev/null` fails the handshake. TLS 1.2 is rejected. - `openssl s_client -connect localhost:8443 -tls1_2 </dev/null` fails the handshake. TLS 1.2 is rejected.
- `openssl s_client -connect localhost:8443 -tls1_3 </dev/null` succeeds and prints the server's SAN list. TLS 1.3 is live. - `openssl s_client -connect localhost:8443 -tls1_3 </dev/null` succeeds and prints the server's SAN list. TLS 1.3 is live.
- A cert rotation test: overwrite the server cert on disk, `kill -HUP` the server PID, confirm the new cert serves on the next `openssl s_client -connect … -showcerts` without a process restart. See the SIGHUP section in [`tls.md`](tls.md). - A cert rotation test: overwrite the server cert on disk, `kill -HUP` the server PID, confirm the new cert serves on the next `openssl s_client -connect … -showcerts` without a process restart. See the SIGHUP section in [`tls.md`](../../operator/tls.md).
Update your runbooks. Every `http://certctl.example.com` URL in internal documentation, monitoring config, and on-call playbooks should become `https://certctl.example.com` plus a CA-trust note. Update your runbooks. Every `http://certctl.example.com` URL in internal documentation, monitoring config, and on-call playbooks should become `https://certctl.example.com` plus a CA-trust note.
## Related docs ## Related docs
- [`tls.md`](tls.md) — cert provisioning patterns, SIGHUP rotation, troubleshooting - [`tls.md`](../../operator/tls.md) — cert provisioning patterns, SIGHUP rotation, troubleshooting
- [`quickstart.md`](quickstart.md) — docker-compose walkthrough (post-HTTPS) - [`quickstart.md`](../../getting-started/quickstart.md) — docker-compose walkthrough (post-HTTPS)
- [`test-env.md`](test-env.md) — integration test environment (HTTPS-only) - [`test-env.md`](../../contributor/test-environment.md) — integration test environment (HTTPS-only)
- Milestone spec: `prompts/https-everywhere-milestone.md` - Milestone spec: `prompts/https-everywhere-milestone.md`
@@ -1,8 +1,17 @@
# Upgrading past G-1 — `CERTCTL_AUTH_TYPE=jwt` removal # Upgrading past G-1 — `CERTCTL_AUTH_TYPE=jwt` removal
> Last reviewed: 2026-05-05
> **Archived 2026-05-05.** This upgrade guide applies to operators
> upgrading past the G-1 milestone (the `CERTCTL_AUTH_TYPE=jwt` removal).
> Current operators on post-G-1 releases don't need this. For the
> steady-state security posture reference, see
> [`docs/operator/security.md`](../../operator/security.md). Preserved
> here for late upgraders.
If your certctl deployment currently sets `CERTCTL_AUTH_TYPE=jwt` (or `server.auth.type=jwt` in Helm), the next certctl upgrade will fail-fast at startup with a dedicated diagnostic. This guide explains why, what to switch to, and how to keep JWT/OIDC at your edge. If your certctl deployment currently sets `CERTCTL_AUTH_TYPE=jwt` (or `server.auth.type=jwt` in Helm), the next certctl upgrade will fail-fast at startup with a dedicated diagnostic. This guide explains why, what to switch to, and how to keep JWT/OIDC at your edge.
For everyone else — operators running `api-key` or `none` — this upgrade is a no-op. Skip to [`upgrade-to-tls.md`](upgrade-to-tls.md) for the v2.2 HTTPS-everywhere migration if you haven't done that one yet. For everyone else — operators running `api-key` or `none` — this upgrade is a no-op. Skip to [`to-tls-v2.2.md`](to-tls-v2.2.md) for the v2.2 HTTPS-everywhere migration if you haven't done that one yet.
## Why we removed it ## Why we removed it
@@ -98,7 +107,7 @@ services:
# ... rest of the certctl env block unchanged # ... rest of the certctl env block unchanged
``` ```
Operators hit `https://<your-host>/`, get redirected through the OIDC provider, land back at oauth2-proxy with a session cookie, and oauth2-proxy proxies their request to certctl on the internal Docker network. certctl itself is HTTPS-only on `:8443` (TLS 1.3, see [`tls.md`](tls.md)) but operator browsers never see that hop directly. Bind certctl-server's `:8443` to the internal Docker network only — do NOT publish it to the host. The audit trail will record the actor as the gateway-forwarded identity if you also configure a small bearer-token-mapping shim at the gateway (most production deployments do this with a per-user api-key issued by the gateway after OIDC validation). Operators hit `https://<your-host>/`, get redirected through the OIDC provider, land back at oauth2-proxy with a session cookie, and oauth2-proxy proxies their request to certctl on the internal Docker network. certctl itself is HTTPS-only on `:8443` (TLS 1.3, see [`tls.md`](../../operator/tls.md)) but operator browsers never see that hop directly. Bind certctl-server's `:8443` to the internal Docker network only — do NOT publish it to the host. The audit trail will record the actor as the gateway-forwarded identity if you also configure a small bearer-token-mapping shim at the gateway (most production deployments do this with a per-user api-key issued by the gateway after OIDC validation).
### Traefik ForwardAuth pattern (Kubernetes) ### Traefik ForwardAuth pattern (Kubernetes)
@@ -147,8 +156,8 @@ There is no on-disk state that changes with this upgrade — no migrations to ro
## Cross-references ## Cross-references
- [`architecture.md`](architecture.md) — "Authenticating-gateway pattern (JWT, OIDC, mTLS)" section. - [`architecture.md`](../../reference/architecture.md) — "Authenticating-gateway pattern (JWT, OIDC, mTLS)" section.
- [`tls.md`](tls.md) — TLS provisioning patterns. The gateway proxying to certctl-server still needs to trust certctl's TLS cert; same patterns apply. - [`tls.md`](../../operator/tls.md) — TLS provisioning patterns. The gateway proxying to certctl-server still needs to trust certctl's TLS cert; same patterns apply.
- [`../deploy/helm/certctl/README.md`](../deploy/helm/certctl/README.md) — Helm-chart-flavored guidance. - [`../deploy/helm/certctl/README.md`](../deploy/helm/certctl/README.md) — Helm-chart-flavored guidance.
- `internal/config/config.go::ValidAuthTypes` — the single source of truth for what's accepted post-G-1. - `internal/config/config.go::ValidAuthTypes` — the single source of truth for what's accepted post-G-1.
- `internal/repository/postgres/db.go::wrapPingError` — unrelated; pattern for runtime diagnostic of operator misconfiguration. - `internal/repository/postgres/db.go::wrapPingError` — unrelated; pattern for runtime diagnostic of operator misconfiguration.
-341
View File
@@ -1,341 +0,0 @@
# NIST SP 800-57 Key Management Alignment
NIST SP 800-57 Part 1 Rev 5 (May 2020) is the authoritative US government guidance on cryptographic key management. This document maps certctl's implementation to its recommendations. certctl follows NIST guidance where applicable; this guide documents the alignment and identifies gaps for future roadmap planning.
## Contents
1. [Key Generation (Section 6.1)](#key-generation-section-61)
2. [Key Storage and Protection (Sections 6.3, 6.4)](#key-storage-and-protection-sections-63-64)
3. [Cryptoperiods (Section 5.3, Table 1)](#cryptoperiods-section-53-table-1)
4. [Key States and Transitions (Section 5.2)](#key-states-and-transitions-section-52)
5. [Algorithm Recommendations (Section 5.1, SP 800-131A)](#algorithm-recommendations-section-51-sp-800-131a)
6. [Key Distribution and Transport (Section 6.2)](#key-distribution-and-transport-section-62)
7. [Revocation and Compromise (NIST SP 800-57 Part 3)](#revocation-and-compromise-nist-sp-800-57-part-3)
8. [Alignment Summary Table](#alignment-summary-table)
9. [Gaps and Remediation Roadmap](#gaps-and-remediation-roadmap)
- [V2 (Current)](#v2-current)
- [V3 (Planned: 2026)](#v3-planned-2026)
- [V5 (Planned: 2027+)](#v5-planned-2027)
- [Post-Quantum (2027+)](#post-quantum-2027)
10. [References](#references)
11. [Questions or Corrections?](#questions-or-corrections)
## Key Generation (Section 6.1)
certctl generates certificate keys on agent infrastructure using Go's `crypto/rand` for entropy, backed by `/dev/urandom` on Linux and `CryptGenRandom` on Windows. Key generation happens as follows:
**Agent-Side Key Generation (Production Default)**
- Agents generate ECDSA P-256 key pairs per certificate using `crypto/ecdsa` + `crypto/elliptic` (Go stdlib)
- Key generation triggered by `AwaitingCSR` job state in renewal/issuance workflows
- Agent creates Certificate Signing Request (CSR) with `x509.CreateCertificateRequest`, signed with the agent's private key
- Only the CSR crosses the network to the control plane; private key material never leaves the agent
- Configuration: `CERTCTL_KEYGEN_MODE=agent` (default, production)
**Server-Side Key Generation (Demo Only)**
- Available for development and testing via `CERTCTL_KEYGEN_MODE=server`
- Explicitly logged as a warning at startup: "server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only"
- Docker Compose demo uses server mode for backward compatibility
- Not recommended for production; agent mode is the secure default
**Entropy Source**
- `crypto/rand` provides cryptographically secure random bytes
- On Linux: backed by `/dev/urandom` via `getrandom()` syscall
- On Windows: backed by `CryptGenRandom()` (now `BCryptGenRandom()`)
- Meets NIST SP 800-90B requirements for entropy generation
## Key Storage and Protection (Sections 6.3, 6.4)
certctl implements tiered key storage with different protection profiles based on key purpose.
**Agent Private Keys**
- Stored on agent filesystem at `CERTCTL_KEY_DIR` (default: `/var/lib/certctl/keys`)
- File permissions: 0600 (read/write by agent process only, no world/group access)
- One PEM file per certificate, organized by certificate ID
- Accessible only to the agent process; isolated from other processes
- For container deployments: use Docker volumes with restricted permissions (`-v /var/lib/certctl/keys:0600`)
**Issuing CA Keys (Local CA Connector)**
- Loaded from disk at server startup via `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` env vars
- Supports RSA (PKCS#1, PKCS#8) and ECDSA (SEC1, PKCS#8) key formats
- Validates certificate constraints before use:
- `IsCA=true` flag present
- `KeyUsageCertSign` extension set
- Valid certificate chain (for sub-CA mode)
- Keys held in memory during server runtime (no on-disk caching after load)
- Cleared from memory only on server shutdown
**Sub-CA Mode (Enterprise Integration)**
- CA certificate and key signed by upstream enterprise root (e.g., Active Directory Certificate Services)
- Certctl acts as subordinate CA, inheriting issuer DN from upstream CA
- All issued certificates chain to enterprise trust anchor
- CA key protection inherits upstream root's key management practices
- Configured via: `CERTCTL_CA_CERT_PATH=/path/to/ca.crt` and `CERTCTL_CA_KEY_PATH=/path/to/ca.key`
**NIST Gap: HSM Storage**
NIST SP 800-57 Part 1 recommends Hardware Security Module (HSM) storage for high-value keys (CA signing keys). certctl V2 uses filesystem storage on the server. HSM support is planned for certctl Pro (V3), enabling integration with:
- AWS CloudHSM
- Azure Dedicated HSM
- Thales Luna, Gemalto SafeNet, YubiHSM (on-premises)
- PKCS#11-compatible devices
## Cryptoperiods (Section 5.3, Table 1)
NIST recommends cryptoperiods (key validity durations) based on key type and security requirements. certctl enforces cryptoperiods through certificate profiles and renewal policies.
**Certificate Profile Enforcement**
- Certificate profiles (M11a) define `max_ttl` constraint per enrollment profile
- All certificates issued through a profile cannot exceed the profile's max_ttl
- Profile configuration example:
```json
{
"id": "prof-web-prod",
"name": "Production Web Certs",
"max_ttl_seconds": 31536000, // 1 year max
"allowed_key_algorithms": ["ECDSA_P256"],
"required_sans": ["example.com"]
}
```
**Renewal Thresholds**
- Renewal policies with configurable `alert_thresholds_days`: `[30, 14, 7, 0]` (days before expiry)
- Background scheduler checks renewal eligibility every 1 hour
- Certificates transitioned to `Expiring` status at 30 days, `Expired` at 0 days
- Renewal workflow can be triggered manually or automatically
**NIST Cryptoperiod Recommendations vs certctl Implementation**
| Key Type | NIST Recommendation | certctl Implementation |
|----------|---------------------|------------------------|
| CA signing key | 310 years | Configured via CA certificate not-after date; inheritable from upstream CA in sub-CA mode |
| End-entity web server cert | 13 years (trending shorter) | Profile `max_ttl` configurable; ACME issuer typically 90 days; SC-081v3 mandating 47 days by 2029 |
| Code signing cert | 28 years | Profile enforcement via `max_ttl`; not primary certctl use case |
| Short-lived credentials | < 1 hour recommended | Profile TTL < 1 hour; exempt from CRL/OCSP (expiry is sufficient revocation); auto-expiry on scheduler tick |
| OCSP signing key | 12 years | Embedded OCSP responder uses issuing CA key (same period as issuer) or delegated signing cert |
| TLS/SSL interoperability cert | 12 years | Trending 1 year or less; certctl's ACME/sub-CA/step-ca issuers all support short periods |
## Key States and Transitions (Section 5.2)
NIST defines lifecycle states for keys: pre-activation, active, suspended, deactivated, compromised, and destroyed. certctl maps these to certificate and job states:
| NIST Key State | certctl Equivalent | Implementation |
|---|---|---|
| **Pre-activation** | `Pending` job state / `AwaitingCSR` | Job created but key not yet generated; awaiting agent CSR submission (agent-mode) or server keygen (demo mode) |
| **Active** | Certificate status `Active` | Cert deployed to targets and in use; within validity period (not before < now < not after) |
| **Suspended** | Job state `AwaitingApproval` | Interactive approval holds deployment job pending human review; resumes on approval or cancels on rejection |
| **Deactivated** | Certificate status `Expired` | Past not-after date; auto-transitioned by scheduler every 2 minutes; renewal eligible |
| **Compromised** | Certificate status `Revoked` | Issued via `POST /api/v1/certificates/{id}/revoke` with RFC 5280 revocation reason |
| **Destroyed** | Archived (implementation detail) | Operator responsibility; certctl retains all certs in audit trail for compliance; no destructive deletion API |
**State Transition Audit Trail**
All transitions logged to immutable `audit_events` table with:
- Event type (e.g., `certificate_revoked`, `renewal_job_completed`)
- Actor (authenticated user or agent ID)
- Timestamp (RFC3339)
- Resource (certificate ID)
- Reason (revocation reason code, approval reason, etc.)
- HTTP method, path, status (for API calls)
Example audit entry for revocation:
```json
{
"id": "ae-2024-0615",
"event_type": "certificate_revoked",
"actor": "ops-alice@example.com",
"timestamp": "2024-06-15T14:23:00Z",
"resource_id": "cert-web-prod-2024",
"resource_type": "certificate",
"description": "Revoked: reason=keyCompromise",
"body_hash": "sha256:a1b2c3d..."
}
```
## Algorithm Recommendations (Section 5.1, SP 800-131A)
NIST SP 800-131A Rev 2 (January 2024) categorizes cryptographic algorithms as Approved, Conditionally Approved, or Disallowed. certctl implements only NIST-approved algorithms:
| Algorithm | NIST Status | certctl Support | Notes |
|-----------|-------------|-----------------|-------|
| **ECDSA P-256** | Approved (128-bit security strength) | Default for agent-side keygen | Meets NIST curve requirements (FIPS 186-4) |
| **ECDSA P-384** | Approved (192-bit security strength) | Supported via profile configuration | Higher security margin; slower than P-256 |
| **ECDSA P-521** | Approved (256-bit security strength) | Supported via profile configuration | Rarely needed; overkill for TLS |
| **RSA 2048** | Approved minimum (112-bit security, transitioning) | Supported via all issuers | Deprecated path; migrate to 3072+ by 2030 per NIST |
| **RSA 3072** | Approved (128-bit security) | Supported via all issuers | Recommended minimum for long-term security |
| **RSA 4096** | Approved (192-bit security) | Supported via all issuers | Supported but slower; overkill for most TLS |
| **SHA-256** | Approved | Used throughout | CSR signing, certificate fingerprints, audit body hashing, CRL/OCSP signing |
| **SHA-384** | Approved (192-bit) | Supported where algorithm selection available | Used in some CA signing scenarios |
| **SHA-512** | Approved (256-bit) | Supported where algorithm selection available | Rarely needed; SHA-256 suffices for most use cases |
| **SHA-1** | Deprecated | Not used in certctl | Browsers reject SHA-1 certs; certctl never generates them |
**Algorithm Enforcement via Profiles**
Certificate profiles enforce allowed key algorithms:
```json
{
"id": "prof-web-prod",
"allowed_key_algorithms": ["ECDSA_P256", "ECDSA_P384", "RSA3072"]
}
```
**Post-Quantum Cryptography (Tracking)**
NIST has finalized PQC standards (FIPS 204, FIPS 205) in August 2024:
- **ML-KEM** (Kyber): Approved key encapsulation mechanism
- **ML-DSA** (Dilithium): Approved digital signature algorithm
- **SLH-DSA** (SPHINCS+): Approved stateless hash-based signature scheme
certctl will track NIST's PQC roadmap and plan integration when hybrid PQC+classical certificate formats reach browser/infrastructure support. Currently, pure PQC certificates are not widely interoperable.
## Key Distribution and Transport (Section 6.2)
NIST SP 800-57 Part 1 Section 6.2 addresses secure key distribution to minimize exposure during transit. certctl implements a zero-transmission-of-private-keys model:
**Private Key Distribution**
- Agent-side keygen model: Private keys never leave agent infrastructure
- CSR transmitted over HTTPS (TLS 1.2+) with mutual TLS optional
- API key authentication via `Authorization: Bearer <api-key>` header
- All API calls logged to immutable audit trail
**Signed Certificate Distribution**
- Certificates (public component) distributed via `GET /agents/{id}/work` over HTTPS
- Work endpoint enriches deployment jobs with certificate PEM and metadata
- Certificate PEM is idempotent (same cert always returns same bytes)
**Target Deployment**
- Deployment to targets via local filesystem write (NGINX, Apache, HAProxy)
- No network transmission of private keys to targets
- Agents read local private key from `CERTCTL_KEY_DIR` on deployment
- For appliances without agents (F5 BIG-IP, IIS), proxy agent pattern:
- Proxy agent runs in same trust zone as appliance
- Proxy agent holds target API credentials (iControl, WinRM)
- Control plane never communicates with appliance directly
- Deployment request includes certificate and proxy agent ID
- Proxy agent executes deployment via appliance API
**Revocation Distribution**
- Certificate Revocation List (CRL) via `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615)
- Returns DER-encoded X.509 CRL signed by issuing CA (`Content-Type: application/pkix-crl`)
- 24-hour validity period
- Includes all revoked serials, reasons, and revocation timestamps
- Served unauthenticated so relying parties without certctl API credentials can fetch it
- Subject to URL caching; OCSP preferred for real-time revocation
- OCSP via `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960)
- Returns DER-encoded OCSP response (OCSPResponse ASN.1 structure, `Content-Type: application/ocsp-response`)
- Signed by issuing CA (or delegated OCSP signing cert)
- Responds with good/revoked/unknown status
- Served unauthenticated — the RFC 6960 relying-party model does not assume API credentials
- Real-time, more bandwidth-efficient than CRL polling
## Revocation and Compromise (NIST SP 800-57 Part 3)
NIST SP 800-57 Part 3 covers revocation (Section 2.5) when keys are suspected compromised or no longer needed. certctl implements comprehensive revocation infrastructure:
**Revocation API**
- Endpoint: `POST /api/v1/certificates/{id}/revoke`
- Request body:
```json
{
"reason": "keyCompromise",
"reason_text": "Private key exposed in log file"
}
```
- Supports all 8 RFC 5280 revocation reason codes:
- `unspecified` — no specific reason provided
- `keyCompromise` — private key suspected compromised
- `caCompromise` — issuing CA key compromised
- `affiliationChanged` — subject org/affiliation changed
- `superseded` — cert superseded by newer cert
- `cessationOfOperation` — key no longer in use
- `certificateHold` — temporary hold (rarely used)
- `privilegeWithdrawn` — subject authorization withdrawn
**Revocation Recording**
- Certificate status updated to `Revoked`
- Entry recorded in `certificate_revocations` table with:
- Certificate serial number
- Revocation timestamp
- Revocation reason code
- Issuer ID
- Idempotent (revoking an already-revoked cert is safe; returns 200 OK)
**Issuer Notification (Best-Effort)**
- Control plane calls `issuer.RevokeCertificate(ctx, serial, reason)` on issuing connector
- Failure does not block the revocation (async, logged, retried)
- Supported issuers:
- Local CA: generates new CRL immediately
- ACME: submits revocation to ACME server (RFC 8555 Section 7.6)
- step-ca: calls `/revoke` API
- OpenSSL: executes user-provided revocation script
**Revocation Notifications**
- Notifiers triggered after revocation recorded: Slack, Teams, PagerDuty, OpsGenie, email, webhook
- Message includes certificate common name, issuer, reason, actor, timestamp
- Delivery is asynchronous and retried on failure
**CRL and OCSP Distribution**
- CRL updated on every revocation (or scheduled refresh for non-issued revocations)
- OCSP responder queries revocation table in real-time
- Short-lived certificate exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
**Bulk Revocation for Large-Scale Compromise Response** (V2.2) — NIST SP 800-57 Part 3 emphasizes rapid revocation when keys are compromised. `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria (profile, owner, agent, issuer) in a single operation. This enables operators to execute fleet-wide revocation for key compromise events affecting multiple certificates. Each bulk revocation creates individual jobs reusing the existing revocation pipeline, ensuring every certificate is recorded in the audit trail with the incident reason.
**Revocation Audit Trail**
All revocation events logged:
- Event type: `certificate_revoked` or `bulk_revocation_initiated` (for fleet operations)
- Actor: authenticated user or service
- Reason code: RFC 5280 enum (or incident justification for bulk operations)
- Timestamp: RFC3339
- Issuer notification status: success or error reason
- Filter criteria: profile_id, owner_id, agent_id, issuer_id (for bulk revocation)
## Alignment Summary Table
| NIST SP 800-57 Area | Status | Coverage | Notes |
|---|---|---|---|
| **Key Generation** | ✅ Aligned | 100% | Agent-side ECDSA P-256 using crypto/rand; server mode flagged as demo-only |
| **Key Storage** | ⚠️ Partially Aligned | 80% | Filesystem with 0600 perms; HSM support planned V3 Pro |
| **Cryptoperiods** | ✅ Aligned | 100% | Profile-enforced max_ttl; threshold-based renewal alerting |
| **Key States** | ✅ Aligned | 100% | Full lifecycle tracking with immutable audit trail |
| **Algorithms** | ✅ Aligned | 100% | NIST-approved algorithms only; post-quantum tracking in progress |
| **Key Distribution** | ✅ Aligned | 100% | Private keys never transmitted; CSR/cert over TLS; agent-local deployment |
| **Revocation** | ✅ Aligned | 100% | CRL, OCSP, all RFC 5280 reason codes; real-time updates |
## Gaps and Remediation Roadmap
### V2 (Current)
- [x] Agent-side key generation
- [x] Profile-enforced cryptoperiods
- [x] CRL and OCSP distribution
- [x] RFC 5280 revocation support
- [x] Immutable audit trail
### V2.2 (Planned: 2026)
- Bulk revocation by profile/owner/agent/issuer (fleet-level revocation for incident response)
### V3 (Planned: 2026)
- Role-based access control (limit revocation/approval to authorized operators)
### V3 Pro (Planned)
- HSM support for CA key storage and agent key storage (TPM 2.0, PKCS#11)
- FIPS 140-2/3 validated crypto module (BoringCrypto build or external FIPS library)
- Key destruction API (explicit secure erasure of agent keys)
- Key escrow / recovery mechanism (backup encrypted private keys for disaster recovery)
### Post-Quantum (2027+)
- ML-KEM and ML-DSA support when browser/TLS ecosystem supports hybrid certificates
- Migration path documentation (how to transition existing RSA certs to PQC)
## References
- NIST SP 800-57 Part 1 Rev 5 (May 2020): https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf
- NIST SP 800-131A Rev 2 (January 2024): https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-131Ar2.pdf
- FIPS 186-4 (Digital Signature Standard): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf
- RFC 5280 (X.509 PKI Certificate and CRL Profile): https://tools.ietf.org/html/rfc5280
- RFC 8555 (Automatic Certificate Management Environment): https://tools.ietf.org/html/rfc8555
- NIST FIPS 204 (ML-DSA): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.204.pdf
- NIST FIPS 205 (ML-KEM): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.205.pdf
## Questions or Corrections?
This document reflects certctl's implementation as of March 2026. For the latest code, refer to:
- Key generation: `cmd/agent/main.go` (agent keygen) and `internal/service/renewal.go` (server keygen)
- Key storage: `internal/config/config.go` (CERTCTL_KEY_DIR, CERTCTL_CA_CERT_PATH)
- Revocation: `internal/service/revocation.go` and `internal/api/handler/certificates.go`
- Audit trail: `internal/api/middleware/audit.go`
-825
View File
@@ -1,825 +0,0 @@
# PCI-DSS 4.0 Compliance Mapping
This guide maps certctl's existing capabilities to PCI-DSS 4.0 requirements relevant to TLS certificate and cryptographic key management. It is **not a compliance attestation** — a qualified security assessor (QSA) must evaluate your organization's complete control environment. Rather, this document helps you understand which PCI-DSS control objectives certctl supports and where operator responsibility lies.
Organizations subject to PCI-DSS typically need to demonstrate control over certificate issuance, renewal, rotation, revocation, and key management. Certctl automates the technical controls for certificate lifecycle; compliance depends on how you deploy, monitor, and audit it.
## Contents
1. [How to Use This Guide](#how-to-use-this-guide)
2. [Requirement 4: Protect Data in Transit](#requirement-4-protect-data-in-transit)
- [4.2.1 — Strong Cryptography for Transmission](#421--strong-cryptography-for-transmission)
- [4.2.2 — Certificate Inventory and Validation](#422--certificate-inventory-and-validation)
3. [Requirement 3: Protect Stored Cardholder Data (Key Management)](#requirement-3-protect-stored-cardholder-data-key-management)
- [3.6 — Cryptographic Key Documentation](#36--cryptographic-key-documentation)
- [3.7 — Key Lifecycle Procedures](#37--key-lifecycle-procedures)
4. [Requirement 8: Identify and Authenticate](#requirement-8-identify-and-authenticate)
- [8.3 — Strong Authentication](#83--strong-authentication)
- [8.6 — Application Account Management](#86--application-account-management)
5. [Requirement 10: Log and Monitor](#requirement-10-log-and-monitor)
- [10.2 — Implement Automated Audit Logging](#102--implement-automated-audit-logging)
- [10.3 — Protect Audit Trail](#103--protect-audit-trail)
- [10.4 — Promptly Review and Address Audit Trail Exceptions](#104--promptly-review-and-address-audit-trail-exceptions)
- [10.7 — Retain and Protect Audit Trail History](#107--retain-and-protect-audit-trail-history)
6. [Requirement 6: Develop and Maintain Secure Systems and Applications](#requirement-6-develop-and-maintain-secure-systems-and-applications)
- [6.3.1 — Security Coding Practices](#631--security-coding-practices)
- [6.5.10 — Broken Authentication and Cryptography Prevention](#6510--broken-authentication-and-cryptography-prevention)
7. [Requirement 7: Restrict Access by Business Need-to-Know](#requirement-7-restrict-access-by-business-need-to-know)
- [7.2 — Implement Access Control](#72--implement-access-control)
8. [Evidence Summary Table](#evidence-summary-table)
9. [Operator Responsibilities](#operator-responsibilities)
10. [V3 Enhancements for PCI-DSS](#v3-enhancements-for-pci-dss)
11. [Next Steps for Compliance](#next-steps-for-compliance)
12. [Questions?](#questions)
## How to Use This Guide
Your QSA will request evidence that your certificate and key management systems meet specific PCI-DSS 4.0 requirements. For each applicable requirement, this guide identifies:
1. **Which certctl features support the control** — API endpoints, database tables, background processes
2. **What evidence you can produce** — audit logs, dashboard metrics, API queries, deployment configs
3. **Operator responsibilities** — what you must do outside certctl (policy, monitoring, access control)
4. **Status** — Available (v1.0 shipped), Planned (future release), or Operator Responsibility (outside scope)
---
## Requirement 4: Protect Data in Transit
**Objective**: Ensure strong cryptography is used to protect sensitive data during transmission.
### 4.2.1 — Strong Cryptography for Transmission
**Requirement**: Use appropriate and current cryptographic algorithms for all TLS and SSH connections protecting card data in transit.
**certctl Support**:
- **Automated TLS certificate lifecycle** — Certctl issues TLS certificates to NGINX, Apache HAProxy targets via `POST /api/v1/deployments`. Certificates include RSA 2048-bit and ECDSA P-256 key types (configurable per profile, M11a).
- **Control plane TLS enforcement** — All REST API endpoints served exclusively over HTTPS. Agent-to-server heartbeat and work polling use TLS. No plaintext protocol options.
- **Issuer connector key negotiation** — ACME v2 (Let's Encrypt, ZeroSSL) validates issuer cryptography. Local CA enforces RSA/ECDSA constraints. step-ca integration ensures Smallstep's cryptography standards.
- **Certificate profiles** (M11a) document allowed key types and minimum key sizes per environment (development, production, cardholder-network).
**Evidence You Can Provide**:
- Exported certificate inventory via `GET /api/v1/certificates` with key algorithm and size (serial JSON).
- Issued certificate details showing RSA 2048+ or ECDSA P-256 for all deployed certificates.
- Audit trail (`GET /api/v1/audit`) showing issuer connector selection and certificate profile assignment per certificate.
- Target deployment logs showing TLS certificate installation on NGINX/Apache/HAProxy.
**Operator Responsibility**:
- Configure certificate profiles for your environments with approved key algorithms.
- Audit cipher suite configuration on deployed targets (certctl deploys certs; you verify target TLS settings).
- Periodically review `CERTCTL_KEYGEN_MODE` — must be `agent` in production (never `server`).
- Monitor issuer connector configuration to ensure issuers meet your cryptography standards.
**Status**: **Available** (v1.0 shipped)
---
### 4.2.2 — Certificate Inventory and Validation
**Requirement**: Ensure all TLS/SSL certificates used for data transmission are valid, current, and meet required cryptographic standards.
**certctl Support**:
- **Managed Certificate Inventory** — Full CRUD API (`/api/v1/certificates`) with sortable, filterable list. Fields: common name, SANs, subject, issuer, serial number, key type/size, not-before/after dates, issuer ID, profile ID, owner, team, status (Active/Expiring/Expired/Revoked).
- **Filesystem Certificate Discovery** (M18b) — Agents scan configured directories (`CERTCTL_DISCOVERY_DIRS` env var) for existing PEM/DER certificates every 6 hours and on startup. Control plane deduplicates by SHA-256 fingerprint. Three triage statuses: Unmanaged (not managed by certctl), Managed (linked to a managed certificate), Dismissed (operator-marked as out-of-scope).
- API endpoints:
- `GET /api/v1/discovered-certificates?status=Unmanaged` — find orphaned certs
- `GET /api/v1/discovery-summary` — aggregate counts by status
- `POST /api/v1/discovered-certificates/{id}/claim` — link to managed certificate
- `POST /api/v1/discovered-certificates/{id}/dismiss` — mark out-of-scope
- **Expiration Threshold Alerting** — Renewal policies support `alert_thresholds_days` (default 30, 14, 7, 0). Background scheduler evaluates daily; certificates transition to Expiring/Expired status automatically. Notifications sent to owners via email/webhook/Slack/Teams/PagerDuty.
- **Certificate Status Tracking** — Four statuses: Active (deployed, not yet expired), Expiring (within threshold, awaiting renewal), Expired (past not-after date), Revoked (revoked via RFC 5280 revocation API). Dashboard charts show status distribution.
- **Revocation Infrastructure** (M15a, M15b, M-006):
- Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes
- CRL endpoint: `GET /.well-known/pki/crl/{issuer_id}` — DER X.509 CRL, 24h validity, signed by issuing CA, served unauthenticated (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`)
- OCSP responder: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` — DER-encoded OCSP response (good/revoked/unknown), served unauthenticated (RFC 6960, `Content-Type: application/ocsp-response`)
- Bulk revocation (V2.2): `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) for fleet-wide incident response
- Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
- **Stats API** (M14) — Real-time visibility:
- `GET /api/v1/stats/summary` — total certs, by status, by issuer
- `GET /api/v1/stats/expiration-timeline?days=90` — expiration distribution (weekly buckets)
- `GET /api/v1/stats/job-trends?days=30` — renewal/issuance job success rates
- `GET /api/v1/certificates` with `?sort=-notAfter&fields=id,commonName,notAfter,status` — sparse, sorted inventory
**Evidence You Can Provide**:
- Discovered certificate report: `GET /api/v1/discovered-certificates` JSON export showing all certs on systems, fingerprints, and status.
- Managed certificate inventory: `GET /api/v1/certificates` with filters (`?status=Expiring` for upcoming renewals).
- Expiration alert configuration: policy JSON showing `alert_thresholds_days` for each environment.
- CRL/OCSP availability proof: unauthenticated HTTP GET requests to `/.well-known/pki/crl/{issuer_id}` (DER, `application/pkix-crl`) and `/.well-known/pki/ocsp/{issuer_id}/{serial}` (DER, `application/ocsp-response`) with signed responses.
- Audit trail for certificate creation/renewal/revocation: `GET /api/v1/audit?type=certificate_issued,certificate_renewed,certificate_revoked`.
- Dashboard charts showing expiration timeline, renewal success trends, status distribution.
**Operator Responsibility**:
- Configure `CERTCTL_DISCOVERY_DIRS` on agents to scan all certificate storage locations (e.g., `/etc/nginx/certs`, `/etc/apache2/certs`, `/usr/local/share/ca-certificates`).
- Regularly triage discovered certificates: `GET /api/v1/discovered-certificates?status=Unmanaged`, claim or dismiss each.
- Set renewal policies for all certificate profiles with appropriate `alert_thresholds_days` (recommendation: 30, 14, 7, 0).
- Monitor expiration dashboard and respond to Expiring alerts before certificates expire.
- Verify that issued certificates meet your organization's cryptography standards (key type, key size, SANs).
- Test CRL/OCSP endpoints periodically to confirm they are reachable and signed correctly.
**Status**: **Available** (v1.0 shipped, discovery M18b, revocation M15a/M15b)
---
## Requirement 3: Protect Stored Cardholder Data (Key Management)
**Objective**: Render cardholder data unreadable anywhere it is stored; protect cryptographic keys used to encrypt data.
### 3.6 — Cryptographic Key Documentation
**Requirement**: Document and implement all key management processes and procedures covering generation, storage, archival, destruction, and change; protect cryptographic keys; and restrict access to keys to the minimum required.
**certctl Support**:
- **Certificate Profile Documentation** (M11a) — Named profiles define allowed key types, maximum TTL, and allowed EKUs per use case. Each profile is a documented policy:
```json
{
"id": "p-web-tls",
"name": "Web TLS Production",
"allowed_key_types": ["RSA_2048", "ECDSA_P256"],
"max_ttl_seconds": 31536000,
"require_sans": true,
"description": "Production TLS certs for external web services"
}
```
- **Owner and Team Tracking** (M11b) — Every certificate is assigned an owner (person + email) and optionally a team. This documents key responsibility and escalation paths.
- **Issuer Connector Specification** — Configuration and API endpoints document which CA and protocol issues each certificate:
- `GET /api/v1/issuers/{id}` returns issuer type (local-ca, acme, step-ca, openssl), CA endpoint, authentication method, constraints
- Each issuer type has documented key handling (e.g., Local CA loads CA key from `CERTCTL_CA_CERT_PATH`, step-ca via JWK provisioner)
- **Immutable Audit Trail** (M19) — Every certificate lifecycle event recorded in append-only `audit_events` table:
- `certificate_issued` — when certificate created, by whom, issuer type, profile
- `certificate_renewed` — when renewed, by whom, issuer
- `certificate_revoked` — when revoked, by whom, RFC 5280 reason code
- `certificate_deployed` — when deployed to target, by agent, target type
- Query: `GET /api/v1/audit?resource_type=certificate&resource_id={cert_id}`
**Evidence You Can Provide**:
- Exported certificate profiles: `GET /api/v1/profiles` showing documented key types, max TTLs, constraints per environment.
- Certificate-to-owner mapping: `GET /api/v1/certificates` with owner/team fields.
- Issuer configuration audit: `GET /api/v1/issuers` showing CA endpoints, key storage paths, auth methods.
- Audit trail for a certificate: `GET /api/v1/audit?resource_type=certificate&resource_id={cert_id}` showing complete lifecycle.
**Operator Responsibility**:
- Define and document certificate profiles for each environment and use case.
- Assign owner and team to each certificate via API or dashboard.
- Document issuer connector configuration (CA endpoint, auth method, key storage location).
- Maintain baseline audit trail exports for compliance evidence.
- Establish certificate retirement policy (how long to retain audit records after certificate expiry/revocation).
**Status**: **Available** (v1.0 shipped)
---
### 3.7 — Key Lifecycle Procedures
**Requirement**: Generate, store, protect, access, and destroy cryptographic keys used to encrypt data in transit or at rest.
This requirement covers key generation, storage, rotation, and destruction. Certctl addresses the certificate/TLS key portion (not symmetric encryption keys used for cardholder data at rest — those are outside scope).
#### 3.7.1 — Key Generation
**Requirement**: Generate new keys using strong cryptography.
**certctl Support**:
- **Agent-Side Key Generation** (M8) — Production mode (default `CERTCTL_KEYGEN_MODE=agent`):
- Agents generate ECDSA P-256 key pairs using `crypto/ecdsa` + `crypto/elliptic.P256()` + `crypto/rand` (cryptographically secure random).
- Key generation happens **only on the agent**, never on the control plane.
- Agent submits Certificate Signing Request (CSR) with public key to control plane via `POST /api/v1/agents/{id}/csr`.
- Issued certificate is returned; private key remains on agent at `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`).
- **Server-Side Fallback** (demo/development only) — `CERTCTL_KEYGEN_MODE=server`:
- Control plane generates RSA 2048-bit or ECDSA P-256 keys using `crypto/rand` + `crypto/rsa`.
- Server signs CSR and stores the private key in the certificate version record for agent deployment. **Security note:** In server keygen mode, the control plane holds private keys — this is why agent keygen mode is the recommended default for production.
- **Must not be used in production.** Explicit warning logged: `server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only`
- **Issuer-Specific Key Negotiation**:
- **ACME (Let's Encrypt, ZeroSSL)**: Let's Encrypt controls key types; certctl requests ECDSA P-256 by default.
- **Local CA**: Supports RSA 2048+, ECDSA (P-256, P-384), PKCS#8 format. Key algorithm inherited from CA cert or specified via profile.
- **step-ca**: Smallstep's provisioner defines key type; certctl respects server constraints.
- **OpenSSL / Custom CA**: User-provided signing script; key type depends on CA backend.
**Evidence You Can Provide**:
- Deployment configuration: `CERTCTL_KEYGEN_MODE=agent` in production (verify in `docker-compose.yml`, Kubernetes manifests, or systemd units).
- Agent log excerpt showing key generation: Go `crypto/ecdsa.GenerateKey(elliptic.P256())` via agent process logs with CSR submission timestamp.
- Certificate CSR audit: `GET /api/v1/audit?type=certificate_issued` showing CSR fingerprint (SHA-256 hash of CSR PEM).
- Renewal job logs showing agent-submitted CSR, not server-generated key.
**Operator Responsibility**:
- **Enforce `CERTCTL_KEYGEN_MODE=agent` in all production deployments.** Never use `server` mode outside demos.
- Verify agent hardware is adequately isolated (crypto/rand relies on OS `/dev/urandom` quality).
- Monitor `CERTCTL_KEY_DIR` on agents for unauthorized file access (use OS-level file audit if available).
- Backup agent key directory (`/var/lib/certctl/keys`) as part of disaster recovery procedure.
**Status**: **Available** (v1.0 shipped)
#### 3.7.2 — Key Storage and Access Control
**Requirement**: Restrict cryptographic key access to the minimum required and protect keys from unauthorized access.
**certctl Support**:
- **Agent-Side Key Storage** (M8) — Private keys written to `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`):
- File permissions: `0600` (readable/writable by agent process owner only).
- Filename convention: one file per certificate (e.g., `web-tls-prod.key`, `api-service.key`).
- No key data passed over the network between agent and control plane (CSR only).
- Keys used locally by agent to sign TLS handshakes, never transmitted to control plane or other systems.
- **Control Plane Key Storage** — Sensitive credentials managed via environment variables or `.env` files:
- CA private key path: `CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH` (for Local CA sub-CA mode).
- ACME account key: embedded in ACME issuer config (not stored separately; ACME library handles in memory).
- step-ca provisioner key: `CERTCTL_STEPCA_KEY_PATH` env var (path to JWK private key file, loaded into memory during runtime).
- API keys: `CERTCTL_API_KEY` (SHA-256 hashed in database, plaintext never stored).
- Database credentials: `CERTCTL_DATABASE_URL` in `.env` file, not in source code.
- **Docker Compose Credential Management**`.env` file (git-ignored) holds all secrets:
```bash
CERTCTL_API_KEY=sk-test-...
CERTCTL_DATABASE_URL=postgres://user:pass@db:5432/certctl
CERTCTL_CA_KEY_PATH=/run/secrets/ca.key
```
Credentials never in `docker-compose.yml` or Dockerfile.
- **Kubernetes Secrets** (operator responsibility) — Deploy control plane with:
```yaml
env:
- name: CERTCTL_DATABASE_URL
valueFrom:
secretKeyRef:
name: certctl-secrets
key: database-url
- name: CERTCTL_API_KEY
valueFrom:
secretKeyRef:
name: certctl-secrets
key: api-key
```
**Evidence You Can Provide**:
- Agent key directory listing (without keys): `ls -la /var/lib/certctl/keys` (shows file count, permissions, timestamps).
- Deployment manifest (`docker-compose.yml` or Kubernetes YAML) showing secrets via env var or Secret object (not inline).
- `.env` file (do not share contents, only confirm existence and git-ignore status).
- API key hash verification: `GET /api/v1/auth/check` with API key, verifying hash matching without plaintext exposure.
**Operator Responsibility**:
- **Store `.env` and credential files outside version control.** Verify `.gitignore` includes `.env`, `*.key`, `ca.key`, etc.
- **Restrict file system access to `/var/lib/certctl/keys` on agents** via OS-level permissions (Linux: `chmod 0700`, owned by agent user).
- **Limit CA key file read access**`CERTCTL_CA_KEY_PATH` should be readable only by certctl server process (OS permissions).
- **Rotate API keys periodically** (recommendation: annually or when personnel changes). No audit trail for API key rotation (outside certctl scope).
- **Backup private key stores** (agent key dirs, CA key file) as part of disaster recovery. Encrypt backups at rest.
- **Monitor access logs** to `/var/lib/certctl/keys` and CA key file location (use OS audit or file integrity monitoring).
**Status**: **Available** (v1.0 shipped)
#### 3.7.3 — Key Rotation
**Requirement**: Rotate cryptographic keys upon expiration or compromise.
**certctl Support**:
- **Automated Certificate Renewal** — Renewal policies trigger certificate renewal automatically:
- Background scheduler checks every 60 minutes (configurable via `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL`).
- For each policy, evaluates all managed certificates: if `(not-after - now) <= policy.renewal_threshold_days`, trigger renewal.
- Renewal job created in AwaitingCSR state; agent receives work, generates new key pair, submits new CSR.
- Issuer connector signs new CSR with new key; old key discarded by agent after new certificate installed.
- New certificate deployed to target via deployment job.
- **Expiration-Based Rotation** — Certificate profiles (M11a) define `max_ttl_seconds` (e.g., 31536000 for 1 year, 3600 for short-lived certs):
- Short-lived certificates (TTL < 1 hour) rotate every deployment cycle, providing defense-in-depth (RFC 5280 revocation not needed).
- Longer-lived certs (90/180/365 days) rotated via renewal policy thresholds (30/14/7 day alerts).
- **Renewal Audit Trail** — Every renewal recorded:
- `GET /api/v1/audit?type=certificate_renewed&resource_id={cert_id}` shows each renewal, old serial, new serial, issuer, actor.
**Evidence You Can Provide**:
- Renewal policy configuration: `GET /api/v1/policies` showing `renewal_threshold_days` and `alert_thresholds_days`.
- Renewal job history: `GET /api/v1/jobs?type=Renewal&status=Completed` with timestamp, before/after serial numbers.
- Certificate version history: `GET /api/v1/certificates/{id}/versions` showing all issued versions, dates, issuers.
- Audit trail: `GET /api/v1/audit?type=certificate_renewed` for trending and compliance reporting.
**Operator Responsibility**:
- **Define renewal policies for all certificate profiles** with appropriate thresholds (typically 30 days before expiration for 90+ day certs, more aggressive for shorter-lived).
- **Monitor renewal job success** via dashboard (M14 charts show renewal success trends) and alerts.
- **Investigate renewal failures** (stuck AwaitingCSR, issuer connectivity, deployment errors) promptly to avoid expired certificates.
- **Test renewal workflow in staging environment** before rolling out to production.
- **Document key rotation schedule** for your organization (renewal policy thresholds, approval workflows if AwaitingApproval).
**Status**: **Available** (v1.0 shipped)
#### 3.7.4 — Key Destruction
**Requirement**: Render cryptographic keys unreadable and unusable when they reach the end of their cryptographic lifetime.
**certctl Support**:
- **Certificate Revocation API** (M15a) — `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes:
- `unspecified` — general revocation
- `keyCompromise` — suspected key compromise
- `caCompromise` — CA compromise
- `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn` — lifecycle management
- Revocation recorded in `certificate_revocations` table with timestamp and reason.
- Issuer notified (best-effort; ACME lacks standard revocation, Local CA skips issuer step).
- Revocation notifications sent to owner via email/webhook/Slack/Teams/PagerDuty.
- **CRL and OCSP Publication** (M15b, M-006) — Revoked certificates published in:
- CRL: `GET /.well-known/pki/crl/{issuer_id}` (DER X.509 signed by CA, 24h validity, RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`)
- OCSP: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain, RFC 6960, `Content-Type: application/ocsp-response`)
- Both endpoints are served unauthenticated so relying parties (browsers, TLS appliances) without certctl API keys can verify revocation — this is the RFC-compliant PKI model.
- Clients checking certificate status via OCSP or CRL see revoked status within 24 hours.
- **Bulk Revocation for Incident Response** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. PCI-DSS Req 4 requires rapid response to data transmission security incidents — bulk revocation enables operators to revoke an entire certificate set (e.g., all certs used by a compromised team or endpoint) in minutes rather than hours.
- **Private Key Destruction on Agent** — When certificate renewed or revoked:
- Agent removes old private key file from `CERTCTL_KEY_DIR` when new certificate deployed.
- Job status tracking confirms old key is no longer needed.
- No audit trail of key deletion (private keys don't pass through control plane).
**Evidence You Can Provide**:
- Revocation requests: `GET /api/v1/audit?type=certificate_revoked` with RFC 5280 reason codes.
- CRL publication: HTTP GET `/.well-known/pki/crl/{issuer_id}` (unauthenticated) returns a DER X.509 CRL — parse with `openssl crl -inform der -noout -text` to show revoked serial numbers, reasons, and timestamps.
- OCSP responder validation: Query `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (unauthenticated) for a known-revoked cert; response includes `revoked` status and can be parsed with `openssl ocsp` tooling.
- Audit trail: Certificate status transitions (Active → Revoked) recorded in `audit_events`.
**Operator Responsibility**:
- **Revoke certificates immediately upon key compromise suspicion** using reason code `keyCompromise`.
- **Revoke certificates at end of lifecycle** (host decommissioning, service sunset) using reason code `cessationOfOperation`.
- **Monitor CRL/OCSP availability** — ensure clients can check revocation status (test with TLS validator tools).
- **Establish certificate revocation procedure** (who can revoke, approval workflow if required, documentation).
- **Physically destroy backup private keys** (if offline backups are kept) when certificate is revoked or after archival period expires.
- **Test revocation workflow in staging** — issue test cert, revoke, verify OCSP/CRL reflects revocation within SLA.
**Status**: **Available** (v1.0 shipped)
---
## Requirement 8: Identify and Authenticate
**Objective**: Limit access to system components and cardholder data by business need-to-know, and authenticate and manage all access.
### 8.3 — Strong Authentication
**Requirement**: Authentication mechanisms must use strong cryptography and render authentication credentials (passwords, passphrases, keys) unreadable during transmission and storage.
**certctl Support**:
- **API Key Authentication** — All REST API endpoints require authentication (default):
- Bearer token format: `Authorization: Bearer sk-...`
- Key stored as SHA-256 hash in database (plaintext never persisted).
- Comparison uses `crypto/subtle.ConstantTimeCompare` to prevent timing attacks.
- Configuration: `CERTCTL_AUTH_TYPE=api-key` (enforced by default, no opt-out without explicit env var).
- **GUI Authentication Context** — Web dashboard login flow:
- Login page (`/login`) accepts API key entry.
- AuthProvider context stores API key in session (localStorage in browser, sent in Authorization header for all API calls).
- 401 Unauthorized responses trigger automatic redirect to login.
- Logout button clears session.
- No session server-side (stateless API).
- **Credential Transmission** — All API traffic over TLS:
- HTTPS enforced at server level (no plaintext HTTP).
- API key transmitted in Authorization header (not URL parameter, not cookie).
- Browser to server: TLS.
- Agent to server: TLS.
- No credential logging (audit records the per-key actor `Name`, never the Bearer token; logs redact the `Authorization` header).
**Evidence You Can Provide**:
- API configuration: `CERTCTL_AUTH_TYPE=api-key` in deployment manifest.
- Key inventory: `CERTCTL_API_KEYS_NAMED` env var (format `name:key:admin,...`) — seeds the in-memory `NamedAPIKey{Name, Key, Admin}` struct at `internal/api/middleware/middleware.go:29`. Keys are constant-time-compared (`subtle.ConstantTimeCompare`) against the Bearer token. No database table stores them; protect the env var contents at rest via a secrets manager (Vault / AWS Secrets Manager / Kubernetes Secrets / Docker Secrets).
- API audit log: `GET /api/v1/audit?action=api_call` showing per-key actor names (`Name` field of matched `NamedAPIKey`) on every call, with zero plaintext or hashed key material recorded.
- TLS certificate on control plane: `openssl s_client -connect {server}:8443` showing valid certificate, TLS 1.2+, strong cipher.
- GUI login flow: browser network tab showing Authorization header (token value redacted in compliance report).
**Operator Responsibility**:
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
- **Rotate API keys using zero-downtime rotation**`CERTCTL_AUTH_SECRET` supports comma-separated keys (e.g., `new-key,old-key`). Add the new key, migrate clients, then remove the old key. Recommendation: rotate at least annually, or immediately when personnel changes.
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
- **Monitor API audit trail** for suspicious access patterns (many 401 errors, access from unexpected IPs, etc.).
**Status**: **Available** (v1.0 shipped)
### 8.6 — Application Account Management
**Requirement**: Users' system access must be restricted to the minimum level of application functions or data needed to perform duties. Application accounts (non-human) must use strong authentication.
**certctl Support**:
- **No Application Account Management in v1** — Certctl does not manage user accounts (no user directory, LDAP, OIDC).
- All authentication via API key (service-to-service or human user with API key).
- No per-user roles or permissions (that's V3 RBAC feature).
- Single API key shared across team or one key per automation script (operator's responsibility to manage).
- **Credentials Not in Source Code** — Security hardening:
- API keys via `CERTCTL_API_KEY` env var (not in `main.go`, Dockerfile, `docker-compose.yml`).
- Database credentials via `CERTCTL_DATABASE_URL` in `.env` (git-ignored).
- CA private key path via `CERTCTL_CA_CERT_PATH`/`CERTCTL_CA_KEY_PATH` (not inline).
- **Service Account Isolation** (planned for V3) — Future RBAC will support:
- Automation script API keys with scoped permissions (e.g., read-only, renew-only, deploy-only).
- OIDC/SSO for human users with fine-grained role assignment (admin, operator, viewer).
- Audit trail showing which account/role performed each action.
**Evidence You Can Provide**:
- Deployment manifest (Dockerfile, docker-compose.yml) showing no hardcoded API keys, database credentials, or CA key paths.
- `.env` file existence (confirm via CI or compliance check, without sharing contents).
- `.gitignore` configuration showing `.env`, `*.key`, secrets excluded.
- Code review: grep `main.go`, `config.go` for `CERTCTL_API_KEY` — should only see env var reference, not hardcoded values.
**Operator Responsibility**:
- **Manage API keys externally** (issue, rotate, revoke).
- **Document who/what has API key access** (automation scripts, team members, third-party integrations).
- **Rotate application credentials** (API keys, database passwords) according to your organization's policy.
- **Segregate credentials** — one API key per automation script where possible, or use V3 RBAC scoping.
- **Monitor application account usage** via audit trail — `GET /api/v1/audit` filtered by action/actor.
**Status**: **Available in part** (v1.0: credentials out of source code). **Planned V3**: scoped API keys and RBAC.
---
## Requirement 10: Log and Monitor
**Objective**: Log and monitor access to network resources and cardholder data.
### 10.2 — Implement Automated Audit Logging
**Requirement**: Automatically log and monitor all access to system components and records containing cardholder data.
**certctl Support**:
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
- `audit_events` table (append-only, no UPDATE/DELETE):
- `method`: HTTP method (GET, POST, PUT, DELETE)
- `path`: API endpoint path only, excluding query parameters (e.g., `/api/v1/certificates` — query strings intentionally omitted to prevent sensitive data persistence in the append-only audit trail)
- `actor`: authenticated user/service (extracted from API key or context)
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
- `latency_ms`: request duration in milliseconds
- `timestamp`: RFC 3339 timestamp
- **Certificate Lifecycle Events** — Higher-level events logged separately:
- `certificate_issued` — new certificate created, issuer, profile, profile ID
- `certificate_renewed` — certificate renewed, old/new serial, renewal policy
- `certificate_revoked` — certificate revoked, RFC 5280 reason code
- `certificate_deployed` — certificate deployed to target, agent, target type
- `certificate_validated` — validation job result (success/failure reason)
- **Job Lifecycle Events** — Job status transitions:
- `job_created` — renewal/issuance/deployment/validation job created
- `job_status_updated` — job state change (Pending → AwaitingCSR → Running → Completed/Failed)
- **Policy and Configuration Events** — Administrative changes:
- `policy_created`, `policy_updated`, `policy_deleted` — renewal policy changes
- `profile_created`, `profile_updated`, `profile_deleted` — certificate profile changes
- `issuer_created`, `issuer_deleted` — CA connector registration changes
- **Excluded Paths** — Health/readiness probes not logged to reduce noise:
- `GET /health` (excluded by default)
- `GET /ready` (excluded by default)
- Configurable via `CERTCTL_AUDIT_EXCLUDE_PATHS` env var
**Evidence You Can Provide**:
- Audit trail export: `GET /api/v1/audit` or manual database query, showing sample events with timestamp, actor, action, resource.
- API call audit log: Query `audit_events` table showing method, path, actor, status code for last 24-48 hours.
- Configuration changes: `GET /api/v1/audit?type=policy_created,policy_updated,issuer_created` showing who changed what and when.
- Certificate lifecycle: `GET /api/v1/audit?resource_type=certificate&resource_id={cert_id}` showing complete issuance → deployment → renewal/revocation history.
**Operator Responsibility**:
- **Enable audit logging** — it's on by default; verify `CERTCTL_AUDIT_EXCLUDE_PATHS` is not set to exclude certificate-related paths.
- **Monitor audit log growth**`audit_events` table will grow with every API call. Recommend database maintenance (log rotation policy, archival after 90 days, etc.).
- **Export and archive audit logs** — periodically `SELECT * FROM audit_events WHERE timestamp > {date}` and export to secure storage (S3, syslog, SIEM).
- **Establish audit review procedure** — QSA may request sample of logs; have export process documented.
- **Test audit logging** — make API call, verify event appears in audit trail within seconds.
**Status**: **Available** (M19 shipped)
### 10.3 — Protect Audit Trail
**Requirement**: Promptly protect audit trail files from unauthorized modifications.
**certctl Support**:
- **Append-Only Database Design** — PostgreSQL triggers and constraints prevent modification:
- `audit_events` table has no `UPDATE` or `DELETE` triggers.
- Application code never executes UPDATE/DELETE on `audit_events`.
- Primary key is `id` (serial); new events always INSERT.
- **Read-Only API Access** — Audit events accessible only via read (`GET /api/v1/audit`):
- No `POST /api/v1/audit/{id}` endpoint (no creation from API).
- No `PUT /api/v1/audit/{id}` endpoint (no modification).
- No `DELETE /api/v1/audit/{id}` endpoint (no deletion).
- Only control plane can record events (via internal service layer, not exposed API).
- **Database Access Control** (operator responsibility) — PostgreSQL user permissions:
- `certctl` application user: INSERT, SELECT on `audit_events`.
- `certctl_read_only` user (for compliance/audit team): SELECT only on `audit_events`.
- `postgres` superuser: restricted to DBA operations, logged separately by PostgreSQL.
**Evidence You Can Provide**:
- Database schema: `\d audit_events` showing columns, primary key, no UPDATE/DELETE triggers.
- Application code review: `internal/service/audit.go` showing `RecordEvent(...)` as only INSERT operation.
- API endpoint audit: grep `internal/api/handler/audit*.go` or `internal/api/router/router.go` — no PUT/DELETE routes for events.
- PostgreSQL permissions: `psql -d certctl -c "\dp audit_events"` showing INSERT/SELECT grants only.
**Operator Responsibility**:
- **Restrict database access** — issue read-only PostgreSQL user for compliance/audit team (no write privileges).
- **Enable PostgreSQL query logging** — log all database connections and operations for DBA audit trail.
- **Backup audit logs** — regularly export `audit_events` to offsite storage (S3, archive tape, syslog aggregator) for long-term retention.
- **Monitor database modifications** — alert if any UPDATE/DELETE is attempted on `audit_events` (log-based alerting or PostgreSQL event triggers).
- **Encrypt audit exports** — if archiving to external storage, encrypt backups at rest.
**Status**: **Available** (v1.0 shipped)
### 10.4 — Promptly Review and Address Audit Trail Exceptions
**Requirement**: Promptly review audit logs and investigate exceptions/anomalies.
**certctl Support**:
- **Dashboard Charts** (M14) — Real-time observability:
- **Renewal Success Trends** (30-day line chart) — shows job success rate; spikes in failures warrant investigation.
- **Certificate Status Distribution** (donut chart) — shows Expiring/Expired counts; high Expired = missed renewals.
- **Expiration Timeline** (90-day weekly heatmap) — shows upcoming expirations; bunching = renewal policy tuning needed.
- **Issuance Rate** (30-day bar chart) — shows certificate creation/renewal activity; anomalies (zero issuances for weeks) indicate stopped automation.
- **Stats API** (M14) — Machine-readable trends:
- `GET /api/v1/stats/job-trends?days=30` — renewal/issuance/deployment success/failure counts per day.
- `GET /api/v1/stats/summary` — total certs, counts by status.
- `GET /api/v1/stats/expiration-timeline?days=90` — expiration buckets for forecasting.
- **Agent Fleet Overview** (M14) — Agent health visibility:
- Pie chart: agent status distribution (healthy, offline, error).
- Version breakdown: agent versions in use (identify outdated agents).
- Per-agent detail: last heartbeat timestamp, OS/architecture, IP address, recent jobs.
- **Alert Notifications** (M3, M16a) — Configurable escalation:
- Email alerts: certificate approaching expiration, renewal failure, revocation notification.
- Webhook: custom HTTP POST to your monitoring system (Slack, Teams, PagerDuty, OpsGenie, custom webhook).
- **Retry & Dead-Letter Queue** (I-005) — Transient notifier failures (SMTP timeout, webhook 5xx) are retried with exponential backoff (`2^n` minutes capped at 1h, 5-attempt budget) before landing in the terminal `dead` status. Operators monitor DLQ depth via the `certctl_notification_dead_total` Prometheus counter and requeue via the Notifications page Dead letter tab once the underlying outage is resolved. Closes the pre-I-005 silent-drop gap where a single 5xx could lose a compliance-relevant alert without evidence.
- Deduplication: one alert per threshold/certificate per day (avoid alert fatigue).
- **Audit Trail Filtering and Export** (M13) — Compliance reporting:
- `GET /api/v1/audit?actor={user}&timestamp_after={date}` — filter audit log by actor, timestamp, type.
- Export CSV/JSON via dashboard: audit page → select filters → "Export CSV" or "Export JSON".
- Can export full audit trail for QSA review.
**Evidence You Can Provide**:
- Dashboard screenshots: expiration timeline, renewal success trends, status distribution.
- Job trend report: `GET /api/v1/stats/job-trends?days=90` showing success/failure rates.
- Agent fleet health: `GET /api/v1/agents` showing heartbeat status, version count distribution.
- Audit log sample: `GET /api/v1/audit?limit=100` showing certificate issuance/renewal/revocation activity.
- Alert configuration: screenshot of renewal policy `alert_thresholds_days` (30, 14, 7, 0) and notifier settings (email, Slack, etc.).
**Operator Responsibility**:
- **Review dashboard charts weekly** — look for anomalies (high Expired count, failure spike, renewal stalled).
- **Respond to alerts promptly** — expiration alert = investigate renewal (check job logs, issuer connectivity, agent heartbeat).
- **Set alert thresholds appropriately** — default 30/14/7/0 days is a starting point; adjust per your SLA and staffing.
- **Maintain alert distribution list** — ensure alerts reach the right on-call engineer/team.
- **Archive and review audit logs** — export monthly/quarterly for compliance trending (e.g., "all certificate changes last quarter").
- **Test alert delivery** — trigger a test renewal failure or manual revocation, verify alert is sent.
**Status**: **Available** (v1.0 shipped, M14 observable charts, M19 audit log)
### 10.7 — Retain and Protect Audit Trail History
**Requirement**: Retain audit trail history for at least one year and ensure it can be retrieved.
**certctl Support**:
- **Immutable Audit Trail** (M19) — `audit_events` table stores all API calls and certificate lifecycle events with timestamps.
- **No Automatic Purge** — Certctl does not delete audit events. They remain in PostgreSQL indefinitely.
- **Queryable History** — All events accessible via `GET /api/v1/audit` with time range, actor, resource filters.
**Evidence You Can Provide**:
- Database retention policy: confirm `audit_events` table has no DELETE triggers or maintenance jobs that purge events.
- Sample audit query: `SELECT COUNT(*) FROM audit_events WHERE timestamp > NOW() - INTERVAL '365 days'` showing one year+ of events.
- Export procedure: documented process for exporting audit logs to cold storage (S3, archive tape, syslog).
**Operator Responsibility**:
- **Configure PostgreSQL backup/retention** — certctl relies on database backups for audit trail protection.
- Backup `audit_events` table daily or per your RPO/RTO.
- Retain backups for at least 1 year (configure retention policy on backup system).
- Test restore procedure annually.
- **Export and archive audit logs** — periodically export `SELECT * FROM audit_events WHERE timestamp > {start_date}` to offsite storage.
- Recommendation: monthly exports to S3 with versioning enabled.
- Encrypt exports at rest.
- Retain archives for at least 3 years (adjust per your compliance requirements).
- **Monitor audit log growth**`audit_events` table will grow ~1-5 MB/day depending on API call volume.
- Estimate: 10,000 API calls/day = ~50 MB/month.
- Plan PostgreSQL storage and backup capacity accordingly.
**Status**: **Available** (v1.0 shipped)
---
## Requirement 6: Develop and Maintain Secure Systems and Applications
**Objective**: Develop and maintain secure systems and applications.
### 6.3.1 — Security Coding Practices
**Requirement**: Develop all custom application code in accordance with secure coding practices and include authentication, access control, input validation, and error handling.
**certctl Support**:
- **Input Validation** — Centralized validators enforce strong input constraints:
- Common name: max 253 chars, DNS-safe characters only, no leading/trailing hyphens.
- CSR PEM: must be valid PEM format (regex validation).
- Policy type: whitelist enum (Issuance, Renewal, Revocation, etc.).
- API key: alphanumeric + hyphens only.
- Implemented in `internal/domain/validation.go` and called from all handler layer inputs.
- **Error Handling** — No sensitive data leakage in error responses:
- HTTP 500 errors return generic "Internal Server Error" message, not stack trace.
- Database errors logged internally (structured slog), not exposed to client.
- 404 errors do not reveal whether resource exists (consistent "Not Found" regardless of auth vs. not-found).
- **No Hardcoded Credentials** — All secrets via environment variables:
- `CERTCTL_API_KEY`, `CERTCTL_DATABASE_URL`, `CERTCTL_CA_KEY_PATH` — env vars only.
- Credentials not in `main.go`, Dockerfile, `docker-compose.yml`, or Git history.
- `.env` file git-ignored and excluded from version control.
- **Dependency Management** — Go module pinning (`go.mod`):
- All external dependencies pinned to specific versions.
- No wildcard versions or `latest` tags.
- CI runs `go mod verify` to detect tampering.
**Evidence You Can Provide**:
- Code review: `internal/domain/validation.go` showing input validation functions (Common name length, CSR PEM, policy type, etc.).
- Error handling audit: `internal/api/handler/certificates.go` showing HTTP error responses (no stack traces).
- Credentials in source code check: `grep -r "CERTCTL_API_KEY\|DATABASE_URL\|CA_KEY" cmd/ internal/ | grep -v ".env"` (should only show env var references, not values).
- `go.mod` review: no wildcard versions, all pinned.
- CI workflow: `.github/workflows/ci.yml` showing `go mod verify` step.
**Operator Responsibility**:
- **Review dependency updates** — keep Go version current, update certctl dependencies regularly (security patches).
- **Scan container images** — use Trivy, Clair, or similar to scan Docker images for known vulnerabilities.
- **Maintain secure coding practices** in any custom issuer/target connectors you deploy (scripts for OpenSSL, BASH/PowerShell for IIS/F5).
**Status**: **Available** (v1.0 shipped)
### 6.5.10 — Broken Authentication and Cryptography Prevention
**Requirement**: Prevent broken authentication and cryptography weaknesses.
**certctl Support**:
- **Authentication** — API key with SHA-256 hashing, constant-time comparison (`crypto/subtle.ConstantTimeCompare`).
- **Cryptography** — Go's `crypto/*` standard library (no weak ciphers). ECDSA P-256, RSA 2048+.
- **TLS** — HTTPS enforced (no plaintext HTTP endpoints).
- **No Sessions** — Stateless API (no session cookies, no session fixation risk).
**Status**: **Available** (v1.0 shipped)
---
## Requirement 7: Restrict Access by Business Need-to-Know
**Objective**: Limit access to system components and cardholder data by business need-to-know and ensure users are authenticated and authorized.
### 7.2 — Implement Access Control
**Requirement**: Ensure proper user identity management and implement access controls based on business need-to-know.
**certctl v1 Support** (limited):
- **Certificate Ownership** (M11b) — Each certificate assigned to owner (person + email) and optional team. Ownership is metadata; access control is not enforced at API level.
- **Agent Groups** (M11b) — Renewal policies target specific agent groups (OS, architecture, CIDR, version). Groups are used for policy targeting, not user access control.
- **Interactive Approval** (M11b) — `AwaitingApproval` job state allows manual approval/rejection of renewals (enforcement of business workflows, not user access control).
**certctl v3 Support** (planned):
- **OIDC/SSO** — Okta, Azure AD, Google integration. Users log in via identity provider.
- **Role-Based Access Control (RBAC)** — Three roles: admin (all operations), operator (issue/renew/deploy), viewer (read-only). Roles assigned via OIDC claims or group membership.
- **Profile/Owner Gating** — Operator can renew only certificates assigned to their team; viewer cannot modify anything.
- **Audit Trail Attribution** — Every action shows which user/role performed it.
**Evidence You Can Provide** (v1):
- Certificate ownership mapping: `GET /api/v1/certificates` showing owner, team fields (metadata only; access not controlled).
- Agent group targeting: `GET /api/v1/policies` showing `agent_group_id` field.
- Interactive approval workflow: job detail showing `AwaitingApproval` state, approve/reject endpoints in API docs.
**Operator Responsibility** (v1):
- **Manage API key distribution** externally — only issue API keys to authorized users/systems.
- **Implement reverse proxy auth** (Nginx, Apache, Okta proxy) in front of certctl to enforce OIDC/LDAP (outside certctl).
- **Plan for V3 RBAC** — budget for upgrade when finer-grained access control is needed.
**Planned** (V3):
- Upgrade to certctl Pro with OIDC/RBAC and per-role audit trail.
**Status**: **Available in part** (v1.0: ownership metadata, agent group targeting). **Planned V3**: OIDC/RBAC enforcement.
---
## Evidence Summary Table
| PCI-DSS Requirement | certctl Feature | API/UI Evidence | Database/Config | Audit Trail | Status |
|---|---|---|---|---|---|
| **4.2.1** Strong Crypto | TLS cert issuance, ACME/step-ca/Local CA, RSA 2048+/ECDSA P-256 | `GET /api/v1/certificates` (key_type, key_size) | Certificate profiles | `GET /api/v1/audit?type=certificate_issued` | Available |
| **4.2.2** Cert Inventory & Validation | Managed cert CRUD, discovery (M18b), expiration alerting, CRL/OCSP | `GET /api/v1/certificates`, `GET /api/v1/discovered-certificates`, `GET /.well-known/pki/crl/{issuer_id}`, `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (both unauthenticated, RFC 5280 / RFC 6960) | `managed_certificates`, `discovered_certificates` tables | `GET /api/v1/audit?type=certificate_*` | Available |
| **3.6** Key Documentation | Profiles, owner/team tracking, issuer config, audit trail | `GET /api/v1/profiles`, `GET /api/v1/issuers`, certificate detail with owner/team | Profiles, certificate owner/team fields, issuer config | `GET /api/v1/audit?resource_type=certificate` | Available |
| **3.7.1** Key Generation | Agent-side ECDSA P-256, server keygen (demo only) | Agent logs, renewal job detail, CSR audit | `CERTCTL_KEYGEN_MODE=agent` (config), job_type=AwaitingCSR | `GET /api/v1/audit?type=certificate_issued` with CSR hash | Available |
| **3.7.2** Key Storage | Agent `/var/lib/certctl/keys` (0600), env var secrets, .env excluded | Deployment manifest (env var refs), agent key dir listing | `.env` file (git-ignored), `CERTCTL_KEY_DIR`, `CERTCTL_CA_KEY_PATH` | No API audit (keys off-platform) | Available |
| **3.7.3** Key Rotation | Auto renewal, expiration thresholds, renewal jobs | Dashboard renewal trends, `GET /api/v1/jobs?type=Renewal`, certificate versions | Renewal policies, certificate version history | `GET /api/v1/audit?type=certificate_renewed` | Available |
| **3.7.4** Key Destruction | Revocation API (RFC 5280), CRL/OCSP, private key cleanup | `POST /api/v1/certificates/{id}/revoke`, unauthenticated `GET /.well-known/pki/crl/{issuer_id}` and `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` | `certificate_revocations` table, CRL publication | `GET /api/v1/audit?type=certificate_revoked` | Available |
| **8.3** Strong Authentication | API key (SHA-256 hash, TLS), GUI login, 401 redirect | GUI login screenshot, API key auth header, TLS cert | API key hash in database | `GET /api/v1/audit` showing API calls | Available |
| **8.6** Acct Management | Credentials out of source, .env excluded, env var config | Code review (no hardcoded secrets), `.gitignore` check | Deployment manifests showing env var refs only | No account lifecycle audit (outside scope) | Available in part |
| **10.2** Audit Logging | API audit middleware (M19), certificate lifecycle events | `GET /api/v1/audit` with filter/pagination | `audit_events` table (every API call) | Real-time via API | Available |
| **10.3** Audit Protection | Append-only table design, read-only API, DB permissions | API endpoint audit (no PUT/DELETE on events), DB schema | `audit_events` table, PostgreSQL GRANT SELECT | Immutable by design | Available |
| **10.4** Review & Alert | Dashboard charts, stats API, notifier integrations | Dashboard (renewal trends, status pie, expiration heatmap), `GET /api/v1/stats/*` | Job results, alert config in policies | `GET /api/v1/audit?type=job_*` | Available |
| **10.7** Retention | 1+ year in PostgreSQL, export/archive procedures | Database query `SELECT COUNT(*) FROM audit_events WHERE timestamp > NOW() - INTERVAL '1 year'` | `audit_events` table retention (no auto-delete) | Manual export/archival (operator) | Available |
| **6.3.1** Secure Coding | Input validation, error handling, no hardcoded secrets, dependency pinning | Code review (validation.go, handlers), error responses | `go.mod` with pinned versions, `.gitignore` | GitHub Actions CI with `go mod verify` | Available |
| **7.2** Access Control | Ownership metadata, agent groups, interactive approval | `GET /api/v1/certificates` (owner/team), `GET /api/v1/agent-groups` | Certificate owner/team fields, agent group criteria | User identity from auth context | Available in part (V3: RBAC) |
---
## Operator Responsibilities
The following control objectives are **outside certctl's scope** and must be managed by your organization:
| Control Objective | Responsibility | Example Actions |
|---|---|---|
| **Network Segmentation** | Isolate certctl control plane from cardholder network | Place certctl on separate VLAN, firewall rules |
| **Physical Security** | Restrict access to servers/databases | Data center access controls, logging |
| **Personnel Screening** | Background checks for staff with access | HR/employment verification |
| **Access Control Enforcement** | User authentication & authorization outside API | Implement reverse proxy with OIDC (V3: use certctl Pro RBAC) |
| **Incident Response** | Procedures for certificate compromise or breach | Document key revocation process, alert escalation |
| **Disaster Recovery** | Backup and restore procedures | Database backup schedule, offsite replication |
| **Change Management** | Approval process for config/cert changes | CAB meetings, documented procedures |
| **Vulnerability Scanning** | ASV scanning, penetration testing, code review | Annual PCI-DSS penetration test |
| **Key Backup & Escrow** | Secure offline storage of CA private keys (if required) | Hardware security module (HSM) or encrypted vault |
| **Audit Log Retention** | Long-term archival and protection of audit logs | Export to S3/syslog, retain 3+ years |
| **QSA Engagement** | Schedule and coordination of compliance assessment | Annual audit with qualified security assessor |
---
## V3 Enhancements for PCI-DSS
Certctl v3 (Pro) adds paid features that strengthen PCI-DSS compliance posture:
| Feature | PCI-DSS Benefit |
|---|---|
| **OIDC/SSO Authentication** | Centralized identity management, audit integration with corporate directory |
| **Role-Based Access Control (RBAC)** | Least-privilege enforcement: admin, operator, viewer roles with profile/team gating |
| **Bulk Revocation by Profile/Owner/Agent** | Rapid incident response (revoke all certs in cardholder network in minutes) |
| **NATS Event Bus with JetStream Audit Streaming** | Real-time event streaming to SIEM (Splunk, ELK, Datadog) for centralized audit trail |
| **Certificate Health Scores** | Proactive risk identification (composite scoring: expiration proximity, rotation age, key strength) |
| **Advanced Search DSL** | Complex audit queries (POST /search with nested AND/OR, regex, field projection) for compliance reporting |
| **CT Log Monitoring** | Detect unauthorized certificate issuance (security vulnerability detection) |
| **DigiCert Issuer Connector** | Enterprise CA integration for compliance audits |
---
## Next Steps for Compliance
1. **Review this mapping with your QSA** — Confirm which requirements apply to your cardholder data environment.
2. **Configure certctl for your environment**:
- Set `CERTCTL_KEYGEN_MODE=agent` in production.
- Define certificate profiles with approved key types.
- Configure renewal policies with appropriate thresholds (e.g., 30 days for 90-day certs).
- Enable notifier integrations (email, Slack, PagerDuty) for alerts.
- Plan `CERTCTL_DISCOVERY_DIRS` on agents to scan all certificate locations.
3. **Implement operator controls**:
- Document certificate management procedures (issuance, renewal, revocation, archival).
- Establish API key rotation schedule.
- Set up audit log export and archival (monthly to S3, retain 1+ year).
- Configure PostgreSQL backups (daily, 1+ year retention).
- Plan incident response (who revokes certs, escalation process, timeline).
4. **Test compliance readiness**:
- Trigger a test renewal and verify CRL/OCSP publication.
- Export audit trail and verify it shows expected events.
- Test revocation workflow and confirm OCSP reflects status within 24 hours.
- Run discovery scan and verify unknown certs are detected and triaged.
5. **Prepare evidence for QSA**:
- API endpoint documentation (OpenAPI spec: `api/openapi.yaml`).
- Audit log sample (last 90 days of events).
- Configuration export (profiles, policies, issuer/target definitions).
- Deployment manifest (showing env var config, no hardcoded secrets).
- Test certificates and CRL/OCSP query results.
6. **Plan for V3** (if RBAC/centralized audit required):
- Evaluate certctl Pro for OIDC/SSO and NATS audit streaming.
- Assess integration with existing identity provider (Okta, Azure AD, etc.).
---
## Questions?
For additional guidance on certctl features and PCI-DSS mapping:
- Review the [Architecture Guide](architecture.md) for system design.
- Check [Connectors Documentation](connectors.md) for issuer/target/notifier capabilities.
- Run the [Quick Start Guide](quickstart.md) to see features in action.
- Consult your QSA for final compliance determination.
**Last Updated**: March 24, 2026 (certctl v1.0 with M18b discovery and M19 audit logging)
-587
View File
@@ -1,587 +0,0 @@
# SOC 2 Type II Compliance Mapping
This guide maps certctl's implemented features to AICPA SOC 2 Trust Service Criteria (TSC). It is **not a SOC 2 certification claim** — rather, it helps security engineers, auditors, and evaluators understand how certctl supports your organization's SOC 2 compliance posture. Use this as evidence input for your own control assessment during SOC 2 audits.
## How to Use This Guide
SOC 2 audits require evidence that your infrastructure meets specific Trust Service Criteria. Auditors ask: "Does your certificate management tooling support CC6.1 logical access controls?" This guide answers by mapping certctl's features to specific criteria and pointing to evidence (API endpoints, configuration, audit trail).
Each section includes:
- **The TSC requirement** — what the auditor is looking for
- **certctl's implementation** — which features address it
- **Evidence location** — where to find proof (API endpoint, config variable, source code, audit events)
- **V2 vs V3 status** — whether feature is in the free community edition (V2) or paid Pro edition (V3)
- **Operator responsibility** — aspects your organization must handle outside of certctl
## Contents
1. [How to Use This Guide](#how-to-use-this-guide)
2. [CC6: Logical and Physical Access Controls](#cc6-logical-and-physical-access-controls)
- [CC6.1 — Logical Access Security](#cc61--logical-access-security)
- [CC6.2 — Prior to Issuing System Credentials](#cc62--prior-to-issuing-system-credentials)
- [CC6.3 — Authentication Policies](#cc63--authentication-policies)
- [CC6.7 — Information Transmission Protection](#cc67--information-transmission-protection)
3. [CC7: System Operations](#cc7-system-operations)
- [CC7.1 — System Monitoring](#cc71--system-monitoring)
- [CC7.2 — Anomaly Detection](#cc72--anomaly-detection)
- [CC7.3 — Incident Response](#cc73--incident-response)
- [CC7.4 — Identify and Develop Risk Mitigation Activities](#cc74--identify-and-develop-risk-mitigation-activities)
4. [A1: Availability](#a1-availability)
- [A1.1/A1.2 — Availability and Recovery](#a11a12--availability-and-recovery)
5. [CC8: Change Management](#cc8-change-management)
- [CC8.1 — Change Control](#cc81--change-control)
6. [Evidence Summary Table](#evidence-summary-table)
7. [What Requires Operator Action](#what-requires-operator-action)
8. [V3 Enhancements](#v3-enhancements)
9. [Conclusion](#conclusion)
## CC6: Logical and Physical Access Controls
### CC6.1 — Logical Access Security
**Requirement**: The entity restricts logical access to digital and information assets and related facilities by applying user identity authentication, registration, access rights, and usage policies.
**certctl Implementation** (V2 — Community Edition):
- **API Key Authentication** — All `/api/v1/*` calls require a Bearer token (hashed with SHA-256, stored securely, validated with constant-time comparison) or are rejected with 401 Unauthorized. Environment: `CERTCTL_AUTH_TYPE` (default `api-key`; `none` requires explicit opt-in with log warning)
- **Standards-based enrollment and PKI distribution endpoints** — EST (`/.well-known/est/*`, RFC 7030), SCEP (`/scep`, `/scep/*`, RFC 8894), and CRL/OCSP (`/.well-known/pki/crl/{issuer_id}`, `/.well-known/pki/ocsp/{issuer_id}/{serial}`, RFC 5280 §5 / RFC 6960 / RFC 8615) are served unauthenticated at the HTTP layer because these protocols cannot present certctl Bearer tokens. Authentication is enforced in-protocol: EST relies on CSR signature verification plus profile policy (RFC 7030 §3.2.3 says EST auth is deployment-specific; §4.1.1 makes `/cacerts` explicitly anonymous); SCEP requires a shared `challengePassword` in the PKCS#10 CSR attributes (OID 1.2.840.113549.1.9.7, RFC 8894 §3.2), validated with `crypto/subtle.ConstantTimeCompare`; CRL and OCSP are intentionally anonymous for relying-party accessibility. CWE-306 (missing authentication for a critical function) is closed for SCEP by `preflightSCEPChallengePassword` in `cmd/server/main.go`, which refuses to start the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes these prefixes through `noAuthHandler` (RequestID + structuredLogger + Recovery only, no auth or rate-limit middleware) and is pinned by the 27-subtest regression harness at `cmd/server/finalhandler_test.go`.
- **GUI Authentication** — Web dashboard includes login screen requiring API key entry. Failed auth redirects to login on 401. Auth context persists across page navigation. Logout clears session.
- **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows.
- **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks.
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
- **Zero-Downtime Key Rotation**`CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `new-key,old-key`). All listed keys are validated with constant-time comparison. Operators can add a new key, migrate clients, then remove the old key — no service restart required for the client migration phase. A single-key warning is logged at startup to encourage rotation configuration.
**Evidence Locations**:
- API auth implementation: `internal/api/middleware/auth.go`
- Auth check endpoint: `GET /api/v1/auth/check` (validates credentials)
- Auth info endpoint: `GET /api/v1/auth/info` (returns current auth mode, served without auth so GUI detects mode)
- Rate limiting middleware: `internal/api/middleware/rate_limit.go`
- CORS configuration: `cmd/server/main.go`, search for `CERTCTL_CORS_ORIGINS`
- Final handler dispatch (authenticated vs. unauthenticated routing): `cmd/server/main.go:buildFinalHandler`
- SCEP preflight gate (CWE-306 closure): `cmd/server/main.go:preflightSCEPChallengePassword`
- SCEP service-layer defense-in-depth (rejects enrollment on empty challenge password, `crypto/subtle.ConstantTimeCompare`): `internal/service/scep.go`
- Final handler dispatch regression harness (27 subtests): `cmd/server/finalhandler_test.go`
- OpenAPI spec `security: []` overrides on unauthenticated paths: `api/openapi.yaml` (EST `/cacerts`, `/simpleenroll`, `/simplereenroll`, `/csrattrs`; SCEP `/scep` GET+POST; PKI `/crl/{issuer_id}`, `/ocsp/{issuer_id}/{serial}`)
**V3 Enhancement**:
- **OIDC / SSO Integration** — Optional OIDC providers (Okta, Azure AD, Google) with multi-tenant support. API key fallback for service accounts.
- **API Key Scoping** — Per-resource or per-action permissions (e.g., "read certificates from production only" or "issue certs, no revoke")
**Operator Responsibility**:
- Generate and securely distribute API keys to authorized users and systems
- Rotate API keys regularly (recommend quarterly)
- Revoke API keys immediately upon employee departure
- Do not commit API keys to version control (use `.env` or secrets management)
- Implement your own IP allowlisting at the firewall if needed (certctl enforces CORS at the HTTP layer, not at network layer)
---
### CC6.2 — Prior to Issuing System Credentials
**Requirement**: The entity provisions, modifies, disables, and removes user identities and rights based on an authorization process that considers user responsibility level and changes in those responsibilities.
**certctl Implementation** (V2):
- **Ownership Attribution** — Certificates can be assigned to an owner (email + name). Owner information is stored and audited (see CC7.2). Ownership is tracked through the lifecycle (issuance, renewal, deployment, revocation). Ownership reassignment is audited via the immutable audit trail.
- **Team Assignment** — Owners can be organized into teams. Certificate policies can route notifications to team email addresses.
- **Audit Trail Attribution** — Every API call records the actor (extracted from the API key or auth context). The audit trail is immutable — no retroactive modification of who did what.
**Evidence Locations**:
- Ownership domain model: `internal/domain/certificate.go` (OwnerID field)
- Owner CRUD API: `GET /api/v1/owners`, `POST /api/v1/owners`, `DELETE /api/v1/owners/{id}`
- Team CRUD API: `GET /api/v1/teams`, `POST /api/v1/teams`, `DELETE /api/v1/teams/{id}`
- Audit trail API: `GET /api/v1/audit` (actor field in every record)
**V3 Enhancement**:
- **RBAC (Role-Based Access Control)** — Predefined roles (Admin, Operator, Viewer) with profile-gated permissions. Administrators manage role assignments.
**Operator Responsibility**:
- Map certctl's ownership model to your organizational structure (departments, teams, on-call rotations)
- Establish a formal access request and approval process
- Remove ownership access when team members depart
- Document your access review process (audit trail shows *who* made changes, but you must justify *why*)
---
### CC6.3 — Authentication Policies
**Requirement**: The entity determines, documents, communicates, and enforces authentication policies that support the identification and authentication of authorized internal and external users and the transmission of user credentials.
**certctl Implementation** (V2):
- **API Key Policy** — All `/api/v1/*` access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup. The standards-based enrollment and PKI distribution endpoints (EST, SCEP, CRL, OCSP) are served unauthenticated at the HTTP layer per their respective RFCs; see CC6.1 for the full authentication contract and CWE-306 closure via `preflightSCEPChallengePassword`.
- **Agent Authentication** — Agents authenticate to the server via API keys (same mechanism as users). Agent credentials are separate from user API keys.
- **Private Key Policy** — Agent-side key generation is the default (`CERTCTL_KEYGEN_MODE=agent`). Server-side keygen (`CERTCTL_KEYGEN_MODE=server`) requires explicit configuration and logs a warning: "server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only".
- **Password Policy** — Not applicable; certctl uses API keys exclusively. Password management is delegated to your organization's IAM system if you integrate OIDC/SSO (V3).
**Evidence Locations**:
- Auth type configuration: `internal/config/config.go`, `CERTCTL_AUTH_TYPE` env var
- Startup logging: `cmd/server/main.go` (logs auth mode at server startup)
- Keygen mode configuration: `internal/config/config.go`, `CERTCTL_KEYGEN_MODE` env var
- Keygen mode warning: `cmd/server/main.go` and `cmd/agent/main.go`
**V3 Enhancement**:
- **OIDC Policy** — Mandatory MFA when OIDC is enabled
- **API Key Expiration** — Automatic key rotation policies (e.g., 90-day expiration for user keys, no expiration for long-lived service account keys)
**Operator Responsibility**:
- Document your API key generation and distribution policy
- Establish a formal change control process for auth configuration changes
- Test authentication failures (e.g., expired keys, malformed tokens) in a non-production environment
- Integrate certctl authentication into your organization's IAM audit reports (who has API keys, when were they issued, who has revoked them)
---
### CC6.7 — Information Transmission Protection
**Requirement**: The entity restricts the transmission, movement, and removal of information in a manner that prevents unauthorized disclosure, whether through digital or non-digital means.
**certctl Implementation** (V2):
- **TLS for Control Plane** — All API communication occurs over HTTPS (TLS 1.2+). Server uses `tls.Dial()` for outbound connections to issuers and targets. Configuration: `CERTCTL_SERVER_HOST` (default `127.0.0.1`) + `CERTCTL_SERVER_PORT` (default `8080`; Docker Compose maps to `8443`).
- **Agent-to-Server Communication** — Agents submit CSRs and heartbeats over HTTPS to the server using the same TLS stack.
- **Private Key Isolation** — Agents generate ECDSA P-256 private keys locally (`crypto/ecdsa` + `crypto/elliptic`). Private keys are never transmitted to the server — agents submit CSRs only. Private keys are stored on agent filesystem (`CERTCTL_KEY_DIR`, default `/var/lib/certctl/keys`) with 0600 (owner read/write only) permissions. Server-side keygen mode logs a development warning; production must use agent-side keygen.
- **Certificate Storage** — Signed certificates are stored in PostgreSQL as PEM text (along with metadata). Certificates are not secrets and may be transmitted plaintext. Private keys are never stored on the control plane in production (agent-side keygen mode).
- **Deployment via Target Connectors** — Target connectors write certificates and keys to local filesystem or network appliance APIs. For NGINX/Apache httpd, files are written with restrictive permissions (0600 for keys). For F5/IIS (V3+), credentials are scoped to a proxy agent in the same network zone — the server never holds network appliance credentials.
**Evidence Locations**:
- TLS configuration: deploy certctl behind a TLS-terminating reverse proxy (NGINX, HAProxy, or cloud load balancer) or use a TLS sidecar
- Agent keygen mode: `cmd/agent/main.go` (ECDSA key generation, filesystem storage with 0600)
- Private key handling: `internal/connector/target/nginx/nginx.go` and similar (cert/key file write)
- Server-side keygen deprecation: `internal/service/renewal.go` (log warning when enabled)
**V3 Enhancement**:
- **Hardware Security Module (HSM) Support** — Optional HSM backend for CA key storage (SubCA and Local CA modes)
- **Secrets Rotation** — Encrypted key rotation without server restart
**Operator Responsibility**:
- Enable TLS on the control plane in production (deploy behind a TLS-terminating reverse proxy or load balancer with valid certificates)
- Enforce TLS on agent-to-server communication via firewall rules (no cleartext HTTP)
- Protect agent filesystem key storage with:
- File-level permissions (already 0600)
- Encrypted filesystems (LUKS, BitLocker, or cloud provider equivalents)
- Backup encryption (keys backed up to vault or HSM, never in cleartext backups)
- Restrict PostgreSQL access to authorized services only (network isolation, authentication)
- For target systems, ensure network traffic from agents to targets is encrypted (TLS, IPsec, or VPN)
---
## CC7: System Operations
### CC7.1 — System Monitoring
**Requirement**: The entity monitors system components and the operation of those components for anomalies that are indicative of malfunction, including the implementation of monitoring tools, the reporting of results of those monitoring activities, and the identification, documentation, analysis, and resolution of system anomalies.
**certctl Implementation** (V2):
- **Health Endpoint**`GET /health` returns 200 OK with service status. Consumed by Docker health checks and Kubernetes probes.
- **Readiness Endpoint**`GET /ready` returns 200 OK when the database is connected and migrations are applied.
- **Background Scheduler Monitoring** — 12 background loops (8 always-on + 4 opt-in) run on a fixed schedule. Authoritative topology in `docs/architecture.md`:
- Renewal loop (always-on, 1 hour): scans for certificates approaching renewal threshold
- Job processor loop (always-on, 30 seconds): picks up pending/waiting jobs and advances their state
- Job retry loop (always-on, 5 minutes, `CERTCTL_SCHEDULER_RETRY_INTERVAL`): retries Failed jobs (I-001)
- Job timeout reaper loop (always-on, 10 minutes, `CERTCTL_JOB_TIMEOUT_INTERVAL`): fails AwaitingCSR/AwaitingApproval jobs past timeout (I-003)
- Agent health check loop (always-on, 2 minutes): pings agents to detect downtime
- Notification dispatcher loop (always-on, 1 minute): sends queued alerts
- Notification retry loop (always-on, 2 minutes, `CERTCTL_NOTIFICATION_RETRY_INTERVAL`): exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005)
- Short-lived cert expiry loop (always-on, 30 seconds): marks expired short-lived credentials
- Network scanner loop (opt-in, 6 hours, `CERTCTL_NETWORK_SCAN_ENABLED`): scans enabled TLS endpoints for certificate discovery
- Digest emailer loop (opt-in, 24 hours, `CERTCTL_DIGEST_INTERVAL`): sends scheduled certificate digest email to configured recipients
- Endpoint health loop (opt-in, 60 seconds, `CERTCTL_HEALTH_CHECK_INTERVAL`): continuous TLS health probes (M48)
- Cloud discovery loop (opt-in, 6 hours, `CERTCTL_CLOUD_DISCOVERY_INTERVAL`): cloud secret manager certificate discovery (M50)
Each loop includes `atomic.Bool` idempotency guards, error handling, and structured slog failure logs.
- **Metrics Endpoints** — Two formats for monitoring integration:
- `GET /api/v1/metrics` — JSON object with gauges, counters, and uptime for custom dashboards
- `GET /api/v1/metrics/prometheus` — Prometheus exposition format (`text/plain; version=0.0.4`) for native scraping by Prometheus, Grafana Agent, Datadog, and other OpenMetrics-compatible collectors
- **Gauges**`certctl_certificate_total`, `certctl_certificate_active`, `certctl_certificate_expiring`, `certctl_certificate_expired`, `certctl_certificate_revoked`, `certctl_agent_total`, `certctl_agent_active`, `certctl_job_pending`
- **Counters**`certctl_job_completed_total`, `certctl_job_failed_total`
- **Uptime**`certctl_uptime_seconds` (seconds since server start)
All values are point-in-time snapshots computed from database tables.
- **Structured Logging** — All scheduler operations, API calls, and connector actions log via `slog` (Go's structured logger). Logs include timestamp, level (DEBUG/INFO/WARN/ERROR), structured fields (e.g., `actor`, `resource_id`, `latency_ms`), and request IDs for tracing.
- **Request ID Propagation** — Each HTTP request gets a unique ID (`X-Request-ID` header). The ID is included in all correlated logs, making it easy to trace a single request through multiple service layers.
**Evidence Locations**:
- Health/readiness endpoints: `internal/api/handler/health.go`
- Background scheduler: `internal/scheduler/scheduler.go` (Start method)
- Metrics endpoint: `internal/api/handler/metrics.go`
- Stats API endpoints (for detailed time-series): `internal/api/handler/stats.go`
- `GET /api/v1/stats/summary` — dashboard KPIs
- `GET /api/v1/stats/certificates-by-status` — cert counts by status
- `GET /api/v1/stats/expiration-timeline?days=N` — cert expiry distribution
- `GET /api/v1/stats/job-trends?days=N` — job completion/failure rates
- `GET /api/v1/stats/issuance-rate?days=N` — cert issuance volume
- Structured logging middleware: `internal/api/middleware/middleware.go`
**Operator Responsibility**:
- Configure log aggregation (e.g., ELK, Datadog, Splunk) to centralize certctl logs
- Set up alerting on scheduler loop failures (e.g., "renewal loop failed to complete within 2h")
- Configure health check monitoring (e.g., Prometheus scrape of `/health` and `/ready`)
- Establish thresholds for metrics (e.g., alert if `pending_jobs > 50` or `agents_healthy < total_agents`)
- Document your log retention policy (audit requirement often mandates 1+ years)
- Integrate certctl metrics into your broader observability stack (Grafana dashboards, SLO tracking)
---
### CC7.2 — Anomaly Detection
**Requirement**: The entity monitors system components and the operation of those components for anomalies that are indicative of malfunction, including the implementation of monitoring tools, the reporting of results of those monitoring activities, and the identification, documentation, analysis, and resolution of system anomalies.
(This criterion overlaps CC7.1 and extends it to specific anomaly response mechanisms.)
**certctl Implementation** (V2):
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, URL path (query parameters intentionally excluded — see security note), actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp. **Security: Query parameters are excluded from the audit path** because they may contain cursor tokens, API keys, or sensitive filter values; since the audit trail is append-only with no deletion, any sensitive data recorded would persist permanently.
- **Audit Trail API**`GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
- **Notification Routing** — Alerts are sent via configured notifiers (Email, Slack, Teams, PagerDuty, OpsGenie). Certificates are routed to their owner's email address (or team email if no individual owner). This allows on-call teams to react to anomalies (e.g., "your production cert will expire in 7 days, request renewal now").
- **Deployment Rollback** — If a deployment fails or an older certificate needs to be reactivated, operators can trigger a "rollback" via the GUI. This redeploys a previous certificate version to the target. Rollback actions are audited.
**Evidence Locations**:
- Audit middleware: `internal/api/middleware/audit.go`
- Audit trail API: `internal/api/handler/audit.go`, `GET /api/v1/audit`
- Expiration alerting: `internal/service/renewal.go` (CheckRenewal method)
- Notification dispatcher: `internal/scheduler/scheduler.go` (notificationTicker)
- Status transitions: `internal/service/certificate.go` (auto status update logic)
- Audit trail CLI export: `certctl-cli audit export --format csv` / `--format json`
**V3 Enhancement**:
- **SIEM Export** — Real-time audit event streaming to SIEM systems (via NATS event bus with JetStream sink)
- **Anomaly Rules Engine** — Configurable rules (e.g., "alert if certificate revoked by non-admin", "alert if >10 certs issued in < 1 hour")
**Operator Responsibility**:
- Integrate audit trail into your SIEM / log analysis platform
- Define alerting rules and thresholds for anomalies (e.g., "revocation of critical cert", "mass issuance")
- Establish a formal incident response workflow (audit trail shows *what* happened; you must decide *what to do* about it)
- Regularly review audit logs (e.g., monthly compliance audit of who accessed what)
- Configure email/Slack/Teams integration so on-call teams are notified of cert expirations immediately
- Encrypt audit trail backups (ACID guarantees don't prevent theft of database backups)
---
### CC7.3 — Incident Response
**Requirement**: The entity detects, investigates, and responds to incidents by executing a defined incident response and management process that includes preparation, detection and analysis, containment, eradication, recovery, and post-incident activities.
**certctl Implementation** (V2):
- **Revocation API**`POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes:
- `unspecified` — catch-all
- `keyCompromise` — private key was exposed
- `caCompromise` — CA itself was compromised (rare)
- `affiliationChanged` — certificate no longer applies to the organization
- `superseded` — newer cert is in use
- `cessationOfOperation` — service is shutting down
- `certificateHold` — temporary revocation (can be "unhold" by reissue)
- `privilegeWithdrawn` — access rights revoked
Revocation is **immediate** (no approval workflow). The certificate is marked `Revoked` in inventory, an audit event is logged, and optional issuer notification is best-effort. All revoked certs are excluded from active deployments.
- **CRL Endpoint**`GET /.well-known/pki/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`), served unauthenticated for relying parties that don't hold certctl API credentials.
- **OCSP Responder**`GET /.well-known/pki/ocsp/{issuer_id}/{serial}` returns a signed OCSP response indicating whether a cert is good, revoked, or unknown (RFC 6960, `Content-Type: application/ocsp-response`). Also unauthenticated. Clients (browsers, TLS libraries) query this endpoint to verify cert validity in real-time.
- **Revocation Notifications** — When a cert is revoked, notifications are sent to:
- Certificate owner (email)
- Configured webhooks (if you have a SIEM that subscribes)
- Slack/Teams channels (if notifiers are configured)
- **Bulk Revocation for Fleet-Wide Incidents** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. Essential for incident response: key compromise affecting multiple certs, CA distrust events, decommissioning a team's infrastructure. Each bulk revocation creates individual jobs reusing the existing revocation pipeline, ensuring audit trail and notifications for every certificate.
- **Short-Lived Cert Exemption** — Certificates with TTL < 1 hour (configured in profile) skip CRL/OCSP publication. Expiry is the revocation mechanism for short-lived certs (e.g., Kubernetes pod certs, session tokens).
- **Deployment Rollback** — If a revoked cert is still deployed (shouldn't happen, but race conditions exist), operators can manually redeploy a previous version via the GUI. Rollback is audited.
**Evidence Locations**:
- Revocation API: `internal/api/handler/certificates.go`, `POST /api/v1/certificates/{id}/revoke`
- Revocation domain model: `internal/domain/revocation.go` (RevocationReason type with RFC 5280 mapping)
- CRL generation: `internal/service/certificate.go` (GenerateDERCRL method)
- OCSP signing: `internal/service/certificate.go` (GetOCSPResponse method)
- Revocation notifications: `internal/service/notification.go` (SendRevocationNotification)
- Short-lived exemption: `internal/domain/revocation.go` (IsShortLivedCert check)
**V3 Enhancement**:
- **Revocation Automation** — Trigger revocation based on external events (e.g., employee termination, security breach alert from CT Log monitoring)
**Operator Responsibility**:
- Establish an incident response policy (e.g., "keyCompromise → immediate deployment to new cert + notify CISO")
- Ensure CRL/OCSP are accessible to all systems using the certs (e.g., CDN or highly-available endpoints if you host on-premises)
- Test revocation workflow in staging (verify that revoked certs are actually blocked by clients)
- Document justification for revocation (audit trail records *that* a cert was revoked, but not *why* — you must document it separately)
- Integrate revocation notifications into your on-call rotation (don't let revocation alerts get lost)
---
### CC7.4 — Identify and Develop Risk Mitigation Activities
**Requirement**: The entity identifies, develops, and implements risk mitigation activities for risks arising from potential business disruptions.
**certctl Implementation** (V2):
- **Renewal Job Tracking** — Renewal jobs track the certificate, target agents, and issuance outcome. Failed renewals are retried (configurable backoff). Job state diagram: Pending → Running → Completed (or Failed). Failed jobs trigger notifications.
- **Agent Health Monitoring** — Health check loop (every 2m) pings all agents via heartbeat. If an agent misses 3 consecutive heartbeats, it's marked as `Unhealthy`. Unhealthy agents are excluded from new deployments.
- **Job Cancellation** — Operators can cancel pending jobs via `POST /api/v1/jobs/{id}/cancel`. Useful when a renewal is already in progress elsewhere (multi-instance deployments) or when a certificate is being phased out.
- **Interactive Approval** — Renewal/issuance jobs can be put in `AwaitingApproval` status. An authorized operator reviews the pending cert and approves or rejects it. Rejection records a reason in the audit trail. This provides a separation of duty between requestor and approver.
- **Scheduled Scanning** — Agents scan configured directories for existing certs (M18b discovery). Operators triage discovered certs (claim = "we manage this now", dismiss = "this is unmanaged and we're OK with that"). Triage decisions are audited.
**Evidence Locations**:
- Job state machine: `internal/domain/job.go` (JobStatus enum)
- Job retry logic: `internal/scheduler/scheduler.go` (jobProcessorTicker)
- Agent health check: `internal/scheduler/scheduler.go` (healthCheckTicker)
- Job cancellation: `internal/api/handler/jobs.go`, `POST /api/v1/jobs/{id}/cancel`
- Approval workflow: `internal/api/handler/jobs.go`, `POST /api/v1/jobs/{id}/approve` / `reject`
- Discovery scan results: `internal/api/handler/discovery.go`, `GET /api/v1/discovered-certificates`
**Operator Responsibility**:
- Monitor renewal job success rate (are certs being renewed before expiry?)
- Set up alert for unhealthy agents (missing 3+ heartbeats = broken agent, take action)
- Establish a formal approval policy (who can approve certs? do they need to involve CISO?)
- Test job cancellation and recovery flows in staging
- Review discovered certs regularly (are there unmanaged certs that should be managed?)
- Document your disaster recovery process (what if control plane database is corrupted?)
---
## A1: Availability
### A1.1/A1.2 — Availability and Recovery
**Requirement**: The entity obtains or generates, uses, retains, and disposes of information to enable the entity to meet its objectives and respond to its responsibility to provide information.
**certctl Implementation** (V2):
- **Health Probes**`/health` and `/ready` endpoints support container orchestration (Docker Compose, Kubernetes, etc.). Docker Compose defines health checks for the server and database. Kubernetes would use liveness/readiness probes pointing to these endpoints.
- **Database Migrations (Idempotent)** — PostgreSQL migrations use `IF NOT EXISTS` and `ON CONFLICT ... DO NOTHING` patterns. Migrations can be safely reapplied — no risk of doubling data or dropping tables mid-migration.
- **Agent Panic Recovery** — Agent binary includes panic recovery in job execution loops. If an agent crashes during a deployment, the control plane marks the job as failed and can retry on a healthy agent.
- **Exponential Backoff** — Agent-to-server communication uses exponential backoff (starting at 1s, capped at 5m) to handle transient network failures. This prevents thundering herd when the control plane is temporarily down.
- **Docker Compose Deployment** — Includes health checks for server and database. Services auto-restart on failure.
- **PostgreSQL Connection Pooling** — Server uses `database/sql` with configurable `MaxOpenConns` and `MaxIdleConns` (default 25/5). Prevents connection exhaustion.
**Evidence Locations**:
- Health endpoints: `internal/api/handler/health.go`
- Database migrations: `migrations/` directory (all use `IF NOT EXISTS`, idempotent patterns)
- Agent panic recovery: `cmd/agent/main.go` (defer recover() in job execution)
- Exponential backoff: `cmd/agent/main.go` (heartbeat and work poll backoff logic)
- Connection pooling: `cmd/server/main.go` (SetMaxOpenConns, SetMaxIdleConns)
**V3 Enhancement**:
- **Multi-Region HA** — Control plane federation with etcd consensus (operator can run N replicas)
- **PostgreSQL HA** — Replication standby with automatic failover (operator responsibility to configure)
**Operator Responsibility**:
- Configure PostgreSQL backups (e.g., WAL archiving, daily full backups). Certctl stores certificates but *also* stores renewal policies, audit trail, deployment history.
- Test backup/restore process in staging (broken backups are discovered during incidents)
- Monitor disk usage (PostgreSQL will fail if `/var` fills up)
- Plan capacity (how many certs, agents, jobs can your PostgreSQL handle? Certctl is tested with 10k+ certs, 100+ agents, but your infra may differ)
- Set up high-availability PostgreSQL if you need zero-downtime upgrades
- Implement network segmentation (only authorized services can reach certctl API and database)
---
## CC8: Change Management
### CC8.1 — Change Control
**Requirement**: The entity identifies, selects, and develops risk mitigation activities for risks arising from potential business disruptions.
**certctl Implementation** (V2):
- **Certificate Profiles** — Named profiles define allowed key types, max TTL, required SANs, and permitted EKUs. Changes to profiles are common (e.g., "increase max TTL from 1 year to 3 years"). All profile changes are audited (who changed what, when). Profile updates are versioned.
- **Policy Engine** — Renewal policies define alert thresholds and approval workflows. Policy changes (e.g., "lower alert threshold from 30 days to 14 days") are audited. Policies have violation rules (e.g., "flag certs longer than 3 years") — violations are recorded in the audit trail.
- **Target Configuration** — When a new target (NGINX server, HAProxy load balancer) is added, it's registered with a name and configuration (JSON). Target deletions require confirmation (to prevent accidental removal). All target changes are audited.
- **Immutable Audit Trail** — Every change (profile, policy, target, cert, agent, owner, team, approval, revocation, deployment) is recorded in `audit_events`. Audit records are append-only; no retroactive modification is possible. Audit trail is encrypted at rest (operator responsibility).
- **GitHub Actions CI** — Pull requests must pass:
- Go unit tests (`go test ./...`) with coverage gates (service layer ≥30%, handler layer ≥50%)
- Go vet (static analysis)
- Frontend TypeScript type checking (`tsc`)
- Frontend Vitest unit tests
- Frontend Vite build (ensures no broken imports)
Only after all checks pass can the PR be merged and deployed.
**Evidence Locations**:
- Profile CRUD: `internal/api/handler/profiles.go`, `GET /api/v1/profiles` / `POST` / `PUT` / `DELETE`
- Policy CRUD: `internal/api/handler/policies.go`
- Target CRUD: `internal/api/handler/targets.go`
- Audit trail: `internal/api/handler/audit.go`, `GET /api/v1/audit` (records action, actor, resource_id, timestamp)
- CI configuration: `.github/workflows/ci.yml` (test, vet, coverage gates, build checks)
**V3 Enhancement**:
- **Change Approval Workflow** — Optional approval gate before profile/policy changes go live
- **Feature Flags** — Enable/disable new features without redeployment (backward compatibility during rolling upgrades)
**Operator Responsibility**:
- Implement formal change control (ticket system, approval, peer review)
- Document the business justification for profile/policy changes
- Test changes in a non-production environment before deploying to production
- Have a rollback plan (can you revert a profile change instantly if it breaks issuance?)
- Include certctl configuration changes in your change log (for audits and incident investigations)
- Version control your certctl configuration (Docker Compose file, environment variables) so you can track changes
---
## Evidence Summary Table
| SOC 2 Criterion | certctl Feature | Evidence Location | V2 (Free) | V3 (Pro) | Operator Responsibility |
|---|---|---|---|---|---|
| **CC6.1** Logical Access Security | API Key Authentication (SHA-256 hashed, constant-time comparison) | `internal/api/middleware/auth.go` | ✅ | Enhanced | API key generation, distribution, rotation |
| | GUI Login with API Key | `web/src/pages/LoginPage.tsx` | ✅ | Enhanced (OIDC) | NA |
| | CORS Allowlist | `CERTCTL_CORS_ORIGINS` env var | ✅ | ✅ | Configure appropriately |
| | Token Bucket Rate Limiting | `internal/api/middleware/rate_limit.go` | ✅ | ✅ | Monitor for brute-force attempts |
| **CC6.2** Prior to Issuing System Credentials | Ownership Attribution | `GET /api/v1/owners`, audit trail records owner assignment | ✅ | Enhanced (RBAC) | Map to org structure, remove on departure |
| | Team Assignment | `GET /api/v1/teams` | ✅ | ✅ | NA |
| | Actor Attribution in Audit Trail | `GET /api/v1/audit` (actor field) | ✅ | ✅ | Justify all changes via separate documentation |
| **CC6.3** Authentication Policies | API Key Enforcement | `CERTCTL_AUTH_TYPE=api-key` (default) | ✅ | Enhanced (OIDC, MFA) | Document policy, test failures, integrate into IAM audit |
| | Agent Authentication | Separate API keys for agents | ✅ | ✅ | Rotate agent keys, monitor compromise |
| | Agent-Side Key Generation | `CERTCTL_KEYGEN_MODE=agent` (default) | ✅ | ✅ | Protect agent filesystem keys via encryption/backup |
| | Private Key Policy | Server-side keygen logs warning, disabled in production | ✅ | ✅ | Never use server-side keygen in production |
| **CC6.7** Information Transmission Protection | TLS for Control Plane | Deploy behind TLS-terminating reverse proxy | ✅ | ✅ | Enable TLS in production via reverse proxy |
| | Agent-to-Server HTTPS | Agents use HTTPS for all API calls | ✅ | ✅ | Enforce TLS via firewall rules |
| | Private Key Isolation | Agent-side keygen (ECDSA P-256), keys stored 0600 on agent FS | ✅ | ✅ | Encrypt agent filesystems, backup securely |
| | Pull-Only Deployment | Server never initiates outbound to agents/targets | ✅ | Enhanced (HSM, proxy agents) | Encrypt agent↔target comms, isolate proxy agents |
| **CC7.1** System Monitoring | Health Endpoint | `GET /health`, `GET /ready` | ✅ | ✅ | Integrate into monitoring (Prometheus, DataDog) |
| | Metrics JSON Endpoint | `GET /api/v1/metrics` (gauges, counters, uptime) | ✅ | ✅ | Set thresholds, configure alerting |
| | Stats API (time-series) | `GET /api/v1/stats/*` (summary, status, expiration, jobs, issuance) | ✅ | ✅ | Integrate into dashboards, SLO tracking |
| | Structured Logging | `slog` middleware with request IDs | ✅ | ✅ | Aggregate logs to SIEM, define retention policy |
| | Background Scheduler | 12 loops (8 always-on: renewal 1h, jobs 30s, job retry 5m I-001, job timeout 10m I-003, health 2m, notifications 1m, notif retry 2m I-005, short-lived 30s; 4 opt-in: network scan 6h, digest 24h, endpoint health 60s M48, cloud discovery 6h M50) | ✅ | ✅ | Alert on scheduler loop failures |
| **CC7.2** Anomaly Detection | Immutable API Audit Trail | `internal/api/middleware/audit.go`, `GET /api/v1/audit` | ✅ | Enhanced (SIEM export) | Integrate into SIEM, search for anomalies, archive long-term |
| | Expiration Threshold Alerting | Configurable per-policy (default 30/14/7/0 days) | ✅ | ✅ | Configure thresholds, integrate notifications |
| | Status Auto-Transitions | Active → Expiring (30d) → Expired (0d) | ✅ | ✅ | Monitor status changes in audit trail |
| | Notification Routing | Email, Slack, Teams, PagerDuty, OpsGenie | ✅ | ✅ | Configure notifiers, on-call integration |
| | Deployment Rollback | Redeploy previous cert version via GUI | ✅ | ✅ | Audit rollback decisions |
| **CC7.3** Incident Response | Revocation API (RFC 5280 reasons) | `POST /api/v1/certificates/{id}/revoke` | ✅ | Enhanced (bulk revocation) | Establish incident response policy |
| | CRL Endpoint (DER, RFC 5280 §5) | `GET /.well-known/pki/crl/{issuer_id}` (unauthenticated, `application/pkix-crl`) | ✅ | ✅ | Ensure CRL/OCSP accessible to all clients without API keys |
| | OCSP Responder (RFC 6960) | `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (unauthenticated, `application/ocsp-response`) | ✅ | ✅ | Test revocation in staging |
| | Revocation Notifications | Email, webhook, Slack/Teams on revocation | ✅ | ✅ | Integrate into on-call, document justification separately |
| | Short-Lived Cert Exemption | TTL < 1h skip CRL/OCSP | ✅ | ✅ | Configure profiles appropriately |
| **CC7.4** Risk Mitigation | Renewal Job Tracking | Job state machine (Pending → Running → Completed/Failed) | ✅ | ✅ | Monitor renewal success rate |
| | Agent Health Monitoring | Health check loop (ping every 2m, mark unhealthy after 3 misses) | ✅ | ✅ | Alert on unhealthy agents, investigate |
| | Job Cancellation | `POST /api/v1/jobs/{id}/cancel` | ✅ | ✅ | Test in staging |
| | Interactive Approval | AwaitingApproval state, `POST /api/v1/jobs/{id}/approve\|reject` | ✅ | ✅ | Define approval policy, audit decisions |
| | Certificate Discovery | Agents scan directories, triage (claim/dismiss) | ✅ | ✅ | Review discovered certs regularly |
| **A1.1/A1.2** Availability and Recovery | Health Probes (Docker, Kubernetes) | `/health` and `/ready` endpoints | ✅ | ✅ | Use in container orchestration |
| | Idempotent Migrations | `IF NOT EXISTS`, `ON CONFLICT ... DO NOTHING` | ✅ | ✅ | Test migration replay in staging |
| | Agent Panic Recovery | Panic recovery in job loops | ✅ | ✅ | Monitor agent crashes in logs |
| | Exponential Backoff | Agent heartbeat/work poll backoff (1s → 5m) | ✅ | ✅ | Monitor for control plane downtime |
| | PostgreSQL Connection Pooling | MaxOpenConns=25, MaxIdleConns=5 (configurable) | ✅ | ✅ | Monitor connection usage |
| **CC8.1** Change Control | Certificate Profiles | CRUD API + GUI, profile changes audited | ✅ | ✅ | Formal change control, test in staging |
| | Policy Engine + Violations | CRUD API + GUI, policy changes audited | ✅ | ✅ | Document justification, implement approval workflow |
| | Target Registration | CRUD API + GUI, changes audited | ✅ | ✅ | Confirm deletions, version control config |
| | Immutable Audit Trail | Append-only `audit_events` table | ✅ | ✅ | Encrypt at rest, archive long-term, no manual edits |
| | GitHub Actions CI | Unit tests, vet, coverage gates, build checks | ✅ | ✅ | Review PRs before merge, maintain test quality |
---
## What Requires Operator Action
**certctl is a tool, not a complete compliance solution.** Your organization must handle:
1. **Physical Security** — Protect the infrastructure (servers, network) running certctl. Certctl can't control who has physical access to your datacenter.
2. **Personnel Background Checks** — Before granting anyone API key access, conduct background checks per your policy. Certctl records *who* accessed *what*, but doesn't verify that people are trustworthy.
3. **Formal Incident Response Plan** — Certctl provides incident detection (anomalies in audit trail) and tools for response (revocation, rollback), but you must define *when* to use them and *who* decides.
4. **Access Review and Removal** — Certctl stores ownership, teams, and API keys. You must:
- Regularly review who has access (quarterly or semi-annually)
- Immediately revoke API keys for departing employees
- Audit that removed access is actually removed (test that old keys fail)
5. **Log Retention and Archival** — Certctl logs to stdout (Docker) and stores audit events in PostgreSQL. You must:
- Ship logs to a long-term archive (SIEM, S3, or equivalent)
- Define retention policy (often 1-7 years per industry regulation)
- Encrypt archived logs
- Test that you can retrieve logs from archive (restoration drills)
6. **Encryption at Rest** — PostgreSQL data (including audit trail) is stored on disk. You must:
- Enable transparent data encryption (TDE) on your database VM
- Encrypt container persistent volumes (if using Kubernetes)
- Encrypt database backups
7. **Network Segmentation** — Certctl API and database must be protected by network access controls. You must:
- Firewall the control plane (only authorized services can connect)
- Use VPN or private networks for agent-to-server communication
- Isolate proxy agents (for F5, IIS, etc.) in the same network zone as their targets
8. **Capacity Planning** — Certctl's performance scales with your PostgreSQL. You must:
- Estimate certificate inventory size (10k, 100k, 1M certs?)
- Test Certctl with your expected scale in staging
- Monitor disk usage, CPU, memory
- Plan for growth (add PostgreSQL replicas, increase connection pool, etc.)
9. **Disaster Recovery** — Certctl data lives in PostgreSQL. You must:
- Back up PostgreSQL regularly (daily or hourly, depending on RPO)
- Test restore process in staging (broken backups discovered during incidents)
- Have a runbook for failover to replica or recovery from backup
- Document RTO/RPO targets (how long can cert management be down? how much data can you afford to lose?)
10. **Integration with Your IAM** — If using OIDC/SSO (V3), you must:
- Configure your OIDC provider (Okta, Azure AD, Google)
- Map user groups to Certctl roles (Admin, Operator, Viewer)
- Manage MFA policy (enforce MFA if required)
- Audit user provisioning/deprovisioning
11. **Documentation and Runbooks** — Certctl documents *what it does* (this guide), but you must document:
- Your organization's certificate lifecycle policy (who requests, who approves, who deploys)
- How to respond to specific incidents (cert compromise, CA compromise, agent down, renewal failed)
- How to operate certctl (day-to-day tasks, escalation procedures)
- Contact info for on-call teams
---
## V3 Enhancements
**certctl Pro (V3, paid edition) adds features that significantly strengthen SOC 2 evidence:**
- **OIDC / SSO Integration** — Integrate with Okta, Azure AD, Google to replace API keys with federated identity. Enables MFA enforcement and centralized access management. Auditors love federated identity (easier to remove access at source).
- **Role-Based Access Control (RBAC)** — Predefined roles (Admin: full access; Operator: issue/renew/revoke, no policy changes; Viewer: read-only) with profile-gated enforcement. Allows separation of duties (e.g., junior operator can't change global policy).
- **NATS Event Bus** — Real-time audit streaming to your SIEM. Hybrid model: HTTP for synchronous APIs, NATS for async events (cert.issued, cert.expiring, agent.heartbeat, job.completed). JetStream persistence for replay and durability.
- **SIEM Export** — Automated export of audit trail to Splunk, ELK, DataDog, etc. (webhooks, syslog, or pull-based APIs). Makes it easy for security teams to hunt for anomalies.
- **Advanced Search DSL**`POST /api/v1/search` with tree-based filters (nested AND/OR, regex, field projection). Enables complex compliance queries (e.g., "all certs issued in the last 30 days by team X that are longer than 1 year").
- **Bulk Revocation** — Revoke all certs issued by a profile, owner, or agent in one operation. Critical for large-scale incidents (e.g., "a team's CA key was compromised, revoke all their certs").
- **Certificate Health Scores** — Composite risk scoring (e.g., "this cert has no short-lived TTL enforcement, extends past your policy max, and hasn't been renewed in 2 years" → health=30%). Helps prioritize remediation.
- **Compliance Scoring** — Audit readiness reporting per certificate (e.g., "compliance=95% — missing only a 3-year max-TTL constraint"). Exportable compliance report.
- **DigiCert Issuer Connector** — OV/EV certificate issuance for public-facing services (web servers, CDNs). Complements Local CA for internal use.
- **CT Log Monitoring** — Passive detection of unauthorized cert issuance. Monitors public CT logs for certs matching your domains and alerts if unexpected certs appear (e.g., attacker obtained a cert for your domain).
- **F5 BIG-IP Implementation** — Full target connector with iControl REST API. Agents can deploy certs to F5 load balancers.
- **IIS Implementation** — Dual-mode: agent-local PowerShell (default) for servers with agents, or proxy agent WinRM (agentless targets). Full Windows Server integration.
---
## Conclusion
certctl provides a strong foundation for SOC 2 compliance with API key authentication, immutable audit logging, automated alerting, and revocation capabilities. However, SOC 2 audits require evidence across your entire infrastructure — certctl is one piece. Use this guide to map certctl features to your audit questionnaire, then work with your auditors to identify gaps that must be filled by your own organizational policies and controls.
For a deeper SOC 2 discussion or a mock audit against this guide, contact your certctl Pro support team.
-122
View File
@@ -1,122 +0,0 @@
# Compliance Mapping Guides
certctl is a certificate lifecycle management tool, not a compliance product. It doesn't make you compliant — your organization, policies, and processes do that. What certctl provides is tooling that supports the technical controls auditors and evaluators look for when assessing certificate and key management practices.
These guides map certctl's features to three widely referenced compliance frameworks. They're designed for security engineers, IT auditors, and procurement teams evaluating certctl for environments with regulatory requirements.
## What's Covered
**[SOC 2 Type II](compliance-soc2.md)** — Maps certctl features to AICPA Trust Service Criteria. Covers logical access controls (CC6), system operations and monitoring (CC7), change management (CC8), and availability (A1). Most relevant for organizations undergoing SOC 2 audits where certificate management is in scope.
**[PCI-DSS 4.0](compliance-pci-dss.md)** — Maps certctl features to PCI Data Security Standard version 4.0 requirements. Covers data-in-transit protection (Req 4), cryptographic key management (Req 3), authentication (Req 8), audit logging (Req 10), secure development (Req 6), and access control (Req 7). Most relevant for organizations handling cardholder data where TLS certificates protect transmission channels.
**[NIST SP 800-57](compliance-nist.md)** — Maps certctl's key management practices to NIST Special Publication 800-57 Part 1 Rev 5 (2020). Covers key generation, storage, cryptoperiods, key state lifecycle, algorithm selection, key transport, and revocation. Most relevant for organizations aligning with US federal cryptographic guidance or using NIST as a key management baseline.
## What These Guides Are Not
These are mapping guides, not certification claims. certctl is not SOC 2 certified, PCI-DSS validated, or NIST-assessed. The guides document how certctl's technical implementation supports the controls these frameworks require — they do not replace your auditor's assessment, your organization's policies, or your security team's judgment.
The guides also clearly identify gaps where certctl's current implementation doesn't fully align with a framework's recommendations, features planned for future versions, and areas where operator action is required regardless of what certctl provides.
## How to Use These Guides
If you're evaluating certctl for a regulated environment, start with the framework your auditor cares about. Each guide includes an evidence summary table mapping specific compliance criteria to certctl features, API endpoints, and configuration — the kind of specifics your auditor will ask for.
If you're preparing for an audit and certctl is already deployed, use the "Operator Responsibilities" section of each guide to identify what your organization must manage beyond what certctl provides.
## Quick Reference
| Framework | Primary Concern | Key certctl Features |
|---|---|---|
| SOC 2 Type II | Trust service criteria for SaaS/infrastructure | API audit trail, auth controls, monitoring, change management |
| PCI-DSS 4.0 | Cardholder data protection | TLS lifecycle, key management, immutable logging, access control |
| NIST SP 800-57 | Cryptographic key management | Agent-side keygen, key isolation, algorithm selection, revocation |
## Audit-Trail Integrity & Privacy (Bundle 6)
Two complementary controls protect the `audit_events` table against tampering and minimize PII exposure. Both apply automatically — no operator action is required at install time, but operators must understand the contract before responding to a legal-hold or retention request.
### Append-Only Enforcement (HIPAA §164.312(b))
<!-- Source: migrations/000018_audit_events_worm.up.sql -->
`audit_events` rows cannot be modified or deleted by the application role. Two layers:
| Layer | Mechanism | Surface |
|---|---|---|
| **DB trigger** | `audit_events_block_modification()` raises `check_violation` on `BEFORE UPDATE OR DELETE` | Catches any UPDATE / DELETE — including direct `psql` from the app role |
| **App-role grant** | `REVOKE UPDATE, DELETE ON audit_events FROM certctl` | Defence-in-depth; the app role can't even attempt the modification |
**Verification.** From a `psql` session connected as the `certctl` app role:
```sql
UPDATE audit_events SET actor = 'tampered' WHERE id = 'audit-001';
-- ERROR: audit_events is append-only (Bundle-6 / M-017 / HIPAA §164.312(b))
-- HINT: Use a compliance superuser role for legitimate retention operations.
```
**Compliance superuser pattern.** Legitimate retention work (legal hold, GDPR right-to-be-forgotten, statutory purges) requires a separate PostgreSQL role provisioned out-of-band that bypasses the trigger. Certctl does NOT auto-create this role — operators provision it per their compliance policy. Suggested shape:
```sql
-- One-time setup by a DBA. Stored procedure pattern keeps the
-- compliance superuser audit-able too: every invocation should
-- itself land in audit_events.
CREATE ROLE certctl_compliance LOGIN PASSWORD '<strong-secret>';
GRANT UPDATE, DELETE ON audit_events TO certctl_compliance;
-- (optional) provision SECURITY DEFINER stored procedures that
-- (a) record the retention reason in audit_events as the FIRST step
-- (b) then perform the UPDATE/DELETE
-- (c) all under the certctl_compliance role's grants.
```
### Body Redaction (GDPR Art. 32, CWE-532)
<!-- Source: internal/service/audit_redact.go -->
`AuditService.RecordEvent` routes every `details` map through `RedactDetailsForAudit` BEFORE marshaling to the JSONB column. Two deny-lists:
| Category | Match | Replacement | Examples |
|---|---|---|---|
| **Credentials** | case-insensitive key match | `"[REDACTED:CREDENTIAL]"` | `api_key`, `password`, `token`, `*_pem`, `eab_secret`, `acme_account_key`, `signature` |
| **PII** | case-insensitive key match | `"[REDACTED:PII]"` | `email`, `phone`, `ssn`, `dob`, `name`, `address`, `postal_code`, `ip_address` |
Nested maps and arrays are walked recursively — sensitive keys at any depth get scrubbed. The redactor is mutation-free (the caller's original map is unchanged) so service-layer code that reuses the map elsewhere is safe.
**Operator visibility — `redacted_keys` array.** The redacted map includes a `redacted_keys` array listing every dotted-path that was scrubbed. This surfaces the redaction footprint to compliance auditors without exposing values. Example before/after:
```jsonc
// Caller's input map (e.g., from a service handler):
{
"action": "create_issuer",
"issuer_id": "iss-acme-prod",
"config": {
"endpoint": "https://acme.example.com",
"eab_secret": "abc123secret",
"contact": { "email": "ops@example.com", "role": "admin" }
}
}
// Persisted in audit_events.details:
{
"action": "create_issuer",
"issuer_id": "iss-acme-prod",
"config": {
"endpoint": "https://acme.example.com",
"eab_secret": "[REDACTED:CREDENTIAL]",
"contact": { "email": "[REDACTED:PII]", "role": "admin" }
},
"redacted_keys": ["config.eab_secret", "config.contact.email"]
}
```
**Maintenance.** When introducing a new credential-bearing field anywhere in the codebase, add the key name to `credentialKeys` (or `piiKeys`) in `internal/service/audit_redact.go`. The unit test suite in `audit_redact_test.go` exercises every entry and proves case-insensitivity + JSON round-trip safety.
## certctl Pro (V3) Enhancements
Several compliance-relevant features are planned for certctl Pro:
- **OIDC/SSO** — Enterprise identity provider integration (SOC 2 CC6.1, PCI-DSS 8.3)
- **RBAC** — Role-based access control with admin/operator/viewer roles (SOC 2 CC6.3, PCI-DSS 7.2)
- **NATS Audit Streaming** — Real-time audit event streaming to SIEM systems (SOC 2 CC7.2, PCI-DSS 10.2)
- **Bulk Revocation** — Fleet-wide incident response capability (NIST SP 800-57 Section 5.4)
- **Health/Compliance Scoring** — Automated compliance posture assessment per certificate
@@ -1,7 +1,9 @@
# CI Pipeline — Operator Guide # CI Pipeline — Operator Guide
> Last reviewed: 2026-05-05
> Authoritative guide to certctl's CI pipeline shape. > Authoritative guide to certctl's CI pipeline shape.
> Per `cowork/ci-pipeline-cleanup-prompt.md` Phase 12. > Per the ci-pipeline-cleanup spec, Phase 12.
## Trigger model ## Trigger model
@@ -17,17 +19,28 @@ This guide covers the **on-push pipeline** only.
## On-push pipeline (7 status checks) ## On-push pipeline (7 status checks)
``` ```mermaid
push to master flowchart TD
├── CI workflow (5 jobs) Push["push to master"]
│ ├── go-build-and-test (~6-7 min) CI["CI workflow (5 jobs)"]
│ ├── frontend-build (~1 min) CodeQL["CodeQL workflow (2 jobs)"]
│ ├── helm-lint (~10 sec) GoBuild["go-build-and-test<br/>~6-7 min"]
│ ├── deploy-vendor-e2e (~5 min, depends on go-build-and-test) Frontend["frontend-build<br/>~1 min"]
│ └── image-and-supply-chain (~3 min, parallel) HelmLint["helm-lint<br/>~10 sec"]
└── CodeQL workflow (2 jobs) Vendor["deploy-vendor-e2e<br/>~5 min, depends on go-build-and-test"]
├── Analyze (go) (~5 min, parallel) Image["image-and-supply-chain<br/>~3 min, parallel"]
└── Analyze (javascript-typescript) (~5 min, parallel) AnalyzeGo["Analyze (go)<br/>~5 min, parallel"]
AnalyzeJS["Analyze (javascript-typescript)<br/>~5 min, parallel"]
Push --> CI
Push --> CodeQL
CI --> GoBuild
CI --> Frontend
CI --> HelmLint
CI --> Vendor
CI --> Image
CodeQL --> AnalyzeGo
CodeQL --> AnalyzeJS
GoBuild -.depends on.-> Vendor
``` ```
End-to-end wall-clock: dominated by `go-build-and-test` + `deploy-vendor-e2e` chain (~12 min) running in parallel with CodeQL (~5 min). Target ~10 min. End-to-end wall-clock: dominated by `go-build-and-test` + `deploy-vendor-e2e` chain (~12 min) running in parallel with CodeQL (~5 min). Target ~10 min.
+68
View File
@@ -0,0 +1,68 @@
# GUI QA Checklist
> Last reviewed: 2026-05-05
Manual GUI verification pass for release sign-off. Vitest covers component-level behavior; this checklist covers end-to-end flows that only land correctly when the React SPA, the REST API, and the database are all wired together.
## Prereqs
The full stack must be running and healthy per [`qa-prerequisites.md`](qa-prerequisites.md). Open `https://localhost:8443` in a fresh browser session (Incognito / Private mode is fine — avoids cached state from previous QA passes).
## Pages to verify
For each page, the verification is "open it, confirm it renders without console errors, exercise the documented action, confirm the action lands as expected."
| Page | Action to verify | Expected result |
|---|---|---|
| `/dashboard` | Page loads, all 4 stat cards populate | Total / Active / Expiring / Expired counts match `GET /api/v1/stats/summary` |
| `/certificates` | Inventory list paginates | "Next page" button works; URL updates with cursor; row count consistent |
| `/certificates/<id>` | Detail page opens for any cert | Cert chain renders, deployment status shows, audit timeline visible |
| `/issuers` | Catalog renders all configured issuers | Each issuer card shows last-used / status; clicking opens detail |
| `/issuers/<id>` | Issuer config form | Edit + Save round-trips through `PATCH /api/v1/issuers/<id>` |
| `/issuers/hierarchy` | CA tree view | Multi-level hierarchy renders; admin-gated CRUD buttons present for admins only |
| `/agents` | Fleet view | Online/offline status accurate; OS/arch grouping correct |
| `/agents/<id>` | Agent detail | Last heartbeat, registered date, deployment job history |
| `/agents/groups` | Agent groups CRUD | Create + edit + delete a test group; verify dynamic membership matching |
| `/jobs` | Job queue | Filter by status / type works; click into a job opens detail |
| `/jobs/<id>` | Job detail | Status, retries, logs, owner attribution |
| `/policies` | Renewal policies CRUD | Edit AlertChannels matrix, save, verify backend reflects change |
| `/profiles` | Certificate profiles | EKU constraints + max TTL editable; profile binding works |
| `/notifications` | Notifier config | Test connection button against each configured notifier |
| `/discovery` | Discovery triage | Claim / Dismiss buttons round-trip to backend |
| `/network-scans` | Scan target CRUD | Create scan target, trigger immediate scan, results appear |
| `/audit` | Audit trail | Filter by actor / action / time range; CSV export works |
| `/short-lived` | Short-lived credential dashboard | Live TTL countdown updates; auto-refresh every 10s |
| `/observability` | Observability dashboard | Charts render: expiration heatmap, renewal trends, issuance rate |
| `/health` | Health monitor | TLS endpoint health: healthy / degraded / down states accurate |
| `/digest` | Digest preview | Email preview renders; "Send digest" button dispatches |
| `/owners` | Owners CRUD | Create owner with team, edit, delete (after reassigning certs) |
| `/teams` | Teams CRUD | Create + delete; verify cascade removes orphan owners |
| `/scep` | SCEP admin tabs | Profiles / Intune Monitoring / Recent Activity all populate |
| `/est` | EST admin tabs | Profiles / Recent Activity / Trust Bundle all populate |
| `/login` | Login flow | API key entry persists for the session; bad key rejected |
## Console hygiene
Open browser DevTools and confirm:
- No uncaught exceptions on any page
- No 404 / 500 responses in the Network tab from API calls
- No CORS errors
- No CSP violations
## Mobile / narrow-viewport
The dashboard is desktop-first but should not break catastrophically on narrow viewports. Resize the browser to 380px width; confirm:
- Sidebar collapses to a hamburger menu
- Tables either scroll horizontally or stack on mobile
- Forms remain usable
## Accessibility spot-check
- Tab through any single page using only the keyboard. Every interactive element must be reachable, and the focus indicator must be visible.
- Lighthouse accessibility audit on `/dashboard`: target ≥ 90.
## Sign-off
Document any deviations in the release sign-off matrix at [`release-sign-off.md`](release-sign-off.md).
+99
View File
@@ -0,0 +1,99 @@
# QA Prerequisites
> Last reviewed: 2026-05-05
Operational prereqs for running release QA against certctl. Before any of the contributor-facing testing surfaces (test-environment.md, gui-qa-checklist.md, release-sign-off.md) are useful, the local stack needs to be in a known-good state.
## Why manual QA on top of automated tests?
Automated tests mock dependencies and run in isolation. Manual QA validates the full integrated stack: real PostgreSQL, real HTTP, real agent binary, real file I/O, real scheduler timing. It catches issues that unit tests can't: migration ordering, Docker networking, env var parsing, browser rendering, and timing-dependent scheduler behavior.
## Environment setup
**Step 1: Start the full stack.**
```bash
cd deploy && docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build -d
```
This builds three containers (postgres, certctl-server, certctl-agent) and runs them on a bridge network. The `--build` flag ensures you're testing the current code, not a stale image. The `demo` overlay is an override file (no `image:` or `build:` of its own) that layers `CERTCTL_DEMO_SEED=true` onto the base — both files must be passed in that order or compose errors with `service "certctl-server" has neither an image nor a build context specified`. The seed populates the database with realistic fixtures.
**Step 2: Wait for healthy state.**
```bash
for i in $(seq 1 30); do
STATUS=$(docker compose ps --format json 2>/dev/null | jq -r 'select(.Health != null) | "\(.Name): \(.Health)"' 2>/dev/null)
echo "$STATUS"
echo "$STATUS" | grep -q "unhealthy\|starting" || break
sleep 2
done
```
Why: Docker Compose starts containers in dependency order (postgres → server → agent), but "started" doesn't mean "ready." Health checks confirm postgres accepts connections, the server responds on `/health`, and the agent process is running.
**Step 3: Set shell variables used throughout the QA flow.**
```bash
export SERVER=https://localhost:8443
export API_KEY="change-me-in-production"
export AUTH="Authorization: Bearer $API_KEY"
export CT="Content-Type: application/json"
export CACERT="--cacert ./deploy/test/certs/ca.crt"
```
Every curl command in QA docs uses these variables. Setting them once avoids typos and keeps the docs copy-pasteable.
> **Note:** The default Docker Compose sets `CERTCTL_AUTH_TYPE: none` for the demo overlay, meaning auth is disabled. Tests that exercise auth require flipping this to `api-key`; instructions are in the relevant test docs.
**Step 4: Build CLI and MCP server binaries on the host.**
```bash
go build -o certctl-cli ./cmd/cli/...
go build -o certctl-mcp ./cmd/mcp-server/...
```
The CLI and MCP server are separate binaries that talk to the server over HTTP. Building them verifies the code compiles and produces the executables you'll test later.
## Demo data baseline
The seed data (`migrations/seed.sql` + `migrations/seed_demo.sql`) pre-populates the database with realistic fixtures. Confirm it loaded:
```bash
curl -s $CACERT -H "$AUTH" $SERVER/api/v1/stats/summary | jq .
```
**Expected shape:**
```json
{
"total_certificates": 15,
"active_certificates": ...,
"expiring_certificates": ...,
"expired_certificates": ...,
"pending_renewals": ...
}
```
**Reference IDs in the demo data** (used across QA docs):
| Resource | IDs | Count |
|---|---|---|
| Teams | `t-platform`, `t-security`, `t-payments`, `t-frontend`, `t-data` | 5 |
| Owners | `o-alice`, `o-bob`, `o-carol`, `o-dave`, `o-eve` | 5 |
| Policies | `rp-standard`, `rp-urgent`, `rp-manual` | 3 |
| Issuers | `iss-local`, `iss-acme-le`, `iss-stepca`, `iss-digicert` | 4 |
| Agents | `ag-web-prod`, `ag-web-staging`, `ag-lb-prod`, `ag-iis-prod`, `ag-data-prod` | 5 |
| Targets | `tgt-nginx-prod`, `tgt-nginx-staging`, `tgt-f5-prod`, `tgt-iis-prod`, `tgt-nginx-data` | 5 |
| Profiles | `prof-standard-tls`, `prof-internal-mtls`, `prof-short-lived`, `prof-high-security` | 4 |
| Certificates | `mc-api-prod`, `mc-web-prod`, `mc-pay-prod`, etc. | 15 |
| Agent Groups | `ag-linux-prod`, `ag-linux-amd64`, `ag-windows`, `ag-datacenter-a`, `ag-manual` | 5 |
| Network Scan Targets | `nst-dc1-web`, `nst-dc2-apps`, `nst-dmz` | 3 |
## Once these are green
Move to the appropriate downstream surface:
- [`test-environment.md`](test-environment.md) — full local environment tutorial with real CAs (Pebble, step-ca, etc.)
- [`gui-qa-checklist.md`](gui-qa-checklist.md) — manual GUI test pass
- [`release-sign-off.md`](release-sign-off.md) — release-day checklist
- [`testing-strategy.md`](testing-strategy.md) — what we test in CI vs daily deep-scan vs manual QA
@@ -1,14 +1,16 @@
# QA Test Suite Guide (`qa_test.go`) # QA Test Suite Guide (`qa_test.go`)
> Last reviewed: 2026-05-05
> **Audience:** Anyone running release QA for certctl — whether you're a first-time contributor or the maintainer cutting a release tag. > **Audience:** Anyone running release QA for certctl — whether you're a first-time contributor or the maintainer cutting a release tag.
> >
> **Companion to:** `docs/testing-guide.md` (the *what* to test). This document explains the *how* — the automated test file, what it covers, what it skips, and how to fill the gaps manually. > **Self-contained.** Through 2026-05-04 this doc was a companion to a separate `docs/testing-guide.md` (the *what* to test) — that companion was pruned during the Phase 5 docs overhaul (its content dispersed across the audience-organized doc tree). The Part-by-Part Coverage Map below is now the canonical inventory of QA Parts.
--- ---
## Test Suite Health (regenerate via `make qa-stats`) ## Test Suite Health (regenerate via `make qa-stats`)
> Snapshot at HEAD. Re-run `make qa-stats` to refresh; CI's QA-doc drift guards (`.github/workflows/ci.yml`) catch out-of-date Part / cert / issuer counts on every PR. **Last regenerated: 2026-04-27 (Bundle P).** > Snapshot at HEAD. Re-run `make qa-stats` to refresh; the QA-doc seed-count drift guard (`.github/workflows/ci.yml::QA-doc seed-count drift guard`) catches out-of-date cert / issuer counts on every PR. The Part-count drift guard retired in the 2026-05-04 docs overhaul Phase 5 (testing-guide.md was pruned; Part counts are now tracked inside `qa_test.go` itself, not against an external doc). **Last regenerated: 2026-04-27 (Bundle P).**
| Metric | Value | Target | Status | | Metric | Value | Target | Status |
|---|---|---|---| |---|---|---|---|
@@ -18,42 +20,34 @@
| Frontend test files | 38 | n/a | | | Frontend test files | 38 | n/a | |
| Fuzz targets | 11 | ≥10 (one per hand-rolled parser) | ✓ | | Fuzz targets | 11 | ≥10 (one per hand-rolled parser) | ✓ |
| `t.Skip` sites | 60 | each carries valid rationale (Bundle O audit) | ✓ | | `t.Skip` sites | 60 | each carries valid rationale (Bundle O audit) | ✓ |
| `qa_test.go` Part_* subtests | 53 | tracks `testing-guide.md` Parts (3 `## Part 15-17` covered indirectly via Parts 4246) | ✓ | | `qa_test.go` Part_* subtests | 53 | covers 49 of 56 historical QA Parts directly + Parts 1517 indirectly via Parts 4246 | ✓ |
| `testing-guide.md` Parts | 56 | n/a | |
| Existential cluster line cov (post-Bundle-J + L.B + Bundle 0.7) | acme 55.6%, stepca 90.4%, local-issuer ≥86%, crypto ≥85% | ≥95% | △ ACME below; tracked in `coverage-matrix.md` | | Existential cluster line cov (post-Bundle-J + L.B + Bundle 0.7) | acme 55.6%, stepca 90.4%, local-issuer ≥86%, crypto ≥85% | ≥95% | △ ACME below; tracked in `coverage-matrix.md` |
| Mutation kill rate (Existential) | unmeasured (operator-runnable per Strengthening #5) | ≥90% | ⚠ | | Mutation kill rate (Existential) | unmeasured (operator-runnable per Strengthening #5) | ≥90% | ⚠ |
| Race detector clean (`-count=10`) | partial (`-count=3` clean per Phase 0) | 0 races | ⚠ | | Race detector clean (`-count=10`) | partial (`-count=3` clean per Phase 0) | 0 races | ⚠ |
## What Is This File? ## What Is This File?
`deploy/test/qa_test.go` is a single Go test file (~1700 lines) that automates as much of `docs/testing-guide.md` as possible against a running certctl Docker Compose demo stack. It replaces the legacy `qa-smoke-test.sh` bash script. `deploy/test/qa_test.go` is a single Go test file (~1700 lines) that automates the historical QA Part inventory (preserved in the Part-by-Part Coverage Map below) against a running certctl Docker Compose demo stack. It replaces the legacy `qa-smoke-test.sh` bash script.
It covers **49 of 56 Parts** of the testing guide as automation; the remaining 7 are It covers **49 of 56 Parts** of the testing guide as automation; the remaining 7 are
either manual-only by design or pending QA-suite coverage: either manual-only by design or pending QA-suite coverage:
- **49 `Part_*` automation wrappers**, **~159 leaf subtests** — API calls, database queries, source file checks, performance benchmarks - **49 `Part_*` automation wrappers**, **~159 leaf subtests** — API calls, database queries, source file checks, performance benchmarks
- **11 fully skipped Parts** — with documented reasons (external CAs, Windows, browser-only, etc.) — see "What This Test Does NOT Cover" below - **11 fully skipped Parts** — with documented reasons (external CAs, Windows, browser-only, etc.) — see "What This Test Does NOT Cover" below
- **4 Parts NOT YET AUTOMATED** — Parts 23 (S/MIME & EKU), 24 (OCSP/CRL), 55 (Agent Soft-Retirement), 56 (Notification Retry & Dead-Letter) — must be tested manually per `docs/testing-guide.md` until QA-suite automation lands - **4 Parts NOT YET AUTOMATED** — Parts 23 (S/MIME & EKU), 24 (OCSP/CRL), 55 (Agent Soft-Retirement), 56 (Notification Retry & Dead-Letter) — must be tested manually until QA-suite automation lands; the Part-by-Part Coverage Map below describes the surface area each Part covers
- **Manual-only flows** in addition: GUI flows, scheduler timing, Docker log inspection — must be done by a human following `docs/testing-guide.md` - **Manual-only flows** in addition: GUI flows, scheduler timing, Docker log inspection — must be done by a human (Coverage Map below describes each)
## Architecture ## Architecture
``` ```mermaid
┌────────────────────────┐ ┌─────────────────────────────────┐ flowchart LR
qa_test.go │────▶│ certctl demo stack │ QA["qa_test.go (//go:build qa)<br/><br/>TestQA(t *testing.T)<br/>├─ Part01_Infra<br/>├─ Part02_Auth<br/>├─ Part03_CertCRUD<br/>├─ ...<br/>└─ Part52_HelmChart"]
│ (//go:build qa) │ │ docker-compose.yml + │ subgraph Stack["certctl demo stack<br/>docker-compose.yml + docker-compose.demo.yml"]
│ │ │ docker-compose.demo.yml │ Server["certctl-server :8443"]
│ TestQA(t *testing.T) │ │ │ Postgres["postgres :5432"]
│ ├─ Part01_Infra │ │ ┌─ certctl-server :8443 │ Agents["certctl-agent (×N)<br/>↑ seed_demo.sql provisions 12 agent rows<br/>(1 active, 2 retired, 9 reserved/sentinel)<br/>for the soft-retire / FSM coverage Parts 5556 exercise"]
│ ├─ Part02_Auth │ │ ├─ postgres :5432 │ end
│ ├─ Part03_CertCRUD │ │ └─ certctl-agent (×N) │ QA --> Stack
│ ├─ ... │ │ ↑ seed_demo.sql provisions │
│ └─ Part52_HelmChart │ │ 12 agent rows (1 active, │
└────────────────────────┘ │ 2 retired, 9 reserved / │
│ sentinel) for the soft- │
│ retire / FSM coverage │
│ Parts 5556 exercise. │
└─────────────────────────────────┘
``` ```
> **Multi-agent demo stack (Bundle Q / L-004 closure).** The demo > **Multi-agent demo stack (Bundle Q / L-004 closure).** The demo
@@ -154,8 +148,8 @@ This table shows what each Part tests and what's left for manual verification.
| 20 | Post-Deployment Verification | 1 | 404 on nonexistent job verification | TLS probing, fingerprint comparison | | 20 | Post-Deployment Verification | 1 | 404 on nonexistent job verification | TLS probing, fingerprint comparison |
| 21 | EST Server | 2 | CACerts (200 + content-type), CSRAttrs (200/204) | simpleenroll with CSR, simplereenroll, PKCS#7 parsing | | 21 | EST Server | 2 | CACerts (200 + content-type), CSRAttrs (200/204) | simpleenroll with CSR, simplereenroll, PKCS#7 parsing |
| 22 | Certificate Export | 3 | PEM export, PKCS#12 export, 404 on nonexistent | Download mode, file content validation | | 22 | Certificate Export | 3 | PEM export, PKCS#12 export, 404 on nonexistent | Download mode, file content validation |
| 23 | S/MIME & EKU Support | 0 (NOT AUTOMATED) | — | S/MIME profile creation; EKU enforcement on issuance; SMIMECapabilities extension presence in issued cert; rejection of profile-violating EKU on CSR. Test manually per `docs/testing-guide.md::Part 23` | | 23 | S/MIME & EKU Support | 0 (NOT AUTOMATED) | — | S/MIME profile creation; EKU enforcement on issuance; SMIMECapabilities extension presence in issued cert; rejection of profile-violating EKU on CSR. Test manually — see the Coverage Map row |
| 24 | OCSP Responder & DER CRL | 0 (NOT AUTOMATED) | — | OCSP request/response (RFC 6960), DER CRL generation, status (Good/Revoked/Unknown), Must-Staple coordination. Test manually per `docs/testing-guide.md::Part 24` | | 24 | OCSP Responder & DER CRL | 0 (NOT AUTOMATED) | — | OCSP request/response (RFC 6960), DER CRL generation, status (Good/Revoked/Unknown), Must-Staple coordination. Test manually — see the Coverage Map row |
| 25 | Certificate Discovery | 5 | List discovered, summary, list scan targets, create target, invalid CIDR 400 | Agent filesystem scan, claim/dismiss workflow | | 25 | Certificate Discovery | 5 | List discovered, summary, list scan targets, create target, invalid CIDR 400 | Agent filesystem scan, claim/dismiss workflow |
| 26 | Enhanced Query API | 4 | Sort descending, cursor pagination, time-range filter, invalid sort field | Field projection correctness, cursor token cycling | | 26 | Enhanced Query API | 4 | Sort descending, cursor pagination, time-range filter, invalid sort field | Field projection correctness, cursor token cycling |
| 27 | Request Body Size Limits | 1 | 2MB body rejected (413/400) | Exact limit boundary (1MB) | | 27 | Request Body Size Limits | 1 | 2MB body rejected (413/400) | Exact limit boundary (1MB) |
@@ -170,7 +164,7 @@ This table shows what each Part tests and what's left for manual verification.
| 3637 | Issuer Catalog, Frontend Audit | SKIP | — | Requires browser | | 3637 | Issuer Catalog, Frontend Audit | SKIP | — | Requires browser |
| 38 | Error Handling | 5 | Malformed JSON, missing required field, method not allowed, UTF-8 CN, empty body | Stack trace suppression, error response format | | 38 | Error Handling | 5 | Malformed JSON, missing required field, method not allowed, UTF-8 CN, empty body | Stack trace suppression, error response format |
| 39 | Performance | 5 | List certs < 200ms, stats < 500ms, metrics < 200ms, Prometheus < 300ms, audit < 500ms | Load testing, concurrent request handling | | 39 | Performance | 5 | List certs < 200ms, stats < 500ms, metrics < 200ms, Prometheus < 300ms, audit < 500ms | Load testing, concurrent request handling |
| 40 | Documentation | 8 | README, quickstart, architecture, connectors, compliance exist; migration guides exist; 8 issuer types in docs; 11 target types in docs | Content accuracy, link validity | | 40 | Documentation | 8 | README, quickstart, architecture, connectors exist; migration guides exist; 8 issuer types in docs; 11 target types in docs | Content accuracy, link validity |
| 41 | Regression | 3 | DELETE 204, per_page max fallback, network scan target seed count | `errors.Is(errors.New())` anti-pattern source scan | | 41 | Regression | 3 | DELETE 204, per_page max fallback, network scan target seed count | `errors.Is(errors.New())` anti-pattern source scan |
| 42 | Envoy Target | 5 | Domain type, connector file, test file, OpenAPI, agent dispatch | Envoy deployment test, SDS config | | 42 | Envoy Target | 5 | Domain type, connector file, test file, OpenAPI, agent dispatch | Envoy deployment test, SDS config |
| 43 | Postfix/Dovecot | 3 | Domain types (Postfix + Dovecot), connector file, OpenAPI | Mail server deployment test | | 43 | Postfix/Dovecot | 3 | Domain types (Postfix + Dovecot), connector file, OpenAPI | Mail server deployment test |
@@ -185,12 +179,12 @@ This table shows what each Part tests and what's left for manual verification.
| 52 | Helm Chart | 5 | Chart.yaml, values.yaml, 4 templates exist, securityContext, health probes | `helm template` rendering, `helm install` | | 52 | Helm Chart | 5 | Chart.yaml, values.yaml, 4 templates exist, securityContext, health probes | `helm template` rendering, `helm install` |
| 53 | Kubernetes Secrets Target Connector (M47) | 18 | Config validation (namespace DNS-1123, secret name DNS subdomain, label keys, required fields), deployment (create/update Secret, chain concatenation, error propagation), validation (serial comparison, not-found, empty cert) | GUI target wizard KubernetesSecrets fields (namespace, secret_name, labels, kubeconfig_path), Helm RBAC toggle, TargetDetailPage type label | | 53 | Kubernetes Secrets Target Connector (M47) | 18 | Config validation (namespace DNS-1123, secret name DNS subdomain, label keys, required fields), deployment (create/update Secret, chain concatenation, error propagation), validation (serial comparison, not-found, empty cert) | GUI target wizard KubernetesSecrets fields (namespace, secret_name, labels, kubeconfig_path), Helm RBAC toggle, TargetDetailPage type label |
| 54 | AWS ACM Private CA Issuer Connector (M47) | 23 | Config validation (region, CA ARN regex, signing algorithm whitelist, validity_days, defaults), issuance (full flow, empty CSR, errors), renewal (reuses issuance), revocation (reason mapping, default, errors), GetOrderStatus completed, GetCACertPEM (success/chain/error), GetRenewalInfo nil | GUI issuer wizard AWSACMPCA fields (region, ca_arn, signing_algorithm, validity_days, template_arn), seed data visibility, create issuer flow | | 54 | AWS ACM Private CA Issuer Connector (M47) | 23 | Config validation (region, CA ARN regex, signing algorithm whitelist, validity_days, defaults), issuance (full flow, empty CSR, errors), renewal (reuses issuance), revocation (reason mapping, default, errors), GetOrderStatus completed, GetCACertPEM (success/chain/error), GetRenewalInfo nil | GUI issuer wizard AWSACMPCA fields (region, ca_arn, signing_algorithm, validity_days, template_arn), seed data visibility, create issuer flow |
| 55 | Agent Soft-Retirement (I-004) | 0 (NOT AUTOMATED) | — | Soft-retire vs hard-retire; force flag; reason capture; foreign-key cascade behavior on retired-agent cert ownership; reactivation. Test manually per `docs/testing-guide.md::Part 55` | | 55 | Agent Soft-Retirement (I-004) | 0 (NOT AUTOMATED) | — | Soft-retire vs hard-retire; force flag; reason capture; foreign-key cascade behavior on retired-agent cert ownership; reactivation. Test manually — see the Coverage Map row |
| 56 | Notification Retry & Dead-Letter Queue (I-005) | 0 (NOT AUTOMATED) | — | Retry loop with exponential backoff, dead-letter transition after N retries, requeue endpoint (`POST /api/v1/notifications/{id}/requeue`), idempotency on retry. Test manually per `docs/testing-guide.md::Part 56` | | 56 | Notification Retry & Dead-Letter Queue (I-005) | 0 (NOT AUTOMATED) | — | Retry loop with exponential backoff, dead-letter transition after N retries, requeue endpoint (`POST /api/v1/notifications/{id}/requeue`), idempotency on retry. Test manually — see the Coverage Map row |
**Totals (verified 2026-04-27):** 49 `Part_*` automation wrappers, ~159 leaf subtests, 11 fully **Totals (verified 2026-04-27):** 49 `Part_*` automation wrappers, ~159 leaf subtests, 11 fully
skipped Parts, 4 Parts not yet automated (23, 24, 55, 56), and an unspecified count of manual-only skipped Parts, 4 Parts not yet automated (23, 24, 55, 56), and an unspecified count of manual-only
flows (GUI, scheduler timing, Docker log inspection). Run `grep -cE '^## Part [0-9]+:' docs/testing-guide.md` flows (GUI, scheduler timing, Docker log inspection). Run `grep -cE 't\.Run\("Part[0-9]+_' deploy/test/qa_test.go` to count Part_* automation wrappers
and `grep -cE 't\.Run\("Part[0-9]+_' deploy/test/qa_test.go` to re-verify. and `grep -cE 't\.Run\("Part[0-9]+_' deploy/test/qa_test.go` to re-verify.
## Coverage by Risk Class ## Coverage by Risk Class
@@ -199,14 +193,14 @@ A buyer's QA lead reading this doc wants "where are the existential bugs caught?
| Risk class | Description | Parts in scope | Automation status | | Risk class | Description | Parts in scope | Automation status |
|---|---|---|---| |---|---|---|---|
| **Existential** (Critical paths — bugs would compromise CA, leak keys, mis-issue, bypass revocation) | Crypto, PKCS#7, local-issuer, OCSP/CRL, agent keygen, CSR validation | 5 (Revocation), 21 (EST), 23 (S/MIME EKU), 24 (OCSP/CRL), 47 (Digest with cert content), 53 (K8s Secrets), 54 (AWS PCA) | 5/7 automated; Parts 23 + 24 pending (Bundle I Skip stubs in `qa_test.go`; manual playbook in `testing-guide.md`) | | **Existential** (Critical paths — bugs would compromise CA, leak keys, mis-issue, bypass revocation) | Crypto, PKCS#7, local-issuer, OCSP/CRL, agent keygen, CSR validation | 5 (Revocation), 21 (EST), 23 (S/MIME EKU), 24 (OCSP/CRL), 47 (Digest with cert content), 53 (K8s Secrets), 54 (AWS PCA) | 5/7 automated; Parts 23 + 24 pending (Bundle I Skip stubs in `qa_test.go`; manual playbook in the Coverage Map below) |
| **High** (FSM corruption, credential leak, authn/z weakening) | Renewal, jobs, agents, issuers, deployment, scheduler | 4, 7, 8, 9, 18, 19, 20, 22, 25, 28, 29, 32, 33, 48, 49, 55, 56 | 14/17 automated; CLI / MCP / scheduler-loop are inherently SKIP (require compiled binaries / Docker logs); Parts 55 + 56 pending | | **High** (FSM corruption, credential leak, authn/z weakening) | Renewal, jobs, agents, issuers, deployment, scheduler | 4, 7, 8, 9, 18, 19, 20, 22, 25, 28, 29, 32, 33, 48, 49, 55, 56 | 14/17 automated; CLI / MCP / scheduler-loop are inherently SKIP (require compiled binaries / Docker logs); Parts 55 + 56 pending |
| **Medium** (Operational pain or silent data drift) | Targets, notifiers, observability, error handling, performance, regression | 14, 15-17, 30, 31, 38, 39, 40, 41, 42, 43, 44, 45, 46 | 14/14 automated (15-17 indirect via Parts 4246) | | **Medium** (Operational pain or silent data drift) | Targets, notifiers, observability, error handling, performance, regression | 14, 15-17, 30, 31, 38, 39, 40, 41, 42, 43, 44, 45, 46 | 14/14 automated (15-17 indirect via Parts 4246) |
| **Low** (Hygiene) | Documentation, docs verification | 40 (Documentation), 50 (Onboarding) | 2/2 automated | | **Low** (Hygiene) | Documentation, docs verification | 40 (Documentation), 50 (Onboarding) | 2/2 automated |
| **Frontend** (XSS, render correctness, mutation contracts) | GUI testing | 35, 36-37 | 0/3 automated in this suite (Vitest covers separately under `web/`); this doc punts to manual + Vitest | | **Frontend** (XSS, render correctness, mutation contracts) | GUI testing | 35, 36-37 | 0/3 automated in this suite (Vitest covers separately under `web/`); this doc punts to manual + Vitest |
| **Compliance** (PCI / SOC2 / HIPAA-relevant) | Audit trail, body-size limits, request limits, Helm chart deploy posture | 27, 32, 51, 52 | 4/4 automated | | **Audit-relevant** | Audit trail, body-size limits, request limits, Helm chart deploy posture | 27, 32, 51, 52 | 4/4 automated |
This is the table acquisition reviewers screenshot for their report. When a new Part lands in `testing-guide.md`, classify it here; the QA-doc Part-count drift guard (`.github/workflows/ci.yml::QA-doc Part-count drift guard`) catches the count mismatch. This is the table acquisition reviewers screenshot for their report. When a new Part_* subtest lands in `qa_test.go`, classify it here.
## Test Categories ## Test Categories
@@ -238,11 +232,11 @@ Timed API requests with threshold assertions:
## What This Test Does NOT Cover ## What This Test Does NOT Cover
These gaps must be filled by manual testing per `docs/testing-guide.md`: These gaps must be filled by manual testing — see each Coverage Map row for surface-area description:
### Not Yet Automated (Parts 23, 24, 55, 56) ### Not Yet Automated (Parts 23, 24, 55, 56)
These Parts are documented in `docs/testing-guide.md` but have no `Part_*` automation These historical QA Parts are listed in the Coverage Map below but have no `Part_*` automation
in `qa_test.go` yet. They are operator-runnable from the manual playbook; QA-suite in `qa_test.go` yet. They are operator-runnable from the manual playbook; QA-suite
automation should land before the next acquisition-grade release. automation should land before the next acquisition-grade release.
@@ -436,7 +430,7 @@ grep -oE 'mutation score is [0-9.]+' tool-output/mutation-crypto.txt | tail -1
When a new feature ships: When a new feature ships:
1. **Add a Part section** in `qa_test.go` following the numbering in `docs/testing-guide.md` 1. **Add a Part section** in `qa_test.go` following the numbering convention in the Coverage Map below
2. **API tests**: use `c.get()`, `c.post()`, `c.bodyStr()`, `c.getJSON()`, `c.timedGet()` 2. **API tests**: use `c.get()`, `c.post()`, `c.bodyStr()`, `c.getJSON()`, `c.timedGet()`
3. **Source checks**: use `fileExists(t, "relative/path")` and `fileContains(t, "path", "substring")` 3. **Source checks**: use `fileExists(t, "relative/path")` and `fileContains(t, "path", "substring")`
4. **DB checks**: use `openQADB(t)` and `db.queryInt(t, "SELECT ...")` 4. **DB checks**: use `openQADB(t)` and `db.queryInt(t, "SELECT ...")`
+93
View File
@@ -0,0 +1,93 @@
# Release Sign-Off
> Last reviewed: 2026-05-05
Release-day checklist for tagging a new certctl release. Walks through the gates that must be green before pushing the tag, in the order they should be verified.
## Pre-release: code state
| Gate | How to check | Pass |
|---|---|---|
| `master` is at the commit you intend to tag | `git log -1 --format='%H %s'` | ☐ |
| Working tree clean | `git status -sb` | ☐ |
| Local matches GitHub | `curl -sS https://api.github.com/repos/certctl-io/certctl/commits/master \| grep -oE '"sha": "[a-f0-9]+"' \| head -1` matches local | ☐ |
| `WORKSPACE-CHANGELOG.md` updated with the release's milestones | manual review | ☐ |
| `certctl/CHANGELOG.md` updated (release-facing) | manual review | ☐ |
| Migration ladder ends cleanly | `ls migrations/*.up.sql \| sort \| tail -3` shows the right last migration | ☐ |
## Pre-release: automated gates (CI)
| Gate | How to check | Pass |
|---|---|---|
| CI pipeline green on the tag-target commit | GitHub Actions web UI | ☐ |
| `make verify` clean locally | run from repo root | ☐ |
| `go test -race -count=1 ./...` clean | full race check | ☐ |
| `golangci-lint run ./...` clean | local lint | ☐ |
| `govulncheck ./...` clean | vulnerability scan | ☐ |
| Coverage thresholds met (service ≥55%, handler ≥60%, domain ≥40%, middleware ≥30%) | `go test -coverprofile=cover.out ./... && go tool cover -func=cover.out` | ☐ |
| Frontend type-check + Vitest + Vite build clean | `cd web && npm run typecheck && npm run test && npm run build` | ☐ |
## Pre-release: manual QA passes
| Surface | Checklist | Pass |
|---|---|---|
| Local stack boots clean from scratch | `qa-prerequisites.md` Steps 1-4 green | ☐ |
| GUI QA checklist | `gui-qa-checklist.md` end to end | ☐ |
| End-to-end test environment | `test-environment.md` Steps 1-14 green | ☐ |
| Performance baselines | `performance-baselines.md` four spot checks within bounds | ☐ |
| Helm chart deploys clean | `helm-deployment.md` install + verify | ☐ |
| ACME server interop (cert-manager) | `make acme-cert-manager-test` green | ☐ |
| ACME server RFC conformance (lego) | `make acme-rfc-conformance-test` green | ☐ |
## Release artefact verification
After the release workflow runs (triggered by tag push), verify the published artefacts:
| Artefact | How to verify | Pass |
|---|---|---|
| Cosign keyless OIDC signature on `checksums.txt` | per `docs/reference/release-verification.md` step 2 | ☐ |
| SLSA Level 3 provenance on each binary | step 3 | ☐ |
| Container image signature + SBOM + provenance | step 4 | ☐ |
| Release notes published on GitHub Releases page | manual review | ☐ |
| ghcr.io images at `ghcr.io/certctl-io/certctl-{server,agent}:<tag>` pullable | `docker pull` round-trips | ☐ |
## Branch protection + tag push
| Gate | How to check | Pass |
|---|---|---|
| `master` branch protection rule allows the tag push | Repository Settings → Branches | ☐ |
| Tag pushed | `git tag -s v<version> -m 'Release v<version>'; git push origin v<version>` | ☐ |
| Release workflow kicked off in GitHub Actions | watch the Actions tab | ☐ |
## Post-release
| Gate | How to check | Pass |
|---|---|---|
| Release workflow completed without errors | GitHub Actions | ☐ |
| Sample binary downloaded and Cosign-verified by an operator who is not the release author | another team member | ☐ |
| `WORKSPACE-CHANGELOG.md` notes the tag commit SHA | manual edit | ☐ |
| workspace-tracking "Active Focus" → "Current tag" updated | manual edit | ☐ |
| `certctl.io/index.html` star count + `data-gh-version` rendering picks up the new tag | open the landing page in 6+ hours (cache TTL) | ☐ |
| Reddit / Hacker News / LinkedIn announcement drafted (if a major release) | per the operator's promotion playbook | ☐ |
## If a gate fails
Revert the tag push immediately:
```bash
git push --delete origin v<version>
git tag -d v<version>
```
Investigate, fix, re-tag.
## Related docs
- [`docs/contributor/qa-prerequisites.md`](qa-prerequisites.md) — local stack prereqs
- [`docs/contributor/test-environment.md`](test-environment.md) — full local environment tutorial
- [`docs/contributor/gui-qa-checklist.md`](gui-qa-checklist.md) — GUI manual QA pass
- [`docs/contributor/testing-strategy.md`](testing-strategy.md) — what we test in CI vs deep-scan vs manual QA
- [`docs/contributor/ci-pipeline.md`](ci-pipeline.md) — CI shape and regression guards
- [`docs/operator/performance-baselines.md`](../operator/performance-baselines.md) — performance regression spot checks
- [`docs/operator/helm-deployment.md`](../operator/helm-deployment.md) — Helm install + verify
- [`docs/reference/release-verification.md`](../reference/release-verification.md) — Cosign / SLSA / SBOM verification procedure
@@ -1,5 +1,7 @@
# certctl Testing Environment # certctl Testing Environment
> Last reviewed: 2026-05-05
A step-by-step guide to running certctl locally with real certificate authorities. Every command is spelled out. Every expected output is shown. If something goes wrong, the troubleshooting section tells you exactly what to check. A step-by-step guide to running certctl locally with real certificate authorities. Every command is spelled out. Every expected output is shown. If something goes wrong, the troubleshooting section tells you exactly what to check.
--- ---
@@ -70,7 +72,7 @@ If this says "command not found", you have an old Docker version. Update Docker
You need the certctl source code on your machine. If you haven't cloned it yet: You need the certctl source code on your machine. If you haven't cloned it yet:
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/certctl-io/certctl.git
cd certctl cd certctl
``` ```
@@ -171,7 +173,7 @@ curl --cacert "$CA" -f https://localhost:8443/health
Expect `{"status":"ok"}`. If `curl` errors with `SSL certificate problem: unable to get local issuer certificate`, the init container hasn't finished yet — wait a few seconds and retry. If the file doesn't exist at all, the bind mount didn't populate; `docker compose -f docker-compose.test.yml logs certctl-tls-init` should show the self-sign ran. Expect `{"status":"ok"}`. If `curl` errors with `SSL certificate problem: unable to get local issuer certificate`, the init container hasn't finished yet — wait a few seconds and retry. If the file doesn't exist at all, the bind mount didn't populate; `docker compose -f docker-compose.test.yml logs certctl-tls-init` should show the self-sign ran.
For a full explanation of the cert provisioning patterns (self-signed bootstrap, operator-supplied, cert-manager), see [`tls.md`](tls.md). For the one-step cutover from the old plaintext test harness to HTTPS, see [`upgrade-to-tls.md`](upgrade-to-tls.md). For a full explanation of the cert provisioning patterns (self-signed bootstrap, operator-supplied, cert-manager), see [`tls.md`](../operator/tls.md). For the one-step cutover from the old plaintext test harness to HTTPS, see [`upgrade-to-tls.md`](../archive/upgrades/to-tls-v2.2.md).
--- ---
@@ -811,17 +813,30 @@ All containers share a bridge network (`certctl-test`, subnet 10.30.50.0/24) wit
### Key Generation Flow (Agent-Side) ### Key Generation Flow (Agent-Side)
``` ```mermaid
Server creates job (AwaitingCSR) → Agent polls, sees job → sequenceDiagram
Agent generates ECDSA P-256 key pair locally → autonumber
Agent creates CSR (public key + CN + SANs) → participant Srv as certctl-server
Agent POSTs CSR to server → Server signs via issuer → participant Iss as Issuer connector
Server stores cert, creates Deployment job (Pending) → participant Agt as certctl-agent
Agent polls, sees Deployment job → participant FS as /var/lib/certctl/keys/<br/>(local agent FS)
Agent fetches signed cert from server → participant Vol as /nginx-certs/<br/>(shared volume)
Agent reads local private key from /var/lib/certctl/keys/ →
Agent writes cert + key + chain to /nginx-certs/ (shared volume) → Srv->>Srv: create Job (AwaitingCSR)
Job marked Completed Agt->>Srv: poll for jobs
Srv-->>Agt: Job(AwaitingCSR)
Agt->>FS: generate ECDSA P-256 keypair
Agt->>Agt: build CSR (pubkey + CN + SANs)
Agt->>Srv: POST CSR
Srv->>Iss: sign CSR
Iss-->>Srv: signed cert
Srv->>Srv: store cert; create Deployment Job (Pending)
Agt->>Srv: poll for jobs
Srv-->>Agt: Job(Deployment)
Agt->>Srv: GET signed cert
Agt->>FS: read private key
Agt->>Vol: write cert + key + chain
Agt->>Srv: mark Job(Completed)
``` ```
### Shared Volume Architecture ### Shared Volume Architecture
@@ -1,12 +1,14 @@
# certctl Testing Strategy & Deep-Scan Operator Runbook # certctl Testing Strategy & Deep-Scan Operator Runbook
> Last reviewed: 2026-05-05
This doc covers the **testing topology** (per-PR fast gates vs. daily deep-scan This doc covers the **testing topology** (per-PR fast gates vs. daily deep-scan
gates), and the **operator runbook** for re-running each deep-scan tool locally gates), and the **operator runbook** for re-running each deep-scan tool locally
when the CI receipt is ambiguous or when an operator wants to validate a fix when the CI receipt is ambiguous or when an operator wants to validate a fix
before the next scheduled scan. before the next scheduled scan.
For the manual end-to-end QA playbook, see [`testing-guide.md`](testing-guide.md). For the manual end-to-end QA playbook, see [`testing-guide.md`](../testing-guide.md).
For the security posture / per-finding closure log, see [`security.md`](security.md). For the security posture / per-finding closure log, see [`security.md`](../operator/security.md).
## CI workflow split ## CI workflow split
@@ -53,7 +55,7 @@ the bug the mutant introduced).
**Acceptance threshold:** ≥80% mutation kill ratio per package. Surviving **Acceptance threshold:** ≥80% mutation kill ratio per package. Surviving
mutants below that threshold get triaged in mutants below that threshold get triaged in
`cowork/comprehensive-audit-2026-04-25/d003-mutation-results.md` — either the project's 2026-04-25 mutation-results notes — either
ship a targeted unit test that kills the mutant, or document an ship a targeted unit test that kills the mutant, or document an
equivalent-mutation justification. equivalent-mutation justification.
@@ -191,8 +193,8 @@ Re-run any of the deep-scan tools locally when:
## Related docs ## Related docs
- [`docs/security.md`](security.md) — security posture, per-finding closure log. - [`docs/operator/security.md`](../operator/security.md) — security posture, per-finding closure log.
- [`docs/testing-guide.md`](testing-guide.md) — manual end-to-end QA playbook. - [`docs/testing-guide.md`](../testing-guide.md) — manual end-to-end QA playbook.
- [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — per-PR fast gates. - [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — per-PR fast gates.
- [`.github/workflows/security-deep-scan.yml`](../.github/workflows/security-deep-scan.yml) — daily deep-scan gates. - [`.github/workflows/security-deep-scan.yml`](../.github/workflows/security-deep-scan.yml) — daily deep-scan gates.
- [`scripts/install-security-tools.sh`](../scripts/install-security-tools.sh) — Go-host-installed tools (the docker-based tools are not in this script). - [`scripts/install-security-tools.sh`](../scripts/install-security-tools.sh) — Go-host-installed tools (the docker-based tools are not in this script).
-1606
View File
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
# Advanced Demo: Certificate Lifecycle End-to-End # Advanced Demo: Certificate Lifecycle End-to-End
> Last reviewed: 2026-05-05
This demo goes beyond browsing pre-loaded data. You'll create a team, register an owner, set up an issuer, create a certificate, trigger renewal, and watch everything appear in the dashboard in real time. Each step includes a technical explanation of what's happening inside certctl and why the system is designed that way. This demo goes beyond browsing pre-loaded data. You'll create a team, register an owner, set up an issuer, create a certificate, trigger renewal, and watch everything appear in the dashboard in real time. Each step includes a technical explanation of what's happening inside certctl and why the system is designed that way.
**Time**: 15-20 minutes **Time**: 15-20 minutes
@@ -363,7 +365,7 @@ curl -s -X POST $API/api/v1/certificates \
| `issuer_id` | Links to the issuer connector that will sign this certificate. Determines which CA backend is used. | | `issuer_id` | Links to the issuer connector that will sign this certificate. Determines which CA backend is used. |
| `renewal_policy_id` | Links to a `renewal_policies` row that defines: how many days before expiry to renew (`renewal_window_days`), whether auto-renewal is enabled (`auto_renew`), max retries, and retry interval. The default policy (`rp-default`) renews 30 days before expiry. | | `renewal_policy_id` | Links to a `renewal_policies` row that defines: how many days before expiry to renew (`renewal_window_days`), whether auto-renewal is enabled (`auto_renew`), max retries, and retry interval. The default policy (`rp-default`) renews 30 days before expiry. |
| `status` | Set to `Pending` because the certificate hasn't been issued yet. The scheduler will pick it up, or you can trigger renewal manually. | | `status` | Set to `Pending` because the certificate hasn't been issued yet. The scheduler will pick it up, or you can trigger renewal manually. |
| `tags` | Arbitrary key-value metadata stored as JSONB. Useful for filtering, reporting, and integration with external systems (e.g., `"pci": "true"` for compliance scoping). | | `tags` | Arbitrary key-value metadata stored as JSONB. Useful for filtering, reporting, and integration with external systems (e.g., `"environment": "production"` for fleet scoping). |
**Check the dashboard now.** Click "Certificates" in the sidebar. You'll see your new "Demo API Certificate" with status "Pending" alongside the pre-loaded demo certificates. Click on it to see the full details. **Check the dashboard now.** Click "Certificates" in the sidebar. You'll see your new "Demo API Certificate" with status "Pending" alongside the pre-loaded demo certificates. Click on it to see the full details.
@@ -603,7 +605,7 @@ curl -s "$API/api/v1/audit?created_after=2026-03-24T09:00:00Z" | jq '.data | len
The audit middleware (M19) records every HTTP request: method, path, status code, actor, request body SHA-256 hash, and latency. This creates a complete API audit trail without blocking responses (logging happens asynchronously). The audit middleware (M19) records every HTTP request: method, path, status code, actor, request body SHA-256 hash, and latency. This creates a complete API audit trail without blocking responses (logging happens asynchronously).
**Why immutable audit:** Compliance frameworks (SOC 2 Type II, PCI-DSS, ISO 27001) require tamper-evident audit logs. By making the repository interface append-only and recording API calls, even a compromised API server can't retroactively delete or modify audit records. In a production deployment, you'd also stream these to an external SIEM (Splunk, Datadog) for additional protection. **Why immutable audit:** tamper-evident audit logs are a hard requirement when an attacker has compromised the API server. By making the repository interface append-only and recording API calls, even a compromised API server can't retroactively delete or modify audit records. In a production deployment, you'd also stream these to an external SIEM (Splunk, Datadog) for additional protection.
**Check the dashboard.** The "Audit" view shows the full timeline of all actions across the system with filtering and CSV/JSON export. **Check the dashboard.** The "Audit" view shows the full timeline of all actions across the system with filtering and CSV/JSON export.
@@ -701,7 +703,7 @@ curl -s -X POST $API/api/v1/certificates \
**Why `environment` matters:** The environment field isn't just metadata — it feeds the policy engine. A policy rule with type `AllowedEnvironments` can restrict which environments are valid. If someone tries to create a certificate with `environment: "yolo"`, the policy engine flags a violation. In a mature deployment, you'd enforce policies strictly: production certificates must use a trusted CA (not Local CA), staging certificates can use Let's Encrypt staging, and development certificates can use the Local CA. **Why `environment` matters:** The environment field isn't just metadata — it feeds the policy engine. A policy rule with type `AllowedEnvironments` can restrict which environments are valid. If someone tries to create a certificate with `environment: "yolo"`, the policy engine flags a violation. In a mature deployment, you'd enforce policies strictly: production certificates must use a trusted CA (not Local CA), staging certificates can use Let's Encrypt staging, and development certificates can use the Local CA.
**Why `pci: true` in tags:** Tags are free-form, but they enable powerful filtering and compliance scoping. A security team could query `GET /api/v1/certificates?tags.pci=true` (not implemented yet, but the JSONB column supports it) to find all PCI-scoped certificates and verify they meet compliance requirements. **Why arbitrary tags in metadata:** Tags are free-form, but they enable powerful filtering and fleet scoping. A security team could query `GET /api/v1/certificates?tags.regulated=true` (not implemented yet, but the JSONB column supports it) to find all certificates marked regulated and verify they meet whatever requirements that label maps to.
**Refresh the dashboard** — you'll see the new payment gateway certificate. Try filtering by environment or status to see how both certificates appear alongside the demo data. **Refresh the dashboard** — you'll see the new payment gateway certificate. Try filtering by environment or status to see how both certificates appear alongside the demo data.
@@ -778,7 +780,7 @@ Check existing violations:
curl -s "$API/api/v1/policies/pr-max-certificate-lifetime/violations" | jq . curl -s "$API/api/v1/policies/pr-max-certificate-lifetime/violations" | jq .
``` ```
**How it works:** This hits `GET /api/v1/policies/{id}/violations`, which queries `SELECT * FROM policy_violations WHERE rule_id = $1`. Each violation references the offending certificate and the rule it violated, creating a traceable link between the policy definition and the specific non-compliance. **How it works:** This hits `GET /api/v1/policies/{id}/violations`, which queries `SELECT * FROM policy_violations WHERE rule_id = $1`. Each violation references the offending certificate and the rule it violated, creating a traceable link between the policy definition and the specific violation.
**In the dashboard**, click "Policies" in the sidebar to see all active rules and which certificates are violating them. **In the dashboard**, click "Policies" in the sidebar to see all active rules and which certificates are violating them.
@@ -844,7 +846,7 @@ curl -s -X POST $API/api/v1/profiles \
**How it works:** Certificate profiles are stored in the `certificate_profiles` table with a `allowed_key_algorithms` JSONB column that defines which key types and minimum sizes are acceptable. When a certificate is assigned to a profile, the profile constraints are enforced during CSR validation. The `max_validity_days` field controls the maximum certificate lifetime — profiles with values translating to under 1 hour enable short-lived certificate mode, where certs are exempt from CRL/OCSP. **How it works:** Certificate profiles are stored in the `certificate_profiles` table with a `allowed_key_algorithms` JSONB column that defines which key types and minimum sizes are acceptable. When a certificate is assigned to a profile, the profile constraints are enforced during CSR validation. The `max_validity_days` field controls the maximum certificate lifetime — profiles with values translating to under 1 hour enable short-lived certificate mode, where certs are exempt from CRL/OCSP.
**Why profiles matter:** Without profiles, any agent can submit a CSR with any key type and any validity period. Profiles create crypto policy guardrails — "production TLS certs must use ECDSA P-256 with 90-day max TTL" — that prevent configuration drift and enforce compliance requirements across the fleet. **Why profiles matter:** Without profiles, any agent can submit a CSR with any key type and any validity period. Profiles create crypto policy guardrails — "production TLS certs must use ECDSA P-256 with 90-day max TTL" — that prevent configuration drift and enforce policy across the fleet.
**In the dashboard**, click "Profiles" in the sidebar to see and manage certificate profiles. **In the dashboard**, click "Profiles" in the sidebar to see and manage certificate profiles.
@@ -894,17 +896,17 @@ Approve or reject them:
# Approve a job # Approve a job
curl -s -X POST $API/api/v1/jobs/JOB_ID/approve \ curl -s -X POST $API/api/v1/jobs/JOB_ID/approve \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"reason": "Verified key type meets compliance requirements"}' | jq . -d '{"reason": "Verified key type meets policy"}' | jq .
# Reject a job # Reject a job
curl -s -X POST $API/api/v1/jobs/JOB_ID/reject \ curl -s -X POST $API/api/v1/jobs/JOB_ID/reject \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"reason": "Key type does not meet PCI requirements"}' | jq . -d '{"reason": "Key type does not meet policy"}' | jq .
``` ```
**How it works:** When a renewal policy has `auto_renew` set to false, renewal jobs enter the `AwaitingApproval` state instead of being processed immediately. An operator must explicitly approve or reject the job via the API or the GUI. Approved jobs transition to `Pending` and are picked up by the job processor. Rejected jobs move to `Cancelled` with the provided reason recorded in the audit trail. **How it works:** When a renewal policy has `auto_renew` set to false, renewal jobs enter the `AwaitingApproval` state instead of being processed immediately. An operator must explicitly approve or reject the job via the API or the GUI. Approved jobs transition to `Pending` and are picked up by the job processor. Rejected jobs move to `Cancelled` with the provided reason recorded in the audit trail.
**Why interactive approval:** Not every certificate renewal should be automatic. PCI-scoped certificates, certs with specific compliance requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline. **Why interactive approval:** Not every certificate renewal should be automatic. High-value certificates, certs with specific policy requirements, or certificates being migrated between issuers benefit from a human checkpoint. The AwaitingApproval state creates that checkpoint without blocking the entire job pipeline.
**In the dashboard:** Click "Jobs" in the sidebar, filter by status "AwaitingApproval", and you'll see a list of renewal jobs waiting for approval. Each job shows the certificate, issuer, and requested validity period. Click a job to open its detail view and see the Approve / Reject buttons with a reason text field. After approval or rejection, the job status updates in real-time and the audit trail records the decision. **In the dashboard:** Click "Jobs" in the sidebar, filter by status "AwaitingApproval", and you'll see a list of renewal jobs waiting for approval. Each job shows the certificate, issuer, and requested validity period. Click a job to open its detail view and see the Approve / Reject buttons with a reason text field. After approval or rejection, the job status updates in real-time and the audit trail records the decision.
@@ -987,7 +989,7 @@ export CERTCTL_API_KEY="test-key-123"
## Part 15: MCP Server for AI Integration (M18a) ## Part 15: MCP Server for AI Integration (M18a)
certctl exposes the full REST API via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants: certctl exposes the full REST API via the Model Context Protocol (MCP), enabling seamless integration with any MCP-compatible AI client:
```bash ```bash
# Build the MCP server # Build the MCP server
@@ -1008,19 +1010,19 @@ export CERTCTL_API_KEY="test-key-123"
- **Binary support** — handles DER-encoded CRL and OCSP responses without mangling - **Binary support** — handles DER-encoded CRL and OCSP responses without mangling
- **Error translation** — converts HTTP errors to user-readable messages - **Error translation** — converts HTTP errors to user-readable messages
**Example usage from Claude:** **Example usage:**
``` ```
User: What certificates are expiring in the next 30 days? User: What certificates are expiring in the next 30 days?
Claude uses the MCP tools to: The AI client uses the MCP tools to:
1. Call tools.listCertificates with filters: {status: "Expiring"} 1. Call tools.listCertificates with filters: {status: "Expiring"}
2. Parse the response 2. Parse the response
3. Display: "mc-api-prod expires in 12 days. mc-cdn-prod expires in 8 days..." 3. Display: "mc-api-prod expires in 12 days. mc-cdn-prod expires in 8 days..."
User: Revoke mc-payments due to key compromise User: Revoke mc-payments due to key compromise
Claude uses the MCP tools to: The AI client uses the MCP tools to:
1. Call tools.revokeCertificate with id="mc-payments" reason="keyCompromise" 1. Call tools.revokeCertificate with id="mc-payments" reason="keyCompromise"
2. Return the audit trail entry showing revocation recorded 2. Return the audit trail entry showing revocation recorded
``` ```
@@ -1,5 +1,7 @@
# Understanding Certificates: A Beginner's Guide # Understanding Certificates: A Beginner's Guide
> Last reviewed: 2026-05-05
If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential. If you've never worked with TLS certificates before, this guide will get you up to speed. By the end, you'll understand what certificates are, why they matter, and why the industry's move toward shorter certificate lifespans — down to 47 days by 2029 — makes automated lifecycle management essential.
## Contents ## Contents
@@ -123,7 +125,7 @@ At no point does the private key leave the agent. This is a fundamental security
Agents also report **metadata** about themselves — their operating system, CPU architecture, IP address, hostname, and version — with every heartbeat. This gives ops teams fleet-wide visibility (e.g., "how many agents are running on ARM?", "which agents are still on v1.0.0?") and powers **agent groups** — dynamic device grouping where policies can be scoped to specific agent criteria like OS type, architecture, or network subnet. Agents also report **metadata** about themselves — their operating system, CPU architecture, IP address, hostname, and version — with every heartbeat. This gives ops teams fleet-wide visibility (e.g., "how many agents are running on ARM?", "which agents are still on v1.0.0?") and powers **agent groups** — dynamic device grouping where policies can be scoped to specific agent criteria like OS type, architecture, or network subnet.
**Retiring an agent.** When you decommission a server, the certctl record for its agent needs to be retired, not deleted. certctl uses a **soft-delete** model: `DELETE /api/v1/agents/{id}` stamps the row with a retired-at timestamp and a reason, instead of removing it. This is deliberate — an audit trail of "who owned this certificate, on which host, for which team" stays intact forever, and the downstream deployment_targets, certificates, and jobs keep valid foreign keys. Retired agents are filtered out of default list views and the dashboard's agent counter, but remain visible through a separate retired-agents view for compliance reconciliation. If the agent still has active deployment targets, deployed certificates, or pending jobs, retirement is blocked by default so you don't silently orphan those rows; the API responds with the exact counts so you can retire or reassign each dependency explicitly. A force-retire escape hatch (`?force=true&reason=...`) is available for true decommission scenarios — it transactionally retires the downstream targets, cancels pending jobs, and records the cascade in the audit trail with the reason you provided. Four internal sentinel agents that back the network scanner and the cloud secret-manager discovery sources cannot be retired at all, even with force, because retiring them would orphan their subsystems. Once retired, an agent that still attempts to heartbeat receives `410 Gone` — the agent process reads that as "you've been retired, shut down" and exits cleanly. **Retiring an agent.** When you decommission a server, the certctl record for its agent needs to be retired, not deleted. certctl uses a **soft-delete** model: `DELETE /api/v1/agents/{id}` stamps the row with a retired-at timestamp and a reason, instead of removing it. This is deliberate — an audit trail of "who owned this certificate, on which host, for which team" stays intact forever, and the downstream deployment_targets, certificates, and jobs keep valid foreign keys. Retired agents are filtered out of default list views and the dashboard's agent counter, but remain visible through a separate retired-agents view for audit reconciliation. If the agent still has active deployment targets, deployed certificates, or pending jobs, retirement is blocked by default so you don't silently orphan those rows; the API responds with the exact counts so you can retire or reassign each dependency explicitly. A force-retire escape hatch (`?force=true&reason=...`) is available for true decommission scenarios — it transactionally retires the downstream targets, cancels pending jobs, and records the cascade in the audit trail with the reason you provided. Four internal sentinel agents that back the network scanner and the cloud secret-manager discovery sources cannot be retired at all, even with force, because retiring them would orphan their subsystems. Once retired, an agent that still attempts to heartbeat receives `410 Gone` — the agent process reads that as "you've been retired, shut down" and exits cleanly.
### Deployment Targets ### Deployment Targets
@@ -220,7 +222,7 @@ certctl implements revocation using three complementary mechanisms:
**Certificate Revocation List (CRL)**: certctl serves DER-encoded X.509 CRLs per issuer at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 wire format, RFC 8615 well-known namespace). The endpoint is unauthenticated so any relying party — browser, TLS client, hardware appliance — can fetch it without a certctl API key. The CRL is signed by the issuing CA's key and has 24-hour validity; clients can download it periodically to check revocation status offline. The response carries `Content-Type: application/pkix-crl`. The CRL is **pre-generated** by a scheduler-driven loop (`crlGenerationLoop`, default interval 1 hour, configurable via `CERTCTL_CRL_GENERATION_INTERVAL`) and persisted in the `crl_cache` table — HTTP fetches read from the cache rather than rebuilding per request, so a busy CA does not DOS itself at scale. Concurrent regeneration requests for the same issuer are coalesced via an in-tree singleflight gate. **Certificate Revocation List (CRL)**: certctl serves DER-encoded X.509 CRLs per issuer at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 wire format, RFC 8615 well-known namespace). The endpoint is unauthenticated so any relying party — browser, TLS client, hardware appliance — can fetch it without a certctl API key. The CRL is signed by the issuing CA's key and has 24-hour validity; clients can download it periodically to check revocation status offline. The response carries `Content-Type: application/pkix-crl`. The CRL is **pre-generated** by a scheduler-driven loop (`crlGenerationLoop`, default interval 1 hour, configurable via `CERTCTL_CRL_GENERATION_INTERVAL`) and persisted in the `crl_cache` table — HTTP fetches read from the cache rather than rebuilding per request, so a busy CA does not DOS itself at scale. Concurrent regeneration requests for the same issuer are coalesced via an in-tree singleflight gate.
**OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder serving both forms RFC 6960 §A.1.1 defines: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (URL-path lookup, useful for ops curl-debugging) and `POST /.well-known/pki/ocsp/{issuer_id}` with a binary `application/ocsp-request` body (the form most production clients use — Firefox, OpenSSL `s_client -status`, cert-manager, Intune device-state validators). Both forms are unauthenticated and return signed OCSP responses (good, revoked, or unknown) with `Content-Type: application/ocsp-response`. OCSP responses are signed by a **dedicated per-issuer OCSP responder cert** (RFC 6960 §2.6 / §4.2.2.2) — NOT by the CA private key directly — that carries the `id-pkix-ocsp-nocheck` extension (RFC 6960 §4.2.2.2.1) so OCSP clients do not recursively check the responder cert's own revocation status. The responder cert auto-rotates within 7 days of expiry (configurable via `CERTCTL_OCSP_RESPONDER_ROTATION_GRACE`), letting the responder key live on disk or rotate frequently while the CA key stays cold. See [`crl-ocsp.md`](crl-ocsp.md) for endpoint examples (curl, OpenSSL, Firefox, Intune) and the responder cert lifecycle. **OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder serving both forms RFC 6960 §A.1.1 defines: `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (URL-path lookup, useful for ops curl-debugging) and `POST /.well-known/pki/ocsp/{issuer_id}` with a binary `application/ocsp-request` body (the form most production clients use — Firefox, OpenSSL `s_client -status`, cert-manager, Intune device-state validators). Both forms are unauthenticated and return signed OCSP responses (good, revoked, or unknown) with `Content-Type: application/ocsp-response`. OCSP responses are signed by a **dedicated per-issuer OCSP responder cert** (RFC 6960 §2.6 / §4.2.2.2) — NOT by the CA private key directly — that carries the `id-pkix-ocsp-nocheck` extension (RFC 6960 §4.2.2.2.1) so OCSP clients do not recursively check the responder cert's own revocation status. The responder cert auto-rotates within 7 days of expiry (configurable via `CERTCTL_OCSP_RESPONDER_ROTATION_GRACE`), letting the responder key live on disk or rotate frequently while the CA key stays cold. See [`crl-ocsp.md`](../reference/protocols/crl-ocsp.md) for endpoint examples (curl, OpenSSL, Firefox, Intune) and the responder cert lifecycle.
Short-lived certificates (those assigned to profiles with TTL under 1 hour) are exempt from CRL and OCSP — their rapid expiry is considered sufficient revocation. This is a deliberate design choice to reduce infrastructure overhead for ephemeral machine-to-machine credentials. Short-lived certificates (those assigned to profiles with TTL under 1 hour) are exempt from CRL and OCSP — their rapid expiry is considered sufficient revocation. This is a deliberate design choice to reduce infrastructure overhead for ephemeral machine-to-machine credentials.
@@ -242,7 +244,7 @@ Every action in certctl — issuing a certificate, renewing one, deploying to a
### Audit Trail ### Audit Trail
Every action is logged: who did it, what changed, when, and why. This is essential for compliance (SOC 2, PCI-DSS, ISO 27001) and for debugging. You can trace a certificate's entire history from creation through every renewal and deployment. Every action is logged: who did it, what changed, when, and why. This is essential for audit and for debugging. You can trace a certificate's entire history from creation through every renewal and deployment.
### Notifications ### Notifications
@@ -256,7 +258,7 @@ The CLI supports both table and JSON output formats (`--format table` or `--form
### MCP Server (AI Integration) ### MCP Server (AI Integration)
certctl includes an MCP (Model Context Protocol) server that exposes the entire REST API as MCP tools. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?" certctl includes an MCP (Model Context Protocol) server that exposes the entire REST API as MCP tools. This enables AI assistants and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?"
The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key. The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key.
@@ -279,7 +281,7 @@ This gives you a three-step triage workflow:
Network scan targets are managed from the **Network Scans** dashboard page — create CIDR ranges and ports to probe, enable/disable targets, trigger on-demand scans, and view results. Discovered certificates from network scans appear in the same Discovery triage page alongside filesystem discoveries. Network scan targets are managed from the **Network Scans** dashboard page — create CIDR ranges and ports to probe, enable/disable targets, trigger on-demand scans, and view results. Discovered certificates from network scans appear in the same Discovery triage page alongside filesystem discoveries.
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter. This is a prerequisite for multi-CA migration, audit reviews, and building confidence that you've found all the certificates that matter.
### Observability ### Observability
@@ -291,4 +293,4 @@ The agent fleet overview page groups agents by OS, architecture, and version, sh
Now that you understand the concepts, head to the [Quick Start Guide](quickstart.md) to get certctl running locally in under 5 minutes. You'll see a pre-loaded dashboard with demo certificates, explore the API, and understand how everything fits together. Now that you understand the concepts, head to the [Quick Start Guide](quickstart.md) to get certctl running locally in under 5 minutes. You'll see a pre-loaded dashboard with demo certificates, explore the API, and understand how everything fits together.
For a deeper look at the system design, see the [Architecture Guide](architecture.md). For terminal-based workflows, check out the CLI Guide (docs coming soon). For AI-native integration, see the [MCP Server Guide](mcp.md). For the full API reference, see the [OpenAPI Spec Guide](openapi.md). For a deeper look at the system design, see the [Architecture Guide](../reference/architecture.md). For terminal-based workflows, check out the CLI Guide (docs coming soon). For AI-native integration, see the [MCP Server Guide](../reference/mcp.md). For the full API reference, see the [OpenAPI Spec Guide](../reference/api.md).
@@ -1,5 +1,7 @@
# Deployment Examples # Deployment Examples
> Last reviewed: 2026-05-05
Five turnkey docker-compose scenarios, each runnable in under 5 minutes. Pick the one closest to your setup. Five turnkey docker-compose scenarios, each runnable in under 5 minutes. Pick the one closest to your setup.
## Which Example Should I Use? ## Which Example Should I Use?
@@ -32,7 +34,7 @@ docker compose up -d
The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../examples/acme-nginx/acme-nginx.md). The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../examples/acme-nginx/acme-nginx.md).
**Migrating from Certbot?** certctl discovers your existing `/etc/letsencrypt/live/` certificates automatically. You keep your ACME account, disable the Certbot cron, and certctl takes over renewal with centralized visibility and deployment verification. The step-by-step process is in [Migrating from Certbot](migrate-from-certbot.md). **Migrating from Certbot?** certctl discovers your existing `/etc/letsencrypt/live/` certificates automatically. You keep your ACME account, disable the Certbot cron, and certctl takes over renewal with centralized visibility and deployment verification. The step-by-step process is in [Migrating from Certbot](../migration/from-certbot.md).
--- ---
@@ -52,7 +54,7 @@ docker compose up -d
The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md). The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md).
**Migrating from acme.sh?** Your existing `dns_*` hook scripts are compatible with certctl's DNS-01 — they use the same pattern (shell scripts creating TXT records). The migration guide covers script adaptation, discovery of existing acme.sh certificates, and phasing out the acme.sh cron. See [Migrating from acme.sh](migrate-from-acmesh.md). **Migrating from acme.sh?** Your existing `dns_*` hook scripts are compatible with certctl's DNS-01 — they use the same pattern (shell scripts creating TXT records). The migration guide covers script adaptation, discovery of existing acme.sh certificates, and phasing out the acme.sh cron. See [Migrating from acme.sh](../migration/from-acmesh.md).
--- ---
@@ -105,7 +107,7 @@ docker compose up -d
The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../examples/multi-issuer/multi-issuer.md). The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../examples/multi-issuer/multi-issuer.md).
**Using cert-manager for Kubernetes?** certctl complements cert-manager — cert-manager handles in-cluster certs, certctl handles everything outside: VMs, bare metal, network appliances, Windows servers. They can share the same CA (ACME, step-ca, Vault PKI). See [certctl for cert-manager Users](certctl-for-cert-manager-users.md). **Using cert-manager for Kubernetes?** certctl complements cert-manager — cert-manager handles in-cluster certs, certctl handles everything outside: VMs, bare metal, network appliances, Windows servers. They can share the same CA (ACME, step-ca, Vault PKI). See [certctl for cert-manager Users](../migration/cert-manager-coexistence.md).
--- ---
@@ -117,4 +119,4 @@ These 5 scenarios cover the most common deployment patterns, but certctl support
**Targets:** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell or WinRM proxy), Postfix, Dovecot, F5 BIG-IP (coming soon). **Targets:** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell or WinRM proxy), Postfix, Dovecot, F5 BIG-IP (coming soon).
See [Connector Reference](connectors.md) for configuration details on every issuer and target. See [Connector Reference](../reference/connectors/index.md) for configuration details on every issuer and target.
@@ -1,5 +1,7 @@
# Quick Start Guide # Quick Start Guide
> Last reviewed: 2026-05-05
Certificate lifespans are dropping to **47 days by 2029**. At that cadence, a team managing 100 certificates is processing 7+ renewals per week — every week, forever. Manual processes break. certctl automates the entire lifecycle: issuance, renewal, deployment, revocation, and audit — with zero human intervention. Certificate lifespans are dropping to **47 days by 2029**. At that cadence, a team managing 100 certificates is processing 7+ renewals per week — every week, forever. Manual processes break. certctl automates the entire lifecycle: issuance, renewal, deployment, revocation, and audit — with zero human intervention.
This guide gets you running in 5 minutes and walks you through everything certctl does. This guide gets you running in 5 minutes and walks you through everything certctl does.
@@ -46,7 +48,7 @@ On Linux, follow the official Docker install guide for your distribution.
### Docker Compose (Quick Start) ### Docker Compose (Quick Start)
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/certctl-io/certctl.git
cd certctl cd certctl
docker compose -f deploy/docker-compose.yml up -d --build docker compose -f deploy/docker-compose.yml up -d --build
``` ```
@@ -120,7 +122,7 @@ curl --cacert "$CA" https://localhost:8443/health
{"status":"healthy"} {"status":"healthy"}
``` ```
If you're bringing your own cert (internal CA, cert-manager, operator-supplied Secret), see [`docs/tls.md`](tls.md) for the full provisioning matrix. If you're cutting over an existing install, see [`docs/upgrade-to-tls.md`](upgrade-to-tls.md) for the failure modes (out-of-date `http://…` agents fail at the TLS handshake) and the one-step procedure. If you're bringing your own cert (internal CA, cert-manager, operator-supplied Secret), see [`docs/operator/tls.md`](../operator/tls.md) for the full provisioning matrix. If you're cutting over an existing install, see [`docs/archive/upgrades/to-tls-v2.2.md`](../archive/upgrades/to-tls-v2.2.md) for the failure modes (out-of-date `http://…` agents fail at the TLS handshake) and the one-step procedure.
## Open the Dashboard ## Open the Dashboard
@@ -130,7 +132,7 @@ Open **https://localhost:8443** in your browser. Your browser will warn about th
> >
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key. > **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, and 90 days of job history — expiring certs, expired certs, active certs, failed renewals, revocations, discovery scans, and approval workflows. A realistic snapshot of what certificate management looks like in a real organization. The dashboard comes pre-loaded with demo data covering certificates across multiple issuers, agents, and 90 days of job history — expiring certs, expired certs, active certs, failed renewals, revocations, discovery scans, and approval workflows. A realistic snapshot of what certificate management looks like in a real organization. (Re-derive exact counts via `grep -oE 'mc-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l`.)
### What you're looking at ### What you're looking at
@@ -322,7 +324,7 @@ curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/approve
# Reject a pending job # Reject a pending job
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/reject \ curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/reject \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"reason": "Key type does not meet compliance requirements"}' | jq . -d '{"reason": "Key type does not meet policy requirements"}' | jq .
``` ```
## Certificate Discovery ## Certificate Discovery
@@ -436,7 +438,7 @@ export CERTCTL_SERVER_CA_BUNDLE_PATH="$CA" # MCP is env-vars-only; no CLI flag
./mcp-server ./mcp-server
``` ```
Exposes the full REST API via MCP over stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail." Exposes the full REST API via MCP over stdio transport. Ask your MCP client: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail."
## Demo Data Reference ## Demo Data Reference
@@ -447,7 +449,7 @@ Exposes the full REST API via MCP over stdio transport. Ask Claude: "What certif
| Issuers | 5 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, ZeroSSL (EAB), Custom OpenSSL CA | | Issuers | 5 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, ZeroSSL (EAB), Custom OpenSSL CA |
| Agents | 9 | 8 real agents (linux/darwin/windows, amd64/arm64) + server-scanner (network discovery) | | Agents | 9 | 8 real agents (linux/darwin/windows, amd64/arm64) + server-scanner (network discovery) |
| Targets | 8 | NGINX prod, NGINX staging, NGINX data, HAProxy, Apache, IIS, Traefik, Caddy | | Targets | 8 | NGINX prod, NGINX staging, NGINX data, HAProxy, Apache, IIS, Traefik, Caddy |
| Certificates | 35 | Active, Expiring, Expired, Failed, Revoked, RenewalInProgress, Wildcard, S/MIME | | Certificates | 32 | Active, Expiring, Expired, Failed, Revoked, RenewalInProgress, Wildcard, S/MIME |
| Jobs | 50+ | 90 days of issuance, renewal, deployment jobs + 2 AwaitingApproval | | Jobs | 50+ | 90 days of issuance, renewal, deployment jobs + 2 AwaitingApproval |
| Discovered Certs | 12 | Unmanaged (filesystem + network), Managed (linked), Dismissed | | Discovered Certs | 12 | Unmanaged (filesystem + network), Managed (linked), Dismissed |
| Discovery Scans | 8 | Historical + recent agent filesystem scans + network TLS scans | | Discovery Scans | 8 | Historical + recent agent filesystem scans + network TLS scans |
@@ -480,7 +482,7 @@ A suggested 5-minute flow:
6. **Agent fleet** — "Agents handle key generation locally (ECDSA P-256). Private keys never leave your infrastructure." 6. **Agent fleet** — "Agents handle key generation locally (ECDSA P-256). Private keys never leave your infrastructure."
7. **Discovery** — "Agents scan filesystems, server probes TLS endpoints. We find what you're not managing yet." 7. **Discovery** — "Agents scan filesystems, server probes TLS endpoints. We find what you're not managing yet."
8. **Bulk operations** — "Select multiple certs, renew or revoke in bulk. At 47-day lifespans with hundreds of certs, this is essential." 8. **Bulk operations** — "Select multiple certs, renew or revoke in bulk. At 47-day lifespans with hundreds of certs, this is essential."
9. **Audit trail** — "Every action recorded. Export to CSV/JSON for compliance." 9. **Audit trail** — "Every action recorded. Export to CSV/JSON for review."
10. **CLI + MCP** — "Terminal users get `certctl-cli`. AI assistants get MCP integration. Everything is API-first." 10. **CLI + MCP** — "Terminal users get `certctl-cli`. AI assistants get MCP integration. Everything is API-first."
## Tear Down ## Tear Down
@@ -496,7 +498,7 @@ The `-v` flag removes the PostgreSQL data volume for a clean slate.
**Ready to deploy with your stack?** The [Deployment Examples](examples.md) page has 5 turnkey docker-compose scenarios — pick the one closest to your setup and have it running in minutes. It also covers migration paths from Certbot, acme.sh, and cert-manager. **Ready to deploy with your stack?** The [Deployment Examples](examples.md) page has 5 turnkey docker-compose scenarios — pick the one closest to your setup and have it running in minutes. It also covers migration paths from Certbot, acme.sh, and cert-manager.
- **[Deployment Examples](examples.md)** — ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer - **[Deployment Examples](examples.md)** — ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA end-to-end - **[Advanced Demo](advanced-demo.md)** — Issue a real certificate via the Local CA end-to-end
- **[Architecture](architecture.md)** — How the control plane, agents, and connectors work together - **[Architecture](../reference/architecture.md)** — How the control plane, agents, and connectors work together
- **[Connector Reference](connectors.md)** — Configuration for all 7 issuers and 10 targets - **[Connector Reference](../reference/connectors/index.md)** — Configuration for all 7 issuers and 10 targets
- **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch - **[Concepts Guide](concepts.md)** — TLS certificates, CAs, and private keys explained from scratch
@@ -1,5 +1,7 @@
# Why certctl? # Why certctl?
> Last reviewed: 2026-05-05
Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi." certctl fills that gap: a self-hosted platform that automates the entire certificate lifecycle, works with any CA, deploys to any server, and keeps private keys on your infrastructure. It's free, source-available, and you own everything. Certificate management is broken at every scale between "one domain on Let's Encrypt" and "Fortune 500 budget for Venafi." certctl fills that gap: a self-hosted platform that automates the entire certificate lifecycle, works with any CA, deploys to any server, and keeps private keys on your infrastructure. It's free, source-available, and you own everything.
## The Math That Forces the Decision ## The Math That Forces the Decision
@@ -32,17 +34,22 @@ This isn't a premium feature. It's the default behavior, free. Most alternatives
### 2. CA-Agnostic Issuer Architecture ### 2. CA-Agnostic Issuer Architecture
certctl works with any certificate authority, not just ACME providers. Nine issuer connectors ship today, all free: certctl works with any certificate authority, not just ACME providers. Twelve issuer connectors ship today, all free:
- **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9773), certificate profile selection - **ACME v2** (Let's Encrypt, ZeroSSL, Google Trust Services, Buypass) — HTTP-01, DNS-01, DNS-PERSIST-01 challenges, External Account Binding, ACME Renewal Information (RFC 9773), certificate profile selection
- **HashiCorp Vault PKI**`/v1/{mount}/sign/{role}` API, token auth - **HashiCorp Vault PKI**`/v1/{mount}/sign/{role}` API, token auth
- **DigiCert CertCentral** — async order model, OV/EV support - **DigiCert CertCentral** — async order model, OV/EV support
- **Sectigo SCM** — async order model, DV/OV/EV support, 3-header auth - **Sectigo SCM** — async order model, DV/OV/EV support, 3-header auth
- **Google Cloud CAS** — Certificate Authority Service, OAuth2 service account auth, CA pool selection - **Google Cloud CAS** — Certificate Authority Service, OAuth2 service account auth, CA pool selection
- **AWS ACM Private CA** — managed private CA on AWS, IAM-authenticated, SDK-waiter for issuance
- **Entrust Certificate Services** — Entrust CA Gateway with mTLS auth, approval-pending support
- **GlobalSign Atlas HVCA** — region-pinned commercial CA with dual mTLS + API key/secret auth
- **EJBCA / Keyfactor** — self-hosted open-source / Keyfactor enterprise CA, mTLS or OAuth2
- **step-ca** (Smallstep) — native /sign API with JWK provisioner auth - **step-ca** (Smallstep) — native /sign API with JWK provisioner auth
- **Local CA** — self-signed or sub-CA mode (chain to ADCS or any enterprise root) - **Local CA** — self-signed or sub-CA mode (chain to ADCS or any enterprise root); supports multi-level CA tree mode
- **OpenSSL / Custom CA** — delegate signing to any shell script - **OpenSSL / Custom CA** — delegate signing to any shell script
- **EST enrollment** (RFC 7030) — device certs for WiFi/802.1X, MDM, IoT
EST (RFC 7030) and SCEP (RFC 8894) are protocol surfaces, not separate issuers — they dispatch to whichever issuer above is configured for the EST/SCEP profile.
Every connector implements the same interface. Running multiple CAs in parallel — Let's Encrypt for public certs, Vault for internal services, your enterprise CA for legacy systems — is configuration, not code. Every connector implements the same interface. Running multiple CAs in parallel — Let's Encrypt for public certs, Vault for internal services, your enterprise CA for legacy systems — is configuration, not code.
@@ -56,19 +63,19 @@ A reload command can exit 0 while the certificate doesn't take effect — wrong
The three differentiators above get the headlines, but the feature surface is wider than most paid platforms: The three differentiators above get the headlines, but the feature surface is wider than most paid platforms:
**13 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), F5 BIG-IP (proxy agent + iControl REST), Postfix, Dovecot, SSH (agentless), Windows Certificate Store, and Java Keystore. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments. **15 deployment targets** — NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS (local PowerShell + remote WinRM), F5 BIG-IP (proxy agent + iControl REST), Postfix/Dovecot (dual-mode), SSH (agentless), Windows Certificate Store, Java Keystore, Kubernetes Secrets, AWS Certificate Manager, and Azure Key Vault. All use a pluggable connector model. The control plane never initiates outbound connections — agents poll for work, meaning certctl works behind firewalls, across network zones, and in air-gapped environments.
**Network certificate discovery** — active TLS scanning of CIDR ranges finds certificates you didn't know existed. Agents also scan local filesystems for PEM/DER files. Everything feeds into a triage workflow where you claim, dismiss, or import discovered certs into management. **Network certificate discovery** — active TLS scanning of CIDR ranges finds certificates you didn't know existed. Agents also scan local filesystems for PEM/DER files. Everything feeds into a triage workflow where you claim, dismiss, or import discovered certs into management.
**Immutable audit trail** — every API call recorded (method, path, actor, body hash, status, latency). Every certificate lifecycle event tracked. Append-only, no update or delete. Mapped to SOC 2, PCI-DSS 4.0, and NIST SP 800-57 compliance frameworks with published evidence guides. **Immutable audit trail** — every API call recorded (method, path, actor, body hash, status, latency). Every certificate lifecycle event tracked. Append-only, no update or delete.
**Policy engine** — 5 rule types (allowed issuers, allowed domains, required metadata, allowed environments, renewal lead time) with violation tracking and severity levels. **Policy engine** — 5 rule types (allowed issuers, allowed domains, required metadata, allowed environments, renewal lead time) with violation tracking and severity levels.
**PKI compliance** — DER-encoded X.509 CRL signed by issuing CA, embedded OCSP responder, RFC 5280 revocation with all reason codes, short-lived certificate exemption. **Revocation infrastructure** — DER-encoded X.509 CRL signed by issuing CA, embedded OCSP responder, RFC 5280 revocation with all reason codes, short-lived certificate exemption.
**Prometheus metrics** — `/api/v1/metrics/prometheus` in standard exposition format. Works with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics. **Prometheus metrics** — `/api/v1/metrics/prometheus` in standard exposition format. Works with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics.
**MCP server** — the entire REST API is exposed via MCP for AI-assisted certificate management via Claude, Cursor, or any MCP-compatible client. No other certificate platform offers this. **MCP server** — the entire REST API is exposed via MCP for AI-assisted certificate management via any MCP-compatible client. No other certificate platform offers this.
**Full REST API** — OpenAPI 3.1-documented operations covering the entire platform. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance. **Full REST API** — OpenAPI 3.1-documented operations covering the entire platform. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
@@ -82,7 +89,7 @@ ACME clients solve one slice of the problem — issuance and renewal from ACME C
### vs. Agent-Based SaaS ### vs. Agent-Based SaaS
The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 9 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever. The closest architectural competitors use the same agent model — local key generation, CSR submission, push-based deployment. Where certctl differs: it supports 12 issuer types (not just ACME), provides CRL/OCSP/revocation infrastructure (not just issuance), includes a policy engine and network discovery, and is source-available with no certificate limit. SaaS alternatives are typically proprietary, priced per certificate ($2+/cert/month), and cap their free tiers at 3-5 certificates. certctl is free for any number of certificates, forever.
### vs. Commercial PKI Platforms ### vs. Commercial PKI Platforms
@@ -105,7 +112,7 @@ certctl isn't the right tool for everyone:
The demo seeds certificates across multiple issuers, agents, and deployment targets with 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately. The demo seeds certificates across multiple issuers, agents, and deployment targets with 180 days of realistic history — jobs, audit events, discovery scans, approval workflows — so you can explore every feature immediately.
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/certctl-io/certctl.git
cd certctl/deploy && docker compose up -d cd certctl/deploy && docker compose up -d
# Dashboard at https://localhost:8443 (self-signed cert — pin deploy/test/certs/ca.crt) # Dashboard at https://localhost:8443 (self-signed cert — pin deploy/test/certs/ca.crt)
``` ```
+181
View File
@@ -0,0 +1,181 @@
# Caddy Integration Walkthrough
> Last reviewed: 2026-05-05
> **Use this walkthrough when** you're already running Caddy 2.7+ and
> want it to ACME-issue from certctl (your internal CA, your private
> PKI, or a local sub-CA chained under an enterprise root) instead of
> Let's Encrypt. The Caddyfile changes are minimal; the load-bearing
> piece is trusting certctl's bootstrap CA so Caddy's ACME client can
> talk to certctl over HTTPS.
End-to-end recipe for issuing certs from a certctl-server deployment
through Caddy 2.7+. Target audience: operator running Caddy on a VM
or container who wants Caddy to ACME-issue from certctl instead of
Let's Encrypt.
## Prereqs
- A reachable certctl-server with `CERTCTL_ACME_SERVER_ENABLED=true`
and at least one profile whose `acme_auth_mode` is set. Profile
setup is identical to the cert-manager walkthrough — see
[`docs/acme-cert-manager-walkthrough.md`](./acme-from-cert-manager.md)
Step 2.
- Caddy 2.7.x or later. `caddy version` should show 2.7.0+.
- Network reachability: Caddy → certctl-server's HTTPS listener (port
8443 by default).
- The certctl bootstrap CA, in PEM form, captured for the trust
configuration below. Capture exactly the same way as the cert-manager
walkthrough Step 3 — use `cat deploy/test/certs/ca.crt`.
## Step 1 — Configure Caddy
Caddy's ACME issuer is configured per-site (or globally) via the
`acme_ca` directive in a Caddyfile, or via the `tls.acme_ca` field
in JSON config. The directive points at the directory URL:
```
{
email ops@example.com
}
example.com {
tls {
acme_ca https://certctl.example.com:8443/acme/profile/prof-test/directory
issuer acme
}
reverse_proxy localhost:8080
}
```
Notes:
- `acme_ca` must point at the directory URL (ending in `/directory`),
not just the base. Caddy uses the directory document to discover
the new-account / new-order URLs, exactly the same way cert-manager
does.
- `issuer acme` is the default; included here for clarity. Caddy can
also be configured with `issuer zerossl` or `issuer internal`; for
certctl integration, `acme` is the correct issuer.
- Caddy auto-discovers `tls-alpn-01` first when port 443 is bound to
Caddy, then falls back to HTTP-01. For `trust_authenticated` mode
profiles, both work without solver round-trips.
## Step 2 — Trust the certctl bootstrap CA
Caddy validates the certctl-server's TLS chain before any ACME call,
the same way cert-manager does. Two options for trust:
### Option A — OS trust store (preferred for VMs)
```
sudo cp deploy/test/certs/ca.crt /usr/local/share/ca-certificates/certctl-bootstrap.crt
sudo update-ca-certificates
sudo systemctl restart caddy
```
Caddy honors the system trust store via the Go runtime's
`crypto/x509` defaults. After `update-ca-certificates`, Caddy's HTTPS
client trusts certctl's self-signed root and the directory call
succeeds.
### Option B — Caddy `tls.cas` (for containerized deployments)
```
{
pki {
ca certctl_bootstrap {
root_cert_file /etc/caddy/certctl-bootstrap.crt
}
}
}
example.com {
tls {
acme_ca https://certctl.example.com:8443/acme/profile/prof-test/directory
ca certctl_bootstrap
issuer acme
}
reverse_proxy localhost:8080
}
```
The `pki.ca` block registers a named CA Caddy can reference; the
`tls.ca certctl_bootstrap` line in the site block scopes that trust
to ACME calls for this site only. This is the right pattern for
multi-tenant Caddy deployments where some sites trust certctl + others
don't.
## Step 3 — Reload Caddy
```
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Caddy reloads atomically; in-flight requests complete on the old
config while new requests use the new ACME issuer. On the next
`example.com` request, Caddy hits certctl's directory URL, registers
an account, submits a new-order, and finalizes — typically completing
in under 5 seconds for `trust_authenticated` mode.
## Step 4 — Verify
```
caddy list-certificates
# example.com (issuer=certctl.example.com): CN=example.com, valid until 2026-06-30
```
The cert is in Caddy's certificate cache (`$XDG_DATA_HOME/caddy/certificates/`
by default). Inspect:
```
openssl x509 -in ~/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.crt -noout -subject -issuer -dates
# subject= CN=example.com
# issuer= CN=certctl test internal CA
```
(Path layout is Caddy-version-dependent; check `caddy environ` for the
canonical data dir.)
On the certctl side, the operator's audit log captures the issuance
event:
```
psql -c "SELECT actor, action, resource_id FROM audit_events
WHERE actor LIKE 'acme:%' ORDER BY created_at DESC LIMIT 5;"
```
## Common failure modes
- **Caddy logs `tls: failed to verify certificate: x509: certificate
signed by unknown authority`** → certctl bootstrap CA is not in
Caddy's trust path. Re-do Step 2; verify with `curl --cacert
/etc/caddy/certctl-bootstrap.crt https://certctl.example.com:8443/acme/profile/prof-test/directory`.
- **Caddy logs `urn:ietf:params:acme:error:rateLimited`** → certctl
per-account orders/hour limit hit (default 100/hr). Tune via
`CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR` if you have
legitimately high throughput.
- **Caddy logs `urn:ietf:params:acme:error:rejectedIdentifier`**
the SAN list includes an identifier the certctl profile policy
rejects. Cross-reference [`docs/acme-server.md` § Troubleshooting](../reference/protocols/acme-server.md#certificate-readyfalse-with-rejectedidentifier).
- **`badNonce` in Caddy logs** → clock skew or multi-replica certctl
without sticky sessions; same fix as the cert-manager walkthrough.
## Cleanup
```
caddy stop
# remove the certctl-specific block from your Caddyfile
sudo systemctl reload caddy
# Optional: delete cached certs from the certctl directory namespace.
rm -rf ~/.local/share/caddy/certificates/certctl.example.com-*
```
## See also
- [`docs/acme-server.md`](../reference/protocols/acme-server.md) — canonical reference.
- [`docs/acme-cert-manager-walkthrough.md`](./acme-from-cert-manager.md) —
K8s-native equivalent.
- [Caddy upstream ACME docs](https://caddyserver.com/docs/automatic-https#acme-issuer)
— verify behavior pinned here against Caddy 2.7.x semantics.
+265
View File
@@ -0,0 +1,265 @@
# cert-manager Integration Walkthrough
> Last reviewed: 2026-05-05
> **Use this walkthrough when** you're already running cert-manager
> 1.15+ in Kubernetes and want it to issue certs from certctl (your
> internal CA, your private PKI, or a local sub-CA chained under an
> enterprise root) via the standard ACME `ClusterIssuer` model. If
> you want certctl to coexist with cert-manager rather than replace
> its issuer backend, see
> [`docs/migration/cert-manager-coexistence.md`](cert-manager-coexistence.md)
> instead.
End-to-end recipe for issuing certs from a certctl-server deployment
through cert-manager 1.15+. Target audience: Kubernetes operator who
has never deployed certctl before and wants a working
`Certificate``Secret` flow on their cluster in under 30 minutes.
The Phase 5 integration test (`make acme-cert-manager-test`) automates
exactly the recipe below. The YAML snippets in this doc are byte-equal
to the files under `deploy/test/acme-integration/` — re-running the
test from a fresh clone produces the same results documented here.
## Prereqs
- A Kubernetes cluster (kind / k3d / EKS / GKE / AKS / on-prem). For
local trial, `kind v0.20+` works exactly the way the Phase 5 test
uses it. The kind config lives at
[`deploy/test/acme-integration/kind-config.yaml`](../deploy/test/acme-integration/kind-config.yaml).
- `kubectl` v1.27+, `helm` v3.13+.
- `cert-manager` v1.15.0 installed in the `cert-manager` namespace.
If absent, run:
```
bash deploy/test/acme-integration/cert-manager-install.sh
```
which is the same idempotent installer the integration test uses.
- A certctl Helm chart published to a registry your cluster can pull
from. The Phase 5 test uses an `image.tag=test` placeholder; production
deployments use the actual image tag for your release line.
## Step 1 — Deploy certctl-server
```
helm install certctl-test deploy/helm/certctl/ \
--set acmeServer.enabled=true \
--set acmeServer.defaultProfileId=prof-test \
--set image.tag=test
kubectl wait --for=condition=Available --timeout=3m deployment/certctl-test
```
`acmeServer.enabled=true` flips the `CERTCTL_ACME_SERVER_ENABLED`
env var which gates the ACME route registration.
`acmeServer.defaultProfileId` sets `CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID`
so the `/acme/*` shorthand path mirrors the per-profile path family.
## Step 2 — Create the certctl profile
The ACME server requires a `certificate_profiles` row to bind issuance
to. Create one via the certctl API or GUI; for the simplest case set
`acme_auth_mode='trust_authenticated'`:
```
curl -X POST https://certctl-test.default.svc.cluster.local:8443/api/profiles \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $CERTCTL_API_KEY" \
-d '{
"id": "prof-test",
"name": "ACME test profile",
"issuer_id": "iss-internal-ca",
"max_ttl_seconds": 7776000,
"acme_auth_mode": "trust_authenticated"
}'
```
Auth-mode tradeoffs are covered in
[`docs/acme-server.md` § Auth-mode decision tree](../reference/protocols/acme-server.md#auth-mode-decision-tree).
For first-time deployments, `trust_authenticated` is the right default.
## Step 3 — Capture the certctl bootstrap CA
cert-manager validates the certctl-server's TLS chain before sending
any account / order / finalize JWS. With certctl's self-signed
bootstrap cert (the demo default at `deploy/test/certs/server.crt`),
cert-manager rejects the directory URL with
`x509: certificate signed by unknown authority` unless you feed the
bootstrap CA in.
```
cat deploy/test/certs/ca.crt | base64 -w0
```
Capture the output for Step 4. This is **the** single biggest first-
time-deploy footgun on the cert-manager integration path. The reference
recipe lives in
[`docs/acme-server.md` § TLS trust bootstrap](../reference/protocols/acme-server.md#tls-trust-bootstrap-read-this-before-configuring-cert-manager).
## Step 4 — Apply the ClusterIssuer
```yaml
# Phase 5 — sample ClusterIssuer for the certctl trust_authenticated
# auth mode (RFC 8555 §6 + certctl auth_mode=trust_authenticated, where
# the JWS-authenticated ACME account is trusted to issue any identifier
# the profile policy permits — no per-identifier ownership challenges).
#
# Use this as the starting template for any internal-PKI rollout.
# Replace the caBundle placeholder with the base64-encoded PEM of the
# certctl-server's self-signed bootstrap root, then `kubectl apply`.
#
# Generate the caBundle via:
# cat deploy/test/certs/ca.crt | base64 -w0
# (See certctl/docs/acme-server.md "TLS trust bootstrap" section for the
# end-to-end walkthrough — this is the single biggest first-time-deploy
# footgun on cert-manager, captured as audit fix #9.)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: certctl-test-trust
spec:
acme:
email: test@example.com
# Replace 'certctl-test' with your release name + adjust the
# profile path segment. Default profile path:
# https://<service>.<namespace>.svc.cluster.local:8443/acme/profile/<profile-id>/directory
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-test/directory
# caBundle: Audit fix #9. cert-manager validates the ACME server's
# TLS chain before submitting any account/order/finalize. With a
# self-signed bootstrap root, the ClusterIssuer MUST carry the root
# explicitly via this field.
caBundle: |
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
privateKeySecretRef:
name: certctl-test-trust-account-key
solvers:
# In trust_authenticated mode the solver is unused at the
# validation step but cert-manager still requires at least one
# solver in the spec. http01-via-ingress-nginx is the cheapest
# placeholder shape that round-trips correctly through cert-
# manager's validation webhooks.
- http01:
ingress:
class: nginx
```
This block is byte-equal to
[`deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml`](../deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml).
Replace the `caBundle` placeholder with the base64 string from Step 3.
The full reference YAML lives at
[`deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml`](../deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml).
```
kubectl apply -f deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml
kubectl wait --for=condition=Ready --timeout=2m clusterissuer/certctl-test-trust
```
The solver block is a placeholder under `trust_authenticated` mode —
cert-manager 1.15 still requires at least one solver in the spec, but
certctl auto-resolves authzs without a solver round-trip. The
http01-ingress-nginx shape validates against cert-manager's webhook
without needing an actual ingress controller deployed.
For `challenge` mode profiles, swap to
[`deploy/test/acme-integration/clusterissuer-challenge.yaml`](../deploy/test/acme-integration/clusterissuer-challenge.yaml)
— same shape, but the solver is now load-bearing and you need
ingress-nginx (or your chosen ingress class) actually deployed for
HTTP-01 to work.
## Step 5 — Apply the Certificate
```yaml
# Phase 5 — Certificate resource the integration test applies and
# waits for. The certctl-test-trust ClusterIssuer (trust_authenticated
# mode) issues the cert without any solver round-trip; the resulting
# Secret 'test-com-tls' is asserted to carry tls.crt + tls.key.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: test-com
namespace: default
spec:
secretName: test-com-tls
commonName: test.example.com
dnsNames:
- test.example.com
- www.test.example.com
issuerRef:
name: certctl-test-trust
kind: ClusterIssuer
duration: 720h # 30d
renewBefore: 240h # 10d
```
This block is byte-equal to
[`deploy/test/acme-integration/certificate-test.yaml`](../deploy/test/acme-integration/certificate-test.yaml).
```
kubectl apply -f deploy/test/acme-integration/certificate-test.yaml
kubectl wait --for=condition=Ready --timeout=3m certificate/test-com
```
cert-manager creates an `Order`, the ACME flow runs against certctl,
and the resulting Secret is populated.
## Step 6 — Verify
```
kubectl get certificate test-com -o wide
# NAME READY SECRET ISSUER STATUS AGE
# test-com True test-com-tls certctl-test-trust Certificate is up to date and has not expired 42s
kubectl get secret test-com-tls -o yaml | yq '.data."tls.crt"' | base64 -d | openssl x509 -noout -subject -issuer -dates
# subject= CN=test.example.com
# issuer= CN=certctl test internal CA
# notBefore=... notAfter=...
```
Both the cert-manager `Certificate` resource and the underlying Secret
are populated. The actor on the certctl side is `acme:<account-id>`,
which you can correlate via the `audit_events` table:
```
psql -c "SELECT created_at, action, resource_type, resource_id
FROM audit_events
WHERE actor LIKE 'acme:%'
ORDER BY created_at DESC LIMIT 10;"
```
## Common failure modes
These are operator-side; full troubleshooting reference is in
[`docs/acme-server.md` § Troubleshooting](../reference/protocols/acme-server.md#troubleshooting).
- `400 Bad Request: badNonce` → clock skew between certctl-server and
cert-manager, or a multi-replica certctl fleet without sticky
sessions.
- `x509: certificate signed by unknown authority` → missing or stale
`caBundle`. Re-run Step 3, paste the fresh value.
- `connection refused` from the HTTP-01 validator → ingress controller
not deployed, OR your network blocks port 80 inbound to the solver
Ingress.
- `Ready=False` with `rejectedIdentifier` → CSR has a SAN your profile
policy doesn't permit. Decode the `subproblems` array of the RFC
7807 problem doc.
## Cleanup
```
kubectl delete -f deploy/test/acme-integration/certificate-test.yaml
kubectl delete -f deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml
helm uninstall certctl-test
# Optional: delete the certctl profile via API.
```
## See also
- [`docs/acme-server.md`](../reference/protocols/acme-server.md) — canonical reference.
- [`docs/acme-server-threat-model.md`](../reference/protocols/acme-server-threat-model.md) —
security posture.
- [`docs/acme-caddy-walkthrough.md`](./acme-from-caddy.md) —
Caddy-side recipe.
- [`docs/acme-traefik-walkthrough.md`](./acme-from-traefik.md) —
Traefik-side recipe.
- [`deploy/test/acme-integration/`](../deploy/test/acme-integration/) —
Phase 5 integration test (the same recipe, automated).
+207
View File
@@ -0,0 +1,207 @@
# Traefik Integration Walkthrough
> Last reviewed: 2026-05-05
> **Use this walkthrough when** you're already running Traefik 3.0+
> (Kubernetes or VM) and want it to ACME-issue from certctl (your
> internal CA, your private PKI, or a local sub-CA chained under an
> enterprise root) instead of Let's Encrypt. The Traefik static config
> changes are minimal; the load-bearing piece is `serversTransport.rootCAs`
> so Traefik trusts certctl's bootstrap CA on every outbound ACME call.
End-to-end recipe for issuing certs from a certctl-server deployment
through Traefik 3.0+. Target audience: operator running Traefik (in
Kubernetes or on a VM) who wants to use certctl as their ACME source
of truth instead of Let's Encrypt.
## Prereqs
- A reachable certctl-server with `CERTCTL_ACME_SERVER_ENABLED=true`
and at least one profile whose `acme_auth_mode` is set. Profile
setup is identical to the cert-manager walkthrough — see
[`docs/acme-cert-manager-walkthrough.md`](./acme-from-cert-manager.md)
Step 2.
- Traefik 3.0+ (the v2 API surface for ACME is also supported but the
`serversTransport.rootCAs` reference below is v3-shaped).
- The certctl bootstrap CA, in PEM form, captured the same way as the
cert-manager walkthrough Step 3.
## Step 1 — Configure Traefik static config
Traefik's ACME issuer is a `certificatesResolver` in the static config
(file or CLI flags or env vars). The relevant fields:
```yaml
# /etc/traefik/traefik.yml (or wherever your static config lives)
certificatesResolvers:
certctl:
acme:
caServer: https://certctl.example.com:8443/acme/profile/prof-test/directory
email: ops@example.com
storage: /etc/traefik/acme-certctl.json
httpChallenge:
entryPoint: web
# OR for trust_authenticated mode profiles:
# tlsChallenge: {}
# certctl uses a self-signed bootstrap cert; Traefik needs the CA
# explicitly via serversTransport.rootCAs to call the directory URL.
serversTransports:
default:
rootCAs:
- /etc/traefik/certctl-bootstrap.crt
# Apply the serversTransport globally so every outbound HTTPS call —
# including ACME directory + finalize — trusts the certctl CA.
api:
insecure: false
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
```
Notes:
- `caServer` must point at the directory URL (ending in `/directory`).
- `httpChallenge.entryPoint: web` requires Traefik's `web` entryPoint
(port 80) to be reachable from certctl-server's HTTP-01 validator.
For `trust_authenticated` mode profiles, this is a no-op formality —
certctl auto-resolves authzs, so the solver round-trip never happens.
- `tlsChallenge: {}` is the alternative that uses TLS-ALPN-01 (RFC 8737)
via Traefik's `websecure` (port 443) entryPoint. Either works under
`challenge` mode; only the default-of-`tlsChallenge` is recommended
for `trust_authenticated` mode.
## Step 2 — Trust the certctl bootstrap CA
Two options:
### Option A — `serversTransport.rootCAs` (preferred)
```
sudo cp deploy/test/certs/ca.crt /etc/traefik/certctl-bootstrap.crt
sudo systemctl reload traefik
```
`serversTransports.default.rootCAs` (shown in Step 1 above) tells
Traefik's outbound HTTPS client to trust the supplied PEM in addition
to the system trust store. This is the right pattern for containerized
Traefik where you don't want to install OS-level trust roots.
### Option B — OS trust store
For Traefik running directly on a VM, `update-ca-certificates`-style
installation works the same way as the Caddy walkthrough Option A.
The `serversTransport.rootCAs` field is unnecessary in that case.
## Step 3 — Reference the resolver from a router
Per-router (dynamic config):
```yaml
# /etc/traefik/dynamic/example-com.yml
http:
routers:
example-com:
rule: "Host(`example.com`)"
entryPoints: [websecure]
tls:
certResolver: certctl
service: example-com-backend
services:
example-com-backend:
loadBalancer:
servers:
- url: "http://localhost:8080"
```
Or, in Kubernetes via `IngressRoute` (Traefik CRD):
```yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: example-com
spec:
entryPoints: [websecure]
routes:
- match: Host(`example.com`)
kind: Rule
services:
- name: example-com-backend
port: 8080
tls:
certResolver: certctl
```
## Step 4 — Reload Traefik
```
sudo systemctl reload traefik
# OR kubectl rollout restart deployment/traefik (if you changed the static config via ConfigMap).
```
On the first request to `example.com`, Traefik hits certctl's directory
URL, registers an account, submits a new-order, and finalizes. The cert
is persisted to `/etc/traefik/acme-certctl.json` (or its in-cluster
PVC equivalent).
## Step 5 — Verify
```
curl -kvI https://example.com 2>&1 | grep -E 'subject|issuer'
# subject: CN=example.com
# issuer: CN=certctl test internal CA
```
The cert is signed by certctl's bound issuer (per the `prof-test`
profile's `issuer_id`).
On the certctl side, the audit log captures the issuance:
```
psql -c "SELECT actor, action, resource_id FROM audit_events
WHERE actor LIKE 'acme:%' ORDER BY created_at DESC LIMIT 5;"
```
## Common failure modes
- **Traefik logs `unable to obtain ACME certificate ... x509: certificate
signed by unknown authority`** → `serversTransport.rootCAs` is not
pointing at the certctl bootstrap CA, OR the file was rotated and
Traefik hasn't reloaded. Verify with
`curl --cacert /etc/traefik/certctl-bootstrap.crt
https://certctl.example.com:8443/acme/profile/prof-test/directory`.
- **Traefik logs `urn:ietf:params:acme:error:rateLimited`** → tune
`CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR` on the certctl
side, OR reduce Traefik's parallel-cert-acquisition concurrency.
- **`acme: error: 400 :: POST :: ... :: badNonce`** → clock skew or
multi-replica certctl without sticky sessions; same fix as the
cert-manager walkthrough.
- **Storage file `acme-certctl.json` shows persistent failures**
Traefik retains failed-acquisition state. After fixing the
underlying cause, delete the storage file and reload:
`rm /etc/traefik/acme-certctl.json && systemctl reload traefik`.
## Cleanup
```
# Remove the certResolver from any router / IngressRoute consuming it.
sudo systemctl reload traefik
# Delete the persisted ACME storage:
sudo rm /etc/traefik/acme-certctl.json
# Or in K8s: drop the resolver from the static-config ConfigMap.
```
## See also
- [`docs/acme-server.md`](../reference/protocols/acme-server.md) — canonical reference.
- [`docs/acme-cert-manager-walkthrough.md`](./acme-from-cert-manager.md) —
cert-manager equivalent.
- [Traefik upstream ACME docs](https://doc.traefik.io/traefik/https/acme/#caserver) —
verify behavior pinned here against Traefik 3.0+ semantics.
@@ -1,5 +1,7 @@
# certctl for cert-manager Users # certctl for cert-manager Users
> Last reviewed: 2026-05-05
You run cert-manager inside Kubernetes and it works well for in-cluster certificates. But you also have VMs, bare-metal servers, network appliances, and legacy systems outside the cluster. cert-manager can't reach those. This guide shows how certctl complements cert-manager to give you unified certificate visibility and automation across your entire infrastructure. You run cert-manager inside Kubernetes and it works well for in-cluster certificates. But you also have VMs, bare-metal servers, network appliances, and legacy systems outside the cluster. cert-manager can't reach those. This guide shows how certctl complements cert-manager to give you unified certificate visibility and automation across your entire infrastructure.
## Not a Replacement ## Not a Replacement
@@ -53,7 +55,7 @@ helm install certctl deploy/helm/certctl/ \
On each VM, bare-metal server, or appliance (via proxy agent): On each VM, bare-metal server, or appliance (via proxy agent):
```bash ```bash
# Linux amd64 # Linux amd64
curl -sSL https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \ curl -sSL https://github.com/certctl-io/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \
-o /usr/local/bin/certctl-agent -o /usr/local/bin/certctl-agent
chmod +x /usr/local/bin/certctl-agent chmod +x /usr/local/bin/certctl-agent
@@ -96,7 +98,7 @@ Go to **Policies** → **+ New Policy** to create enforcement rules:
- **Severity:** `high` - **Severity:** `high`
- **Config:** set your enforcement parameters - **Config:** set your enforcement parameters
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet. Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other policy rules across your fleet.
### 6. View Unified Inventory ### 6. View Unified Inventory
@@ -139,7 +141,7 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
## Next Steps ## Next Steps
1. Run through the [Quick Start](./quickstart.md) for a 5-minute demo 1. Run through the [Quick Start](../getting-started/quickstart.md) for a 5-minute demo
2. Try the [Multi-Issuer example](../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard 2. Try the [Multi-Issuer example](../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard
3. Explore [Architecture](./architecture.md#agents) for deployment patterns 3. Explore [Architecture](../reference/architecture.md#agents) for deployment patterns
4. Check the [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment 4. Check the [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
@@ -1,5 +1,7 @@
# Migrate from acme.sh to certctl # Migrate from acme.sh to certctl
> Last reviewed: 2026-05-05
You use acme.sh to automate Let's Encrypt renewal across multiple servers. It works — but without centralized visibility, deployment verification, or policy enforcement. You use acme.sh to automate Let's Encrypt renewal across multiple servers. It works — but without centralized visibility, deployment verification, or policy enforcement.
This guide walks through moving your acme.sh workload to certctl while keeping your existing DNS provider setup. This guide walks through moving your acme.sh workload to certctl while keeping your existing DNS provider setup.
@@ -29,7 +31,7 @@ certctl adds a control plane that sees all your certificates, deploys with verif
Start with Docker Compose (5 minutes): Start with Docker Compose (5 minutes):
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/certctl-io/certctl.git
cd certctl/deploy cd certctl/deploy
docker compose up -d docker compose up -d
``` ```
@@ -41,7 +43,7 @@ Access the dashboard at `https://localhost:8443` with the API key from `.env`. T
On each server running acme.sh certs, install the certctl agent: On each server running acme.sh certs, install the certctl agent:
```bash ```bash
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash curl -sSL https://raw.githubusercontent.com/certctl-io/certctl/master/install-agent.sh | bash
# Prompted for server URL and API key # Prompted for server URL and API key
``` ```
@@ -49,7 +51,7 @@ Or manually:
```bash ```bash
# Download and install agent binary # Download and install agent binary
wget https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 wget https://github.com/certctl-io/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64
chmod +x certctl-agent-linux-amd64 chmod +x certctl-agent-linux-amd64
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
@@ -270,6 +272,6 @@ certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist
## Next Steps ## Next Steps
- Try the [Wildcard DNS-01 example](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider - Try the [Wildcard DNS-01 example](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider
- See [Connector Reference](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts) - See [Connector Reference](../reference/connectors/index.md) for advanced ACME options (EAB, ARI, custom timeouts)
- See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale - See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale
- See all [Deployment Examples](./examples.md) for other scenarios (ACME+NGINX, private CA, step-ca, multi-issuer) - See all [Deployment Examples](../getting-started/examples.md) for other scenarios (ACME+NGINX, private CA, step-ca, multi-issuer)
@@ -1,5 +1,7 @@
# Migrating from Certbot to certctl # Migrating from Certbot to certctl
> Last reviewed: 2026-05-05
You have 50 Let's Encrypt certificates across 10 servers, managed by a mix of Certbot cron jobs and manual renewals. Certbot handles issuance, but you lack inventory visibility, centralized alerting, and audit trails. This guide walks you through moving to certctl while keeping your existing certificates and ACME account. You have 50 Let's Encrypt certificates across 10 servers, managed by a mix of Certbot cron jobs and manual renewals. Certbot handles issuance, but you lack inventory visibility, centralized alerting, and audit trails. This guide walks you through moving to certctl while keeping your existing certificates and ACME account.
## Why Migrate ## Why Migrate
@@ -38,7 +40,7 @@ On each of your 10 servers running Certbot:
```bash ```bash
# Linux amd64 (adjust for your architecture) # Linux amd64 (adjust for your architecture)
curl -sSL https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \ curl -sSL https://github.com/certctl-io/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \
-o /usr/local/bin/certctl-agent -o /usr/local/bin/certctl-agent
chmod +x /usr/local/bin/certctl-agent chmod +x /usr/local/bin/certctl-agent
@@ -168,6 +170,6 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
## Next Steps ## Next Steps
- Try the [ACME + NGINX example](../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production - Try the [ACME + NGINX example](../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs) - Review the [Concepts Guide](../getting-started/concepts.md) for terminology (profiles, policies, agents, jobs)
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about - Explore [Network Discovery](../getting-started/quickstart.md#network-discovery-agentless) to find certificates you didn't know about
- See all [Deployment Examples](./examples.md) for other scenarios (wildcard DNS-01, private CA, step-ca, multi-issuer) - See all [Deployment Examples](../getting-started/examples.md) for other scenarios (wildcard DNS-01, private CA, step-ca, multi-issuer)
+134
View File
@@ -0,0 +1,134 @@
# Issuance approval workflow
> Last reviewed: 2026-05-05
certctl can gate certificate issuance + renewal on a per-profile, two-person-integrity check. Operators configure this on production-tier `CertificateProfile` rows so every renewal-loop tick or manual `POST /api/v1/certificates/{id}/renew` blocks at `JobStatusAwaitingApproval` until a different actor approves.
Closes the procurement-checklist question "How do you enforce two-person integrity on cert issuance?" — without this surface the answer is "we don't"; with `requires_approval=true` on the profile, the answer is "here's the RBAC contract + here's the audit query that proves bypass mode is off in production."
## End-to-end flow
```mermaid
sequenceDiagram
autonumber
participant A as Operator A<br/>(or scheduler)
participant SVC as CertificateService<br/>.TriggerRenewal
participant JOB as Job + ApprovalRequest
participant B as Operator B
participant APR as ApprovalService.Approve
participant SCH as Scheduler
A->>SVC: POST /api/v1/certificates/{id}/renew<br/>(or renewal-loop tick)
SVC->>JOB: read profile.RequiresApproval;<br/>create Job @ JobStatusAwaitingApproval;<br/>create ApprovalRequest<br/>(state=pending, requested_by=Operator A)
Note over JOB,SCH: Scheduler skips —<br/>AwaitingApproval is NOT a dispatchable status
B->>JOB: GET /api/v1/approvals?state=pending
B->>APR: POST /api/v1/approvals/{id}/approve<br/>(decided_by=Operator B, note=...)
APR->>APR: RBAC: reject if Operator B == Operator A<br/>→ ErrApproveBySameActor (HTTP 403)
APR->>JOB: ApprovalRequest → state=approved;<br/>Job AwaitingApproval → Pending;<br/>audit row (action=approval_approved,<br/>actor=Operator B);<br/>certctl_approval_decisions_total<br/>{outcome=approved,profile_id=...}++
SCH->>JOB: pick up Pending → dispatch to issuer connector
JOB-->>A: cert issues normally
```
## Configuration
Set `requires_approval=true` on a `CertificateProfile`:
```bash
curl -X PUT https://certctl/api/v1/profiles/p-prod-cdn \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production CDN",
"requires_approval": true,
...
}'
```
Every certificate bound to that profile is now gated. The default is `requires_approval=false` — existing profiles keep the historical unattended renewal path.
## RBAC: the two-person integrity rule
The actor that triggers a renewal **cannot** be the actor that approves it. The check happens at the service layer and surfaces as **HTTP 403** at the handler. The error message contains the substring `two-person integrity` so server-log greps detect attempted self-approvals.
This is the load-bearing two-person-integrity contract. Pinned by:
- `internal/service/approval_test.go::TestApproval_Approve_RejectsSameActor` — service-level pin.
- `internal/api/handler/approval_test.go::TestApproval_HandlerApproveAsSameActor_Returns403` — handler-level pin (HTTP 403 + body contains "two-person integrity").
## Operator playbook: "I need to approve a renewal"
```bash
# 1. Find the pending request
curl -s "https://certctl/api/v1/approvals?state=pending" \
-H "Authorization: Bearer $API_KEY" | jq
# 2. Inspect the request — confirm CN, SANs, requester
curl -s "https://certctl/api/v1/approvals/ar-abc123" \
-H "Authorization: Bearer $API_KEY" | jq
# 3. Approve as a different actor than the requester
curl -X POST "https://certctl/api/v1/approvals/ar-abc123/approve" \
-H "Authorization: Bearer $APPROVER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"note":"approved per ticket SECOPS-12345"}'
# 4. Confirm the job transitioned to Pending
curl -s "https://certctl/api/v1/jobs?certificate_id=mc-foo" \
-H "Authorization: Bearer $API_KEY" | jq '.[] | {id,status,type}'
```
To **reject** instead, swap the path: `POST /api/v1/approvals/{id}/reject` with the same body shape. The job transitions to `Cancelled` and the `note` is recorded in the audit row.
## Operator playbook: "approval timed out"
The scheduler reaper transitions stale pending requests + their linked jobs after `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT` (default `168h` = 7 days):
- `ApprovalRequest.state``expired`
- `Job.Status``Cancelled` (with `error_message="approval expired"`)
- One audit row per expiry (`action=approval_expired, actor=system-reaper, actorType=System`)
- `certctl_approval_decisions_total{outcome="expired",profile_id="..."}` increments
Resolve by re-triggering the renewal once the underlying delay is sorted:
```bash
curl -X POST "https://certctl/api/v1/certificates/mc-foo/renew" \
-H "Authorization: Bearer $API_KEY"
```
Tighten the timeout for short-window deployments via the env var, e.g. `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT=24h`.
## Bypass mode (dev / CI ONLY)
Setting `CERTCTL_APPROVAL_BYPASS=true` short-circuits the workflow: every `RequestApproval` call auto-approves with `decided_by=system-bypass` and `actorType=System`. Used by dev / CI to keep renewal-scheduler tests fast without standing up an approver.
**Production deploys MUST leave this unset.** The bypass emits a typed audit event (`action=approval_bypassed`) so reviewers detect misuse via:
```sql
SELECT count(*) FROM audit_events WHERE actor = 'system-bypass';
```
returning **zero rows in production** and a high count in dev. The certctl-server logs a `WARN` line at boot when bypass is enabled — operators alert on that log line in production environments.
## Prometheus metrics
```
certctl_approval_decisions_total{outcome,profile_id} counter
certctl_approval_pending_age_seconds histogram
(le buckets:
60, 300, 1800, 3600,
21600, 86400, +Inf)
```
`outcome` is one of `approved`, `rejected`, `expired`, `bypassed`. `profile_id` is the `CertificateProfile.ID` that triggered the gate (cardinality-bounded — operators have <100 profiles in production).
The pending-age histogram observes seconds-since-creation at the moment of decision. Alert when p99 hits hours/days — production deployments usually have a same-day decision deadline.
## Future free V2 work
- **M-of-N approver chains.** Today's primitive is single-approver. Future V2 work adds chains — e.g., "needs 2 of 3 platform-team members."
- **Time-windowed auto-approve.** Today's reaper hard-cancels at the static deadline. Policy-driven time-windowed auto-approve (T+30m unattended → cancel; T+24h business hours → escalate) is future work.
- **External ticketing integration.** ServiceNow / JIRA bridging so approval state mirrors the change-management record.
- **Per-owner / per-team routing.** Today's pool is global. Per-owner / per-team routing matches cert ownership to approver pools.
- **Approval delegation.** Today the same-actor rule is strict. Time-bounded delegation is future work.
Tracked in `WORKSPACE-ROADMAP.md` under the Future Free V2 Work section — every item ships free under BSL.
@@ -1,6 +1,8 @@
# Database TLS — Postgres Transport Encryption # Database TLS — Postgres Transport Encryption
**Audit reference:** Bundle B / M-018. PCI-DSS v4.0 Req 4 §2.2.5; CWE-319. > Last reviewed: 2026-05-05
**Audit reference:** Bundle B / M-018. CWE-319 (Cleartext transmission of sensitive information).
certctl talks to Postgres over a single connection-string URL controlled by the certctl talks to Postgres over a single connection-string URL controlled by the
`CERTCTL_DATABASE_URL` env var. The `sslmode` query parameter on that URL `CERTCTL_DATABASE_URL` env var. The `sslmode` query parameter on that URL
@@ -13,16 +15,16 @@ explicit opt-in / opt-out paths for the four real-world deployment shapes.
| Deployment shape | Default `sslmode` | When to change | | Deployment shape | Default `sslmode` | When to change |
|------------------------------------------------|--------------------|----------------| |------------------------------------------------|--------------------|----------------|
| Helm chart, bundled Postgres, in-cluster | `disable` | When the cluster does not provide pod-network encryption (CNI without WireGuard / IPSec) and the workload is in PCI-DSS scope. | | Helm chart, bundled Postgres, in-cluster | `disable` | When the cluster does not provide pod-network encryption (CNI without WireGuard / IPSec) and the workload handles sensitive data. |
| Helm chart, external Postgres (RDS / Cloud SQL / Azure DB) | not auto-set | **Always** set to `verify-full` and provide the cloud provider's server CA bundle. | | Helm chart, external Postgres (RDS / Cloud SQL / Azure DB) | not auto-set | **Always** set to `verify-full` and provide the cloud provider's server CA bundle. |
| docker-compose, bundled Postgres on docker bridge | `disable` | Demo/dev only; not a deployment shape we expect operators to harden. | | docker-compose, bundled Postgres on docker bridge | `disable` | Demo/dev only; not a deployment shape we expect operators to harden. |
| docker-compose / k8s with external Postgres | not auto-set | **Always** set `CERTCTL_DATABASE_URL` to a connection string with `sslmode=verify-full`. | | docker-compose / k8s with external Postgres | not auto-set | **Always** set `CERTCTL_DATABASE_URL` to a connection string with `sslmode=verify-full`. |
`sslmode` values come from `lib/pq` (the underlying driver). The full set is: `sslmode` values come from `lib/pq` (the underlying driver). The full set is:
`disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full`. PCI-DSS `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full`.
Req 4 v4.0 §2.2.5 considers `verify-ca` the floor for sensitive-data transport; `verify-ca` is the floor for sensitive-data transport; `verify-full`
`verify-full` is the floor for systems exposed to spoofing risk (it adds is the floor for systems exposed to spoofing risk (it adds hostname
hostname validation against the server cert's CN/SAN). validation against the server cert's CN/SAN).
## Helm chart (Bundle B) ## Helm chart (Bundle B)
+120
View File
@@ -0,0 +1,120 @@
# Helm Deployment
> Last reviewed: 2026-05-05
Operator runbook for deploying certctl on Kubernetes via the bundled Helm chart at `deploy/helm/certctl/`.
## Prereqs
- Kubernetes cluster, v1.27+
- `kubectl` configured and authenticated
- `helm` v3.13+
- Storage class for the PostgreSQL StatefulSet PVC
- TLS cert source: either an operator-supplied `kubernetes.io/tls` Secret OR a cert-manager `ClusterIssuer` / `Issuer`. The chart refuses to render without one. See [`tls.md`](tls.md) for the four cert provisioning patterns.
## Install
```bash
helm install certctl deploy/helm/certctl/ \
--namespace certctl \
--create-namespace \
--set server.apiKey=$(openssl rand -hex 32) \
--set postgres.password=$(openssl rand -hex 32) \
--set server.tls.existingSecret=certctl-server-tls
```
`server.apiKey` and `postgres.password` should be high-entropy values. The example above generates them inline; production deployments use a secrets manager (Vault, External Secrets Operator, AWS Secrets Manager) instead.
## What you get
- **Server Deployment** with a configurable replica count (default 1; HA needs sticky sessions on the ACME server's nonce path)
- **PostgreSQL StatefulSet** with PVC-backed persistence
- **Agent DaemonSet** with one agent per node (configurable via `agent.daemonset.enabled=false` if you don't want the in-cluster agent)
- Health probes (`/health` liveness + `/ready` readiness)
- Security contexts: non-root, read-only root filesystem
- Optional Ingress (off by default; opt in via `ingress.enabled=true`)
## Cert source patterns
### Pattern 1 — operator-supplied Secret (recommended for non-cert-manager shops)
```bash
kubectl create secret tls certctl-server-tls \
--cert=server.crt --key=server.key \
--namespace certctl
helm install certctl deploy/helm/certctl/ \
--namespace certctl \
--set server.tls.existingSecret=certctl-server-tls
```
### Pattern 2 — cert-manager Certificate CR (recommended for cert-manager shops)
```bash
helm install certctl deploy/helm/certctl/ \
--namespace certctl \
--set server.tls.certManager.enabled=true \
--set server.tls.certManager.issuerRef.name=my-cluster-issuer \
--set server.tls.certManager.issuerRef.kind=ClusterIssuer
```
### Refuses to render without one of the above
```bash
helm install certctl deploy/helm/certctl/ --namespace certctl
# Error: server.tls.existingSecret OR server.tls.certManager.enabled must be set
```
The render-time guard catches the missing config at `helm install` time, not at pod-crash-loop time.
## Verify the install
```bash
kubectl wait --for=condition=Ready --timeout=3m \
-n certctl pod -l app.kubernetes.io/name=certctl-server
kubectl port-forward -n certctl svc/certctl-server 8443:8443 &
# Bundle the TLS root from the Secret to verify
kubectl get secret -n certctl certctl-server-tls -o jsonpath='{.data.ca\.crt}' \
| base64 -d > /tmp/certctl-ca.crt
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
# {"status":"healthy"}
```
If the Secret has no `ca.crt` key (operator-supplied Secrets often don't), use `tls.crt` as the bundle. For a self-signed cert the two files are identical; for a chained cert distribute the root CA bundle separately via ConfigMap.
## Upgrade
```bash
helm upgrade certctl deploy/helm/certctl/ \
--namespace certctl \
--reuse-values
```
Postgres state survives the upgrade (the PVC is retained). The server / agent images bump per the chart's `image.tag`. See [`docs/archive/upgrades/`](../archive/upgrades/) for version-specific upgrade guidance.
## Configuration reference
Every value is documented at `deploy/helm/certctl/values.yaml`. Common tweaks:
- `server.replicaCount` — replica count (default 1)
- `server.resources.{requests,limits}` — pod resource bounds
- `agent.daemonset.enabled` — toggle the in-cluster agent (default true)
- `postgres.storageSize` — PVC size (default 10Gi)
- `ingress.enabled` + `ingress.host` — opt into Ingress
## Troubleshooting
**Pod crash-loops with TLS error.** Cert + key in the Secret don't pair. Verify with `openssl x509 -modulus -in server.crt -noout | md5` against `openssl rsa -modulus -in server.key -noout | md5` — outputs must match.
**Agent DaemonSet pods can't reach the server.** Service DNS / NetworkPolicy issue. Confirm the agent's `CERTCTL_SERVER_URL` env points at the in-cluster service name (`https://certctl-server.certctl.svc.cluster.local:8443`).
**Postgres won't start.** PVC permissions. Check `kubectl describe pvc -n certctl certctl-postgres` and confirm the storage class supports `fsGroup`.
## Related docs
- [`tls.md`](tls.md) — cert provisioning patterns + SIGHUP rotation
- [`security.md`](security.md) — production security posture
- [`runbooks/disaster-recovery.md`](runbooks/disaster-recovery.md) — Postgres restore + recovery procedures
- [`docs/archive/upgrades/`](../archive/upgrades/) — version-specific upgrade procedures
+209
View File
@@ -0,0 +1,209 @@
# Legacy Clients (TLS 1.2) — Reverse-Proxy Runbook
> Last reviewed: 2026-05-05
**Audit reference:** Bundle F / M-023. CWE-326 (Inadequate encryption strength).
## What this is
certctl's control plane pins `tls.Config.MinVersion = tls.VersionTLS13`
(`cmd/server/tls.go:131`). Some embedded EST (RFC 7030) and SCEP (RFC 8894)
clients only speak TLS 1.0/1.1/1.2 — those clients cannot complete the
handshake against certctl directly. This runbook documents the supported
operator pattern: terminate the legacy TLS version at a front-door reverse
proxy and pass the request through to certctl over TLS 1.3.
## Why TLS 1.3 minimum
certctl's audit posture and the M-001 PBKDF2 work factor both assume
modern transport crypto. TLS 1.2 with the cipher suites still in the
wild has known attack surface (BEAST, POODLE, ROBOT, raccoon — all
CVE-categorized); allowing TLS 1.2 directly on the certctl listener
would invalidate the guarantee that the server-side encryption chain
is the strongest the ecosystem currently supports.
## When this runbook applies
You need this if **all three** are true:
1. You operate certctl with EST or SCEP enabled (`CERTCTL_EST_ENABLED=true`
or `CERTCTL_SCEP_ENABLED=true`).
2. Your enrolling clients are embedded devices (printers, network
appliances, IoT boards, legacy MFPs, point-of-sale terminals) whose TLS
stack pre-dates 2018 and only speaks TLS 1.2 or older.
3. Replacing those clients is not feasible on a 6-month horizon.
If your enrolling clients are modern (any current Linux/Windows/macOS
host, anything Go-based, anything Rust/Python/Node from 2019 onward),
they speak TLS 1.3 natively and this runbook is unnecessary — point them
straight at certctl on `:8443`.
## Architecture
```mermaid
flowchart LR
Client["legacy EST/SCEP client"]
Proxy["nginx / HAProxy<br/>reverse proxy"]
Server["certctl :8443"]
Client -->|"TLS 1.2/1.3<br/>(allowed TLS 1.2)"| Proxy
Proxy -->|"TLS 1.3<br/>(re-encrypts as TLS 1.3)"| Server
```
The reverse proxy:
- Terminates the legacy-version TLS handshake on the public-facing port.
- Forwards the request to certctl over TLS 1.3 on a private network.
- (For EST mTLS) forwards the client certificate via an
`X-SSL-Client-Cert` header that certctl reads only when the connection
arrives from a configured-trusted source IP.
## nginx config
```nginx
upstream certctl_backend {
# Private-network address; not reachable from outside the proxy host.
server 10.0.0.10:8443;
}
server {
listen 443 ssl http2;
server_name est.example.com;
# Public-facing legacy listener. ssl_protocols includes TLSv1.2 explicitly.
# Keep ssl_ciphers conservative — only strong AEAD suites with forward
# secrecy.
ssl_certificate /etc/nginx/certs/est.example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/certs/est.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
# mTLS for EST: optional client cert, verified against the EST CA.
ssl_client_certificate /etc/nginx/certs/est-clients-ca.pem;
ssl_verify_client optional;
location ~ ^/\.well-known/(est|pki) {
# Forward the client cert (if presented) to certctl over the
# private hop. The current certctl implementation IGNORES the
# X-SSL-Client-Cert header (header-agnostic by default — see
# the certctl-side configuration section below). EST/SCEP
# authentication still works correctly because both protocols
# carry their own auth (CSR signature for EST, challengePassword
# for SCEP) inside the request body.
proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# The proxy-to-certctl hop is itself TLS 1.3.
proxy_pass https://certctl_backend;
proxy_ssl_protocols TLSv1.3;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/certs/certctl-internal-ca.pem;
}
# SCEP endpoints — same pattern, no client-cert requirement
# (SCEP authenticates via challengePassword inside the CSR).
location ^~ /scep {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass https://certctl_backend;
proxy_ssl_protocols TLSv1.3;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/certs/certctl-internal-ca.pem;
}
}
```
## HAProxy config (alternative)
```
frontend est_legacy
bind *:443 ssl crt /etc/haproxy/certs/est.example.com.pem alpn h2,http/1.1 \
ssl-min-ver TLSv1.2 \
ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
acl is_est_path path_beg /.well-known/est
acl is_pki_path path_beg /.well-known/pki
acl is_scep_path path_beg /scep
use_backend certctl_backend if is_est_path or is_pki_path or is_scep_path
default_backend certctl_modern
backend certctl_backend
server certctl 10.0.0.10:8443 ssl verify required \
ca-file /etc/haproxy/certs/certctl-internal-ca.pem \
ssl-min-ver TLSv1.3
http-request set-header X-Forwarded-For %[src]
http-request set-header X-Forwarded-Proto https
```
## certctl-side configuration
The current implementation is **header-agnostic**: certctl ignores any
`X-SSL-Client-Cert` / `X-Forwarded-For` headers from the proxy. EST
authentication still happens via in-protocol CSR signature + profile
policy (RFC 7030 §3.2.3); SCEP authentication still happens via the
`challengePassword` attribute embedded in the CSR (RFC 8894 §3.2). Both
mechanisms are inside the request body and survive the reverse-proxy
hop without server-side header trust.
**Why this is the correct default:** trusting a proxy-supplied header
for client identity opens a header-spoofing attack surface that requires
careful design (CIDR allowlist of trusted proxies, fail-closed defaults,
explicit operator opt-in). The Bundle F closure of M-023 ships the
TLS-bridge guidance as documentation only; a future commit can extend
certctl with proxy-header trust if and when an operator demonstrates a
deployment shape that requires it. Until that lands, the runbook above
is operationally complete: legacy EST and SCEP clients continue to
authenticate via their in-protocol mechanisms, and the reverse proxy is
purely a TLS-version bridge.
If your deployment requires proxy-supplied client identity (e.g., the
proxy terminates mTLS and you want certctl to record the client-cert
subject in the audit trail beyond what the CSR carries), open an issue
and a future commit will add a header-trust contract behind two
fail-closed env vars: a CIDR allowlist of trusted proxies, plus an
explicit opt-in toggle. Both knobs would be required together; setting
only one would fail loud at startup. Until that work ships, the
header-agnostic default described above is the only supported
configuration.
## TLS posture summary
The configuration above:
- Pins TLS 1.2 + TLS 1.3 only (no SSLv3, TLS 1.0, TLS 1.1).
- Uses only AEAD cipher suites with forward secrecy (ECDHE-* with GCM or
ChaCha20-Poly1305).
- Re-encrypts to TLS 1.3 on the proxy-to-certctl hop so the certctl
listener never speaks anything below 1.3.
That is the strongest posture currently achievable while still allowing
the legacy clients to enroll. Reviewers looking for the attestation
should be pointed at this section + the proxy's TLS config.
## What this runbook does NOT cover
- **Replacing the legacy clients.** That's the long-term fix; this
runbook is the bridge while you're migrating.
- **Network segmentation.** The reverse proxy assumes the proxy-to-certctl
hop is on a network that an external attacker can't reach. If it's
not, you need a deeper architecture review.
- **Client-cert revocation.** EST mTLS revocation is the relying party's
responsibility. certctl's EST handler accepts the cert; the proxy can
enforce CRL/OCSP via `ssl_crl_path` (nginx) or `crl-file` (HAProxy).
## When TLS 1.2 itself sunsets
Major browsers and OS vendors will eventually deprecate TLS 1.2. When
that happens, this runbook becomes obsolete; the only path forward
will be to replace the legacy clients. Watch the IETF TLS working
group, the major browser vendors' announcement channels, and your
own embedded-device vendors for deprecation notices.
## Related docs
- [`docs/operator/tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only control plane, MinVersion pin)
- [`docs/operator/security.md`](security.md) — overall security posture
- [`docs/operator/database-tls.md`](database-tls.md) — Postgres TLS opt-in (Bundle B / M-018)
- [`docs/reference/protocols/scep-server.md`](../reference/protocols/scep-server.md) — SCEP RFC 8894 native server reference
- [`docs/reference/protocols/est.md`](../reference/protocols/est.md) — EST RFC 7030 server reference
+106
View File
@@ -0,0 +1,106 @@
# Performance Baselines
> Last reviewed: 2026-05-05
Operator-runnable benchmarks for spot-checking certctl performance against published baselines. Useful as a regression detector after upgrades or infra changes.
## Why these specific spots?
certctl's hot paths are dominated by three workloads:
1. **API request handling** — auth, rate-limit decision, route dispatch, DB read
2. **Renewal scheduler** — periodic scan + dispatch
3. **Certificate inventory queries** — large list returns with sparse fields
The baselines below cover those three.
## Baseline #1: API request handling (single endpoint)
Hit a hot read endpoint with a tight loop and compare against the baseline.
```bash
SERVER=https://localhost:8443
CACERT="--cacert ./deploy/test/certs/ca.crt"
AUTH="Authorization: Bearer change-me-in-production"
# Warm the connection pool (5 requests, discard timing)
for i in $(seq 1 5); do
curl -s $CACERT -H "$AUTH" $SERVER/api/v1/stats/summary > /dev/null
done
# Measured run: 100 requests, capture mean latency
time (for i in $(seq 1 100); do
curl -s $CACERT -H "$AUTH" $SERVER/api/v1/stats/summary > /dev/null
done)
```
**Baseline (M3 MacBook Pro, Docker Desktop):** real time under 5 seconds for 100 sequential requests = mean ~50ms p50.
If you're seeing > 100ms mean, something is wrong: PostgreSQL connection pool exhaustion, agent flooding the work-poll endpoint, or rate-limiter mis-tuned.
## Baseline #2: Inventory list with cursor pagination
```bash
# Cursor-paginated full inventory walk
NEXT=""
PAGES=0
START=$(date +%s)
while true; do
RESP=$(curl -s $CACERT -H "$AUTH" "$SERVER/api/v1/certificates?limit=100&cursor=$NEXT")
NEXT=$(echo "$RESP" | jq -r '.next_cursor // empty')
PAGES=$((PAGES + 1))
[ -z "$NEXT" ] && break
done
END=$(date +%s)
echo "Walked $PAGES pages in $((END - START))s"
```
**Baseline:** for the demo dataset (15 certificates, 1 page), under 1 second total. For a 1000-cert inventory (10 pages of 100), under 3 seconds total = ~300ms per page.
If you're seeing > 1s per page on a 1000-cert inventory, the cursor index on `managed_certificates(created_at, id)` is missing or the query plan went wrong.
## Baseline #3: Scheduler tick (renewal scan)
The renewal scheduler runs every hour by default. Force a tick and observe the time-to-completion in the logs:
```bash
# Trigger an immediate renewal scan via the admin endpoint
curl -s $CACERT -H "$AUTH" -X POST $SERVER/api/v1/admin/scheduler/run-now/renewal | jq .
# Tail the log and look for the matching `renewal scan complete` line
docker compose logs -f certctl-server | grep 'renewal'
```
**Baseline (15-cert demo dataset):** "renewal scan complete" within 100ms of the trigger.
For a 1000-cert inventory: under 5 seconds. The dominant cost is the per-cert profile + policy + alert-channel resolve plus the threshold-comparison math. If you're seeing > 10 seconds, profile resolution is likely doing N+1 queries.
## Baseline #4: Bulk revoke
```bash
# Bulk-revoke all certs from a (test) issuer
TIME=$(date +%s)
curl -s $CACERT -H "$AUTH" -H "$CT" -X POST $SERVER/api/v1/certificates/bulk-revoke \
-d '{"filter":{"issuer_id":"iss-test"},"reason":"superseded"}' | jq .
echo "Bulk revoke: $(($(date +%s) - TIME))s"
```
**Baseline:** linear in cert count. For 100 certs from one issuer: under 5 seconds. For 1000 certs: under 30 seconds (dominated by per-cert audit row + per-cert CRL refresh).
## When to re-baseline
After any of:
- Postgres major-version upgrade
- Go major-version upgrade
- Significant migration (add a column to `managed_certificates`, add an index)
- Connection pool config change
- Changing the renewal scheduler interval
Capture timing in your own loadtest-baselines log so future regressions surface against a real baseline rather than the operator's gut feeling.
## Related docs
- [`docs/contributor/ci-pipeline.md`](../contributor/ci-pipeline.md) — CI guard for performance regression
- [`docs/operator/security.md`](security.md) — rate limit tuning
- [`docs/reference/architecture.md`](../reference/architecture.md) — request path through handler → service → repository
+336
View File
@@ -0,0 +1,336 @@
# Runbook: cloud-target deployment connectors (AWS ACM + Azure Key Vault)
> Last reviewed: 2026-05-05
This runbook covers the SDK-driven cloud target connectors that ship in
certctl post-2026-05-03 (Rank 5 of the Infisical deep-research
deliverable). It complements the operator-facing
[AWS Certificate Manager](connectors.md#aws-certificate-manager-acm) and
[Azure Key Vault](connectors.md#azure-key-vault) sections in
`docs/connectors.md`.
Audience: a platform sysadmin or SRE who needs to configure, debug, or
audit certctl's cloud-target deploys. Not a walkthrough of how to
install certctl.
---
## End-to-end flow (cloud targets)
```mermaid
flowchart TD
Renew["cert renewed → renewal job created"]
Pick["agent picks up DeployCertificate work item"]
Dispatch["target.Connector.DeployCertificate(ctx, request)"]
Renew --> Pick --> Dispatch
Dispatch --> AWS
Dispatch --> AZ
subgraph AWS["AWS ACM path"]
A1["1. rotate-in-place only:<br/>DescribeCertificate(arn)"]
A2["2. GetCertificate(arn) —<br/>capture snapshot bytes for rollback"]
A3["3. ImportCertificate(arn, new_bytes) —<br/>fresh ARN OR rotate-in-place"]
A4["4. AddTagsToCertificate(arn, provenance) —<br/>ACM strips on re-import; we re-apply"]
A5["5. DescribeCertificate(arn) —<br/>verify serial matches expected"]
A6["6. ON MISMATCH: rollback<br/>ImportCertificate(arn, snapshot_bytes)"]
A1 --> A2 --> A3 --> A4 --> A5 --> A6
end
subgraph AZ["Azure Key Vault path"]
Z1["1. GetCertificate(name, '' = latest) —<br/>capture snapshot CER bytes"]
Z2["2. Build PFX from cert+chain+key<br/>(PKCS#12 via go-pkcs12)"]
Z3["3. ImportCertificate(name, PFX, tags) —<br/>ALWAYS creates a new version"]
Z4["4. Tags carried forward automatically"]
Z5["5. GetCertificate(name, '' = latest) —<br/>verify serial matches expected"]
Z6["6. ON MISMATCH: rollback<br/>ImportCertificate(name, snapshot_PFX) —<br/>new version"]
Z1 --> Z2 --> Z3 --> Z4 --> Z5 --> Z6
end
A6 --> Audit
Z6 --> Audit
Audit["7. Audit row + Prometheus counters<br/>certctl_deploy_attempts_total{target_type, result}<br/>certctl_deploy_rollback_total{target_type, outcome}"]
```
---
## Configuring an AWS ACM target
### Minimum config
```bash
curl -X POST https://certctl.example.com/api/v1/targets \
-H 'Authorization: Bearer ${TOKEN}' \
-H 'Content-Type: application/json' \
-d '{
"name": "Production ALB cert",
"type": "AWSACM",
"agent_id": "ag-server",
"config": {
"region": "us-east-1",
"tags": {"env": "production"}
}
}'
```
Empty `certificate_arn` on first deploy = ACM creates a fresh ARN; the
deployment record's Metadata captures it. Update the
`deployment_targets.config.certificate_arn` field via the GUI / API /
direct SQL to pin the ARN for subsequent renewals.
### Minimum IAM policy
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"acm:ImportCertificate",
"acm:GetCertificate",
"acm:DescribeCertificate",
"acm:ListCertificates",
"acm:AddTagsToCertificate"
],
"Resource": "arn:aws:acm:us-east-1:*:certificate/*"
}]
}
```
Pin `Resource` to the specific region / account where the ALB lives.
Cross-account deploys use AssumeRole — configure the agent's role with
`sts:AssumeRole` against the target account's role ARN.
### Auth: IRSA (recommended for EKS-hosted agents)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: certctl-agent
namespace: certctl-system
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/certctl-acm-deployer
```
Trust policy on `certctl-acm-deployer`:
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE:sub": "system:serviceaccount:certctl-system:certctl-agent"
}
}
}]
}
```
---
## Configuring an Azure Key Vault target
### Minimum config
```bash
curl -X POST https://certctl.example.com/api/v1/targets \
-H 'Authorization: Bearer ${TOKEN}' \
-H 'Content-Type: application/json' \
-d '{
"name": "Production AGW cert",
"type": "AzureKeyVault",
"agent_id": "ag-server",
"config": {
"vault_url": "https://prod-vault.vault.azure.net",
"certificate_name": "api-prod",
"credential_mode": "managed_identity",
"tags": {"env": "production"}
}
}'
```
### Minimum RBAC role
Off-the-shelf builtin: **Key Vault Certificates Officer** (assigns at
the vault scope).
Custom minimum-permission role:
```json
{
"properties": {
"roleName": "certctl-keyvault-deployer",
"description": "Minimum permissions for certctl Key Vault target",
"assignableScopes": [
"/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault-name>"
],
"permissions": [{
"actions": [],
"notActions": [],
"dataActions": [
"Microsoft.KeyVault/vaults/certificates/import/action",
"Microsoft.KeyVault/vaults/certificates/read",
"Microsoft.KeyVault/vaults/certificates/listversions/read"
],
"notDataActions": []
}]
}
}
```
### Auth: AKS workload identity (recommended for AKS-hosted agents)
Annotate the agent's ServiceAccount:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: certctl-agent
namespace: certctl-system
annotations:
azure.workload.identity/client-id: <app-registration-client-id>
labels:
azure.workload.identity/use: "true"
```
Federated credential on the app registration:
```json
{
"name": "certctl-agent-federated",
"issuer": "https://<oidc-issuer-url>",
"subject": "system:serviceaccount:certctl-system:certctl-agent",
"audiences": ["api://AzureADTokenExchange"]
}
```
Set `credential_mode: workload_identity` on the deployment_target
config.
---
## Operator playbook
### "Did the cert get imported to ACM / Key Vault?"
**AWS:**
```bash
aws acm describe-certificate \
--certificate-arn arn:aws:acm:us-east-1:...:certificate/<id> \
--query 'Certificate.{Status:Status,Serial:Serial,Issued:IssuedAt,NotAfter:NotAfter,Tags:[Tags]}'
```
**Azure:**
```bash
az keyvault certificate show \
--vault-name prod-vault \
--name api-prod \
--query '{Serial:x509ThumbprintHex, Version:id, NotAfter:attributes.expires}'
```
In both cases, the `certctl-managed-by` tag confirms the cert was
imported by certctl (and not someone running aws-cli directly).
### "Why did the rollback fail?"
The Prometheus counter
`certctl_deploy_rollback_total{outcome="also_failed"}` ticks when the
rollback's own ImportCertificate / Set call also returns an error. Look
at the agent's slog at ERROR level for the per-call diagnostic; the
underlying cloud SDK error message tells you whether it was IAM
denial, throttling, or a structural input problem.
Manual recovery:
**AWS ACM:**
```bash
# Get the snapshot of a known-good cert from S3 / Vault / wherever the
# operator stores backup PEMs:
aws acm import-certificate \
--certificate fileb://known-good.crt \
--private-key fileb://known-good.key \
--certificate-chain fileb://known-good.chain \
--certificate-arn arn:aws:acm:us-east-1:...:certificate/<id> \
--tags Key=certctl-managed-by,Value=manual-recovery
```
**Azure Key Vault:**
```bash
# Import a fresh PFX as a new version under the same name:
az keyvault certificate import \
--vault-name prod-vault \
--name api-prod \
--file known-good.pfx \
--tags certctl-managed-by=manual-recovery
```
After the manual recovery, certctl's next renewal-loop tick re-verifies
the live cert via `ValidateDeployment` and resumes normal operation.
### "How do I know certctl is the only one writing to this ARN / vault cert?"
**AWS — via CloudTrail:**
```
EventName = "ImportCertificate"
Resources.ARN = "arn:aws:acm:us-east-1:...:certificate/<id>"
```
Filter by user identity to see which principal made each call. The
certctl agent's IAM role / IRSA-bound role should be the only writer.
**Azure — via Activity Log:**
```bash
az monitor activity-log list \
--resource-id /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault>/certificates/<name> \
--offset 30d \
--query "[?operationName.value=='Microsoft.KeyVault/vaults/certificates/import/action'].{caller:caller, time:eventTimestamp}"
```
---
## Cardinality + cost
- Per-target-type Prometheus counters: 2 new
`certctl_deploy_attempts_total` series (AWSACM + AzureKeyVault) ×
2 results = 4 series. Comfortable.
- AWS ACM costs: ImportCertificate is free; CloudTrail logs at $2 per
GB. Renewing 100 certs/month adds ~10 KB to CloudTrail.
- Azure Key Vault costs: certificate operations $0.03 per 10K
operations (V2 pricing as of 2026-05). 100 certs/month = $0.0009 in
cert-op spend. Activity Log retention is configurable (default 90
days, free).
---
## V3-Pro forward path
Tracked under "Adapter hardening" on the project roadmap:
- **AWS CloudFront direct-attach** — UpdateDistribution after an ACM
ImportCertificate so the CloudFront edge picks up the new cert
without operator intervention. Requires `cloudfront:UpdateDistribution`
IAM permission on top of the ACM minimum.
- **Azure Front Door direct-attach** — UpdateRoutingConfig equivalent.
- **AWS ALB / Azure App Gateway auto-bind** — currently operators
attach the ARN / KID URI to the LB out-of-band (Terraform);
V3-Pro adds the auto-attach step.
- **Soft-delete recovery for Azure Key Vault** — V2 always
re-imports as a new version; V3 detects soft-deleted prior
versions and offers operator-confirmed recovery.
- **GCP Certificate Manager target** — Google Cloud's equivalent to
ACM; mirrors the AWS ACM connector shape. Separate cloud,
separate connector.
@@ -1,5 +1,7 @@
# Disaster recovery runbook # Disaster recovery runbook
> Last reviewed: 2026-05-05
> **Status (this document):** Production hardening II Phase 10 > **Status (this document):** Production hardening II Phase 10
> deliverable. Codifies the fail-safe behaviors that already exist in > deliverable. Codifies the fail-safe behaviors that already exist in
> the codebase and the operator procedures for recovering from > the codebase and the operator procedures for recovering from
@@ -7,10 +9,10 @@
> if a procedure here doesn't work as documented, that's a bug in > if a procedure here doesn't work as documented, that's a bug in
> docs (file an issue). > docs (file an issue).
This runbook is the SOC 2 / PCI procurement-team deliverable: it tells This runbook is the on-call deliverable: it tells reviewers and
auditors and on-call operators what to do when a piece of certctl's on-call operators what to do when a piece of certctl's state
state corrupts, when a CA key needs rotation, or when Postgres needs corrupts, when a CA key needs rotation, or when Postgres needs a
a point-in-time restore. Read it once when you set up certctl; print point-in-time restore. Read it once when you set up certctl; print
the [DR checklist](#dr-checklist) and pin it near your on-call rotation. the [DR checklist](#dr-checklist) and pin it near your on-call rotation.
## Contents ## Contents
@@ -55,7 +57,7 @@ without operator action. The fail-safes in the codebase:
These fail-safes mean most of this runbook is "delete the corrupt These fail-safes mean most of this runbook is "delete the corrupt
row + wait for the next tick" rather than "restore from backup + row + wait for the next tick" rather than "restore from backup +
manually re-issue." The runbook documents the full procedures manually re-issue." The runbook documents the full procedures
anyway because compliance auditors need to see them written down. anyway because reviewers need to see them written down.
## CRL cache recovery ## CRL cache recovery
@@ -236,7 +238,7 @@ remains trusted by relying parties until its `notAfter` (typical
openssl x509 -in new-cert -noout -issuer openssl x509 -in new-cert -noout -issuer
``` ```
**Future:** when the HSM/PKCS#11 driver bundle (`cowork/hsm-pkcs11- **Future:** when the HSM/PKCS#11 driver bundle (planned;
driver-prompt.md`) ships, this rotation procedure changes driver-prompt.md`) ships, this rotation procedure changes
substantially — the HSM-backed key never moves, only the cert wrap substantially — the HSM-backed key never moves, only the cert wrap
rotates. The signer interface seam is the load-bearing prerequisite rotates. The signer interface seam is the load-bearing prerequisite
@@ -286,7 +288,7 @@ backups. Without them, a restored DB is unusable.
## Trust-bundle reload semantics ## Trust-bundle reload semantics
This section codifies the fail-safe behavior that's already in code, This section codifies the fail-safe behavior that's already in code,
for compliance auditors who need to see the procedure documented. for reviewers who need to see the procedure documented.
**Pattern:** every trust-bundle holder (`internal/trustanchor.Holder`, **Pattern:** every trust-bundle holder (`internal/trustanchor.Holder`,
used by SCEP/Intune dispatcher + EST mTLS sibling route) implements used by SCEP/Intune dispatcher + EST mTLS sibling route) implements
@@ -340,9 +342,9 @@ Print this. Pin it near your on-call rotation.
## Related docs ## Related docs
- [`crl-ocsp.md`](crl-ocsp.md) — CRL/OCSP responder operator guide. - [`crl-ocsp.md`](../../reference/protocols/crl-ocsp.md) — CRL/OCSP responder operator guide.
- [`tls.md`](tls.md) — control-plane TLS bootstrap. - [`tls.md`](../../operator/tls.md) — control-plane TLS bootstrap.
- [`security.md`](security.md) — production-grade security posture. - [`security.md`](../../operator/security.md) — production-grade security posture.
- [`scep-intune.md`](scep-intune.md) — SCEP/Intune trust-anchor - [`scep-intune.md`](../../reference/protocols/scep-intune.md) — SCEP/Intune trust-anchor
rotation specifics. rotation specifics.
- [`est.md`](est.md) — EST mTLS trust-bundle rotation specifics. - [`est.md`](../../reference/protocols/est.md) — EST mTLS trust-bundle rotation specifics.
+228
View File
@@ -0,0 +1,228 @@
# Runbook: certificate-expiry alerts (multi-channel)
> Last reviewed: 2026-05-05
This runbook covers the per-policy multi-channel expiry-alert dispatch
path that ships in certctl post-2026-05-03 (Rank 4 of the Infisical
deep-research deliverable). It complements the operator-facing
[Routing expiry alerts across channels](connectors.md#routing-expiry-alerts-across-channels)
section in `docs/connectors.md`.
Audience: a platform sysadmin or on-call engineer who needs to
configure, debug, or audit certctl's expiry-alert routing. Not a
walkthrough of how to install certctl — that lives in the README.
---
## End-to-end flow
```mermaid
flowchart TD
Tick["daily ticker (renewalCheckLoop)"]
Check["RenewalService.CheckExpiringCertificates"]
Tick --> Check --> Loop
subgraph Loop["for cert in expiring (≤30 days)"]
L1["1. Resolve RenewalPolicy"]
L2["2. Compute daysUntil"]
L3["3. updateCertExpiryStatus"]
L4["4. sendThresholdAlerts"]
L5["5. Create renewal job<br/>(if issuer registered +<br/>ARI allows)"]
L1 --> L2 --> L3 --> L4 --> L5
end
L4 --> Threshold
subgraph Threshold["per threshold"]
T1["a. resolve severity tier<br/>via AlertSeverityMap"]
T2["b. resolve channel set<br/>via AlertChannels[tier]"]
T1 --> T2 --> Channel
end
subgraph Channel["for each channel (fault-isolating)"]
C1["i. dedup via notification_events<br/>(cert, threshold, channel)"]
C2["ii. SendThresholdAlertOnChannel<br/>→ notifierRegistry[channel]<br/>→ Send(recipient, subj, body)"]
C3["iii. record audit row<br/>event_type=expiration_alert_sent<br/>metadata.channel, metadata.severity_tier"]
C4["iv. bump Prometheus counter<br/>certctl_expiry_alerts_total<br/>{channel, threshold, result}"]
C1 --> C2 --> C3 --> C4
end
```
The dispatch loop's per-channel error handling is
**fault-isolating**: PagerDuty's failure does NOT skip Slack/Email
at the same threshold. Each channel runs independently, with its
own dedup row + audit row + metric increment.
---
## Configuring the per-policy channel matrix
The matrix is a property of `RenewalPolicy`. Two new JSONB columns
on the `renewal_policies` table back it (migration 000026):
- `alert_channels JSONB``map[severity_tier][]channel_name`. Default `{}`
→ fall through to `DefaultAlertChannels` (Email-only at every tier).
- `alert_severity_map JSONB``map[threshold_days]severity_tier`. Default
`{}` → fall through to `DefaultAlertSeverityMap` (`30→informational,
14→warning, 7→warning, 0→critical`).
### Example: production-grade routing
```bash
curl -X PUT https://certctl.example.com/api/v1/renewal-policies/rp-production \
-H 'Authorization: Bearer ${TOKEN}' \
-H 'Content-Type: application/json' \
-d '{
"name": "Production CDN renewal policy",
"renewal_window_days": 30,
"auto_renew": true,
"max_retries": 3,
"retry_interval_seconds": 300,
"alert_thresholds_days": [30, 14, 7, 0],
"alert_channels": {
"informational": ["Slack"],
"warning": ["Slack", "Email"],
"critical": ["PagerDuty", "OpsGenie", "Email"]
},
"alert_severity_map": {
"30": "informational",
"14": "warning",
"7": "warning",
"0": "critical"
}
}'
```
After this PUT, the next renewal-loop tick that finds a cert under
this policy will fan out alerts as documented above.
### Example: opt out of informational alerts
If your team doesn't want T-30 informational alerts (you'd rather
hear about a cert only at warning tier and beyond):
```json
"alert_channels": {
"informational": [],
"warning": ["Email"],
"critical": ["PagerDuty", "Email"]
}
```
The empty `informational` list causes the dispatch loop to record
an `expiration_alert_skipped_no_channels` audit row at T-30 and
skip the dispatch. Other tiers still fire.
---
## Operator playbook
### "Did the on-call team get paged?"
```sql
SELECT created_at,
metadata->>'channel' AS channel,
metadata->>'threshold_days' AS threshold,
metadata->>'severity_tier' AS severity
FROM audit_events
WHERE event_type = 'expiration_alert_sent'
AND resource_id = '<cert-id>'
ORDER BY created_at DESC;
```
One row per (channel, threshold) attempt. If you see a row with
`channel = 'PagerDuty'` and `severity = 'critical'`, the page went
out (or was at least dispatched to the notifier).
### "Why didn't I get an alert at T-7?"
Three places to look:
1. **Audit log** — `SELECT FROM audit_events WHERE event_type IN
('expiration_alert_sent','expiration_alert_skipped_no_channels',
'expiration_alert_skipped_invalid_channel') AND resource_id =
'<cert-id>'`. If `expiration_alert_skipped_no_channels` appears,
your policy's tier list is empty for the resolved tier. If
`expiration_alert_skipped_invalid_channel` appears, your matrix
has a typo (the `metadata->>'invalid_channel'` field tells you
which value).
2. **Notifications table**
`SELECT FROM notification_events WHERE certificate_id = '<cert-id>'
AND type = 'ExpirationWarning' ORDER BY created_at DESC`. If
rows exist with `channel = 'Slack'` and `status = 'failed'`,
the dispatch reached the channel but the channel rejected the
send. Look at the `error` column for the upstream message.
3. **Prometheus counters**
`curl /api/v1/metrics/prometheus | grep certctl_expiry_alerts_total`.
Sustained `{result="failure"}` counts indicate a notifier
connector misconfiguration (bad webhook URL, expired API key,
etc.).
### "How do I test the matrix without waiting for a real expiry?"
certctl ships an admin endpoint for this:
```bash
curl -X POST https://certctl.example.com/api/v1/admin/notifications/test \
-H 'Authorization: Bearer ${TOKEN}' \
-H 'Content-Type: application/json' \
-d '{
"certificate_id": "mc-test-cert",
"threshold_days": 0,
"channel": "PagerDuty"
}'
```
This calls `NotificationService.SendThresholdAlertOnChannel`
directly and bypasses the renewal loop's threshold check. Useful
for "did I configure PagerDuty correctly?" without having to set
up a deliberately-expiring cert. The admin endpoint requires
`role=admin` (V3-Pro RBAC); V2 deploys gate it on the bearer
token only.
### "How do I rotate a notifier credential without downtime?"
1. Update the `CERTCTL_PAGERDUTY_ROUTING_KEY` (or equivalent) env
var in your deployment.
2. Restart `certctl-server`. The notifier registry rebuilds
with the new credential.
3. Confirm with the admin-test endpoint above against the cert
you most care about.
The renewal loop is idempotent — a missed tick during the restart
window does NOT cause double-dispatch on the next tick (per-channel
dedup on the `notification_events` table guards against that).
---
## Cardinality + cost
- Default 6 channels × 4 thresholds × 3 results = **72 Prometheus series**.
- Custom-thresholds policies (e.g. `[60, 45, 30, 14, 7, 3, 1, 0]`)
expand the threshold dimension proportionally — 6 × 8 × 3 = 144 series.
- Closed-enum discipline at the dispatch site means typos in
`alert_channels` do NOT grow this count.
- A daily renewal-loop tick over 10K certs each policy-bound to the
matrix above produces O(channels × thresholds × certs) audit rows
+ notification rows in the worst case (every cert has crossed
every threshold and no dedup applies). Operators sizing
Postgres should plan for an `audit_events` row count on the
order of `unique_certs × channels_per_critical_tier` per fan-out
batch — which is ~3-5× the pre-Rank-4 row count.
---
## V3-Pro forward path
Tracked under "Adapter hardening" on the project roadmap:
- Per-owner / per-team / per-tenant channel routing (the matrix is
per-policy today, not per-owner).
- Calendar-aware suppression (no T-30 alerts on weekends for non-
on-call teams).
- Escalation chains (T-1 unanswered for 30m → escalate to
manager's PagerDuty).
- Per-channel rate limiting (downstream of I-005's retry+DLQ).
@@ -1,5 +1,7 @@
# certctl Security Posture & Operator Guidance # certctl Security Posture & Operator Guidance
> Last reviewed: 2026-05-05
This document collects the operator-facing security guidance that the source This document collects the operator-facing security guidance that the source
code's per-finding comment blocks reference. Each section names the audit code's per-finding comment blocks reference. Each section names the audit
finding it closes, the threat model, and the operator action required (if finding it closes, the threat model, and the operator action required (if
+9 -6
View File
@@ -1,8 +1,10 @@
# TLS on the Control Plane # TLS on the Control Plane
> Last reviewed: 2026-05-05
certctl's control plane is HTTPS-only as of v2.2. There is no plaintext `http://` listener, no `auto` mode, no dual-listener bridge, no TLS 1.2 escape hatch. The server refuses to start without a cert+key pair, the agent/CLI/MCP clients reject `http://` URLs at startup, and the Helm chart refuses to render without either an operator-supplied Secret or a cert-manager Certificate CR. certctl's control plane is HTTPS-only as of v2.2. There is no plaintext `http://` listener, no `auto` mode, no dual-listener bridge, no TLS 1.2 escape hatch. The server refuses to start without a cert+key pair, the agent/CLI/MCP clients reject `http://` URLs at startup, and the Helm chart refuses to render without either an operator-supplied Secret or a cert-manager Certificate CR.
This doc covers four cert provisioning patterns, SIGHUP-based cert rotation, and the client-side CA-trust configuration agents and the CLI need to talk to the server. If you are upgrading from a pre-HTTPS release and want the step-by-step cutover procedure, read [`upgrade-to-tls.md`](upgrade-to-tls.md) first and come back here for reference. This doc covers four cert provisioning patterns, SIGHUP-based cert rotation, and the client-side CA-trust configuration agents and the CLI need to talk to the server. If you are upgrading from a pre-HTTPS release and want the step-by-step cutover procedure, read [`upgrade-to-tls.md`](../archive/upgrades/to-tls-v2.2.md) first and come back here for reference.
## What you get ## What you get
@@ -154,7 +156,7 @@ Same three controls as CLI, env-var-driven only (no flags — MCP runs as a stdi
- `CERTCTL_SERVER_CA_BUNDLE_PATH` optional CA bundle - `CERTCTL_SERVER_CA_BUNDLE_PATH` optional CA bundle
- `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` optional skip - `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` optional skip
Claude Desktop / other MCP client configs should set all three in the tool's env block. MCP-client configs should set all three in the tool's env block.
## Troubleshooting: fail-loud preflight errors ## Troubleshooting: fail-loud preflight errors
@@ -173,7 +175,7 @@ Both files exist but `tls.LoadX509KeyPair` refused them. Typical causes: the pri
The client did not trust the CA that signed the server cert. Either mount the CA bundle via `CERTCTL_SERVER_CA_BUNDLE_PATH`, add the CA to the system trust store on the client host, or (dev only) set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`. The client did not trust the CA that signed the server cert. Either mount the CA bundle via `CERTCTL_SERVER_CA_BUNDLE_PATH`, add the CA to the system trust store on the client host, or (dev only) set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`.
**Client side: `tls: first record does not look like a TLS handshake`** **Client side: `tls: first record does not look like a TLS handshake`**
The client is speaking plaintext HTTP to an HTTPS server (or vice-versa). Check that `CERTCTL_SERVER_URL` starts with `https://`. If you are upgrading from a pre-v2.2 release and your agents are old, they will surface this error until you roll the DaemonSet — see [`upgrade-to-tls.md`](upgrade-to-tls.md). The client is speaking plaintext HTTP to an HTTPS server (or vice-versa). Check that `CERTCTL_SERVER_URL` starts with `https://`. If you are upgrading from a pre-v2.2 release and your agents are old, they will surface this error until you roll the DaemonSet — see [`upgrade-to-tls.md`](../archive/upgrades/to-tls-v2.2.md).
## InsecureSkipVerify justifications (Audit L-001) ## InsecureSkipVerify justifications (Audit L-001)
@@ -199,6 +201,7 @@ to this table is the right way to extend the surface.
| `internal/connector/target/f5/f5.go:128` | F5 BIG-IP iControl REST | F5 default install ships with a self-signed cert; operators who haven't replaced it use `config.Insecure`. The connector logs this on every dial and the operator-facing config docs this. | | `internal/connector/target/f5/f5.go:128` | F5 BIG-IP iControl REST | F5 default install ships with a self-signed cert; operators who haven't replaced it use `config.Insecure`. The connector logs this on every dial and the operator-facing config docs this. |
| `internal/connector/issuer/acme/acme.go:146` | Pebble (ACME test server) | Hard-coded for tests that drive against Pebble locally. Pebble issues self-signed; verifying the chain would defeat the purpose. | | `internal/connector/issuer/acme/acme.go:146` | Pebble (ACME test server) | Hard-coded for tests that drive against Pebble locally. Pebble issues self-signed; verifying the chain would defeat the purpose. |
| `internal/service/network_scan.go:460` | Network scanner probe | Same rationale as `tlsprobe/probe.go` above — discovery surfaces broken certs by design. | | `internal/service/network_scan.go:460` | Network scanner probe | Same rationale as `tlsprobe/probe.go` above — discovery surfaces broken certs by design. |
| `internal/api/acme/validators.go` (TLS-ALPN-01 validator) | RFC 8737 §3 TLS-ALPN-01 challenge validation | RFC 8737 mandates this: the responding TLS server presents a self-signed cert with the proof embedded in the `id-pe-acmeIdentifier` extension (OID 1.3.6.1.5.5.7.1.31). The chain is intentionally NOT validated — the proof is in the extension's SHA-256 of the key authorization, not the cert chain. Validating the chain would defeat the purpose: clients running TLS-ALPN-01 self-sign their challenge cert specifically because they don't have a trusted cert yet (that's what they're trying to obtain via ACME). The validator additionally checks that ALPN negotiated `acme-tls/1` and that the cert's `id-pe-acmeIdentifier` extension value is exactly SHA-256 of the expected key authorization. SSRF posture: the validator runs `validation.IsReservedIPForDial` against the resolved IP before the dial, refusing any private-IP target — same posture as the HTTP-01 validator. |
**What is NOT covered by this list:** `*_test.go` files use **What is NOT covered by this list:** `*_test.go` files use
`InsecureSkipVerify` freely against `httptest.Server` instances; that's a `InsecureSkipVerify` freely against `httptest.Server` instances; that's a
@@ -207,8 +210,8 @@ ignores `_test.go`.
## Related docs ## Related docs
- [`upgrade-to-tls.md`](upgrade-to-tls.md) — one-step cutover from pre-HTTPS releases - [`upgrade-to-tls.md`](../archive/upgrades/to-tls-v2.2.md) — one-step cutover from pre-HTTPS releases
- [`quickstart.md`](quickstart.md) — docker-compose walkthrough with HTTPS examples - [`quickstart.md`](../getting-started/quickstart.md) — docker-compose walkthrough with HTTPS examples
- [`test-env.md`](test-env.md) — integration test environment (also HTTPS-only) - [`test-env.md`](../contributor/test-environment.md) — integration test environment (also HTTPS-only)
- [`security.md`](security.md) — overall security posture, OCSP Must-Staple guidance, encryption-at-rest spec - [`security.md`](security.md) — overall security posture, OCSP Must-Staple guidance, encryption-at-rest spec
- Milestone spec: `prompts/https-everywhere-milestone.md` (authoritative source for locked decisions) - Milestone spec: `prompts/https-everywhere-milestone.md` (authoritative source for locked decisions)
+8 -7
View File
@@ -1,6 +1,8 @@
# OpenAPI Specification Guide # OpenAPI Specification Guide
certctl ships with a complete OpenAPI 3.1 specification at `api/openapi.yaml`. This spec documents all 78 API operations currently specified, every request/response schema, pagination conventions, authentication requirements, and error formats. It's the single source of truth for the documented REST API. (Note: The spec will be updated to include 7 additional certificate discovery endpoints from M18b.) > Last reviewed: 2026-05-05
certctl ships with a complete OpenAPI 3.1 specification at `api/openapi.yaml`. The spec documents every operation (re-derive count via `grep -cE '^\s+operationId:' api/openapi.yaml`), every request/response schema, pagination conventions, authentication requirements, and error formats. It's the single source of truth for the documented REST API.
This guide covers how to use the spec for API exploration, client SDK generation, and integration testing. This guide covers how to use the spec for API exploration, client SDK generation, and integration testing.
@@ -12,9 +14,8 @@ The spec lives at `api/openapi.yaml` in the repository root. It's versioned alon
# View the spec # View the spec
cat api/openapi.yaml cat api/openapi.yaml
# Count operations # Count operations (includes health + ready)
grep "operationId:" api/openapi.yaml | wc -l grep -cE '^\s+operationId:' api/openapi.yaml
# 78 (includes health + ready, 7 discovery endpoints pending spec update)
``` ```
## Viewing with Swagger UI ## Viewing with Swagger UI
@@ -151,7 +152,7 @@ npx @apidevtools/swagger-cli validate api/openapi.yaml
Import the spec directly into Postman: Import the spec directly into Postman:
1. Open Postman → Import → File → select `api/openapi.yaml` 1. Open Postman → Import → File → select `api/openapi.yaml`
2. Postman creates a collection with all 78 documented operations organized by tag 2. Postman creates a collection with every documented operation organized by tag
3. Set the `baseUrl` variable to `https://localhost:8443` (HTTPS-only as of v2.2) 3. Set the `baseUrl` variable to `https://localhost:8443` (HTTPS-only as of v2.2)
4. Add an `Authorization: Bearer your-api-key` header to the collection 4. Add an `Authorization: Bearer your-api-key` header to the collection
5. Import the demo stack CA bundle (`deploy/test/certs/ca.crt`) into Postman's Settings → Certificates → CA Certificates, or disable certificate verification for the `localhost` host (Settings → General → SSL certificate verification) 5. Import the demo stack CA bundle (`deploy/test/certs/ca.crt`) into Postman's Settings → Certificates → CA Certificates, or disable certificate verification for the `localhost` host (Settings → General → SSL certificate verification)
@@ -191,6 +192,6 @@ This sends randomized valid requests to every endpoint and verifies the response
## What's Next ## What's Next
- [MCP Server Guide](mcp.md) — AI-native access to the certctl API - [MCP Server Guide](mcp.md) — AI-native access to the certctl API
- [Quick Start](quickstart.md) — Get certctl running locally - [Quick Start](../getting-started/quickstart.md) — Get certctl running locally
- [Connector Guide](connectors.md) — Build custom issuer and target connectors - [Connector Guide](connectors/index.md) — Build custom issuer and target connectors
- [Architecture](architecture.md) — System design deep dive - [Architecture](architecture.md) — System design deep dive
@@ -1,5 +1,7 @@
# Architecture Guide # Architecture Guide
> Last reviewed: 2026-05-05
## Contents ## Contents
1. [Overview](#overview) 1. [Overview](#overview)
@@ -61,7 +63,7 @@ flowchart TB
API["REST API\n(Go net/http, :8443)"] API["REST API\n(Go net/http, :8443)"]
SVC["Service Layer"] SVC["Service Layer"]
REPO["Repository Layer\n(database/sql + lib/pq)"] REPO["Repository Layer\n(database/sql + lib/pq)"]
SCHED["Background Scheduler\n8 always-on + 4 optional loops"] SCHED["Background Scheduler\n9 always-on + 5 opt-in loops"]
DASH["Web Dashboard\n(React SPA)"] DASH["Web Dashboard\n(React SPA)"]
end end
@@ -493,11 +495,11 @@ Short-lived certificates (those with profile TTL < 1 hour) return "good" from OC
#### Bulk Revocation #### Bulk Revocation
For compliance events requiring fleet-wide revocation (key compromise, CA distrust, mass decommission), certctl supports bulk revocation by filter criteria. The `POST /api/v1/certificates/bulk-revoke` endpoint accepts filter parameters (profile_id, owner_id, agent_id, issuer_id) and creates individual revocation jobs for each matching certificate. Bulk revocation reuses the same 7-step single-cert flow for each certificate — no new issuer notification or audit mechanics. The operation is idempotent: revoking an already-revoked certificate is a no-op. Partial failures are tolerated — if one certificate fails to revoke (e.g., issuer unavailable), the operation continues for remaining certs and returns a summary. A single `bulk_revocation_initiated` audit event logs the operation with filter criteria, operator actor, and summary (total requested, succeeded, failed counts). Audit events for individual certificate revocations record the operator identity separately. The GUI bulk revoke button on the certificates list filters by visible selections and displays an affected-cert count modal before confirmation. For incident-response events requiring fleet-wide revocation (key compromise, CA distrust, mass decommission), certctl supports bulk revocation by filter criteria. The `POST /api/v1/certificates/bulk-revoke` endpoint accepts filter parameters (profile_id, owner_id, agent_id, issuer_id) and creates individual revocation jobs for each matching certificate. Bulk revocation reuses the same 7-step single-cert flow for each certificate — no new issuer notification or audit mechanics. The operation is idempotent: revoking an already-revoked certificate is a no-op. Partial failures are tolerated — if one certificate fails to revoke (e.g., issuer unavailable), the operation continues for remaining certs and returns a summary. A single `bulk_revocation_initiated` audit event logs the operation with filter criteria, operator actor, and summary (total requested, succeeded, failed counts). Audit events for individual certificate revocations record the operator identity separately. The GUI bulk revoke button on the certificates list filters by visible selections and displays an affected-cert count modal before confirmation.
### 4. Automatic Renewal ### 4. Automatic Renewal
The control plane runs a scheduler with 8 always-on loops plus up to 4 optional loops (enabled by configuration). `internal/scheduler/scheduler.go:262-265` is the authoritative count. The control plane runs a scheduler with 9 always-on loops plus up to 5 opt-in loops (enabled by configuration). Re-derive the count via `grep -cE '^func \(s \*Scheduler\) [a-zA-Z]+Loop' internal/scheduler/scheduler.go`; the opt-in gating lives in `cmd/server/main.go` startup wiring (`cfg.NetworkScan.Enabled`, `digestService != nil`, `healthCheckService != nil`, `cloudDiscoveryService != nil`, `cfg.ACMEServer.Enabled && cfg.ACMEServer.GCInterval > 0`).
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -703,20 +705,17 @@ The EST (Enrollment over Secure Transport) server provides an industry-standard
**Architecture:** EST is a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. This means EST is not a new issuer — it's a new *interface* to the existing issuance infrastructure. The `ESTService` bridges the `ESTHandler` to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. **Architecture:** EST is a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. This means EST is not a new issuer — it's a new *interface* to the existing issuance infrastructure. The `ESTService` bridges the `ESTHandler` to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`.
``` ```mermaid
Client (WiFi AP, MDM, IoT) flowchart TD
Client["Client (WiFi AP, MDM, IoT)"]
Handler["ESTHandler (handler layer)"]
ESTHandler (handler layer) Service["ESTService (service layer)"]
│ CSR parsing, PKCS#7 response encoding Issuer["IssuerConnector (connector layer via IssuerConnectorAdapter)"]
Result["Signed certificate returned as PKCS#7 certs-only"]
ESTService (service layer) Client --> Handler
│ CSR validation, CN/SAN extraction, audit recording Handler -->|"CSR parsing, PKCS#7 response encoding"| Service
Service -->|"CSR validation, CN/SAN extraction, audit recording"| Issuer
IssuerConnector (connector layer via IssuerConnectorAdapter) Issuer -->|"certificate signing (Local CA, step-ca, etc.)"| Result
│ Certificate signing (Local CA, step-ca, etc.)
Signed certificate returned as PKCS#7 certs-only
``` ```
**Wire format:** EST uses PKCS#7 (RFC 2315) certs-only degenerate SignedData for certificate responses and base64-encoded DER for CSR requests. The handler includes a hand-rolled ASN.1 PKCS#7 builder — no external PKCS#7 dependency. The CSR reader accepts both base64-encoded DER (standard EST wire format) and PEM-encoded PKCS#10 (convenience for debugging). **Wire format:** EST uses PKCS#7 (RFC 2315) certs-only degenerate SignedData for certificate responses and base64-encoded DER for CSR requests. The handler includes a hand-rolled ASN.1 PKCS#7 builder — no external PKCS#7 dependency. The CSR reader accepts both base64-encoded DER (standard EST wire format) and PEM-encoded PKCS#10 (convenience for debugging).
@@ -795,20 +794,17 @@ The SCEP (Simple Certificate Enrollment Protocol) server provides certificate en
**Architecture:** SCEP follows the exact same layering as EST — a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. The `SCEPService` bridges the `SCEPHandler` to whichever issuer connector is configured via `CERTCTL_SCEP_ISSUER_ID`. **Architecture:** SCEP follows the exact same layering as EST — a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. The `SCEPService` bridges the `SCEPHandler` to whichever issuer connector is configured via `CERTCTL_SCEP_ISSUER_ID`.
``` ```mermaid
Client (MDM, network device, SCEP client) flowchart TD
Client["Client (MDM, network device, SCEP client)"]
Handler["SCEPHandler (handler layer)"]
SCEPHandler (handler layer) Service["SCEPService (service layer)"]
│ PKCS#7 envelope parsing, CSR extraction, challenge password extraction Issuer["IssuerConnector (connector layer via IssuerConnectorAdapter)"]
Result["Signed certificate returned as PKCS#7 certs-only"]
SCEPService (service layer) Client --> Handler
│ Challenge password validation, CSR validation, CN/SAN extraction, audit recording Handler -->|"PKCS#7 envelope parsing, CSR extraction, challenge password extraction"| Service
Service -->|"challenge password validation, CSR validation, CN/SAN extraction, audit recording"| Issuer
IssuerConnector (connector layer via IssuerConnectorAdapter) Issuer -->|"certificate signing (Local CA, step-ca, etc.)"| Result
│ Certificate signing (Local CA, step-ca, etc.)
Signed certificate returned as PKCS#7 certs-only
``` ```
**Wire format:** Two paths, tried in order. The new RFC 8894 path (post-2026-04-29) parses the full PKIMessage shape: ContentInfo → SignedData → SignerInfo (POPO over auth-attrs verified via `internal/pkcs7/signedinfo.go::SignerInfo.VerifySignature` with the canonical SET-OF Attribute re-serialisation per RFC 5652 §5.4) → EnvelopedData (decrypted via `internal/pkcs7/envelopeddata.go::EnvelopedData.Decrypt` with RSA PKCS#1v1.5 keyTrans + AES-CBC content + constant-time PKCS#7 unpad to close the padding-oracle leak) → inner PKCS#10 CSR. Auth-attrs (messageType, transactionID, senderNonce) flow through to the service layer via `domain.SCEPRequestEnvelope`. The handler dispatches on messageType: PKCSReq (19) → initial enrollment; RenewalReq (17) → re-enrollment with chain validation; GetCertInitial (20) → polling stub returns FAILURE+badCertID. Responses are full CertRep PKIMessages (`internal/pkcs7/certrep.go::BuildCertRepPKIMessage`) signed by the per-profile RA cert/key with the issued cert chain encrypted to the device's transient signing cert (RFC 8894 §3.3.2). On parse failure the handler falls through to the legacy MVP path: base64-encoded PKCS#7 and raw CSR submissions are still accepted; responses use the legacy PKCS#7 certs-only shape via the shared `internal/pkcs7` package. The MVP fall-through is non-negotiable — backward compat with lightweight SCEP clients that don't speak full RFC 8894. Single certs are returned as raw DER for `GetCACert`, chains as PKCS#7. **Wire format:** Two paths, tried in order. The new RFC 8894 path (post-2026-04-29) parses the full PKIMessage shape: ContentInfo → SignedData → SignerInfo (POPO over auth-attrs verified via `internal/pkcs7/signedinfo.go::SignerInfo.VerifySignature` with the canonical SET-OF Attribute re-serialisation per RFC 5652 §5.4) → EnvelopedData (decrypted via `internal/pkcs7/envelopeddata.go::EnvelopedData.Decrypt` with RSA PKCS#1v1.5 keyTrans + AES-CBC content + constant-time PKCS#7 unpad to close the padding-oracle leak) → inner PKCS#10 CSR. Auth-attrs (messageType, transactionID, senderNonce) flow through to the service layer via `domain.SCEPRequestEnvelope`. The handler dispatches on messageType: PKCSReq (19) → initial enrollment; RenewalReq (17) → re-enrollment with chain validation; GetCertInitial (20) → polling stub returns FAILURE+badCertID. Responses are full CertRep PKIMessages (`internal/pkcs7/certrep.go::BuildCertRepPKIMessage`) signed by the per-profile RA cert/key with the issued cert chain encrypted to the device's transient signing cert (RFC 8894 §3.3.2). On parse failure the handler falls through to the legacy MVP path: base64-encoded PKCS#7 and raw CSR submissions are still accepted; responses use the legacy PKCS#7 certs-only shape via the shared `internal/pkcs7` package. The MVP fall-through is non-negotiable — backward compat with lightweight SCEP clients that don't speak full RFC 8894. Single certs are returned as raw DER for `GetCACert`, chains as PKCS#7.
@@ -890,23 +886,27 @@ each per-profile dispatcher carries its own **trust anchor pool**:
the public certs the operator extracted from the Connector's the public certs the operator extracted from the Connector's
installation. Every Intune-flavored enrollment goes through: installation. Every Intune-flavored enrollment goes through:
``` ```mermaid
┌─────────────────────────────────┐ flowchart TD
Per-profile TrustAnchorHolder TAH["Per-profile TrustAnchorHolder<br/>(RWMutex pool, SIGHUP-reloadable)"]
│ (RWMutex pool, SIGHUP-reloadable) │ Device[device]
└────────────┬────────────────────┘ Handler[handler]
│ Get() Dispatch["SCEPService.dispatchIntuneChallenge"]
Validate["intune.ValidateChallenge<br/>(sig + iat/exp + audience)"]
device → SCEP PKIMessage → handler → SCEPService.dispatchIntuneChallenge Match["claim.DeviceMatchesCSR<br/>(set-equality)"]
Replay["intune.ReplayCache.CheckAndInsert"]
├─► intune.ValidateChallenge (sig + iat/exp + audience) Rate["intune.PerDeviceRateLimiter.Allow"]
├─► claim.DeviceMatchesCSR (set-equality) Compliance["(V3-Pro) ComplianceCheck hook"]
├─► intune.ReplayCache.CheckAndInsert Process["processEnrollment → IssuerConnector"]
├─► intune.PerDeviceRateLimiter.Allow Device -->|SCEP PKIMessage| Handler
└─► (V3-Pro) ComplianceCheck hook Handler --> Dispatch
TAH -.->|Get()| Dispatch
Dispatch --> Validate
processEnrollment → IssuerConnector Dispatch --> Match
Dispatch --> Replay
Dispatch --> Rate
Dispatch --> Compliance
Dispatch --> Process
``` ```
The trust anchor file is mode-0600 on disk; certctl loads it at The trust anchor file is mode-0600 on disk; certctl loads it at
@@ -932,22 +932,16 @@ See [`scep-intune.md`](scep-intune.md) for the full migration playbook
The local issuer's CA private key is wrapped behind the `signer.Signer` interface in `internal/crypto/signer/`. Every CA-signing call site — leaf certificate issuance (`x509.CreateCertificate`), CRL generation (`x509.CreateRevocationList`), and OCSP response signing (`ocsp.CreateResponse`) — accesses the key through this interface rather than touching `crypto.Signer` directly. The interface embeds the stdlib `crypto.Signer` and adds a single `Algorithm() Algorithm` method so call sites can pick the matching `x509.SignatureAlgorithm` without reflecting on the concrete key type. The local issuer's CA private key is wrapped behind the `signer.Signer` interface in `internal/crypto/signer/`. Every CA-signing call site — leaf certificate issuance (`x509.CreateCertificate`), CRL generation (`x509.CreateRevocationList`), and OCSP response signing (`ocsp.CreateResponse`) — accesses the key through this interface rather than touching `crypto.Signer` directly. The interface embeds the stdlib `crypto.Signer` and adds a single `Algorithm() Algorithm` method so call sites can pick the matching `x509.SignatureAlgorithm` without reflecting on the concrete key type.
``` ```mermaid
┌─────────────────────────────────┐ flowchart LR
│ signer.Driver (pluggable) │ Local["internal/connector/issuer/local<br/>c.caSigner signer.Signer"]
├─────────────────────────────────┤ subgraph Driver["signer.Driver (pluggable)"]
internal/connector/issuer/local signer.FileDriver (default) File["signer.FileDriver (default)<br/>PEM key on disk"]
c.caSigner signer.Signer ──────────► │ PEM key on disk │ Memory["signer.MemoryDriver (tests)<br/>in-memory only"]
│ │ PKCS11["signer.PKCS11Driver (V3-Pro)<br/>HSM token (future)"]
│ signer.MemoryDriver (tests) │ Cloud["signer.CloudKMSDriver (V3-Pro)<br/>AWS / GCP / Azure (future)"]
│ in-memory only │ end
│ │ Local --> Driver
│ signer.PKCS11Driver (V3-Pro) │
│ HSM token (future) │
│ │
│ signer.CloudKMSDriver (V3-Pro) │
│ AWS / GCP / Azure (future) │
└─────────────────────────────────┘
``` ```
Today only `FileDriver` (production) and `MemoryDriver` (tests) ship. The interface exists so PKCS#11/HSM and cloud-KMS drivers can land in follow-on packages (`internal/crypto/signer/pkcs11`, etc.) without modifying any call site or any other driver. The L-014 file-on-disk threat-model carve-out documented at the top of `internal/connector/issuer/local/local.go` applies to `FileDriver`-backed signers; alternative drivers that keep the key inside an HSM token or cloud KMS close the disk-exposure leg of the threat model entirely. Today only `FileDriver` (production) and `MemoryDriver` (tests) ship. The interface exists so PKCS#11/HSM and cloud-KMS drivers can land in follow-on packages (`internal/crypto/signer/pkcs11`, etc.) without modifying any call site or any other driver. The L-014 file-on-disk threat-model carve-out documented at the top of `internal/connector/issuer/local/local.go` applies to `FileDriver`-backed signers; alternative drivers that keep the key inside an HSM token or cloud KMS close the disk-exposure leg of the threat model entirely.
@@ -1050,7 +1044,9 @@ For deployments that need JWT/OIDC/mTLS, the standard pattern is to put an authe
### Concurrency Safety ### Concurrency Safety
The background scheduler uses `sync/atomic.Bool` idempotency guards on every loop (8 always-on plus up to 4 optional) — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit. The background scheduler uses `sync/atomic.Bool` idempotency guards on every loop (9 always-on plus up to 5 opt-in) — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
The job-processor tick fans the per-job work out across up to `CERTCTL_RENEWAL_CONCURRENCY` goroutines (default 25), gated by `golang.org/x/sync/semaphore.Weighted`. The cap is the operator's lever for "how many concurrent CA calls per scheduler tick" — operators with permissive upstream limits and large fleets (>10k certs) can bump to 100; operators with strict limits or async-CA-heavy fleets should stay at 25 or lower. Values ≤ 0 normalise to 1 (sequential). The Acquire is ctx-aware so a shutdown-driven ctx cancel interrupts the dispatch loop promptly; in-flight goroutines drain via Wait before the tick returns. Closes the #9 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit (pre-fix the fan-out had no cap, so a 5,000-cert sweep tripped DigiCert / Entrust / Sectigo rate limits and the next tick re-fanned-out the same calls).
### Logging ### Logging
@@ -1100,11 +1096,11 @@ Health checks live outside the API prefix: `GET /health` and `GET /ready`.
## MCP Server ## MCP Server
certctl includes an MCP (Model Context Protocol) server as a separate binary (`cmd/mcp-server/`) that enables AI assistants to interact with the certificate platform. The MCP server uses the official MCP Go SDK (`modelcontextprotocol/go-sdk`) with stdio transport for integration with Claude, Cursor, and other MCP-compatible tools. certctl includes an MCP (Model Context Protocol) server as a separate binary (`cmd/mcp-server/`) that enables AI assistants to interact with the certificate platform. The MCP server uses the official MCP Go SDK (`modelcontextprotocol/go-sdk`) with stdio transport for integration with any MCP-compatible AI client.
```mermaid ```mermaid
flowchart LR flowchart LR
AI["AI Assistant\n(Claude, Cursor)"] -->|"stdio"| MCP["MCP Server\ncmd/mcp-server/"] AI["AI Assistant\n(any MCP client)"] -->|"stdio"| MCP["MCP Server\ncmd/mcp-server/"]
MCP -->|"HTTP + Bearer token"| API["certctl REST API\n:8443"] MCP -->|"HTTP + Bearer token"| API["certctl REST API\n:8443"]
subgraph "MCP Tools" subgraph "MCP Tools"
@@ -1254,7 +1250,7 @@ flowchart TB
1. **Pluggable sources** — Each cloud provider implements the `DiscoverySource` interface (Name, Type, Discover, ValidateConfig). Three built-in sources: AWS Secrets Manager, Azure Key Vault, GCP Secret Manager 1. **Pluggable sources** — Each cloud provider implements the `DiscoverySource` interface (Name, Type, Discover, ValidateConfig). Three built-in sources: AWS Secrets Manager, Azure Key Vault, GCP Secret Manager
2. **CloudDiscoveryService orchestrator** — Iterates registered sources, calls `Discover()` on each, feeds reports into `ProcessDiscoveryReport()`. Errors from one source don't prevent other sources from running 2. **CloudDiscoveryService orchestrator** — Iterates registered sources, calls `Discover()` on each, feeds reports into `ProcessDiscoveryReport()`. Errors from one source don't prevent other sources from running
3. **Scheduler integration** — opt-in cloud discovery scheduler loop (6h default; see `docs/architecture.md` 12-loop topology), runs immediately on startup, `atomic.Bool` idempotency guard 3. **Scheduler integration** — opt-in cloud discovery scheduler loop (6h default; one of the 14 loops in the scheduler topology — see the Background Scheduler section above), runs immediately on startup, `atomic.Bool` idempotency guard
4. **Sentinel agents** — Each source uses its own sentinel agent ID (`cloud-aws-sm`, `cloud-azure-kv`, `cloud-gcp-sm`) for dedup and triage filtering 4. **Sentinel agents** — Each source uses its own sentinel agent ID (`cloud-aws-sm`, `cloud-azure-kv`, `cloud-gcp-sm`) for dedup and triage filtering
5. **Source path format**`aws-sm://{region}/{secret}`, `azure-kv://{cert-name}/{version}`, `gcp-sm://{project}/{secret}` 5. **Source path format**`aws-sm://{region}/{secret}`, `azure-kv://{cert-name}/{version}`, `gcp-sm://{project}/{secret}`
6. **No new schema** — Reuses existing `discovered_certificates` and `discovery_scans` tables. Sentinel agent IDs leverage existing `(fingerprint_sha256, agent_id, source_path)` dedup constraint 6. **No new schema** — Reuses existing `discovered_certificates` and `discovery_scans` tables. Sentinel agent IDs leverage existing `(fingerprint_sha256, agent_id, source_path)` dedup constraint
@@ -1268,7 +1264,7 @@ flowchart TB
- **Claims it** via `POST /discovered-certificates/{id}/claim` — links to existing managed cert or creates new enrollment - **Claims it** via `POST /discovered-certificates/{id}/claim` — links to existing managed cert or creates new enrollment
- **Dismisses it** via `POST /discovered-certificates/{id}/dismiss` — removes from triage, marked as "Dismissed" - **Dismisses it** via `POST /discovered-certificates/{id}/dismiss` — removes from triage, marked as "Dismissed"
9. **Status tracking**`discovery_cert_claimed` and `discovery_cert_dismissed` events audit the operator's decision 9. **Status tracking**`discovery_cert_claimed` and `discovery_cert_dismissed` events audit the operator's decision
10. **Summary**`GET /api/v1/discovery-summary` returns count of Unmanaged, Managed, and Dismissed certs (useful for compliance reporting) 10. **Summary**`GET /api/v1/discovery-summary` returns count of Unmanaged, Managed, and Dismissed certs (useful for inventory reporting)
This data flow is pull-based and non-blocking. Agents discover at their own pace; the server stores results for later review. There's no pressure to claim or dismiss; operators can leave certificates in "Unmanaged" status indefinitely. This data flow is pull-based and non-blocking. Agents discover at their own pace; the server stores results for later review. There's no pressure to claim or dismiss; operators can leave certificates in "Unmanaged" status indefinitely.
@@ -1320,13 +1316,22 @@ certctl is extensively tested across eight layers with CI-enforced coverage gate
For detailed test procedures, smoke tests, and the release sign-off checklist, see the [Testing Guide](testing-guide.md). For setting up the Docker Compose test environment with real CA backends, see [Test Environment](test-env.md). For detailed test procedures, smoke tests, and the release sign-off checklist, see the [Testing Guide](testing-guide.md). For setting up the Docker Compose test environment with real CA backends, see [Test Environment](test-env.md).
## Performance Characteristics
Closes the #8 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. Pre-audit, certctl had no benchmarks or load tests for any API path, so any throughput claim was hand-waved; the harness in `deploy/test/loadtest/` substantiates the API-tier capacity numbers with reproducible methodology.
The harness drives a k6 client at sustained 50 req/s × 2 scenarios × 5 minutes against a docker-compose stack of postgres + tls-init + certctl-server. Two scenarios run in parallel: `POST /api/v1/certificates` (issuance-acceptance hot path: auth + JSON decode + validation + service `CreateCertificate` + `managed_certificates` insert) and `GET /api/v1/certificates?per_page=50` (most-trafficked read endpoint). Hard regression-guard thresholds: p99 < 5 s for issuance-acceptance, p99 < 2 s for list, error rate < 1% globally. k6 exits non-zero on any threshold breach so a future PR that pushes p99 above the bar fails `make loadtest`. Run via `make loadtest` from the repo root or via `.github/workflows/loadtest.yml` (`workflow_dispatch` + weekly cron — never per-push).
What this measures vs what it does NOT: the harness intentionally measures the API tier (auth → DB), not the issuer connector round-trip latency. Connector calls (DigiCert, ACME, Vault, AWS ACM PCA, etc.) happen asynchronously through the renewal scheduler and are pinned by the `certctl_issuance_duration_seconds{issuer_type=...}` Prometheus histogram (audit fix #4 from the same audit). Driving them through k6 would amount to load-testing someone else's API, which is the wrong thing to do. The full ACME enrollment flow (multi-RTT order/challenge/finalize against pebble) is deferred — sustained 100/s through that flow needs pebble tuning + crypto helpers k6 doesn't ship out of the box.
Captured baseline numbers are committed in `deploy/test/loadtest/README.md` once an operator runs the harness on a representative workstation; future tuning commits land alongside refreshed baseline numbers so each commit's impact is diffable. Operators considering certctl for a 50k-cert fleet at 47-day TLS rotation (CA/B Forum SC-081v3, lands 2029) have a published number with documented methodology to compare against, not a claim.
## What's Next ## What's Next
- [Quick Start](quickstart.md) — Get certctl running locally - [Quick Start](../getting-started/quickstart.md) — Get certctl running locally
- [Advanced Demo](demo-advanced.md) — Issue a certificate end-to-end - [Advanced Demo](../getting-started/advanced-demo.md) — Issue a certificate end-to-end
- [Connector Guide](connectors.md) — Build custom connectors - [Connector Guide](connectors/index.md) — Build custom connectors
- [Compliance Mapping](compliance.md) — SOC 2, PCI-DSS 4.0, and NIST SP 800-57 alignment
- [MCP Server Guide](mcp.md) — AI-native access to the API - [MCP Server Guide](mcp.md) — AI-native access to the API
- [OpenAPI Spec](openapi.md) — Full API reference and SDK generation - [API Reference](api.md) — OpenAPI 3.1 spec and SDK generation
- [Testing Guide](testing-guide.md) — Test procedures and release sign-off - [QA Test Suite](../contributor/qa-test-suite.md) — Test procedures and release sign-off
- [Test Environment](test-env.md) — Docker Compose test environment setup - [Test Environment](../contributor/test-environment.md) — Docker Compose test environment setup
+156
View File
@@ -0,0 +1,156 @@
# certctl CLI
> Last reviewed: 2026-05-05
`certctl-cli` is the command-line interface to certctl. It wraps the REST API as terminal commands so operators and CI/CD pipelines can drive certctl without writing curl invocations.
## Install
```bash
go install github.com/certctl-io/certctl/cmd/cli@latest
```
The binary lands at `$GOBIN/cli` (or `$HOME/go/bin/cli` if `GOBIN` is unset). Rename to `certctl-cli` if you prefer.
## Configure
The CLI reads three environment variables:
```bash
export CERTCTL_SERVER_URL=https://localhost:8443
export CERTCTL_API_KEY=your-api-key
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt
```
Or pass them per-invocation:
```bash
certctl-cli --server https://localhost:8443 --api-key your-key --ca-bundle ca.crt certs list
```
For local development against a self-signed bootstrap cert, `--insecure` skips TLS verification. **Never set this in production.**
## Command groups
The CLI is organized by resource:
```
certctl-cli certs [list|get|renew|revoke]
certctl-cli agents [list|get]
certctl-cli jobs [list|get|cancel]
certctl-cli import [bulk PEM import]
certctl-cli est [enroll|reenroll]
certctl-cli status [server health + summary stats]
certctl-cli version [CLI + server version]
```
## Common workflows
### List + filter certificates
```bash
# All certs
certctl-cli certs list
# Filter by environment
certctl-cli certs list --env production
# JSON output (default is table)
certctl-cli certs list --format json
# Sort + paginate
certctl-cli certs list --sort -expires_at --limit 50
# Time-range filter (RFC 3339)
certctl-cli certs list --expires-before 2026-06-01T00:00:00Z
# Sparse fields — only return the columns you need
certctl-cli certs list --fields id,common_name,expires_at,status
```
### Trigger renewal
```bash
certctl-cli certs renew mc-api-prod
# Returns the job id; track with: certctl-cli jobs get <job-id>
# Recovery: clear a stuck in-flight renewal so a new one can start
certctl-cli certs renew mc-api-prod --force
```
`--force` clears the server-side `RenewalInProgress` block — used when a previous renewal job hung without releasing the status flag. `--force` does NOT override `Archived` or `Expired` (those are terminal states; archived = decommissioned, expired = issue a new cert instead of renewing a dead one).
### Revoke
```bash
# Single revoke — --reason is REQUIRED (no silent fallback to 'unspecified')
certctl-cli certs revoke mc-api-prod --reason keyCompromise
# snake_case is accepted and normalised to camelCase before dispatch
certctl-cli certs revoke mc-api-prod --reason key_compromise
# Bulk revoke by filter
certctl-cli certs revoke --profile prof-deprecated --reason superseded
certctl-cli certs revoke --team t-payments --reason cessationOfOperation
certctl-cli certs revoke --issuer iss-old-vault --reason caCompromise
```
`--reason` is mandatory: omitting it prints the canonical RFC 5280 §5.3.1 menu and exits non-zero. Compliance reporting (PCI-DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful, so the CLI no longer falls back silently. Valid camelCase set: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `removeFromCRL`, `privilegeWithdrawn`, `aaCompromise`. snake_case variants (`key_compromise`, `cessation_of_operation`, etc.) are accepted and normalised.
### Bulk import
```bash
# Import a directory of PEMs
certctl-cli import /etc/letsencrypt/live/
# Import a single concatenated bundle
certctl-cli import certs.pem
```
Each cert lands in the inventory as `Unmanaged` (per the discovery model). Triage from the dashboard or via `certctl-cli certs claim <id>` once you've decided to actively manage it.
### EST enrollment
```bash
# Enroll a new device cert via EST simpleenroll
certctl-cli est enroll --csr device.csr --output device.crt
# Re-enroll (renew) an existing device cert
certctl-cli est reenroll --csr device.csr --client-cert device.crt --client-key device.key
```
### Server status
```bash
certctl-cli status
# Health: ok
# Total certificates: 145
# Expiring (30d): 12
# Active jobs: 3
# Pending renewals: 8
```
## Output formats
- `--format table` (default) — human-readable terminal output
- `--format json` — JSON for piping into `jq`, scripts, dashboards
The CLI is built with Go's standard library only — no external dependencies. The binary is small (~10MB) and statically linked.
## Wiring into CI/CD
Common pattern: a CI step that issues a cert from your internal CA, deploys it via certctl, and verifies the deploy:
```bash
certctl-cli certs renew mc-api-prod --wait
certctl-cli jobs get $(certctl-cli certs renew mc-api-prod --json | jq -r '.job_id') --wait
certctl-cli certs get mc-api-prod --json | jq -r '.expires_at'
```
The `--wait` flag blocks until the job reaches a terminal state (Completed / Failed / Cancelled), which is what CI scripts actually need.
## Related docs
- [`docs/reference/api.md`](api.md) — the OpenAPI 3.1 spec the CLI wraps
- [`docs/reference/mcp.md`](mcp.md) — the MCP server that exposes the same surface to AI assistants
- [`docs/contributor/qa-prerequisites.md`](../contributor/qa-prerequisites.md) — local environment setup before the CLI can talk to a server
+98
View File
@@ -0,0 +1,98 @@
# Configuration Reference
> Last reviewed: 2026-05-05
Compact reference for `CERTCTL_*` environment variables consumed by
`certctl-server` and `certctl-agent`. Most operators don't need to
touch these — defaults are tuned for the common case. Reach for them
when the system's behaviour needs tuning beyond what's exposed in the
GUI / API.
This page enumerates the operator-tunable knobs that don't have a
dedicated home elsewhere. Connector-specific env vars are documented
on the per-connector pages under
[`docs/reference/connectors/`](connectors/index.md). Protocol env
vars (ACME server, EST, SCEP) are documented under
[`docs/reference/protocols/`](protocols/). TLS env vars are
documented in [`docs/operator/tls.md`](../operator/tls.md).
## Scheduler intervals
The scheduler runs N background loops; intervals are tunable for
performance / contention tuning.
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the agent-health loop scans for stale heartbeats and transitions agents to `Unhealthy` / `Offline`. |
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the job-processor loop dispatches `Pending` jobs to agents. |
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the notification-dispatcher loop fans out queued alerts to channels. |
| `CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL` | `5m` | How often the short-lived-expiry loop watches certs whose TTL is less than 1h for imminent expiry. |
For the full scheduler topology (14 loops, 9 always-on + 5 opt-in)
see [`architecture.md`](architecture.md) "Scheduler topology".
## Job lifecycle
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_JOB_AWAITING_CSR_TIMEOUT` | `24h` | How long a job stays in `AwaitingCSR` before the scheduler marks it `Failed` (the agent never picked it up). |
## Rate limiting
The control plane API is rate-limited by default; tune for
high-volume environments (mass-rotation events, bulk imports).
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Master toggle. Disable only for trusted-network single-tenant deploys where the API is firewall-protected. |
| `CERTCTL_RATE_LIMIT_PER_USER_RPS` | `0` (= use global default) | Per-user requests-per-second cap. Zero opts each user into the global default in `internal/api/middleware`. |
| `CERTCTL_RATE_LIMIT_PER_USER_BURST` | `0` (= use global default) | Per-user token-bucket burst size. Same opt-in semantics. |
## Audit trail
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS` | `30` | How long the audit-event flush worker waits for the buffered batch to drain before forcing a flush at shutdown. |
## Deploy verification
The deploy-hardening primitive wraps every cert deploy in
atomic-write + post-verify + rollback. These env vars tune the
post-deploy TLS verification phase.
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_VERIFY_DEPLOYMENT` | `true` | Master toggle for post-deploy TLS verify. Disable only for connectors / environments where the verify endpoint is not reachable from the agent. |
| `CERTCTL_VERIFY_DELAY` | `2s` | How long to wait after the reload command completes before the first verify-handshake attempt (gives the daemon time to pick up new keys). |
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | Per-attempt TLS-handshake timeout. |
| `CERTCTL_DEPLOY_BACKUP_RETENTION` | `3` | How many `.certctl-bak.<unix-nanos>.<ext>` rollback snapshots to keep per target after a successful deploy. `0` uses the default of 3; `-1` opts out of pruning entirely. |
For the full deploy contract see
[`deployment-model.md`](deployment-model.md).
## Database
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Filesystem path to the `*.up.sql` / `*.down.sql` migration set. Override only when running `certctl-server` from a non-standard layout. |
## Agent
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_AGENT_ID` | (none — required) | The agent's unique ID, issued by `POST /api/v1/agents/register` and bundled into the agent's registration response. Pass via this env var when the agent runs as a systemd unit / container without the `-agent-id` CLI flag. |
## SCEP profile binding (single-profile back-compat)
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_SCEP_PROFILE_ID` | (empty) | Optional certificate profile ID for the legacy single-profile SCEP path. The multi-profile path uses `CERTCTL_SCEP_PROFILES=<list>` + `CERTCTL_SCEP_PROFILE_<NAME>_PROFILE_ID` instead — see [`scep-server.md`](protocols/scep-server.md). |
## Related references
- [`architecture.md`](architecture.md) — scheduler topology, system design, security model
- [`deployment-model.md`](deployment-model.md) — atomic write + verify + rollback contract
- [`operator/security.md`](../operator/security.md) — full security posture (auth, rate limits, encryption at rest)
- [`operator/tls.md`](../operator/tls.md) — control-plane TLS env vars
- Per-connector pages under [`reference/connectors/`](connectors/index.md) for connector-specific config
- Per-protocol pages under [`reference/protocols/`](protocols/) for ACME / SCEP / EST / CRL+OCSP / async-CA polling
+235
View File
@@ -0,0 +1,235 @@
# ACME Issuer Connector — Operator Deep-Dive
> Last reviewed: 2026-05-05
>
> Operator-grade documentation for the outbound ACME v2 issuer
> connector (certctl as an ACME *client*). For the inbound ACME
> server (certctl as an ACME *server*), see
> [acme-server.md](../protocols/acme-server.md). For the
> connector-development context (interface contract, registry,
> ports/adapters), see the [connector index](index.md).
## Overview
The ACME connector implements the full ACME v2 protocol (RFC 8555)
using Go's `golang.org/x/crypto/acme` package. It supports three
challenge methods and ARI (RFC 9773) for renewal-window negotiation.
Compatible CAs include Let's Encrypt, ZeroSSL, Sectigo, Buypass,
Google Trust Services, SSL.com, and any other RFC 8555 ACME
implementation. step-ca's ACME directory is also compatible if you
prefer ACME over the native step-ca connector.
Implementation lives at `internal/connector/issuer/acme/`.
## When to use this connector
Use the ACME connector when:
- You need public-trust certificates (Let's Encrypt, ZeroSSL,
Sectigo via ACME, Google Trust Services, SSL.com).
- You want certctl to drive renewal lifecycle on top of the ACME
CA's free or paid issuance.
- You want one tool that covers both internal PKI (Local, Vault,
step-ca) and public-trust ACME issuance.
Look elsewhere when:
- You need OV / EV certificates and your CA doesn't expose them
via ACME — use the DigiCert or Sectigo SCM REST connectors.
- You're standing up internal-only PKI and don't want to operate
ACME challenge infrastructure — use Local CA or Vault PKI for a
simpler synchronous path.
## Challenge methods
### HTTP-01 (default)
A built-in temporary HTTP server starts on demand during
certificate issuance. The domain being validated must resolve to
the machine running the connector, and the configured HTTP port
must be reachable from the internet.
```json
{
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"email": "admin@example.com",
"http_port": 80
}
```
### DNS-01 (for wildcards)
Creates DNS TXT records via user-provided scripts. Required for
wildcard certificates (`*.example.com`) and hosts that can't serve
HTTP on port 80. The connector invokes external scripts to create
and clean up `_acme-challenge` TXT records, making it compatible
with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
```json
{
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
"email": "admin@example.com",
"challenge_type": "dns-01",
"dns_present_script": "/etc/certctl/dns/create-record.sh",
"dns_cleanup_script": "/etc/certctl/dns/delete-record.sh",
"dns_propagation_wait": 30
}
```
DNS hook scripts receive these environment variables:
- `CERTCTL_DNS_DOMAIN` — domain being validated
- `CERTCTL_DNS_FQDN` — full record name (`_acme-challenge.<domain>`
for dns-01, `_validation-persist.<domain>` for dns-persist-01)
- `CERTCTL_DNS_VALUE` — TXT record value
- `CERTCTL_DNS_TOKEN` — ACME challenge token
The present script must create the TXT record and exit 0; the
cleanup script removes it (dns-01 only).
### DNS-PERSIST-01 (standing record)
Creates a one-time persistent TXT record at
`_validation-persist.<domain>` containing the CA's issuer domain
and your ACME account URI. Once set, this record authorizes
unlimited future certificate issuances without per-renewal DNS
updates. Based on
[draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)
and CA/Browser Forum ballot SC-088v3.
If the CA doesn't offer dns-persist-01 yet, the connector falls
back to dns-01 automatically.
```json
{
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
"email": "admin@example.com",
"challenge_type": "dns-persist-01",
"dns_present_script": "/etc/certctl/dns/create-record.sh",
"dns_persist_issuer_domain": "letsencrypt.org",
"dns_propagation_wait": 30
}
```
The present script creates a TXT record at
`_validation-persist.<domain>` with the value
`letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/<your-id>`.
This record is permanent — no cleanup script is needed.
## ACME Renewal Information (ARI, RFC 9773)
Instead of using fixed renewal thresholds (e.g. renew 30 days
before expiry), certctl can ask the CA when it should renew.
Enable with `CERTCTL_ACME_ARI_ENABLED=true`.
The ARI protocol lets the CA specify a `suggestedWindow` (start
and end times) for when you should renew — useful for distributing
load during maintenance windows or coordinating mass-revocation
scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`.
If the CA doesn't support ARI (404 response), certctl
automatically falls back to threshold-based renewal with no
operator intervention required.
## External Account Binding (EAB)
ZeroSSL, Google Trust Services, and SSL.com require EAB for ACME
account registration. For most CAs, get your EAB credentials from
the CA's dashboard and provide them via `eab_kid` and `eab_hmac`.
The HMAC key must be base64url-encoded (no padding). CAs that
don't require EAB (Let's Encrypt, Buypass) ignore these fields.
```json
{
"directory_url": "https://acme.zerossl.com/v2/DV90",
"email": "admin@example.com",
"eab_kid": "your-zerossl-eab-kid",
"eab_hmac": "your-zerossl-eab-hmac-base64url"
}
```
### ZeroSSL auto-EAB
When the directory URL points to ZeroSSL and no EAB credentials
are provided, certctl automatically fetches them from ZeroSSL's
public API (`api.zerossl.com/acme/eab-credentials-email`) using
your configured email address. No dashboard visit required — just
set the directory URL and email. Same approach used by Caddy and
acme.sh.
```json
{
"directory_url": "https://acme.zerossl.com/v2/DV90",
"email": "admin@example.com"
}
```
## Certificate profiles (Let's Encrypt, GA January 2026)
Let's Encrypt supports ACME certificate profile selection. Set
`CERTCTL_ACME_PROFILE=shortlived` to request 6-day certificates —
ideal for ephemeral workloads where short validity substitutes for
revocation. The `tlsserver` profile produces standard TLS
certificates. When the profile field is empty (default), the CA
uses its default profile.
## Environment variables
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
- `CERTCTL_ACME_EAB_KID` — External Account Binding Key ID
- `CERTCTL_ACME_EAB_HMAC` — External Account Binding HMAC key
(base64url-encoded)
- `CERTCTL_ACME_CHALLENGE_TYPE``http-01` (default), `dns-01`,
or `dns-persist-01`
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation
script
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup
script (dns-01 only)
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for
persistent record (dns-persist-01 only)
- `CERTCTL_ACME_PROFILE` — Certificate profile for the newOrder
request
## Revocation by serial number (Top-10 fix #7)
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: the original PEM was lost, the private key was
rotated, the operator clicked "revoke" in the GUI based on a row
in the certs list.
certctl's ACME
`RevokeCertificate(ctx, RevocationRequest{Serial: ...})` looks the
serial up in the local cert store
(`certificate_versions.pem_chain`), decodes the leaf-cert PEM into
DER, and calls the ACME revoke endpoint with
`(accountKey, der, reasonCode)` — RFC 8555 §7.6 case 1,
"revocation request signed with account key". This works because
the same account key issued the cert, so authority is intrinsic.
The cert version must exist in the local store: this means the
cert was issued through certctl, not imported. If
`GetVersionBySerial` returns `sql.ErrNoRows`, the connector
returns an actionable error pointing at the local-store
requirement. Revoke-by-serial is therefore only available for
ACME certs that certctl issued.
Reason codes follow RFC 5280 §5.3.1: nil reason maps to
`unspecified` (0), and the connector accepts the canonical
camelCase form (`keyCompromise`, `cACompromise`,
`affiliationChanged`, `superseded`, `cessationOfOperation`,
`certificateHold`, `removeFromCRL`, `privilegeWithdrawn`,
`aACompromise`) plus underscore_lower and ALL_CAPS_UNDERSCORE
variants. An unknown reason returns an error rather than silently
demoting to `unspecified` — operators rely on the reason for
audit reporting.
## Related docs
- [ACME server](../protocols/acme-server.md) — certctl *as* an ACME server (the inverse direction)
- [Connector index](index.md) — interface contract, registry, port/adapter wiring
- [migration/acme-from-cert-manager.md](../../migration/acme-from-cert-manager.md) — point cert-manager at certctl's ACME server
- [migration/acme-from-traefik.md](../../migration/acme-from-traefik.md) — point Traefik at certctl's ACME server
+112
View File
@@ -0,0 +1,112 @@
# Active Directory Certificate Services (ADCS) Integration — Operator Deep-Dive
> Last reviewed: 2026-05-05
>
> Operator-grade documentation for integrating certctl with Microsoft
> ADCS as the enterprise root. For the connector-development context
> (interface contract, registry, ports/adapters), see the
> [connector index](index.md).
## Overview
ADCS integration is **not** a separate connector. certctl integrates
with ADCS via the **sub-CA mode** of the Local CA issuer: certctl
operates as a subordinate CA whose signing certificate was issued by
ADCS, so all certctl-issued certificates chain back to the enterprise
ADCS root.
This is the canonical pattern for Windows-shop deployments where
ADCS is already the root of trust and operators want certctl to
handle automation (lifecycle, renewal, deployment, alerts) without
ADCS having to support a non-Microsoft REST API surface.
## When to use this integration
Use ADCS sub-CA mode when:
- ADCS is your enterprise root and you don't want to introduce a
parallel root of trust.
- You want all certctl-issued certificates to validate against the
ADCS chain that's already in your Windows trust stores, mobile
device profiles, and load-balancer configurations.
- You need certctl's automation surface (ACME, SCEP, EST, profile
policy, scheduler, deployment connectors) but want ADCS to remain
the signing authority for the root.
Look elsewhere when:
- You want certctl to issue from its own root of trust — use the
Local CA issuer in self-signed mode.
- ADCS is being decommissioned or replaced — the migration path
from ADCS to Vault PKI / step-ca / Local CA needs its own
rollout plan; that's not what this connector covers.
## How sub-CA mode works
The Local CA issuer loads a pre-signed CA certificate and key from
disk:
- `CERTCTL_CA_CERT_PATH` — path to the certctl signing cert PEM
(the one ADCS issued).
- `CERTCTL_CA_KEY_PATH` — path to the matching private key PEM.
Every leaf certctl issues is signed with this key, and the chain
returned to clients includes both the certctl signing cert and the
ADCS root (so verifying clients see a complete chain to the
enterprise root).
The signing certificate certctl uses is just a normal CA cert with
`Basic Constraints: CA=true` and an appropriate path-length
constraint. ADCS issues this certificate using its standard
"Subordinate Certification Authority" template; the operator just
takes the resulting cert + key and points certctl at them.
## Operator playbook
### Provisioning the certctl sub-CA
1. Generate a new keypair for certctl on the host that will run it
(or in the HSM / KMS the operator wants to delegate signing to,
via the `internal/crypto/signer/` driver interface when alternate
drivers are configured).
2. Build a CSR with `Basic Constraints: CA=true`, the operator's
chosen path-length constraint, and key usages including
`keyCertSign` and `cRLSign`.
3. Submit the CSR to ADCS using the Subordinate Certification
Authority template (or a custom template that grants those key
usages).
4. Place the signed certctl-cert and the matching key at
`CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH`.
5. Restart certctl-server (or Rebuild the issuer via the API).
Subsequent issuance chains to the ADCS root.
### Rotating the sub-CA cert
When the certctl sub-CA cert is approaching expiry:
1. Generate a new keypair (re-keying is recommended at sub-CA
rotation time).
2. CSR + ADCS signing cycle as above.
3. Stage the new cert and key at fresh on-disk paths and follow the
[intermediate-CA hierarchy
runbook](../intermediate-ca-hierarchy.md) for the cutover (rotate
`CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH` to the new files
when ready). The
key concern is overlap: both the old and new sub-CA certs must
chain to the ADCS root during the rollover so existing leaves
keep validating.
### Revocation chain
CRL and OCSP for ADCS-rooted leaves are handled by certctl's CRL
distribution point and OCSP responder
([crl-ocsp.md](../protocols/crl-ocsp.md)). The ADCS root publishes
its own CRL covering the certctl sub-CA cert; relying parties walk
both CDP entries to determine the full revocation status.
## Related docs
- [Local CA issuer](index.md#built-in-local-ca) — the connector this integration uses
- [Intermediate CA hierarchy](../intermediate-ca-hierarchy.md) — how certctl manages multi-level CA trees, including ADCS-rooted setups
- [CRL and OCSP](../protocols/crl-ocsp.md) — how relying parties validate ADCS-rooted leaves
- [Architecture](../architecture.md) — `internal/crypto/signer/` driver interface for HSM / KMS / cloud-KMS alternatives to file-on-disk for the certctl sub-CA private key
@@ -1,6 +1,11 @@
# Apache httpd Connector — Operator Deep-Dive # Apache httpd Connector — Operator Deep-Dive
> Per Phase 14 of the deploy-hardening II master bundle. > Last reviewed: 2026-05-05
>
> Per Phase 14 of the deploy-hardening II master bundle. For the
> connector-development context (interface contract, registry, atomic
> deploy primitive shared across all targets), see the
> [connector index](index.md).
## Overview ## Overview
@@ -73,7 +78,7 @@ per-file ownership is preserved per Bundle I Phase 5.
`TestVendorEdge_Apache_ReloadVsRestart_PreservesConnections_E2E` `TestVendorEdge_Apache_ReloadVsRestart_PreservesConnections_E2E`
In-flight TLS sessions survive `apachectl graceful` worker In-flight TLS sessions survive `apachectl graceful` worker
swap. Documented in `docs/deployment-atomicity.md`. swap. Documented in `docs/reference/deployment-model.md`.
### SNI server_name binding ### SNI server_name binding
@@ -97,5 +102,5 @@ supplied ordering across rotation.
## Related docs ## Related docs
- [Atomic deploy + post-verify + rollback](deployment-atomicity.md) - [Atomic deploy + post-verify + rollback](../deployment-model.md)
- [Vendor compatibility matrix](deployment-vendor-matrix.md) - [Vendor compatibility matrix](../vendor-matrix.md)
+165
View File
@@ -0,0 +1,165 @@
# AWS ACM Private CA Issuer Connector — Operator Deep-Dive
> Last reviewed: 2026-05-05
>
> Operator-grade documentation for the AWS Certificate Manager
> Private Certificate Authority (ACM PCA) issuer connector. For the
> connector-development context (interface contract, registry,
> ports/adapters), see the [connector index](index.md).
## Overview
AWS ACM Private CA is a managed private CA on AWS. The connector
calls `IssueCertificate` (which is asynchronous at the ACM PCA API
level), then runs the SDK's `NewCertificateIssuedWaiter` until the
cert reaches `CERTIFICATE_ISSUED` state, then `GetCertificate` to
retrieve the PEM. Default waiter timeout is 5 minutes; tune by
editing `defaultWaiterTimeout` in
`internal/connector/issuer/awsacmpca/awsacmpca.go`.
Implementation lives at `internal/connector/issuer/awsacmpca/`.
## When to use this connector
Use the AWS ACM PCA connector when:
- Your workloads are AWS-native and you want the CA to live inside
your AWS account (for blast-radius, IAM, and audit reasons).
- You need ACM PCA's CRL distribution and OCSP responder to serve
status to relying parties without certctl being in the OCSP path.
- You want IAM-based access control (no API keys to rotate) for
certctl's signing path.
Look elsewhere when:
- You're not on AWS — Google CAS or Azure Key Vault are the cloud-
native equivalents on those platforms.
- You need public-trust certificates — ACM PCA is private only.
- You don't already pay for ACM PCA (it has a non-trivial monthly
cost). Vault, step-ca, or the Local CA issuer are free
self-hosted alternatives.
## Configuration
| Setting | Required | Default | Description |
|---|---|---|---|
| `CERTCTL_AWS_PCA_REGION` | Yes | — | AWS region (e.g. `us-east-1`) |
| `CERTCTL_AWS_PCA_CA_ARN` | Yes | — | ARN of the ACM Private CA |
| `CERTCTL_AWS_PCA_SIGNING_ALGORITHM` | No | `SHA256WITHRSA` | Signing algorithm |
| `CERTCTL_AWS_PCA_VALIDITY_DAYS` | No | `365` | Certificate validity in days |
| `CERTCTL_AWS_PCA_TEMPLATE_ARN` | No | — | Optional certificate template ARN |
Supported signing algorithms: `SHA256WITHRSA`, `SHA384WITHRSA`,
`SHA512WITHRSA`, `SHA256WITHECDSA`, `SHA384WITHECDSA`,
`SHA512WITHECDSA`.
## Authentication
Standard AWS credential chain via
`aws-sdk-go-v2/config.LoadDefaultConfig()`. Resolves credentials in
this order:
1. Environment variables (`AWS_ACCESS_KEY_ID`,
`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`).
2. Shared config files (`~/.aws/config`, `~/.aws/credentials`,
profile via `AWS_PROFILE`).
3. IAM Roles for Service Accounts (IRSA) on EKS.
4. EC2 instance profiles.
5. ECS task roles.
6. SSO.
certctl never stores AWS credentials directly — set them in the
certctl process's environment or via the IAM role attached to the
host.
## Minimal IAM policy
The IAM principal that certctl authenticates as needs the following
actions against the CA's ARN:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"acm-pca:IssueCertificate",
"acm-pca:GetCertificate",
"acm-pca:RevokeCertificate",
"acm-pca:GetCertificateAuthorityCertificate"
],
"Resource": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012"
}
]
}
```
Replace the `Resource` ARN with your own CA ARN. If you use a
`TemplateArn` (subordinate-CA template), the policy needs no
additional permissions — `IssueCertificate` covers it.
## Worked example: add the issuer via API
```bash
curl -k -X POST https://localhost:8443/api/v1/issuers \
-H 'Content-Type: application/json' \
-d '{
"id": "iss-aws-prod",
"name": "AWS ACM PCA (prod)",
"type": "AWSACMPCA",
"config": {
"region": "us-east-1",
"ca_arn": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
"signing_algorithm": "SHA256WITHRSA",
"validity_days": 90
}
}'
```
The certctl server process must have AWS credentials available
before the issuer is created (or before any subsequent issuance
call). For a local dev run with shared-config creds:
`export AWS_PROFILE=my-profile` before `docker compose up`. For an
EKS deployment: attach an IRSA-bound IAM role to the certctl pod's
service account.
## Troubleshooting
### `AccessDeniedException: User ... is not authorized to perform: acm-pca:IssueCertificate`
The IAM principal certctl is using lacks the required actions.
Apply the IAM policy above (scoped to your CA ARN) to the
role/user. The principal can be inspected with
`aws sts get-caller-identity` from the certctl host.
### `ResourceNotFoundException: Could not find Certificate Authority`
The `CAArn` doesn't match any CA in the configured region. Common
causes: region mismatch (CA is in `us-west-2`, certctl region is
set to `us-east-1`), CA was deleted, ARN typo. Verify with
`aws acm-pca describe-certificate-authority --certificate-authority-arn <arn> --region <region>`.
### `acmpca waiter (waiting for issuance): exceeded max wait time`
The cert was submitted but didn't reach `CERTIFICATE_ISSUED` state
within 5 minutes. Check the CA's CloudWatch metrics for backlog;
check the CA's audit reports for any policy violations on the
request. If the wait is consistently slow, edit
`defaultWaiterTimeout` in
`internal/connector/issuer/awsacmpca/awsacmpca.go` and rebuild.
## Revocation
CRL and OCSP are managed by AWS ACM PCA directly. certctl records
revocations locally and notifies AWS via the `RevokeCertificate`
API with RFC 5280 reason mapping (e.g. `keyCompromise`
`KEY_COMPROMISE`). AWS ACM PCA's CRL distribution point and OCSP
responder serve the resulting status to verifying clients —
certctl is **not** in the OCSP path for this connector.
## Related docs
- [Connector index](index.md) — interface contract, registry, port/adapter wiring
- [Async CA polling](../protocols/async-ca-polling.md) — bounded-polling primitive (ACM PCA uses the SDK waiter, not certctl's polling, but the same operator concerns apply)
- [Disaster recovery runbook](../../operator/runbooks/disaster-recovery.md) — what happens to ACM PCA-issued certs if the CA is deleted
+208
View File
@@ -0,0 +1,208 @@
# AWS Certificate Manager (ACM) Target Connector — Operator Deep-Dive
> Last reviewed: 2026-05-05
>
> Operator-grade documentation for the AWS Certificate Manager
> (ACM) target connector. For the connector-development context
> (interface contract, registry, atomic deploy primitive shared
> across all targets), see the [connector index](index.md).
>
> **Note:** this is the **target** connector that deploys
> certificates *into* ACM for ALB / CloudFront / API Gateway / App
> Runner consumption. The **issuer** connector that pulls certs
> *from* AWS ACM Private CA is documented separately at
> [aws-acm-pca.md](aws-acm-pca.md).
## Overview
The AWS ACM target connector deploys certificates into AWS
Certificate Manager — the public AWS service that ALB /
CloudFront / API Gateway / App Runner consume by ARN. Closes the
"we terminate TLS at AWS, how do we get certctl-issued certs to
ALB?" question for cloud-first deployments. Rank 5 of the
2026-05-03 Infisical deep-research deliverable.
Implementation lives at `internal/connector/target/awsacm/`.
## When to use this connector
Use the AWS ACM target connector when:
- TLS terminates at AWS-managed edges (ALB, CloudFront, API
Gateway, App Runner) and those services consume certs by ACM
ARN.
- You want certctl to drive the rotation while Terraform /
CloudFormation handles the ARN-to-resource attachment.
- You need short-lived IAM credentials (IRSA, instance profiles)
rather than long-lived access keys.
Look elsewhere when:
- The target is an EC2 instance running NGINX / HAProxy / Apache
directly — those connectors are simpler than the ACM round-trip.
- You're using ACM Private CA for internal trust — that's the
[aws-acm-pca.md](aws-acm-pca.md) issuer, a different connector.
## Configuration
```json
{
"region": "us-east-1",
"certificate_arn": "arn:aws:acm:us-east-1:123456789012:certificate/abcdef01-2345-6789-abcd-ef0123456789",
"tags": {"env": "production", "app": "api-gateway"}
}
```
| Field | Default | Description |
|---|---|---|
| `region` | (required) | AWS region for the ACM endpoint (e.g. `us-east-1`). CloudFront-attached certs MUST live in `us-east-1`; ALB / API Gateway use the same region as the load balancer. |
| `certificate_arn` | — | ARN of an existing ACM certificate to rotate in place. Empty on first deploy — the adapter creates a new ACM cert via `ImportCertificate` and the deployment record's Metadata captures the resulting ARN. Operators can also pre-create the ARN out-of-band (Terraform, CloudFormation) and pin it here. |
| `tags` | — | Tags applied to the ACM cert at first import + re-applied via `AddTagsToCertificate` on every subsequent import (ACM strips tags on re-import). The reserved keys `certctl-managed-by` and `certctl-certificate-id` are set automatically and cannot be overridden. |
## IAM policy (minimum permissions)
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"acm:ImportCertificate",
"acm:GetCertificate",
"acm:DescribeCertificate",
"acm:ListCertificates",
"acm:AddTagsToCertificate"
],
"Resource": "arn:aws:acm:*:*:certificate/*"
}]
}
```
## Auth recipes
- **IRSA (IAM Roles for Service Accounts) — recommended for K8s
deploys.** Annotate the agent's ServiceAccount with
`eks.amazonaws.com/role-arn=arn:aws:iam::<account>:role/certctl-acm-deployer`.
The role's trust policy allows the cluster's OIDC provider;
permission policy is the JSON above. Short-lived STS
credentials are auto-rotated by EKS — no long-lived access
keys.
- **EC2 instance profile — recommended for VM-based agents.**
Attach an instance profile referencing the same role. SDK's
`LoadDefaultConfig` picks credentials up via the IMDS metadata
service.
- **AWS SSO / `aws configure sso` — recommended for operator
workstations.** SDK reads `~/.aws/config` for the SSO profile
and refreshes tokens via the existing CLI session.
- **Long-lived access keys are NOT supported in connector
Config** — the credential chain is configured at the SDK
level, not the connector level. This is a procurement-
readability decision: a security reviewer reading the
`deployment_targets` table should never find an access key.
## Atomic-rollback contract
Every `DeployCertificate` snapshots the existing cert via
`DescribeCertificate` + `GetCertificate` BEFORE calling
`ImportCertificate` with the new bytes. After import, the
connector re-fetches the cert metadata and compares serial
numbers.
On serial-mismatch (post-verify failure), the connector calls
`ImportCertificate` again with the snapshotted bytes to restore
the previous cert. The rollback path emits a `WARN`-level slog
entry; the rollback's own success or failure is exposed via
`certctl_deploy_rollback_total{target_type="AWSACM",outcome="restored"|"also_failed"}`
per the deploy-hardening I Phase 10 metric exposer.
Mirrors the Bundle 5+ pre-deploy-snapshot pattern shipped for
IIS / WinCertStore / JavaKeystore.
## ALB attachment recipe
certctl creates / rotates the ACM cert; the operator (or
Terraform / CloudFormation) attaches it to the ALB listener
separately. For Terraform-driven deployments, look up the ARN by
tag:
```hcl
data "aws_acm_certificate" "certctl_managed" {
domain = "api.example.com"
most_recent = true
# Filter by certctl provenance tags so an unrelated ACM cert with
# the same SAN doesn't get picked up.
tags = {
"certctl-managed-by" = "certctl"
"certctl-certificate-id" = "mc-api-prod"
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.api.arn
port = 443
protocol = "HTTPS"
certificate_arn = data.aws_acm_certificate.certctl_managed.arn
# ...
}
```
The ARN updates in place across renewals (ACM `ImportCertificate`
is upsert-style when given an ARN), so the ALB listener's
`certificate_arn` reference doesn't change. CloudFront / API
Gateway distributions can reference the same ARN via their
respective Terraform resources.
## Threat model carve-outs
- **Cert key bytes never written to disk on the agent.**
`DeployCertificate` reads `request.KeyPEM` from memory and
passes it to the SDK's `ImportCertificate` call. No temp file.
No swap-out window.
- **Provenance tags are mandatory.** The reserved
`certctl-managed-by=certctl` + `certctl-certificate-id=<mc-id>`
pair is set automatically on every import. Operators
identifying a stray ACM cert in their account can match
against `certctl-managed-by` to confirm it was certctl-issued
(or NOT — the absence of the tag means a manual import).
- **No long-lived AWS credentials in `Config`.** `Config`
carries region + ARN + operator tags only. AWS auth is the
SDK credential chain (IRSA / instance profile / SSO).
- **`ListCertificates` IAM permission is required for the V2
ARN-discovery dance to work.** Operators who pin
`Config.CertificateArn` after the first deploy can drop this
permission; the V2 fallback emits a warning and reverts to
"always create new ARN" if the operator forgets to update
`certificate_arn` post-first-deploy.
## Procurement checklist crib
Paste into security review:
- certctl uses short-lived IAM-role credentials via IRSA /
instance profile, not long-lived access keys.
- The cert key is held only in agent memory during the import
call; never written to disk.
- Every imported ACM cert is tagged with
`certctl-managed-by=certctl` +
`certctl-certificate-id=<mc-id>` for forensic traceability.
- Failed imports trigger automatic rollback to the snapshotted
previous cert; both outcomes are surfaced via Prometheus.
- The minimum IAM policy is 5 actions on
`arn:aws:acm:*:*:certificate/*`; CloudTrail captures every
API call for audit.
## ValidateOnly contract
ACM has no dry-run API for `ImportCertificate`; `ValidateOnly`
returns `target.ErrValidateOnlyNotSupported` per the deploy-
hardening I Phase 3 sentinel contract. Operators preview deploys
via `ValidateConfig` + `aws acm describe-certificate
--certificate-arn <arn>` against the current ARN.
## Related docs
- [Connector index](index.md) — interface contract, registry, deploy primitive
- [Azure Key Vault](azure-kv.md) — Azure equivalent target
- [AWS ACM Private CA issuer](aws-acm-pca.md) — the *issuer* counterpart (same vendor, opposite direction)
- [Cloud targets runbook](../../operator/runbooks/cloud-targets.md) — operator playbook covering both AWS ACM and Azure KV

Some files were not shown because too many files have changed in this diff Show More