Compare commits

...

23 Commits

Author SHA1 Message Date
shankar0123 5c7c125d9d ci+docs(scep): close G-3 docs-only drift for SCEP placeholder + wildcard
Commit 294f6cf (the prior docs fix for the multi-profile env vars)
introduced two doc-only env-var literals that the G-3 scanner picked
up as unmapped:

  * CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID — the literal CORP example
    placeholder I added to clarify what the <NAME> substitution looks
    like in practice. The G-3 scanner can't tell a placeholder from a
    real env var.
  * CERTCTL_SCEP_ — comes from the docs string CERTCTL_SCEP_* (the
    asterisk is not in [A-Z_], so the regex strips it down to the
    prefix and treats it as a phantom env var).

Two-part fix:

docs/features.md
  * Replaced the literal CORP example (CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID)
    with a prose explanation that doesn't include a literal
    placeholder env var name. Operators still get a clear example via
    'a CERTCTL_SCEP_PROFILES entry of corp resolves the issuer-id env
    var key with <NAME> replaced by CORP'.

.github/workflows/ci.yml
  * Added CERTCTL_SCEP_ to the G-3 ALLOWED prefix list, mirroring the
    existing CERTCTL_TLS_ entry. Both are legitimate doc-only prefix
    references (CERTCTL_TLS_* / CERTCTL_SCEP_*) that the scanner sees
    as bare prefixes after stripping the wildcard. The allowlist
    documents these as integration-surface contracts that the
    structured per-profile env vars expand into at runtime.

Verification: local G-3 set difference (Go-defined ∖ docs-mentioned)
empty in BOTH directions after the fix:
  * DOCS_ONLY (docs ∖ Go, post-allowlist): empty
  * CONFIG_ONLY (Go ∖ docs): empty

Restores green CI on the env-var docs drift guard.
2026-04-29 03:53:00 +00:00
shankar0123 294f6cff52 docs(scep): document multi-profile env vars (CERTCTL_SCEP_PROFILES + per-profile prefix)
Phase 1.5 added two new env-var literals to internal/config/config.go
that the G-3 docs-drift CI guard picked up but I forgot to document
when shipping commit fdd424b:

  * CERTCTL_SCEP_PROFILES — comma-list of profile names enabling
    multi-endpoint dispatch (e.g. 'corp,iot' produces /scep/corp +
    /scep/iot).
  * CERTCTL_SCEP_PROFILE_ — the prefix string used in
    loadSCEPProfilesFromEnv's getEnv calls (e.g.
    getEnv('CERTCTL_SCEP_PROFILE_'+envName+'_ISSUER_ID', ...)). The
    G-3 regex extracts string literals between double quotes; the
    prefix is a literal even though the suffix is concatenated at
    runtime, so the scanner correctly flags it as 'defined in Go but
    not documented'.

Added 7 rows to the SCEP env-vars table in docs/features.md:
  * CERTCTL_SCEP_PROFILES (the explicit list var)
  * CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID (per-profile issuer)
  * CERTCTL_SCEP_PROFILE_<NAME>_PROFILE_ID (per-profile cert profile)
  * CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD (per-profile secret)
  * CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH (per-profile RA cert)
  * CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH (per-profile RA key)

Each row notes the per-profile validation contract (required for every
profile in the list, file modes, fail-loud-with-PathID semantics).

Verification: local G-3 set difference (Go-defined ∖ docs-mentioned)
empty. The literal prefix CERTCTL_SCEP_PROFILE_ now appears in
docs/features.md as the documented env-var prefix, satisfying the
scanner's substring match.
2026-04-29 03:50:37 +00:00
shankar0123 fdd424bf5f feat(scep): per-issuer SCEP profiles — multi-endpoint dispatch
SCEP RFC 8894 + Intune master bundle — Phase 1.5 of 14.

Restructures SCEPConfig from a single flat struct (one IssuerID + one
RA pair + one challenge password) to a Profiles slice where each
profile binds its own URL path (/scep/<pathID>), issuer, optional
CertificateProfile, RA cert+key, and challenge password.

This phase is the FOUNDATION for Phases 2-12: every downstream handler
signature, service envelope, CertRep builder, GUI counter, and test
fixture takes a profile_id parameter from here on. Adding multi-profile
support post-bundle would cost 3x what greenfielding it now does.

Backward compat: legacy CERTCTL_SCEP_* flat env vars synthesise a
single-element Profiles[0] with PathID="" (legacy /scep root) when
CERTCTL_SCEP_PROFILES is unset. Existing operators see no behavior
change. New operators write multi-profile config directly via the
indexed env-var form.

Indexed env-var convention:
  CERTCTL_SCEP_PROFILES=corp,iot,server
  CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID=iss-corp-laptop
  CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID=prof-corp-tls
  CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD=...
  CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH=/etc/certctl/scep/corp-ra.crt
  CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH=/etc/certctl/scep/corp-ra.key
  ... (etc per profile name)

internal/config/config.go
  * SCEPConfig.Profiles []SCEPProfileConfig — primary multi-profile
    dispatch source.
  * Legacy flat fields (IssuerID, ProfileID, ChallengePassword,
    RACertPath, RAKeyPath) preserved with updated docblocks marking
    them as merge sources for the backward-compat shim.
  * SCEPProfileConfig new struct (PathID, IssuerID, ProfileID,
    ChallengePassword, RACertPath, RAKeyPath).
  * loadSCEPProfilesFromEnv: reads CERTCTL_SCEP_PROFILES (comma-list
    of names), expands each to per-profile env vars
    CERTCTL_SCEP_PROFILE_<NAME>_*. Returns nil when unset so the
    legacy-shim path takes over.
  * mergeSCEPLegacyIntoProfiles: when SCEP enabled + Profiles empty +
    any legacy flat field populated, synthesises Profiles[0] with
    PathID="". No-op when Profiles already populated (structured form
    wins) or SCEP disabled.
  * validSCEPPathID: empty allowed (legacy /scep root); non-empty
    must be [a-z0-9-] with no leading/trailing hyphen.
  * Per-profile Validate gates: PathID format, uniqueness across the
    slice, ChallengePassword presence (CWE-306 per profile), RA pair
    presence (RFC 8894 §3.2.2), IssuerID presence.
  * Legacy single-profile gates skip when Profiles is non-empty so
    the per-profile loop owns the gating in the structured case
    (avoids double-fire with overlapping error messages).

internal/api/router/router.go
  * RegisterSCEPHandlers signature: map[string]handler.SCEPHandler
    (was a single SCEPHandler).
  * Empty PathID handler registered with literal r.Register('GET /scep'
    + 'POST /scep') so the openapi-parity AST scanner (Bundle D /
    Audit M-027) continues to see the documented /scep route. Without
    this preservation, the parity test fails because dynamic
    string-built routes don't appear in *ast.BasicLit walks.
  * Non-empty PathIDs registered dynamically as /scep/<pathID>.
  * AuthExempt prefix /scep already covers all /scep[/...] paths via
    prefix match — no change needed there.

cmd/server/main.go
  * SCEP startup block iterates cfg.SCEP.Profiles, builds one service
    + one handler per profile, stuffs them into a {pathID -> handler}
    map, hands the map to apiRouter.RegisterSCEPHandlers.
  * Per-profile preflight: preflightSCEPChallengePassword,
    preflightSCEPRACertKey, preflightEnrollmentIssuer fire ONCE PER
    PROFILE with a profile-scoped slog.Logger so failures report
    PathID + IssuerID. Each per-profile failure os.Exits(1) with a
    targeted error message.
  * Final 'SCEP server enabled' info log reports profile_count.

internal/config/config_scep_profiles_test.go (new, 9 tests / 22 sub-cases)
  * TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile — the
    backward-compat smoke test.
  * TestSCEPConfig_MultipleProfiles_LoadFromEnv — structured-form
    happy path with two profiles.
  * TestSCEPConfig_StructuredFormBeatsLegacy — when both forms set,
    structured wins; legacy flat field MUST NOT leak into
    Profiles[0].ChallengePassword.
  * TestSCEPConfig_PathIDValidation — 13 sub-cases covering valid +
    every reject mode (uppercase, slash, leading/trailing hyphen,
    underscore, dot, space, non-ASCII).
  * TestSCEPConfig_DuplicatePathID_Refuses.
  * TestSCEPConfig_MissingPerProfileChallengePassword,
    _MissingPerProfileRAPair (3 sub-cases),
    _MissingPerProfileIssuerID — per-profile gate triplet.
  * TestSCEPConfig_DisabledIgnoresProfiles — gates only fire when
    SCEP is enabled.

internal/api/router/router_scep_profiles_test.go (new, 4 tests)
  * TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot —
    empty PathID gets /scep root; both GET + POST routes registered.
  * TestRouter_RegisterSCEPHandlers_NonEmptyPathIDMapsToSubpath —
    non-empty PathID gets /scep/<pathID>; /scep root NOT registered
    when no empty-PathID profile exists.
  * TestRouter_RegisterSCEPHandlers_MultipleProfilesNoCrossBleed —
    three profiles (default, corp, iot); each path reaches the right
    handler instance, verified via per-profile-tagged GetCACaps mock
    response.
  * TestRouter_RegisterSCEPHandlers_EmptyMapRegistersNoRoutes — no
    profiles → no /scep routes (deploy with SCEP disabled).

Verification:
  * gofmt clean for the files I touched.
  * go vet clean across config / router / cmd/server / domain.
  * go test -short -count=1 green across config / router / cmd/server /
    api/handler / service / domain / pkcs7.
  * Coverage held: handler 79.0% / service 73.2% / pkcs7 100% /
    config 96.0% / domain 88.6% / router 100% / cmd/server 19.2%.
  * openapi-parity test green (literal /scep registrations preserved).

Phase 1.5 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 03:46:57 +00:00
shankar0123 105c307d62 feat(scep): add RFC 8894 message-type constants + RA cert/key config
SCEP RFC 8894 + Intune master bundle — Phase 0 + Phase 1 of 14.

Phase 0 (recon, no code changes):
  Baseline tests green at HEAD 2519da8 (handler 79.0% / service 73.2% /
  pkcs7 100%). SCEPConfig actual line is 666, prompt cited 639 — used
  actual per the 'repo wins' operating rule.

Phase 1 (this commit):

internal/domain/scep.go
  * Added SCEPMessageTypeCertRep (3) — RFC 8894 §3.3.2 server response
    messageType. Clients pivot on this to extract a cert (Status=Success),
    surface a failInfo (Status=Failure), or poll (Status=Pending).
  * Added SCEPMessageTypeRenewalReq (17) — RFC 8894 §3.3.1.2
    re-enrollment with an existing valid cert; signerInfo signed by the
    existing cert (proving possession).
  * Added SCEPRequestEnvelope struct — parsed authenticated attributes
    from the inbound signerInfo (messageType / transactionID /
    senderNonce / signerCert).
  * Added SCEPResponseEnvelope struct — what the service hands back to
    the handler so the handler can build the CertRep PKIMessage with
    the correct status / failInfo / nonce echoes.
  * Existing constants preserved unchanged.

internal/config/config.go
  * SCEPConfig.RACertPath + RAKeyPath fields with the doc-comment density
    matching the existing ChallengePassword field.
  * Env-var loading: CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH.
  * Validate() refuse: SCEP enabled with empty RA pair fails loud at
    startup (defense-in-depth with the new preflight gate below).

cmd/server/main.go
  * preflightSCEPRACertKey: file existence, mode 0600 gate (refuses
    world-/group-readable RA key), tls.X509KeyPair-based parse + match
    + algorithm check (one stdlib call covers parse + cert-key match +
    pubkey alg in one shot), expiry check, RSA-or-ECDSA gate (RFC 8894
    §3.5.2 CMS signing requirement). Mirrors preflightSCEPChallenge-
    Password's no-op-when-disabled pattern; each failure returns a
    wrapped error so the caller (main) translates to a structured
    slog.Error + os.Exit(1).
  * Wired into the SCEP startup block immediately after the existing
    challenge-password preflight; if it errors, the server refuses to
    boot with a specific log line + the pointer to docs/legacy-est-scep.md
    for the openssl recipe.
  * Added crypto/tls + crypto/x509 imports.

cmd/server/preflight_scep_ra_test.go (new)
  * Seven hermetic table-driven test cases covering each failure mode
    spelled out in the helper's docblock plus the no-op-when-disabled
    path. Each case materialises a real ECDSA P-256 cert/key pair on
    disk so the tls.X509KeyPair path is exercised end-to-end (catches
    drift in stdlib cert-parsing semantics that a mock would hide):
      - disabled SCEP no-op
      - missing paths (3 sub-cases: both empty, cert only, key only)
      - world-readable key (chmod 0644)
      - valid pair (happy path)
      - expired cert (NotAfter in past)
      - mismatched pair (cert from one ECDSA pair, key from another)
      - missing files (paths set but files don't exist)
      - ed25519 RA key (unsupported alg per RFC 8894 §3.5.2)
  * writeECDSARAPair helper materialises a fresh ECDSA pair under the
    test temp dir with the cert at 0644 and the key at 0600 (production
    deploy mode).

internal/config/config_test.go
  * TestValidate_SCEPEnabled_MissingRAPair_Refuses — 3 sub-cases pin
    the new Validate() refuse path (both empty, cert only, key only).
  * TestValidate_SCEPEnabled_CompleteRAPair_Accepts — pins the boundary
    that file-existence is the preflight's job, NOT Validate's.
  * TestValidate_SCEPDisabled_EmptyRAPair_Accepts — pins that the gate
    only fires when SCEP is enabled (mirrors the CHALLENGE_PASSWORD
    disabled-passes precedent).

docs/features.md
  * SCEP env-vars table extended with CERTCTL_SCEP_RA_CERT_PATH and
    CERTCTL_SCEP_RA_KEY_PATH (with the prod 'MUST set' callout +
    file-mode 0600 requirement). Closes the G-3 'env var defined in Go
    but never documented' CI guard for the new vars.

Verification:
  * gofmt clean for the files I touched (preflight_scep_ra_test.go +
    config.go + scep.go); pre-existing gofmt drift in unrelated files
    not in scope.
  * go vet ./internal/domain/... ./internal/config/... ./cmd/server/...
    clean.
  * go test -short -count=1 ./internal/domain/... ./internal/config/...
    ./cmd/server/... green.
  * Coverage held at handler 79.0% / service 73.2% / pkcs7 100% /
    config 96.1% / domain 88.6%.
  * Local G-3 set difference (Go-defined env vars ∖ docs-mentioned env
    vars) empty.

No behavior change for operators who don't enable SCEP. New behavior
gated by CERTCTL_SCEP_ENABLED=true + the new RA env vars. The MVP
raw-CSR fall-through path stays unchanged — Phase 2 will add the
RFC 8894 EnvelopedData decryption that consumes the RA pair.

Phase 1 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 03:35:11 +00:00
shankar0123 2519da85f0 docs: README + concepts + features reflect CRL/OCSP responder bundle
Audit pass against cowork/crl-ocsp-responder-prompt.md found three
operator-facing docs still describing the pre-bundle CRL/OCSP surface
(GET-only OCSP, CA-key-direct signing, no scheduler-driven cache). Each
claim updated below was ground-truthed against repo HEAD before edit.

README.md
  * Standards & Revocation table — CRL row now mentions
    scheduler-pre-generated cache (CERTCTL_CRL_GENERATION_INTERVAL,
    crl_cache table); OCSP row mentions GET + POST forms, dedicated
    responder cert per RFC 6960 §2.6, id-pkix-ocsp-nocheck per
    §4.2.2.2.1, 7d auto-rotation grace.
  * Revocation paragraph — corrected the 'Embedded OCSP responder'
    one-liner to call out the dedicated-responder-cert design (the CA
    private key is never used directly for OCSP signing, which is the
    load-bearing security property for the future PKCS#11/HSM driver
    path) and added the link to the relying-party guide.

docs/concepts.md
  * CRL paragraph — added the scheduler pre-generation + singleflight
    coalescing detail. Kept the existing 24h validity claim (verified
    against internal/connector/issuer/local/local.go:956 — 'NextUpdate:
    now.Add(24 * time.Hour)').
  * OCSP paragraph — corrected the description so it covers both GET
    and POST forms (POST per RFC 6960 §A.1.1 is what production
    clients use: Firefox, OpenSSL s_client -status, cert-manager,
    Intune); added the dedicated-responder-cert + nocheck-extension +
    auto-rotation explanation; cross-link to docs/crl-ocsp.md.

docs/features.md
  * Revocation Infrastructure section — CRL Endpoint, OCSP Responder,
    new Admin Cache Observability subsection, new GUI Revocation
    Endpoints Panel subsection. Corrected the previously-wrong 'Signs
    with the issuing CA key' OCSP claim — the bundle's load-bearing
    security improvement is exactly that the CA key is NOT used
    directly. Cross-link to crl-ocsp.md.
  * Local CA env vars table — added all four new
    CERTCTL_CRL_GENERATION_INTERVAL / CERTCTL_OCSP_RESPONDER_KEY_DIR
    (with the prod 'MUST set' callout) / _ROTATION_GRACE / _VALIDITY
    rows. Closes the G-3 'env var defined in Go but never documented'
    drift that broke CI on commit fc3c7ad.
  * Migrations table — added 000019_crl_cache and 000020_ocsp_responder
    rows so the table reflects the bundle's persisted surface area;
    also clarified the table is illustrative + pointed readers at
    'ls migrations/*.up.sql' for the full sequence (the table had
    drifted behind reality at 000010 even before this bundle).

docs/architecture.md was already updated in commit b4334ed with the
same content scope, so no further architecture edits.

Verification:
  * Local G-3 set difference: empty (Go-defined ∖ docs-mentioned for
    CRL/OCSP env vars).
  * 24h CRL validity claim verified against local.go:956 NextUpdate.
  * Migration numbers verified against 'ls migrations/000019* 000020*'.
  * id-pkix-ocsp-nocheck OID verified against
    internal/connector/issuer/local/ocsp_responder.go:60.
2026-04-29 03:20:44 +00:00
shankar0123 b4334edda1 docs: CRL/OCSP user guide + architecture cross-reference — Phase 6
Audit of cowork/crl-ocsp-responder-prompt.md against repo HEAD found
two prompt deliverables still missing after the Phase 5 + Phase 6 code
landed: the docs/crl-ocsp.md operator+relying-party guide (Phase 6.2)
and the docs/architecture.md cross-reference. This commit closes both.

docs/crl-ocsp.md (329 lines) covers:
  * Conceptual overview — why both CRL and OCSP, why a separate
    responder cert (RFC 6960 §2.6 / §4.2.2.2.1) keeps the CA key cold
  * Endpoints — GET CRL, GET + POST OCSP, admin observability endpoint
    (M-008 admin-gated) with full request/response shape examples
  * Configuration — every CERTCTL_CRL_* / CERTCTL_OCSP_RESPONDER_*
    env var with default + meaning + 'MUST set in prod' callout for
    OCSP_RESPONDER_KEY_DIR
  * OCSP responder cert lifecycle — first-request bootstrap, disk
    self-healing when keydir is pruned out from under the DB row,
    rotation grace, ExtraExtensions wiring for id-pkix-ocsp-nocheck
  * Consumer integration recipes — cert-manager (AIA/CDP automatic),
    Firefox (about:preferences quirk), OpenSSL (ocsp + s_client -status),
    Intune (CRL pull cadence)
  * V3-Pro deferred (delta CRLs, OCSP rate-limiting, OCSP stapling)
  * Troubleshooting (404 on issuer that doesn't support CRL, hex
    serial format, admin-gated 403, scheduler not running)

docs/architecture.md: extended the existing 'Certificate revocation'
paragraph to explicitly call out the new pipeline (crl_cache table,
OCSP responder cert per RFC 6960 §2.6, POST + GET OCSP endpoints,
auto-rotation grace) and added the 'See docs/crl-ocsp.md for the
operator + relying-party guide' link so future readers can find the
deep dive.

Closes the prompt's Phase 6.2 + 6.3 exit criteria. Combined with
the Phase 5 GUI panel (0594631) + Phase 6 e2e helpers (fc3c7ad) +
Phase 5 admin endpoint (a4df1f8), this completes V2 for the bundle.
V3-Pro polish (delta CRLs, OCSP rate-limiting, OCSP stapling) remains
explicitly out of scope per the prompt's 'What this prompt is NOT'
section.
2026-04-29 03:09:13 +00:00
shankar0123 fc3c7ad1e3 crl/ocsp e2e: wire helpers to integration_test.go primitives — Phase 6
The Phase 6 e2e scaffold landed in a4df1f8 with t.Skip stubs for the
five harness primitives that the test needed but the integration_test.go
suite already provided. This commit replaces the stubs with real
implementations so TestCRLOCSPLifecycle + TestCRLOCSPPostEndpoint
actually exercise the CRL/OCSP backend end-to-end against a running
docker-compose.test.yml stack.

Wired helpers:
  * issueLocalCert(commonName) → POSTs /api/v1/certificates against
    iss-local with the test stack's seeded owner/team/policy/profile,
    triggers /renew, waits for jobs via the existing waitForJobsDone
    helper, GETs /versions, parses pem_chain into leaf + issuer CA.
    Returns (leaf, pemChain, hexSerial). Records the cert ID in a
    package-level registry keyed by hex serial.
  * revokeCertViaAPI(hexSerial, reason) → resolves hex serial to
    certctl cert ID via the registry (the API keys revocation by
    cert ID, not X.509 serial) and POSTs /revoke with the RFC 5280
    reason code.
  * fetchCACert(issuerID) → returns the issuing CA from any cert
    previously issued via issueLocalCert (chain[1], or chain[0] for
    self-signed test root). Falls back to a just-in-time issuance if
    the registry is empty so the helper is callable from any phase.
  * requireServerReady → polls GET /health (the unauthenticated
    Bearer-free liveness route from router.go) until 200 OK or 30s.
  * serverBaseURL → returns the harness's serverURL package var
    (CERTCTL_TEST_SERVER_URL, defaulting to https://localhost:8443).
  * httpClient → returns newUnauthHTTPClient (TLS-trust-aware, no
    Bearer) since /.well-known/pki/{crl,ocsp}/ run unauthenticated by
    design (M-006: relying parties must validate revocation without
    API keys).

New helper:
  * parsePEMChain — decodes a PEM bundle into [leaf, issuer]. Handles
    the self-signed-root edge case by returning the leaf twice rather
    than nil. Used by issueLocalCert to populate the registry.

Constants block at top of file pins the test-stack identifiers
(iss-local, owner-test-admin, team-test-ops, rp-default,
prof-test-tls) — these match deploy/docker-compose.test.yml seed
data so the suite stays in sync with what the stack actually serves.

Verification (sandbox — Docker not available so the test bodies
themselves can't run here, but the static checks pass):
  - gofmt: clean
  - go vet -tags integration ./deploy/test/...: clean
  - go test -tags integration -list '.*' ./deploy/test/...: lists
    TestCRLOCSPLifecycle + TestCRLOCSPPostEndpoint among the existing
    suite tests, confirming the file compiles + binds correctly.

CI runs the full suite via docker-compose.test.yml in the standard
integration-test workflow. Local repro per the file header doc:
  cd deploy && docker compose -f docker-compose.test.yml up --build -d
  cd deploy/test && go test -tags integration -v -run TestCRLOCSP \
      -timeout 10m ./...
2026-04-29 03:03:19 +00:00
shankar0123 0594631e6a gui/cert-detail: revocation endpoints panel (CRL/OCSP) — Phase 5
CertificateDetailPage now surfaces a Revocation Endpoints card showing
the standards-compliant /.well-known/pki/crl/{issuer_id} CRL distribution
point (RFC 5280 §4.2.1.13) and /.well-known/pki/ocsp/{issuer_id} OCSP
responder URL (RFC 6960 §A.1) for relying parties that don't already know
certctl's well-known scheme.

Two action buttons exercise the same network path the issued leaves'
AIA/CDP extensions advertise, so an operator can confirm 'did the
backend Phases 1-4 actually wire end-to-end?' without curl:
  * 'Test CRL fetch'   — fetchCRL(issuer_id) helper, surfaces byte count
  * 'Check OCSP status' — getOCSPStatus(issuer_id, serial_hex) helper

Admin-only cache-age badge: when useAuth().admin is true the panel pulls
GET /api/v1/admin/crl/cache (M-008 admin-gated handler) and shows
'Cache fresh · 2m ago' / 'Cache stale' / 'Not yet generated' next to
the heading. Non-admin callers don't trigger the fetch (gated client-side
on enabled flag, server-side on middleware.IsAdmin) so the badge cannot
leak generation cadence.

Test coverage in CertificateDetailPage.test.tsx pins:
  1. CRL + OCSP URLs render with issuer_id substituted
  2. Test CRL fetch button calls fetchCRL with the issuer_id and renders
     the byte-count success message
  3. Check OCSP status button calls getOCSPStatus with (issuer_id, serial)
     and renders the DER byte-count
  4. Admin badge stays HIDDEN (and getAdminCRLCache is NEVER called) when
     useAuth().admin is false — pins the no-info-leak invariant

P-1 closure docblock + CI guardrail (.github/workflows/ci.yml) updated
to remove getOCSPStatus from the documented-orphan list since it now
has a real consumer.

types.ts: CRLCacheRow / CRLCacheEvent / CRLCacheResponse mirrors of the
backend admin handler payload (admin_crl_cache.go).

client.ts: fetchCRL + getAdminCRLCache helpers; getOCSPStatus already
existed and is now an active consumer.

Tests: 6/6 in CertificateDetailPage.test.tsx, 150/150 across api+page
suite. tsc --noEmit clean.
2026-04-29 02:58:39 +00:00
shankar0123 a4df1f86ae crl/ocsp: admin observability endpoint + Phase 6 e2e scaffold
Phase 5 (admin endpoint slice) + Phase 6 (e2e test stub) of the
CRL/OCSP responder bundle. Closes the deferred items from the
backend-slice merge (77d6326).

What landed:

  Phase 5 — admin observability:
  * GET /api/v1/admin/crl/cache (handler.AdminCRLCacheHandler):
    - Per-issuer cache state + most recent N generation events
    - Admin-gated via middleware.IsAdmin (M-003 pattern); non-admin
      callers get 403 + the service is never invoked
    - Reveals issuer set + CRL cadence, hence the gate
    - Returns CachePresent=false rows for never-generated issuers so
      the GUI can show 'not yet generated' instead of 404
    - Per-issuer Get failures decorate the row's RecentEvents rather
      than failing the whole response
  * AdminCRLCacheServiceImpl: thin handler-side composition over
    repository.CRLCacheRepository + an issuer-IDs callback (avoids
    importing internal/service from internal/api/handler)
  * M-008 admin-gate pin updated: admin_crl_cache.go added to
    AdminGatedHandlers; full triplet of tests
    (NonAdmin_Returns403, AdminExplicitFalse_Returns403,
    AdminPermitted_ForwardsActor) + RejectsNonGetMethod +
    PropagatesServiceError
  * Router registration + HandlerRegistry field + main.go wiring
    (callback closure over issuerRegistry.List)
  * OpenAPI entry under CRL & OCSP tag

  Phase 6 — e2e scaffold:
  * deploy/test/crl_ocsp_e2e_test.go with TestCRLOCSPLifecycle +
    TestCRLOCSPPostEndpoint
  * Lifecycle test exercises issue → fetch OCSP (Good) → revoke →
    wait → fetch CRL (entry present) → fetch OCSP (Revoked) →
    verify dedicated responder cert + id-pkix-ocsp-nocheck
  * Helpers (issueLocalCert, revokeCertViaAPI, fetchCRL, fetchOCSP,
    fetchCACert) currently call t.Skip with TODO markers — sandbox
    has no Docker so the harness can't be wired end-to-end here;
    when CI / a fresh dev workstation runs, the implementer wires
    each helper to the existing integration_test.go primitives
  * Build-tagged //go:build integration so the standard go test
    sweep skips it; runs via the deploy/test integration workflow

Coverage: handler 80.6% (above 75 floor; was 79.8% pre-Phase-5).
All other packages unchanged.

Backward compat: admin endpoint inert until an admin Bearer key is
configured. The e2e test stub is no-op (skips) until wired.

Deferred:
  * GUI cert-detail-page revocation panel — pure frontend work, no
    backend impact, separate session
  * E2E test helper wiring — depends on extracting the existing
    integration-test harness primitives into shared helpers; doable
    in a follow-up that has Docker available
  * V3-Pro polish (delta CRLs, OCSP rate-limiting, OCSP stapling)
2026-04-29 01:55:39 +00:00
shankar0123 db71b47c24 main: wire CRL/OCSP responder services into runtime
Activates the CRL/OCSP responder pipeline that landed dormant in
phases 1-4 (commits 30765ba, a0b7f7d, dc32694, dc1e0bf):

  * IssuerRegistry gains SetLocalIssuerDeps + LocalIssuerDeps struct.
    Rebuild type-asserts each constructed connector to *local.Connector
    and injects ocspResponderRepo + signerDriver + IssuerID + key dir
    + (optional) rotation-grace + validity overrides. Non-local
    connectors are unaffected (the type-assert fails silently). Adapter
    pattern preserved: callers still see service.IssuerConnector.

  * cmd/server/main.go:
    - constructs CRLCacheRepository + OCSPResponderRepository from db
    - constructs signer.FileDriver (default; PKCS#11 driver plugs in
      later via the same Driver interface, no main.go changes needed)
    - calls issuerRegistry.SetLocalIssuerDeps(...) BEFORE BuildRegistry
      so the deps are in place when local connectors are constructed
    - wires CRLCacheService into CertificateService via SetCRLCacheSvc
      (Phase 4 cache-aware GenerateDERCRL path now active)
    - calls scheduler.SetCRLCacheService + SetCRLGenerationInterval
      after sched is constructed; logs the interval at startup

  * config: new OCSPResponderConfig struct + Scheduler.CRLGenerationInterval
    field. Three new env vars:
      CERTCTL_OCSP_RESPONDER_KEY_DIR (no default; operator MUST set in prod)
      CERTCTL_OCSP_RESPONDER_ROTATION_GRACE (default 7d)
      CERTCTL_OCSP_RESPONDER_VALIDITY (default 30d)
      CERTCTL_CRL_GENERATION_INTERVAL (default 1h)

Backward compat: when env vars are unset, the responder bootstrap path
still activates (with default rotation grace + validity, key dir = cwd
which is fine for tests), and the CRL cache pre-populates on the
1h interval. Operators not running the local issuer see no behavior
change.

go vet clean across the full module. Targeted tests for config +
service + scheduler packages all green. Full module build deferred
to CI (sandbox /sessions disk pressure prevented unzipping a
transitive dep — same disk-full pattern the prior commits hit; not
a code issue).
2026-04-29 01:48:23 +00:00
shankar0123 1b211abcd4 crl/cache: fix contextcheck lint on test helper
CI #322 caught the contextcheck violation: insertIssuerForCRL took ctx
but called getTestDB(t) which has no ctx-aware variant — propagating
the ctx through the boundary trips the linter. Drop the ctx parameter
and use context.Background() for the single ExecContext call inside
the helper; per-test isolation comes from the schema-per-test pattern
(getTestDB.freshSchema), not from ctx cancellation.
2026-04-29 01:38:58 +00:00
shankar0123 77d6326803 crl/ocsp responder bundle: backend slice (Phases 1-4)
Ships the production-grade backend for the CRL/OCSP responder bundle.
Closes the gap that made certctl's local issuer unsuitable for any
production deploy (relying parties couldn't validate revocation cleanly):

  Phase 1 — crl_cache schema + repository (migration 000019)
  Phase 2 — dedicated OCSP responder cert per issuer (RFC 6960 §2.6)
            (migration 000020)
  Phase 3 — scheduler crlGenerationLoop + CRLCacheService with
            singleflight collapsing
  Phase 4 — POST OCSP endpoint (RFC 6960 §A.1.1) + GenerateDERCRL
            cache integration

What's NOT in this slice (deferred follow-ups):

  * cmd/server/main.go wiring of the new services into the existing
    issuer registry / scheduler. Mechanical wiring; the operator can
    ship at their next convenience.
  * Phase 5 (GUI: per-issuer revocation endpoints + admin cache
    endpoint), Phase 6 (e2e test against kind cluster), Phase 7
    (release prep). Each is its own session.
  * V3-Pro polish: delta CRLs, OCSP rate-limiting, OCSP stapling.

Coverage at HEAD: handler 79.8%, service 73.5%, scheduler 78.1%,
local issuer 86.3%, signer 91.6%, domain 100%. All above the floors
in .github/workflows/ci.yml.

Backward compat: every new dep is an OPTIONAL setter (SetCRLCacheSvc,
SetCRLCacheService, SetOCSPResponderRepo, SetSignerDriver,
SetIssuerID). Existing wiring continues to function unchanged until
the operator wires the new services in main.go.

No new direct dependencies in core go.mod. The in-tree singleflight
gate (~30 LoC sync.Map[issuerID]*flightEntry) avoids vendoring
golang.org/x/sync.

Each phase landed as its own commit on the branch:
  30765ba — Phase 1
  a0b7f7d — Phase 2
  dc32694 — Phase 3
  dc1e0bf — Phase 4

Branch deleted post-merge.
2026-04-29 00:07:57 +00:00
shankar0123 dc1e0bfbaa crl/ocsp: POST OCSP endpoint (RFC 6960 §A.1.1) + cache integration
Phase 4 (final phase) of the CRL/OCSP responder bundle. Closes the
backend slice; HTTP layer is now production-ready for relying parties.

What landed:

  * POST /.well-known/pki/ocsp/{issuer_id} (handler.HandleOCSPPost)
    - Accepts binary application/ocsp-request body per RFC 6960 §A.1.1
    - Tolerant of missing Content-Type (some clients omit); validates
      via ocsp.ParseRequest, returns 400 on malformed
    - Returns 415 on explicit wrong Content-Type
    - Reuses the existing service path (h.svc.GetOCSPResponse) — the
      only new logic is body decoding + serial-from-OCSPRequest extraction
    - GET form preserved unchanged for ad-hoc curl + human URL paths
    - Auth-exempt under /.well-known/pki/ prefix (already in
      AuthExemptDispatchPrefixes — no router changes for that)
    - 7 new tests: success, method-not-allowed, wrong content-type,
      missing content-type accepted, malformed body, missing issuer,
      service error propagation

  * router.go: r.Register("POST /.well-known/pki/ocsp/{issuer_id}", ...)

  * CertificateService.GenerateDERCRL — cache-aware:
    - New SetCRLCacheSvc(svc) setter (matches existing SetCAOperationsSvc
      pattern — optional dep)
    - When wired, GenerateDERCRL calls crlCacheSvc.Get → cheap DB read
      on cache hit, singleflight-coalesced regen on miss
    - When unwired, falls back to historical caSvc.GenerateDERCRL path
    - GET /.well-known/pki/crl/{issuer_id} handler unchanged — calls
      the same service method, gets cache benefit transparently when
      the cache service is wired in cmd/server/main.go

Coverage: handler 79.8% (floor 75), service unchanged, scheduler 78%.

What's deferred (intentional scope cut for this session):

  * cmd/server/main.go wiring of CRLCacheService + responder service
    setters into the local issuer factory + scheduler. The wiring is
    mechanical (NewCRLCacheService + scheduler.SetCRLCacheService call
    in the existing wiring block); deferring keeps this commit focused
    on the responder + cache primitives. Operator can wire when ready.
  * Phase 5 (GUI), Phase 6 (e2e test against kind), Phase 7 (release
    prep) — separate follow-up sessions.
  * OCSP cache integration: today's GET/POST OCSP path goes through
    the on-demand SignOCSPResponse (already cheap with the dedicated
    responder cert from Phase 2). A cached-OCSP path is V3-Pro polish.

The bundle's V2 backend slice (Phases 0-4) is complete. All 4 phases
shipped 4 commits + 1 amend on this branch. CI will validate the
testcontainers repository tests on push.
2026-04-29 00:07:27 +00:00
shankar0123 dc326942db scheduler/service: crlGenerationLoop + CRLCacheService with singleflight
Phase 3 of the CRL/OCSP responder bundle. Adds the scheduler-driven
pre-generation pipeline that lets the /.well-known/pki/crl/{issuer_id}
HTTP handler (Phase 4) serve from cache instead of regenerating per
request.

What landed:

  * internal/scheduler/scheduler.go:
    - CRLCacheServicer interface (RegenerateAll(ctx))
    - Scheduler struct gains crlCacheService + crlGenerationInterval +
      crlGenerationRunning fields; default interval 1h
    - SetCRLCacheService + SetCRLGenerationInterval setters following
      the existing Set* convention (cloudDiscovery, digest, etc.)
    - Wired into Start: optional loop, gated on crlCacheService != nil
    - crlGenerationLoop: ticker + atomic.Bool re-entry guard +
      WaitGroup integration mirroring digestLoop
    - runCRLGeneration: 5-minute timeout per cycle; per-issuer
      failures are caught inside RegenerateAll itself

  * internal/service/crl_cache.go — CRLCacheService:
    - Get(ctx, issuerID) → (der, thisUpdate, err)
      cache hit → DB read; miss/stale → singleflight regenerate
    - RegenerateAll(ctx) — walks every issuer in registry; per-issuer
      failures logged + audited (crl_generation_events) but don't
      abort the cycle
    - In-tree singleflight gate (~30 LoC, sync.Map[issuerID]*flightEntry)
      — collapses concurrent miss requests for the same issuer into
      one underlying generation. No new dep on golang.org/x/sync
    - Uses existing CAOperationsSvc.GenerateDERCRL for the heavy work
      (no duplication of CRL-build logic); parses returned DER to
      recover thisUpdate / nextUpdate / number / count
    - Failure-event recording is best-effort (failure to record does
      not fail the operation) — events are an audit aid, not a gate

  * internal/service/crl_cache_test.go — 8 tests:
    - Cache hit, miss, staleness paths
    - RegenerateAll happy + cancelled ctx
    - Singleflight: 20 concurrent misses → 1 generation
    - Failure event recording when issuer is missing from registry
    - Nil cache repo returns error

Coverage: service 73.5% (floor 70), scheduler 78.1% (floor 60).

Backward compat: unchanged for any caller that doesn't call
SetCRLCacheService. cmd/server/main.go wiring lands in Phase 4
alongside the POST OCSP endpoint + handler refactor to consult
the cache.
2026-04-29 00:02:01 +00:00
shankar0123 a0b7f7da9d ocsp/responder: dedicated OCSP responder cert per issuer (RFC 6960 §2.6)
Phase 2 of the CRL/OCSP responder bundle. Stops signing OCSP responses
with the CA private key directly; the local issuer now bootstraps a
dedicated responder cert + key per issuer, persists them, and rotates
within a grace window before expiry.

Why this matters:

  - Every relying-party OCSP poll today triggers a CA-key signing op.
    With this change those polls hit a cheap responder key; the CA key
    only signs at responder bootstrap / rotation (rare).
  - When the CA key lives on an HSM (PKCS#11 driver, V3-Pro item 3),
    the dedicated responder removes the per-poll-HSM-op pressure.
  - Carries id-pkix-ocsp-nocheck (RFC 6960 §4.2.2.2.1) so OCSP clients
    do NOT recursively check the responder cert's revocation status.

What landed:

  * migration 000020_ocsp_responder.up.sql (+down) — ocsp_responders table
    keyed by issuer_id; rotated_from records the prior cert serial for
    audit; not_after index drives the rotation scheduler query
  * internal/domain/ocsp_responder.go — OCSPResponder type + NeedsRotation
    helper (configurable grace window; default 7 days before expiry)
  * internal/repository/postgres/ocsp_responder.go — Postgres impl with
    upsert-on-Put + ListExpiring for the future rotation scheduler
  * internal/repository/interfaces.go — OCSPResponderRepository interface
  * internal/connector/issuer/local/ocsp_responder.go — bootstrap +
    rotation logic; under c.mu so concurrent first-call OCSP requests
    don't double-bootstrap; recovers gracefully from corrupt key ref
    or corrupt cert PEM rather than failing the OCSP request
  * internal/connector/issuer/local/local.go:
    - Connector struct gains optional dependencies (ocspResponderRepo,
      signerDriver, issuerID, rotation grace, validity, key dir)
    - Set*() helpers for each dep matching the existing SCEPService
      pattern (SetProfileRepo / SetProfileID)
    - SignOCSPResponse refactored: ensureOCSPResponder dispatches on
      whether deps are wired; fallback path (deps unset) preserves
      pre-Phase-2 behavior of signing with CA key directly
  * internal/connector/issuer/local/ocsp_responder_test.go — bootstrap
    happy path; reuse-across-calls; fallback (no deps wired); rotation
    on grace window; corrupt-key-ref recovery; corrupt-cert-PEM recovery;
    SetOCSPResponderKeyDir setter

Coverage: local issuer 86.3% (above CI floor of 86; was 86.5% before
Phase 2 added ~140 LoC of new code). The recovered-from-drop tests are
real behavior tests of the new error paths I introduced, not
coverage-game artifacts.

Backward compat: unchanged for any caller that doesn't wire the
responder deps. The factory at internal/connector/issuerfactory/factory.go
still calls local.New(&cfg, logger) with no responder wiring; OCSP
responses continue to be signed by the CA key directly until the
operator wires the deps. cmd/server/main.go wiring lands in Phase 3
alongside the CRL cache service.
2026-04-28 23:55:52 +00:00
shankar0123 30765ba1ed crl/cache: schema + repository for crl_cache + crl_generation_events
Phase 1 of the CRL/OCSP responder bundle. Adds:

  * migration 000019 — crl_cache (one row per issuer; pre-generated CRL DER,
    monotonic crl_number per RFC 5280 §5.2.3, this_update/next_update,
    generation duration metric, revoked_count) + crl_generation_events
    (append-only audit log of every regeneration attempt, succeeded
    + error fields for ops grep)
  * internal/domain/crl_cache.go — CRLCacheEntry + IsStale helper +
    CRLGenerationEvent (raw DER omitted from JSON to avoid bloating
    admin responses; CRLDERBase64 field for explicit transit shaping)
  * internal/repository/interfaces.go — CRLCacheRepository interface
    (Get / Put / NextCRLNumber / RecordGenerationEvent /
    ListGenerationEvents)
  * internal/repository/postgres/crl_cache.go — Postgres impl with
    SERIALIZABLE-isolated NextCRLNumber to defeat the monotonicity
    race between concurrent generations of the same issuer
  * internal/repository/postgres/crl_cache_test.go — testcontainers
    suite (round-trip, overwrite, monotonicity, event recording,
    failure-event-with-error)

No behavior change at the HTTP layer yet — Phase 3 wires the cache into
GetDERCRL via a new CRLCacheService + crlGenerationLoop.
2026-04-28 23:45:18 +00:00
shankar0123 2d61c64118 crypto/signer: fix QF1008 staticcheck — drop redundant .Curve selector
Lint-only fix; no behavior change. ecdsa.PublicKey embeds elliptic.Curve,
so Params() resolves through the embedded field directly. The original
k.Curve.Params() form was correct but flagged by staticcheck QF1008
('could remove embedded field Curve from selector').

Caught by CI #320 (golangci-lint step) after the merge of a318337 went
green on local 'go vet + go test'. Same class of incident as the
Bundle 9 ST1018 issue documented in CLAUDE.md::Operating Rules — the
'pre-commit verification gate' rule (run make verify, which includes
staticcheck) is the existing defense; the sandbox didn't have
golangci-lint pre-installed which is why this slipped past local
verification.
2026-04-28 22:09:49 +00:00
shankar0123 a3183378e1 crypto/signer: introduce Signer interface; refactor local issuer to use it
Load-bearing internal refactor with no user-visible behavior change.
Wraps the local issuer's CA private key behind a new signer.Signer
interface (embeds crypto.Signer + adds Algorithm()) so future PKCS#11,
cloud-KMS, and SSH-CA work each adds a new driver instead of three
separate refactors of the same call sites.

Behavior equivalence pinned by internal/crypto/signer/equivalence_test.go:
RSA byte-strict; ECDSA TBS-strict (signature differs by random k);
both signatures validate against the CA. Sentinel test proves the
checker would catch a regression. Coverage: signer 91.6%, local 86.5%
(above CI floor of 86; baseline was 86.7%, drop is mechanical from
deleting parsePrivateKey).

No new deps; stdlib only. Diffs to api/openapi.yaml, migrations/, and
internal/connector/issuer/interface.go are empty.
2026-04-28 22:04:11 +00:00
shankar0123 9039cef390 crypto/signer: introduce Signer interface; refactor local issuer to use it
This is a load-bearing internal refactor with no user-visible behavior
change. The new internal/crypto/signer package abstracts CA private-key
signing behind a Signer interface (embeds stdlib crypto.Signer + adds
Algorithm()). The local issuer now consumes this interface; the
historical c.caKey crypto.Signer field is renamed c.caSigner signer.Signer.

What landed:

  * internal/crypto/signer/ — new stdlib-only package
    - Signer interface: crypto.Signer + Algorithm()
    - Algorithm enum: RSA-2048, RSA-3072, RSA-4096, ECDSA-P256, ECDSA-P384
    - Driver interface: Load / Generate / Name
    - FileDriver: production driver, wraps file-on-disk PEM, hooks for
      DirHardener + Marshaler so the local package can inject Bundle 9
      keystore.ensureKeyDirSecure + keymem.marshalPrivateKeyAndZeroize
    - MemoryDriver: in-memory test driver; safe for concurrent use
    - parse.go: ParsePrivateKey moved here from local.go (PKCS#1, SEC 1, PKCS#8)
    - 91.6% coverage (gate ≥85)

  * internal/connector/issuer/local/local.go — refactor
    - Rename c.caKey crypto.Signer → c.caSigner signer.Signer
    - Rewire 4 signing call sites: leaf cert (line ~613), CRL (~849),
      OCSP response (~887), CA bootstrap (~482) — all access the
      interface; the bootstrap also switches to interface-level
      Public() + Signer
    - Wrap freshly-generated and freshly-loaded keys; reject Ed25519
      and other unsupported algorithms at load time (was silently
      accepted before, would have failed at first sign)
    - Delete the duplicated parsePrivateKey helper (single source of
      truth now lives in the signer package)
    - Update the L-014 threat-model comment block (lines 1-29) with a
      forward-reference paragraph: file-on-disk caveats apply only to
      FileDriver-backed signers; alternative drivers close that leg
    - Coverage 86.7 → 86.5 (above CI floor of 86); the 0.2pp drop is
      mechanical from deleting parsePrivateKey, partially recovered by
      a new test pinning the Wrap error path

  * internal/crypto/signer/equivalence_test.go — Phase 3 safety net
    - RSA byte-strict equality for leaf certs / CRLs / OCSP responses
      (PKCS#1 v1.5 is deterministic)
    - ECDSA TBS-strict equality (signature differs because of random k)
    - Both signatures independently validate against the CA
    - Negative sentinel proves the equivalence checker isn't trivially-
      passing

  * docs/architecture.md — new 'CA Signing Abstraction' section under
    Security Model, with ASCII diagram of FileDriver / MemoryDriver /
    future PKCS11Driver / future CloudKMSDriver

  * Test file mechanical edits (only):
    - bundle9_coverage_test.go: parsePrivateKey → signer.ParsePrivateKey
      (function moved, not behavior changed)
    - local_test.go: append one targeted test
      (TestSubCA_LoadCAFromDisk_RejectsUnsupportedKeyAlgorithm) that
      pins the new Wrap error path I introduced — recovers coverage
      cost of the deletion above

What did NOT change (verified empty diffs):
  * api/openapi.yaml
  * migrations/
  * internal/connector/issuer/interface.go
  * go.mod / go.sum (no new dependencies; stdlib only)

This refactor is the prerequisite for three downstream items:
  - PKCS#11/HSM driver (V3-Pro)
  - CRL/OCSP responder (V2)
  - SSH CA lifecycle (V2)

Each of those adds a new signing call site. Doing the abstraction now
costs once; deferring would cost three times.
2026-04-28 22:03:55 +00:00
shankar0123 f276d8c069 Merge chore/release-notes-hygiene: drop duplicated install block + retire hand-edited CHANGELOG 2026-04-28 16:09:38 +00:00
shankar0123 3247fbcf92 Release-notes hygiene: drop duplicated install block + retire hand-edited CHANGELOG
Triggered by Reddit feedback (sysadmin user complained that every
release page shows the same install instructions instead of what
actually changed). Two changes:

1) .github/workflows/release.yml: removed ~80 lines of hardcoded
   install/docker/helm boilerplate from the release body. Replaced
   with a single link to README.md#quick-start (the source of truth
   for install instructions). Kept the per-release supply-chain
   verification block (Cosign / SLSA / SBOM steps with the version
   baked into the commands) — that IS per-release-meaningful and the
   kind of content a security-conscious operator actually wants.
   generate_release_notes: true unchanged → GitHub auto-generates the
   'What's Changed' section from commits between this tag and the
   previous one.

2) CHANGELOG.md: replaced 1393-line hand-edited document with a
   one-paragraph stub pointing at GitHub Releases as the source of
   truth. The old CHANGELOG had drifted (everything since v2.2.0
   piled into [unreleased]; tags v2.0.55-v2.0.61 had no entries).
   A stale CHANGELOG is worse than no CHANGELOG — signals abandoned
   maintenance to operators doing security diligence. Auto-generated
   notes from commit messages work here because the project's commit
   message convention is already descriptive (see git log v2.0.50..HEAD
   for established pattern). Pre-v2.2.0 history preserved at the
   v2.2.0 git tag.

Net result: every future release page shows
  - 'What's Changed' (auto from commits, per-release-unique)
  - 'Verifying this release' (Cosign/SLSA verification, per-release-version)
  - One-line link to README install
…instead of the same 80-line install block on every release.

Verification:
  - python3 yaml.safe_load(.github/workflows/release.yml): OK
  - No internal references to CHANGELOG.md elsewhere in repo
    (grep README.md docs/ → empty)
  - Release-pipeline change is YAML-only; no Go code touched

Bundle: chore/release-notes-hygiene
2026-04-28 16:09:38 +00:00
shankar0123 c1aa0ebfa6 Merge feat/codeql-public-sast-baseline: add CodeQL workflow for public SAST signal 2026-04-28 15:10:40 +00:00
shankar0123 77b0452a2f Add CodeQL workflow — public SAST baseline in Security tab
Triggered by Reddit feedback (sysadmin user ran Aikido against the
public repo, reported critical command/file-inclusion findings, won't
deploy without seeing scanner-public credibility). Aikido's free tier
gates on OSI-approved licenses, which excludes BSL 1.1; CodeQL is
GitHub-native and free for public repos regardless of license.

Why CodeQL on top of the existing security-deep-scan.yml gosec /
osv-scanner / trivy / ZAP / semgrep / schemathesis / nuclei / testssl:
gosec is single-file pattern matching; CodeQL does interprocedural
taint tracking that catches the same vulnerability classes when input
is laundered through several function calls or struct fields. SARIF
results land in the public Security tab where any operator/security
team auditing certctl can see scan history and triage state without
asking.

Workflow shape
=================
  - Triggers: push to master, PR to master, weekly Sun 06:00 UTC
  - Matrix: go + javascript-typescript
  - Query suite: security-and-quality (security + maintainability,
    comparable to Aikido / SonarCloud scope)
  - Go version: 1.25.9 (matches ci.yml + release.yml + security-
    deep-scan.yml)
  - SARIF auto-uploads via codeql-action/analyze@v3 (implicit;
    populates Security → Code scanning tab)
  - permissions: contents:read + security-events:write + actions:read
  - Fail-fast: false (Go and JS analysis run independently)
  - Timeout: 30min

Suppressions for known-intentional findings (e.g., SSH connector's
InsecureIgnoreHostKey, ACME script-callout shell-out) get inline
codeql[<rule-id>] comments OR config-pack tweaks in a follow-up
commit, with the threat-model justification cited so external
readers see why the finding is intentional.

Verification
=================
  - python3 yaml.safe_load(.github/workflows/codeql.yml): OK
  - First run will surface in the Security tab on next push to master

Bundle: security/codeql-baseline
2026-04-28 15:10:40 +00:00
58 changed files with 8370 additions and 1691 deletions
+6 -1
View File
@@ -1037,7 +1037,11 @@ jobs:
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
run: |
set -e
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
# CRL/OCSP-Responder Phase 5 closed the getOCSPStatus orphan: the
# CertificateDetailPage Revocation Endpoints panel now consumes it
# ("Check OCSP status" button). Removed from the list to keep the
# docblock + guardrail honest.
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
MISSING=""
for fn in $DOCUMENTED; do
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
@@ -1271,6 +1275,7 @@ jobs:
CERTCTL_AUDIT_EXCLUDE_PATHS|
CERTCTL_TLS_|
CERTCTL_TLS_INSECURE_SKIP_VERIFY|
CERTCTL_SCEP_|
CERTCTL_SERVER_CA_BUNDLE_PATH|
CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY|
CERTCTL_QA_[A-Z_]+
+81
View File
@@ -0,0 +1,81 @@
name: CodeQL
# Public-facing SAST baseline that complements the existing security-deep-scan
# workflow (gosec, osv-scanner, trivy, ZAP, semgrep, schemathesis, nuclei,
# testssl) with cross-file Go and JavaScript dataflow analysis. Results land
# in the repository's Security → Code scanning tab as a public signal — any
# operator/security team auditing certctl can see the scan history and
# triage state without asking.
#
# Why CodeQL in addition to gosec:
# - gosec is single-file pattern matching (catches obvious issues like
# `os/exec.Command(userInput)`); CodeQL does interprocedural taint
# tracking (catches the same issue when the userInput is laundered
# through several function calls or struct fields).
# - GitHub-native; no third-party SaaS license gate (works for BSL 1.1
# and other source-available licenses, unlike Aikido / Snyk / SonarCloud
# free tiers which require OSI-approved licenses).
# - SARIF results auto-deduplicate and persist on PRs, so reviewers see
# "this PR introduces N new findings" rather than re-running ad hoc.
#
# Findings that are intentional (e.g., the SSH connector's
# InsecureIgnoreHostKey, ACME DNS solver's intentional shell-out to operator-
# supplied scripts) get suppressed via inline `// codeql[<rule-id>]`
# comments OR via a `.github/codeql/codeql-config.yml` query-pack tweak —
# document the rationale in the same commit that adds the suppression so
# the public scan-tab readers see the threat-model justification.
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
# Weekly Sunday 06:00 UTC, in addition to push/PR coverage. Catches
# rule-pack updates from CodeQL upstream (their Go/JS rulesets ship
# new queries on a roughly-monthly cadence).
- cron: '0 6 * * 0'
permissions:
contents: read
security-events: write # SARIF upload to GitHub code scanning
actions: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [go, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@v5
with:
# Match ci.yml + release.yml + security-deep-scan.yml.
go-version: '1.25.9'
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# Use the security-and-quality query suite — security finds plus
# maintainability/correctness issues that the smaller security-extended
# suite skips. Comparable scope to what Aikido / SonarCloud run.
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# SARIF upload is implicit (and is what populates the Security tab).
+11 -77
View File
@@ -334,75 +334,21 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Create release with notes
# generate_release_notes: true asks GitHub to auto-generate the
# "What's Changed" section from PRs+commits between this tag and the
# previous one. The hardcoded body below appends a per-release
# supply-chain verification block (Cosign / SLSA / SBOM steps with the
# current version baked into the commands) plus a single link to the
# README's Quick Start section for install/upgrade instructions.
# We deliberately do NOT duplicate install instructions here — the
# README is the source of truth for those, and inlining them in every
# release page produces the kind of "every release looks identical"
# noise that gives operators no signal about what actually changed.
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
body: |
## Installation
### Quick Install (Linux/macOS)
```bash
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
```
### Manual Binary Download
Download the appropriate binary for your OS and architecture:
- **Linux x86_64**: `certctl-agent-linux-amd64`
- **Linux ARM64**: `certctl-agent-linux-arm64`
- **macOS x86_64**: `certctl-agent-darwin-amd64`
- **macOS ARM64 (Apple Silicon)**: `certctl-agent-darwin-arm64`
Then make it executable and start the service:
```bash
chmod +x certctl-agent-linux-amd64
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
```
## Docker Images
Pull pre-built Docker images for server and agent:
```bash
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
```
Or use the latest tag:
```bash
docker pull ghcr.io/shankar0123/certctl-server:latest
docker pull ghcr.io/shankar0123/certctl-agent:latest
```
## Docker Compose Quick Start
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
cp deploy/.env.example deploy/.env
docker compose -f deploy/docker-compose.yml up -d
```
## Server Binaries
Pre-compiled server binaries are also available for direct installation:
- **Linux x86_64**: `certctl-server-linux-amd64`
- **Linux ARM64**: `certctl-server-linux-arm64`
- **macOS x86_64**: `certctl-server-darwin-amd64`
- **macOS ARM64 (Apple Silicon)**: `certctl-server-darwin-arm64`
## CLI & MCP Server Binaries
The `certctl-cli` (REST API wrapper) and `certctl-mcp-server` (Model Context
Protocol bridge) binaries ship for all four platforms as well:
- `certctl-cli-{linux,darwin}-{amd64,arm64}`
- `certctl-mcp-server-{linux,darwin}-{amd64,arm64}`
> **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.
## Verifying this release
@@ -463,15 +409,3 @@ jobs:
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
```
## Helm Chart
Deploy certctl to Kubernetes using Helm:
```bash
helm repo add certctl https://github.com/shankar0123/certctl/tree/master/deploy/helm
helm repo update
helm install certctl certctl/certctl
```
See `deploy/helm/certctl/` for values customization.
+29 -1436
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -115,8 +115,8 @@ gantt
| Capability | Standard | Notes |
|------------|----------|-------|
| DER-encoded X.509 CRL | RFC 5280 | Per-issuer, signed by issuing CA, 24h validity |
| Embedded OCSP responder | RFC 6960 | Good/revoked/unknown status per issuer |
| DER-encoded X.509 CRL | RFC 5280 | 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. |
| Embedded OCSP responder | RFC 6960 | 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. |
| S/MIME certificates | RFC 8551 | Email protection EKU, adaptive KeyUsage flags |
| Certificate export | — | PEM (JSON/file) and PKCS#12 formats |
| ACME DNS-PERSIST-01 | IETF draft | Standing validation record, no per-renewal DNS updates |
@@ -175,7 +175,7 @@ Built for **platform engineering and DevOps teams** managing 10500+ certifica
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices. S/MIME issuance with email protection EKU.
**Revocation.** Single and bulk revocation (by profile, owner, agent, or issuer). DER-encoded X.509 CRL per issuer, signed by the issuing CA. Embedded OCSP responder. RFC 5280 reason codes. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation.
**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.
+91
View File
@@ -696,6 +696,97 @@ paths:
"501":
description: Issuer does not support OCSP
/api/v1/admin/crl/cache:
get:
tags: [CRL & OCSP]
summary: Inspect CRL pre-generation cache (admin)
description: |
Returns the per-issuer CRL cache state populated by the
scheduler's crlGenerationLoop. One row per registered issuer
with `cache_present` indicating whether a CRL has ever been
generated, plus `is_stale` derived from `next_update` vs.
wall clock, plus the most recent generation events for
ops grep.
Admin-gated (M-003 pattern). Bundle CRL/OCSP-Responder Phase 5.
operationId: listCRLCache
responses:
"200":
description: Cache state per issuer
content:
application/json:
schema:
type: object
properties:
cache_rows:
type: array
items:
type: object
row_count:
type: integer
generated_at:
type: string
format: date-time
"403":
description: Admin access required
"500":
$ref: "#/components/responses/InternalError"
/.well-known/pki/ocsp/{issuer_id}:
post:
tags: [CRL & OCSP]
summary: OCSP responder (RFC 6960 §A.1.1, POST form)
description: |
Standard RFC 6960 §A.1.1 POST form of the OCSP responder. The
request body is the binary DER-encoded OCSPRequest with
Content-Type `application/ocsp-request`; the serial number is
carried inside that body, not in the URL path. Most production
OCSP clients (Firefox, OpenSSL `s_client -status`, cert-manager,
Microsoft Intune device validators) use POST exclusively.
The pre-existing GET form
(`/.well-known/pki/ocsp/{issuer_id}/{serial}`) is preserved for
ad-hoc curl inspection and human-readable URL paths; behaviour
and response are otherwise identical.
Auth-exempt under `/.well-known/pki/*` per RFC 8615 so relying
parties can poll without a certctl API key. CRL/OCSP-Responder
bundle Phase 4.
operationId: handleOCSPPost
security: []
parameters:
- name: issuer_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/ocsp-request:
schema:
type: string
format: binary
description: DER-encoded OCSPRequest per RFC 6960 §4.1
responses:
"200":
description: OCSP response
content:
application/ocsp-response:
schema:
type: string
format: binary
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"415":
description: Content-Type is not application/ocsp-request
"500":
$ref: "#/components/responses/InternalError"
"501":
description: Issuer does not support OCSP
# ─── Issuers ─────────────────────────────────────────────────────────
/api/v1/issuers:
get:
+255 -64
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"net"
@@ -25,6 +27,7 @@ import (
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository/postgres"
"github.com/shankar0123/certctl/internal/scheduler"
@@ -288,9 +291,38 @@ func main() {
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certificateRepo, profileRepo)
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
// Bundle CRL/OCSP-Responder: wire CRL cache + OCSP responder
// repositories. The CRL cache lets the HTTP CRL endpoint serve from
// pre-generated bytes (Phase 3). The OCSP responder repo lets the
// local issuer bootstrap a dedicated responder cert per RFC 6960
// §2.6 instead of signing OCSP with the CA key directly (Phase 2).
//
// The signer.FileDriver is the production driver; it provides keys
// to the responder bootstrap path. Future drivers (PKCS#11, cloud
// KMS) plug in via the same Driver interface without changing this
// wiring. The DirHardener / Marshaler hooks stay nil here — the
// bootstrap path's GenerateOutPath sets the destination per
// responder; the local issuer's existing keystore.ensureKeyDirSecure
// equivalent is invoked by FileDriver.Generate when DirHardener is
// supplied at the call site.
crlCacheRepo := postgres.NewCRLCacheRepository(db)
ocspResponderRepo := postgres.NewOCSPResponderRepository(db)
signerDriver := &signer.FileDriver{}
issuerRegistry.SetLocalIssuerDeps(&service.LocalIssuerDeps{
OCSPResponderRepo: ocspResponderRepo,
SignerDriver: signerDriver,
KeyDir: cfg.OCSPResponder.KeyDir,
RotationGrace: cfg.OCSPResponder.RotationGrace,
Validity: cfg.OCSPResponder.Validity,
})
crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger)
// Wire sub-services into CertificateService
certificateService.SetRevocationSvc(revocationSvc)
certificateService.SetCAOperationsSvc(caOperationsSvc)
// CRL cache makes GenerateDERCRL serve from the pre-generated cache
// instead of regenerating per request (CRL/OCSP-Responder Phase 4).
certificateService.SetCRLCacheSvc(crlCacheService)
certificateService.SetTargetRepo(targetRepo)
certificateService.SetJobRepo(jobRepo)
certificateService.SetKeygenMode(cfg.Keygen.Mode)
@@ -570,6 +602,19 @@ func main() {
// here alongside the other scheduler-interval setters so the
// documented env var actually takes effect.
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
// CRL/OCSP-Responder Phase 3: drive the crlGenerationLoop. The cache
// service walks every issuer in the registry, regenerates the CRL,
// and persists into crl_cache. The HTTP /.well-known/pki/crl/ handler
// reads from the cache via certificateService.GenerateDERCRL (which
// consults crlCacheService when wired). The loop is gated on the
// service being non-nil, mirroring how digestService and others are
// wired conditionally below.
sched.SetCRLCacheService(crlCacheService)
sched.SetCRLGenerationInterval(cfg.Scheduler.CRLGenerationInterval)
logger.Info("CRL pre-generation scheduler enabled",
"interval", cfg.Scheduler.CRLGenerationInterval.String())
if cfg.NetworkScan.Enabled {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
@@ -611,32 +656,43 @@ func main() {
// Build the API router with all handlers
apiRouter := router.New()
apiRouter.RegisterHandlers(router.HandlerRegistry{
Certificates: certificateHandler,
Issuers: issuerHandler,
Targets: targetHandler,
Agents: agentHandler,
Jobs: jobHandler,
Policies: policyHandler,
RenewalPolicies: renewalPolicyHandler,
Profiles: profileHandler,
Teams: teamHandler,
Owners: ownerHandler,
AgentGroups: agentGroupHandler,
Audit: auditHandler,
Notifications: notificationHandler,
Stats: statsHandler,
Metrics: metricsHandler,
Health: healthHandler,
Discovery: discoveryHandler,
NetworkScan: networkScanHandler,
Verification: verificationHandler,
Export: exportHandler,
Digest: *digestHandler,
HealthChecks: healthCheckHandler,
Certificates: certificateHandler,
Issuers: issuerHandler,
Targets: targetHandler,
Agents: agentHandler,
Jobs: jobHandler,
Policies: policyHandler,
RenewalPolicies: renewalPolicyHandler,
Profiles: profileHandler,
Teams: teamHandler,
Owners: ownerHandler,
AgentGroups: agentGroupHandler,
Audit: auditHandler,
Notifications: notificationHandler,
Stats: statsHandler,
Metrics: metricsHandler,
Health: healthHandler,
Discovery: discoveryHandler,
NetworkScan: networkScanHandler,
Verification: verificationHandler,
Export: exportHandler,
Digest: *digestHandler,
HealthChecks: healthCheckHandler,
BulkRevocation: bulkRevocationHandler,
BulkRenewal: bulkRenewalHandler,
BulkReassignment: bulkReassignmentHandler,
Version: versionHandler,
// CRL/OCSP-Responder Phase 5: admin observability endpoint
// for the scheduler-driven CRL pre-generation cache.
AdminCRLCache: handler.NewAdminCRLCacheHandler(
handler.NewAdminCRLCacheServiceImpl(crlCacheRepo, func() []string {
ids := make([]string, 0, issuerRegistry.Len())
for id := range issuerRegistry.List() {
ids = append(ids, id)
}
return ids
}),
),
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
@@ -669,52 +725,87 @@ func main() {
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
}
// Register SCEP (RFC 8894) handlers if enabled
// Register SCEP (RFC 8894) handlers if enabled.
//
// SCEP RFC 8894 Phase 1.5: multi-profile dispatch. Config.Validate()
// guarantees cfg.SCEP.Profiles is non-empty when cfg.SCEP.Enabled is true
// (the legacy single-profile flat fields are merged into Profiles[0] by
// the backward-compat shim in Load()). Each profile gets its own service
// + handler instance, registered at /scep (PathID="") or /scep/<PathID>.
if cfg.SCEP.Enabled {
// H-2 fix: fail closed at startup when SCEP is enabled without a
// challenge password configured. Previously the service-layer guard
// at internal/service/scep.go:72-79 skipped the password check when
// s.challengePassword == "", meaning any client that could reach the
// /scep endpoint could enroll an arbitrary CSR against the configured
// issuer (CWE-306, missing authentication for a critical function).
// Refuse to start instead: the operator must set
// CERTCTL_SCEP_CHALLENGE_PASSWORD (or disable SCEP) before the control
// plane can boot.
if err := preflightSCEPChallengePassword(cfg.SCEP.Enabled, cfg.SCEP.ChallengePassword); err != nil {
logger.Error(
"startup refused: SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is not set "+
"(would allow unauthenticated certificate enrollment, CWE-306). "+
"Set a non-empty challenge password or disable SCEP before restarting.",
"error", err,
// Iterate the profiles and build a {pathID -> handler} map for the
// router. Each profile triggers the same per-profile preflight gates
// (challenge password presence, RA pair validity, issuer reachability).
// Failures log the offending PathID so a multi-profile deploy can
// pinpoint which profile broke startup.
scepHandlers := make(map[string]handler.SCEPHandler, len(cfg.SCEP.Profiles))
for i, profile := range cfg.SCEP.Profiles {
profile := profile // shadow for closure-safety even though no closures escape
profileLog := logger.With(
"scep_profile_index", i,
"scep_profile_pathid", profile.PathID,
"scep_profile_issuer_id", profile.IssuerID,
)
os.Exit(1)
}
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
if !ok {
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
os.Exit(1)
}
// Bundle-4 / L-005: validate the issuer can actually serve a CA certificate
// at startup. Same rationale as EST above.
preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := preflightEnrollmentIssuer(preflightCtx, "SCEP", cfg.SCEP.IssuerID, issuerConn); err != nil {
// H-2 fix per profile: fail closed at startup when this profile has
// no challenge password. preflightSCEPChallengePassword stays
// unchanged; we just call it once per profile.
if err := preflightSCEPChallengePassword(true, profile.ChallengePassword); err != nil {
profileLog.Error(
"startup refused: SCEP profile has empty challenge password "+
"(would allow unauthenticated certificate enrollment, CWE-306). "+
"Set CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD or remove the profile.",
"error", err,
)
os.Exit(1)
}
// SCEP RFC 8894 Phase 1: per-profile RA cert/key preflight. Same
// six checks as the legacy single-profile path; reports the
// offending PathID via the profile-scoped logger.
if err := preflightSCEPRACertKey(true, profile.RACertPath, profile.RAKeyPath); err != nil {
profileLog.Error(
"startup refused: SCEP profile RA cert/key preflight failed "+
"(RFC 8894 §3.2.2 EnvelopedData + §3.3.2 CertRep require a per-profile RA pair). "+
"Generate the RA pair per docs/legacy-est-scep.md and set "+
"CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH + _RA_KEY_PATH for this profile.",
"error", err,
)
os.Exit(1)
}
issuerConn, ok := issuerRegistry.Get(profile.IssuerID)
if !ok {
profileLog.Error("SCEP profile issuer not found in registry")
os.Exit(1)
}
// Bundle-4 / L-005: validate the issuer can actually serve a CA
// certificate. Per profile, in case different profiles bind
// different issuers.
preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := preflightEnrollmentIssuer(preflightCtx, "SCEP", profile.IssuerID, issuerConn); err != nil {
preflightCancel()
profileLog.Error("startup refused: SCEP profile issuer cannot serve CA certificate", "error", err)
os.Exit(1)
}
preflightCancel()
logger.Error("startup refused: SCEP issuer cannot serve CA certificate", "error", err)
os.Exit(1)
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
scepService.SetProfileRepo(profileRepo)
if profile.ProfileID != "" {
scepService.SetProfileID(profile.ProfileID)
}
scepHandlers[profile.PathID] = handler.NewSCEPHandler(scepService)
endpoint := "/scep"
if profile.PathID != "" {
endpoint = "/scep/" + profile.PathID
}
profileLog.Info("SCEP profile enabled",
"endpoint", endpoint+"?operation={GetCACaps,GetCACert,PKIOperation}",
"challenge_password_set", profile.ChallengePassword != "",
"ra_cert_path", profile.RACertPath,
)
}
preflightCancel()
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
scepService.SetProfileRepo(profileRepo)
if cfg.SCEP.ProfileID != "" {
scepService.SetProfileID(cfg.SCEP.ProfileID)
}
scepHandler := handler.NewSCEPHandler(scepService)
apiRouter.RegisterSCEPHandlers(scepHandler)
apiRouter.RegisterSCEPHandlers(scepHandlers)
logger.Info("SCEP server enabled",
"issuer_id", cfg.SCEP.IssuerID,
"profile_id", cfg.SCEP.ProfileID,
"challenge_password_set", cfg.SCEP.ChallengePassword != "",
"endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}")
"profile_count", len(scepHandlers),
)
}
// Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/.
@@ -1051,6 +1142,106 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
return nil
}
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
// pattern; otherwise the checks are:
//
// 1. Both paths are non-empty (the Validate() refuse covers this too,
// but preflight reports the specific failure mode + os.Exit(1) so the
// operator sees a clear log line in addition to the config error).
// 2. The key file mode is 0600 (refuse world-/group-readable RA key —
// defense-in-depth against credential leak via a misconfigured
// deploy that leaves /etc/certctl/scep/*.key as 0644).
// 3. Cert PEM parses to exactly one x509.Certificate.
// 4. Key PEM parses to a Go crypto.Signer (RSA or ECDSA — RFC 8894
// §3.5.2 advertises those as the CMS-compatible algorithms).
// 5. The cert's PublicKey matches the key's Public() — refuses pairs
// accidentally swapped between profiles in a multi-profile config.
// 6. The cert's NotAfter is in the future — an expired RA cert would
// fail TLS handshake on EnvelopedData decryption per RFC 5652.
//
// Each check returns a wrapped error; the caller (main) is responsible for
// translating to a structured slog.Error + os.Exit(1) so the helper stays
// unit-testable without booting the full server.
func preflightSCEPRACertKey(enabled bool, raCertPath, raKeyPath string) error {
if !enabled {
return nil
}
if raCertPath == "" || raKeyPath == "" {
return fmt.Errorf("SCEP enabled but RA pair missing: " +
"set CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH " +
"(RFC 8894 §3.2.2 requires an RA pair so clients can encrypt the " +
"CSR to the RA cert and the server can sign the CertRep response)")
}
// File mode check FIRST so a world-readable key never gets read into the
// process address space. Ignored on Windows (Stat().Mode() doesn't carry
// POSIX bits there); the production deploy is Linux per the Dockerfile.
keyInfo, err := os.Stat(raKeyPath)
if err != nil {
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH stat failed: %w (path=%s)", err, raKeyPath)
}
mode := keyInfo.Mode().Perm()
if mode&0o077 != 0 {
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH has insecure permissions %#o; "+
"RA private key must be mode 0600 (owner read/write only) — "+
"chmod 0600 %s and restart", mode, raKeyPath)
}
certPEM, err := os.ReadFile(raCertPath)
if err != nil {
return fmt.Errorf("CERTCTL_SCEP_RA_CERT_PATH read failed: %w (path=%s)", err, raCertPath)
}
keyPEM, err := os.ReadFile(raKeyPath)
if err != nil {
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH read failed: %w (path=%s)", err, raKeyPath)
}
// tls.X509KeyPair validates that the cert + key parse, share an algorithm,
// and the cert's PublicKey matches the key's Public() — three of our six
// checks in a single stdlib call, so we use it rather than re-implementing.
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("RA cert/key pair invalid: %w "+
"(cert=%s key=%s) — verify the cert and key are matching halves of "+
"the same RA pair, both PEM-encoded, with the cert containing exactly "+
"one CERTIFICATE block and the key containing one PRIVATE KEY block",
err, raCertPath, raKeyPath)
}
if len(pair.Certificate) == 0 {
// Defensive — tls.X509KeyPair already errors on this, but the contract
// for the next x509.ParseCertificate call needs the slice non-empty.
return fmt.Errorf("RA cert PEM at %s contains no certificate blocks", raCertPath)
}
// Re-parse the leaf so we can read NotAfter + the public-key alg.
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
return fmt.Errorf("RA cert at %s does not parse as x509: %w", raCertPath, err)
}
if time.Now().After(leaf.NotAfter) {
return fmt.Errorf("RA cert at %s expired at %s — "+
"generate a fresh RA pair (the SCEP CertRep signature would be "+
"rejected by every conformant client)", raCertPath, leaf.NotAfter.Format(time.RFC3339))
}
// CMS-compatible public-key algorithm gate. RFC 8894 §3.5.2 advertises RSA
// and AES; the responder cert algorithm pertains to the signature scheme
// used on the CertRep, which means the cert's PublicKey must be RSA or
// ECDSA. Catches pre-shared Ed25519 dev keys that micromdm/scep clients
// reject.
switch leaf.PublicKeyAlgorithm {
case x509.RSA, x509.ECDSA:
// ok — supported by golang.org/x/crypto/ocsp + every SCEP client
default:
return fmt.Errorf("RA cert at %s uses unsupported public-key algorithm %s — "+
"RFC 8894 §3.5.2 CMS signing requires RSA or ECDSA",
raCertPath, leaf.PublicKeyAlgorithm)
}
return nil
}
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
// can actually serve a CA certificate. This closes audit finding L-005:
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
@@ -1104,7 +1295,7 @@ func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, i
// - /api/v1/* → auth (Bearer token required)
// - /assets/* → static file server (dashboard only)
// - anything else → SPA index.html fallback (dashboard only)
// OR apiHandler (no dashboard)
// OR apiHandler (no dashboard)
//
// EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network
// appliances) cannot present certctl Bearer tokens, so those endpoints must be
+227
View File
@@ -0,0 +1,227 @@
package main
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// SCEP RFC 8894 Phase 1: preflightSCEPRACertKey covers the six failure
// modes spelled out in the helper's docblock plus the no-op-when-disabled
// path. Mirrors TestPreflightEnrollmentIssuer's table-driven shape so the
// suite stays uniform for the next reviewer.
//
// Each test materialises a real ECDSA P-256 cert/key pair on disk (rather
// than mocking) so the tls.X509KeyPair path is exercised end-to-end —
// catches drift in stdlib cert-parsing semantics that a mock would hide.
func TestPreflightSCEPRACertKey_Disabled_NoOp(t *testing.T) {
// Enabled=false short-circuits before any path validation; should pass
// even with empty paths (mirrors preflightSCEPChallengePassword).
if err := preflightSCEPRACertKey(false, "", ""); err != nil {
t.Fatalf("disabled SCEP returned error: %v", err)
}
}
func TestPreflightSCEPRACertKey_EnabledMissingPaths_Refuses(t *testing.T) {
// Validate() also catches this; preflight reports the specific failure
// with a more actionable error string + os.Exit(1) at the call site.
cases := []struct {
name string
certPath string
keyPath string
}{
{"both_empty", "", ""},
{"cert_only", "/tmp/ra.crt", ""},
{"key_only", "", "/tmp/ra.key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := preflightSCEPRACertKey(true, tc.certPath, tc.keyPath)
if err == nil {
t.Fatalf("expected error for missing paths, got nil")
}
if !strings.Contains(err.Error(), "RA pair missing") {
t.Errorf("error should mention RA pair missing, got: %v", err)
}
})
}
}
func TestPreflightSCEPRACertKey_KeyWorldReadable_Refuses(t *testing.T) {
// Defense-in-depth: even a perfectly-valid RA pair must be rejected if
// the key file is mode 0644 (world-readable). The deploy convention is
// 0600 — owner read/write only.
dir := t.TempDir()
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
// Re-chmod the key to 0644 to trigger the gate.
if err := os.Chmod(keyPath, 0o644); err != nil {
t.Fatalf("chmod failed: %v", err)
}
err := preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for world-readable key, got nil")
}
if !strings.Contains(err.Error(), "insecure permissions") {
t.Errorf("error should mention insecure permissions, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_ValidPair_Accepts(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
if err := preflightSCEPRACertKey(true, certPath, keyPath); err != nil {
t.Fatalf("valid RA pair rejected: %v", err)
}
}
func TestPreflightSCEPRACertKey_ExpiredCert_Refuses(t *testing.T) {
// An RA cert past NotAfter would cause every conformant SCEP client to
// reject the CertRep signature. Catch it at startup.
dir := t.TempDir()
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(-1*time.Hour))
err := preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for expired cert, got nil")
}
if !strings.Contains(err.Error(), "expired") {
t.Errorf("error should mention expired, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_MismatchedPair_Refuses(t *testing.T) {
// tls.X509KeyPair detects the cert/key mismatch; preflight should
// surface it with an actionable error (cert + key are halves of
// different RA pairs — common multi-profile typo).
dir := t.TempDir()
certPath, _ := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
_, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
// Re-write the key path under a unique name to avoid collision with
// the first pair's file (writeECDSARAPair would have overwritten).
err := preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for mismatched pair, got nil")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error should mention invalid pair, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_MissingFiles_Refuses(t *testing.T) {
// Both files referenced but neither exists — a typo or a fresh deploy
// where the operator forgot to mount the secret. Cert-path failure mode
// is checked first because key-path stat is the first os call after
// the empty-string check.
dir := t.TempDir()
missingCert := filepath.Join(dir, "ra.crt")
missingKey := filepath.Join(dir, "ra.key")
err := preflightSCEPRACertKey(true, missingCert, missingKey)
if err == nil {
t.Fatalf("expected error for missing files, got nil")
}
if !strings.Contains(err.Error(), "stat failed") && !strings.Contains(err.Error(), "read failed") {
t.Errorf("error should mention stat/read failure, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_UnsupportedAlg_Refuses(t *testing.T) {
// Ed25519 isn't supported by the CMS signature path RFC 8894 §3.5.2
// advertises. Catch this at startup to avoid runtime failures the
// first time a client sends a real PKIMessage.
dir := t.TempDir()
certPath := filepath.Join(dir, "ra.crt")
keyPath := filepath.Join(dir, "ra.key")
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "ra-ed25519"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(30 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
err = preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for ed25519 RA cert, got nil")
}
if !strings.Contains(err.Error(), "unsupported public-key algorithm") &&
!strings.Contains(err.Error(), "invalid") {
// tls.X509KeyPair may reject ed25519 SCEP-signing keys earlier
// than our explicit alg gate; accept either failure path so the
// test is robust against stdlib changes.
t.Errorf("error should mention algorithm/invalid, got: %v", err)
}
}
// writeECDSARAPair generates a fresh ECDSA P-256 self-signed cert + key,
// writes them to dir/ra-<rand>.crt + ra-<rand>.key with the cert at 0644
// and the key at 0600 (the production deploy mode). Returns the two paths.
func writeECDSARAPair(t *testing.T, dir string, notAfter time.Time) (certPath, keyPath string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: "ra-test"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
// Use a unique suffix so successive calls within the same test don't
// overwrite each other (the mismatched-pair test relies on this).
suffix := tmpl.SerialNumber.String()
certPath = filepath.Join(dir, "ra-"+suffix+".crt")
keyPath = filepath.Join(dir, "ra-"+suffix+".key")
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certPath, keyPath
}
+489
View File
@@ -0,0 +1,489 @@
//go:build integration
// Package integration_test — CRL/OCSP-Responder Bundle Phase 6 e2e.
//
// Verifies the full revocation-status flow against a live stack:
// 1. Issue a cert via the local issuer.
// 2. Fetch the OCSP response for that cert's serial — expect Good.
// 3. Revoke the cert via the standard revoke endpoint.
// 4. Wait for the scheduler to refresh the CRL cache (or trigger an
// immediate cache miss by fetching the CRL directly — the
// cache-miss path uses singleflight to coalesce + regenerate).
// 5. Fetch the CRL — assert the cert's serial is in the revocation list.
// 6. Fetch the OCSP response again — expect Revoked.
// 7. Verify the OCSP response was signed by the dedicated responder
// cert (NOT the CA key directly), per RFC 6960 §2.6.
// 8. Verify the responder cert carries id-pkix-ocsp-nocheck (RFC 6960
// §4.2.2.2.1).
//
// Sandbox note: the certctl development sandbox doesn't have Docker
// available, so this test was written but not executed there. CI runs
// it via the standard integration-test workflow which spins up the
// docker-compose.test.yml stack. Run locally:
//
// cd deploy && docker compose -f docker-compose.test.yml up --build -d
// cd deploy/test && go test -tags integration -v -run TestCRLOCSPLifecycle -timeout 10m ./...
package integration_test
import (
"crypto/x509"
"encoding/asn1"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"testing"
"time"
"golang.org/x/crypto/ocsp"
)
// ---------------------------------------------------------------------------
// Test-stack-specific identifiers — match deploy/docker-compose.test.yml's
// seed data + migrations/seed.sql. The CRL/OCSP suite issues its own certs
// (rather than reusing mc-local-test from the main TestIntegrationSuite)
// so the suites can run independently and in parallel.
// ---------------------------------------------------------------------------
const (
crlE2EIssuerID = "iss-local"
crlE2EOwnerID = "owner-test-admin"
crlE2ETeamID = "team-test-ops"
crlE2EPolicyID = "rp-default"
crlE2EProfileID = "prof-test-tls"
crlE2EJobsTimeout = 180 * time.Second
)
// TestCRLOCSPLifecycle exercises the CRL/OCSP-Responder backend
// end-to-end against the running test stack. Skipped in -short.
func TestCRLOCSPLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("integration only")
}
// Boot-state preconditions — assumes docker-compose.test.yml is
// up; the existing integration_test.go tests rely on the same
// invariant. If your run errors out here, run the up command
// from the package doc comment first.
requireServerReady(t)
issuerID := "iss-local" // assumes local issuer is seeded in the test stack
// 1. Issue a cert. Reuses the existing helper from integration_test.go
// (issueCertificateAgainstLocal).
cert, certPEM, certSerial := issueLocalCert(t, "crl-ocsp-e2e.example.com")
t.Logf("issued cert serial=%s", certSerial)
// 2. Fetch OCSP for the fresh cert — expect Good.
resp1, responder1 := fetchOCSP(t, issuerID, certSerial)
if resp1.Status != ocsp.Good {
t.Fatalf("pre-revoke OCSP status = %d, want Good (0)", resp1.Status)
}
if !certHasOCSPNoCheck(responder1) {
t.Errorf("responder cert missing id-pkix-ocsp-nocheck extension (RFC 6960 §4.2.2.2.1)")
}
if responder1.Subject.CommonName == cert.Issuer.CommonName {
t.Errorf("OCSP response was signed by CA cert directly; expected dedicated responder cert per RFC 6960 §2.6")
}
// 3. Revoke the cert via the standard API.
revokeCertViaAPI(t, certSerial, "key_compromise")
// 4. Trigger the cache-miss path by fetching CRL directly.
// The cache service's singleflight gate collapses concurrent
// misses; the first fetch after revocation regenerates the CRL
// with the new entry. (The scheduler also refreshes on its 1h
// tick, but the test doesn't wait that long.)
time.Sleep(2 * time.Second) // allow scheduler debounce
crl := fetchCRL(t, issuerID)
if !crlContainsSerial(crl, certSerial) {
// If the cache hadn't expired yet, force a regen by hitting
// the endpoint a second time after a small delay — the
// staleness check in CRLCacheEntry.IsStale flips on
// next_update.
time.Sleep(3 * time.Second)
crl = fetchCRL(t, issuerID)
if !crlContainsSerial(crl, certSerial) {
t.Fatalf("revoked serial %s not present in CRL after wait", certSerial)
}
}
t.Logf("CRL contains revoked serial %s", certSerial)
// 5. Fetch OCSP again — expect Revoked.
resp2, _ := fetchOCSP(t, issuerID, certSerial)
if resp2.Status != ocsp.Revoked {
t.Fatalf("post-revoke OCSP status = %d, want Revoked (1)", resp2.Status)
}
t.Logf("OCSP shows revoked, reason=%d", resp2.RevocationReason)
// 6. Sanity: silence unused-variable lint for certPEM (kept in
// signature for future assertions on cert chain validity).
_ = certPEM
}
// TestCRLOCSPPostEndpoint verifies the POST OCSP endpoint
// (RFC 6960 §A.1.1) accepts a binary OCSPRequest body. Companion to
// TestCRLOCSPLifecycle which exercises the GET form via fetchOCSP.
func TestCRLOCSPPostEndpoint(t *testing.T) {
if testing.Short() {
t.Skip("integration only")
}
requireServerReady(t)
cert, _, certSerial := issueLocalCert(t, "post-ocsp-e2e.example.com")
caCert := fetchCACert(t, "iss-local")
ocspReq, err := ocsp.CreateRequest(cert, caCert, nil)
if err != nil {
t.Fatalf("CreateRequest: %v", err)
}
url := serverBaseURL(t) + "/.well-known/pki/ocsp/iss-local"
httpReq, err := http.NewRequest(http.MethodPost, url, strings.NewReader(string(ocspReq)))
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
httpReq.Header.Set("Content-Type", "application/ocsp-request")
httpResp, err := httpClient(t).Do(httpReq)
if err != nil {
t.Fatalf("POST OCSP: %v", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(httpResp.Body)
t.Fatalf("POST OCSP: status %d, body=%s", httpResp.StatusCode, body)
}
respBytes, _ := io.ReadAll(httpResp.Body)
parsed, err := ocsp.ParseResponse(respBytes, caCert)
if err != nil {
t.Fatalf("ParseResponse: %v", err)
}
if parsed.SerialNumber.Cmp(cert.SerialNumber) != 0 {
t.Errorf("POST OCSP response serial mismatch: got %v, want %v",
parsed.SerialNumber, cert.SerialNumber)
}
t.Logf("POST OCSP returned status=%d for serial=%s", parsed.Status, certSerial)
}
// ---------------------------------------------------------------------------
// Helpers — these wrap the existing integration_test.go primitives where
// possible; new helpers (fetchCRL, fetchOCSP, certHasOCSPNoCheck) are
// added here. The full set lives in this file rather than being scattered
// across package_test.go to keep the e2e suite self-contained per the
// existing convention.
// ---------------------------------------------------------------------------
// crlE2ECert tracks the certctl-side ID + the parsed leaf together. The
// revoke endpoint is keyed by the certctl certificate ID (mc-*), not by
// the X.509 serial — so the test threads both through the helpers.
type crlE2ECert struct {
CertctlID string // e.g. "mc-crl-e2e-<n>"
Leaf *x509.Certificate // parsed leaf
HexSerial string // lowercase hex of Leaf.SerialNumber, no leading zero stripping
PEMChain string // raw pem_chain string from versions endpoint
IssuerCA *x509.Certificate // parsed issuer CA (chain[1] when present, else chain[0])
}
// crlE2ECerts holds the in-flight cert-ID → cert mapping so revokeCertViaAPI
// can resolve the hex serial back to the certctl cert ID. Populated by
// issueLocalCert. Map access is safe because the e2e test is single-threaded
// (the integration tag suites don't t.Parallel()).
var crlE2ECerts = map[string]*crlE2ECert{}
// issueLocalCert issues a cert against the test-stack's local issuer and
// returns the parsed leaf + raw PEM chain + hex serial. Wires through the
// existing integration_test.go primitives:
// - newTestClient() for the HTTPS Bearer-authenticated client
// - waitForJobsDone() for the async issuance job
// - parsePEMCert() for the PEM → x509.Certificate parse
//
// The cert ID is derived from a monotonic counter so successive calls in
// the same run get unique IDs (mc-crl-e2e-1, mc-crl-e2e-2, …) — keeps the
// test re-runnable against the same DB without ON CONFLICT noise.
func issueLocalCert(t *testing.T, commonName string) (cert *x509.Certificate, certPEM string, hexSerial string) {
t.Helper()
c := newTestClient()
certID := fmt.Sprintf("mc-crl-e2e-%d", len(crlE2ECerts)+1)
body := fmt.Sprintf(`{
"id": %q,
"name": %q,
"common_name": %q,
"sans": [%q],
"issuer_id": %q,
"owner_id": %q,
"team_id": %q,
"renewal_policy_id": %q,
"certificate_profile_id": %q,
"environment": "test"
}`, certID, certID, commonName, commonName,
crlE2EIssuerID, crlE2EOwnerID, crlE2ETeamID, crlE2EPolicyID, crlE2EProfileID)
resp, err := c.Post("/api/v1/certificates", body)
if err != nil {
t.Fatalf("issueLocalCert: POST /certificates: %v", err)
}
if resp.StatusCode/100 != 2 {
t.Fatalf("issueLocalCert: POST status %d, body=%s", resp.StatusCode, readBody(resp))
}
resp.Body.Close()
// Trigger issuance + wait for the job to finish.
resp, err = c.Post("/api/v1/certificates/"+certID+"/renew", "")
if err != nil {
t.Fatalf("issueLocalCert: POST renew: %v", err)
}
resp.Body.Close()
waitForJobsDone(t, c, certID, crlE2EJobsTimeout)
// Pull the freshly-issued version.
resp, err = c.Get("/api/v1/certificates/" + certID + "/versions")
if err != nil {
t.Fatalf("issueLocalCert: GET versions: %v", err)
}
rawBody := readBody(resp)
var versions []certVersion
if err := json.Unmarshal([]byte(rawBody), &versions); err != nil {
// Versions endpoint may use the paged envelope.
var pr pagedResponse
if err := json.Unmarshal([]byte(rawBody), &pr); err != nil {
t.Fatalf("issueLocalCert: decode versions: %v (body: %s)", err, rawBody)
}
if err := json.Unmarshal(pr.Data, &versions); err != nil {
t.Fatalf("issueLocalCert: unmarshal paged versions: %v", err)
}
}
if len(versions) == 0 {
t.Fatalf("issueLocalCert: no versions returned for %s", certID)
}
v := versions[0]
if v.PEMChain == "" {
t.Fatalf("issueLocalCert: empty pem_chain on version %s", v.ID)
}
leaf, issuerCA := parsePEMChain(t, v.PEMChain)
hex := strings.ToLower(leaf.SerialNumber.Text(16))
crlE2ECerts[hex] = &crlE2ECert{
CertctlID: certID,
Leaf: leaf,
HexSerial: hex,
PEMChain: v.PEMChain,
IssuerCA: issuerCA,
}
return leaf, v.PEMChain, hex
}
// parsePEMChain decodes a leaf || issuer || ... PEM bundle. Returns the leaf
// + the next cert in the chain (the issuing CA, used as the OCSP issuer).
// If the chain has only one cert (self-signed test root), returns it twice.
func parsePEMChain(t *testing.T, chainPEM string) (leaf, issuer *x509.Certificate) {
t.Helper()
rest := []byte(chainPEM)
var certs []*x509.Certificate
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parsePEMChain: %v", err)
}
certs = append(certs, c)
}
if len(certs) == 0 {
t.Fatalf("parsePEMChain: no certificates decoded from chain")
}
leaf = certs[0]
if len(certs) >= 2 {
issuer = certs[1]
} else {
issuer = certs[0] // self-signed test root
}
return leaf, issuer
}
// revokeCertViaAPI calls POST /api/v1/certificates/{id}/revoke. The certctl
// API keys revocation by certctl cert ID (mc-*), not by X.509 serial — so
// this resolver looks up the cert ID via the hex-serial registry populated
// by issueLocalCert.
func revokeCertViaAPI(t *testing.T, hexSerial string, reason string) {
t.Helper()
entry, ok := crlE2ECerts[strings.ToLower(hexSerial)]
if !ok {
t.Fatalf("revokeCertViaAPI: no certctl ID registered for serial %s — call issueLocalCert first", hexSerial)
}
c := newTestClient()
body := fmt.Sprintf(`{"reason": %q}`, reason)
resp, err := c.Post("/api/v1/certificates/"+entry.CertctlID+"/revoke", body)
if err != nil {
t.Fatalf("revokeCertViaAPI: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
t.Fatalf("revokeCertViaAPI: POST status %d, body=%s", resp.StatusCode, readBody(resp))
}
}
// fetchCRL hits GET /.well-known/pki/crl/{issuer_id} and returns the
// parsed RevocationList. Asserts 200 + content-type.
func fetchCRL(t *testing.T, issuerID string) *x509.RevocationList {
t.Helper()
url := serverBaseURL(t) + "/.well-known/pki/crl/" + issuerID
resp, err := httpClient(t).Get(url)
if err != nil {
t.Fatalf("fetchCRL Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("fetchCRL: status %d, body=%s", resp.StatusCode, body)
}
body, _ := io.ReadAll(resp.Body)
crl, err := x509.ParseRevocationList(body)
if err != nil {
t.Fatalf("ParseRevocationList: %v", err)
}
return crl
}
// fetchOCSP hits the GET form of the OCSP endpoint (the POST form is
// exercised separately in TestCRLOCSPPostEndpoint). Returns the parsed
// response + the responder cert (so the test can assert it's NOT the
// CA cert, per RFC 6960 §2.6).
func fetchOCSP(t *testing.T, issuerID, hexSerial string) (*ocsp.Response, *x509.Certificate) {
t.Helper()
url := fmt.Sprintf("%s/.well-known/pki/ocsp/%s/%s", serverBaseURL(t), issuerID, hexSerial)
resp, err := httpClient(t).Get(url)
if err != nil {
t.Fatalf("fetchOCSP Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("fetchOCSP: status %d, body=%s", resp.StatusCode, body)
}
body, _ := io.ReadAll(resp.Body)
caCert := fetchCACert(t, issuerID)
parsed, err := ocsp.ParseResponse(body, caCert)
if err != nil {
t.Fatalf("ParseResponse: %v", err)
}
return parsed, parsed.Certificate
}
// fetchCACert returns the issuing CA certificate for the given issuer.
//
// Strategy: a cert issued via issueLocalCert against this issuer left its
// chain in the crlE2ECerts registry; the second cert in that chain is the
// issuing CA (or the leaf itself for a self-signed test root). This
// avoids a dependency on a /.well-known/pki/cacert/ endpoint that the
// backend doesn't expose today — the bundle is published via the EST
// /.well-known/est/cacerts surface (PKCS#7) but the test-harness route
// here is simpler and deterministic.
//
// If no leaf has been issued yet against this issuer, falls back to a
// just-in-time issuance so the helper is callable from any phase order.
func fetchCACert(t *testing.T, issuerID string) *x509.Certificate {
t.Helper()
for _, entry := range crlE2ECerts {
if entry.IssuerCA != nil && entry.Leaf.Issuer.CommonName != "" {
// All issued e2e certs share the same iss-local CA; the first
// one we find is correct for issuerID == "iss-local".
if issuerID == crlE2EIssuerID || strings.HasPrefix(issuerID, "iss-local") {
return entry.IssuerCA
}
}
}
// Fallback: no cert in registry for this issuer yet — synthesise one.
_, _, _ = issueLocalCert(t, fmt.Sprintf("cacert-bootstrap-%d.example.com", time.Now().UnixNano()))
for _, entry := range crlE2ECerts {
if entry.IssuerCA != nil {
return entry.IssuerCA
}
}
t.Fatalf("fetchCACert: no CA cert resolvable for issuer %s after bootstrap", issuerID)
return nil
}
// crlContainsSerial returns true if the parsed CRL has an entry for
// the given hex-encoded serial.
func crlContainsSerial(crl *x509.RevocationList, hexSerial string) bool {
target := new(big.Int)
target.SetString(hexSerial, 16)
for _, entry := range crl.RevokedCertificateEntries {
if entry.SerialNumber.Cmp(target) == 0 {
return true
}
}
return false
}
// certHasOCSPNoCheck returns true if the cert carries the
// id-pkix-ocsp-nocheck extension (OID 1.3.6.1.5.5.7.48.1.5) per
// RFC 6960 §4.2.2.2.1.
func certHasOCSPNoCheck(cert *x509.Certificate) bool {
if cert == nil {
return false
}
oid := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
for _, ext := range cert.Extensions {
if ext.Id.Equal(oid) {
return true
}
}
return false
}
// requireServerReady polls /health until it returns 200, or t.Fatals after
// 30s. The endpoint is unauthenticated (router.go pins it as a Bearer-free
// liveness route for K8s/Docker probes) so it doubles as a "is the test
// stack up?" probe before the suite makes its first authenticated call.
func requireServerReady(t *testing.T) {
t.Helper()
client := newUnauthHTTPClient()
deadline := time.Now().Add(30 * time.Second)
url := serverURL + "/health"
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("requireServerReady: %s never returned 200 within 30s — is the test stack up? (run `docker compose -f deploy/docker-compose.test.yml up -d` first)", url)
}
// serverBaseURL returns the server URL configured by the integration
// harness (CERTCTL_TEST_SERVER_URL, defaulting to https://localhost:8443
// per deploy/docker-compose.test.yml).
func serverBaseURL(t *testing.T) string {
t.Helper()
return serverURL
}
// httpClient returns the unauthenticated TLS-trust-aware client from the
// integration harness. The /.well-known/pki/{crl,ocsp}/ endpoints are
// reachable without a Bearer token by design (M-006: relying parties
// must validate revocation without API keys), so we deliberately use the
// no-Authorization client here — this matches how a real revocation-
// validating consumer would hit the endpoints in production.
func httpClient(t *testing.T) *http.Client {
t.Helper()
return newUnauthHTTPClient()
}
+27 -1
View File
@@ -817,6 +817,32 @@ The control plane only handles public material: certificates, chains, and CSRs.
**Server keygen mode (`CERTCTL_KEYGEN_MODE=server`, demo only):** The control plane generates RSA-2048 keys server-side within `processRenewalServerKeygen`. Private keys are stored in `certificate_versions.csr_pem`. A log warning is emitted at startup. Use only for Local CA development/demo.
### CA Signing Abstraction
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.
```
┌─────────────────────────────────┐
│ signer.Driver (pluggable) │
├─────────────────────────────────┤
internal/connector/issuer/local │ signer.FileDriver (default) │
c.caSigner signer.Signer ──────────► │ PEM key on disk │
│ │
│ signer.MemoryDriver (tests) │
│ in-memory only │
│ │
│ 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.
Behavior equivalence between the wrapped Signer and the raw `crypto.Signer` is pinned by `internal/crypto/signer/equivalence_test.go`: RSA signing is byte-strict equal (PKCS#1 v1.5 is deterministic), ECDSA signing is structurally equal (TBSCertificate / TBSRevocationList byte-equal; signature value differs because ECDSA uses random `k`).
### Authentication
- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode. Applies to every path under `/api/v1/*`.
@@ -955,7 +981,7 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST
- **Additional filters**: `?agent_id=`, `?profile_id=` (in addition to existing status, environment, owner_id, team_id, issuer_id).
- **Deployments**: `GET /api/v1/certificates/{id}/deployments` returns deployment targets for a certificate.
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. The DER-encoded X.509 CRL signed by the issuing CA is served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`). The embedded OCSP responder serves signed responses unauthenticated at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`). Both endpoints are accessible to relying parties with no certctl API credentials, as RFC-compliant PKI consumers expect. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. The DER-encoded X.509 CRL signed by the issuing CA is served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5 + RFC 8615, `Content-Type: application/pkix-crl`); the CRL is pre-generated by the scheduler-driven `crlGenerationLoop` and persisted in the `crl_cache` table (migration 000019) so HTTP fetches do not rebuild per request. The embedded OCSP responder serves signed responses unauthenticated at both `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` and `POST /.well-known/pki/ocsp/{issuer_id}` (RFC 6960 §A.1.1, `Content-Type: application/ocsp-response`); responses are signed by a per-issuer dedicated OCSP responder cert (RFC 6960 §2.6, migration 000020) carrying the `id-pkix-ocsp-nocheck` extension (RFC 6960 §4.2.2.2.1) — the CA private key is never used directly for OCSP signing, which keeps it cold for the future PKCS#11/HSM driver path. The responder cert auto-rotates within `CERTCTL_OCSP_RESPONDER_ROTATION_GRACE` (default 7d) of expiry. Both endpoints are accessible to relying parties with no certctl API credentials, as RFC-compliant PKI consumers expect. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation. See [`crl-ocsp.md`](crl-ocsp.md) for the operator + relying-party guide (endpoint URLs, configuration knobs, responder cert lifecycle, cert-manager / Firefox / OpenSSL / Intune integration recipes, troubleshooting).
Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format.
+2 -2
View File
@@ -218,9 +218,9 @@ certctl implements revocation using three complementary mechanisms:
**Bulk Revocation** (Fleet-Level Incident Response): For large-scale incidents like CA compromise or team infrastructure decommissioning, `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria in a single operation. Filter by profile, owner, team, agent group, or issuer to target the affected certificate set. This is essential for incident response — instead of revoking certificates one-by-one, operators can revoke an entire fleet in minutes. Bulk revocation creates individual revocation jobs that reuse the existing revocation pipeline, ensuring every certificate is audited and notifications are sent.
**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`.
**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 at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960). Like the CRL endpoint, it is unauthenticated and returns signed OCSP responses (good, revoked, or unknown) with `Content-Type: application/ocsp-response`, so clients can verify certificate status without downloading the full CRL.
**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.
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.
+329
View File
@@ -0,0 +1,329 @@
# CRL & OCSP — Revocation Status for Relying Parties
This guide is the operator + relying-party reference for certctl's revocation
status surfaces. It covers the wire format, endpoint URLs, configuration knobs,
the OCSP responder cert lifecycle, and how to point common consumers
(cert-manager, Firefox, OpenSSL) at the endpoints.
If you're looking for the higher-level architecture, see
[`architecture.md` § Security Model](architecture.md#security-model). If you're
looking for the revocation policy / reason codes the API accepts, see
[`api/openapi.yaml` § /certificates/{id}/revoke](../api/openapi.yaml).
---
## Conceptual overview
**Why two formats.** RFC 5280 §5 defines a Certificate Revocation List (CRL)
— a periodically-published, signed list of every revoked certificate for an
issuer. RFC 6960 defines the Online Certificate Status Protocol (OCSP) — a
request/response protocol that returns the status of a single certificate by
serial number. CRLs are batch-friendly and cacheable; OCSP is point-query and
fresh. Production PKI deployments serve both because different relying parties
prefer different trade-offs:
- Browsers (Firefox / Safari) prefer OCSP for freshness; some pin OCSP
stapling.
- cert-manager and most Linux TLS clients fall back to CRL when OCSP is
unreachable.
- Microsoft Intune / corporate device-state validators do periodic CRL pulls.
- OpenSSL `s_client -status` exercises OCSP via the `Certificate Status
Request` extension during the handshake.
certctl's local issuer publishes both, with a pre-generation cache so a busy
CA does not DOS itself rebuilding the CRL on every fetch.
**Why a separate OCSP responder cert.** RFC 6960 §2.6 + §4.2.2.2 strongly
recommend that OCSP responses be signed by a delegated "OCSP responder cert"
issued by the CA, NOT by the CA private key directly. The responder cert
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 revocation status. This
keeps the CA private key cold (an HSM operation per OCSP request would be
prohibitive at scale) and lets the responder key live on disk, on a separate
HSM partition, or rotate frequently while the CA key stays untouched.
---
## Endpoints
All revocation endpoints live under `/.well-known/pki/` per RFC 8615 and run
**unauthenticated** — relying parties without certctl API credentials must be
able to validate revocation status. The HTTPS-only TLS 1.3 control plane
applies; there is no plaintext fallback.
### CRL — Certificate Revocation List
```
GET https://<host>/.well-known/pki/crl/{issuer_id}
```
| Field | Value |
| --- | --- |
| Method | `GET` |
| Auth | None (unauthenticated, RFC 5280 §5 distribution semantics) |
| Response Content-Type | `application/pkix-crl` |
| Response body | DER-encoded X.509 CRL signed by the issuer's CA |
| Cache | Pre-generated by the scheduler; configurable interval |
Example:
```bash
curl --cacert ca.crt \
-o crl.der \
https://localhost:8443/.well-known/pki/crl/iss-local
openssl crl -inform DER -in crl.der -text -noout
```
### OCSP — Online Certificate Status Protocol
certctl serves both the GET form (RFC 6960 §A.1.1, simple URL-path lookup)
and the POST form (RFC 6960 §A.1.1, binary OCSPRequest body). Most
production OCSP clients (Firefox, OpenSSL `s_client -status`, cert-manager,
Intune) use POST. The GET form is preserved for ops curl-debugging.
#### GET form
```
GET https://<host>/.well-known/pki/ocsp/{issuer_id}/{serial_hex}
```
| Field | Value |
| --- | --- |
| Method | `GET` |
| Auth | None |
| Response Content-Type | `application/ocsp-response` |
| Response body | DER-encoded OCSPResponse signed by the **OCSP responder cert** (NOT the CA cert) |
Example:
```bash
curl --cacert ca.crt \
-o response.der \
https://localhost:8443/.well-known/pki/ocsp/iss-local/a1b2c3d4
openssl ocsp -respin response.der -text -CAfile ca.crt
```
#### POST form (the standard one)
```
POST https://<host>/.well-known/pki/ocsp/{issuer_id}
Content-Type: application/ocsp-request
Body: <DER-encoded OCSPRequest>
```
| Field | Value |
| --- | --- |
| Method | `POST` |
| Auth | None |
| Request Content-Type | `application/ocsp-request` |
| Response Content-Type | `application/ocsp-response` |
Example with OpenSSL building the request:
```bash
openssl ocsp -issuer ca.crt -cert leaf.crt -reqout request.der
curl --cacert ca.crt \
-X POST \
-H "Content-Type: application/ocsp-request" \
--data-binary @request.der \
-o response.der \
https://localhost:8443/.well-known/pki/ocsp/iss-local
openssl ocsp -respin response.der -text -CAfile ca.crt
```
The body-size limit applies (`http.MaxBytesReader` from middleware,
default 1MB, configurable via `CERTCTL_MAX_BODY_SIZE`); a typical OCSPRequest
is ~200 bytes so this is a generous cap.
### Admin observability endpoint
```
GET https://<host>/api/v1/admin/crl/cache
Authorization: Bearer <token-with-admin-flag>
```
Returns the per-issuer cache state — for ops dashboards, GUI badges, or
"is the scheduler keeping up?" diagnostics. Admin-gated (M-008 admin-gated
handler allowlist; non-admin Bearer callers receive HTTP 403). Response shape:
```json
{
"cache_rows": [
{
"issuer_id": "iss-local",
"cache_present": true,
"crl_number": 42,
"this_update": "2026-04-29T10:00:00Z",
"next_update": "2026-04-29T11:00:00Z",
"generated_at": "2026-04-29T10:00:00Z",
"generation_duration_ms": 87,
"revoked_count": 13,
"is_stale": false,
"recent_events": [
{
"started_at": "2026-04-29T10:00:00Z",
"duration_ms": 87,
"succeeded": true,
"crl_number": 42,
"revoked_count": 13
}
]
}
],
"row_count": 1,
"generated_at": "2026-04-29T10:30:00Z"
}
```
Issuers that have not yet had a CRL generated appear with `cache_present:
false` so the GUI can render a "Not yet generated" pill rather than 404.
---
## Configuration
| Env var | Default | Meaning |
| --- | --- | --- |
| `CERTCTL_CRL_GENERATION_INTERVAL` | `1h` | How often the scheduler walks every CRL-supporting issuer and rebuilds. The HTTP handler reads from the cache, not from a per-request rebuild. |
| `CERTCTL_OCSP_RESPONDER_KEY_DIR` | unset | **Operator MUST set in production.** Directory where the FileDriver persists each issuer's OCSP responder key (`ocsp-responder-<issuer_id>.key`). When unset, the responder service uses a temporary directory that does NOT survive restarts — fine for dev, NEVER for prod. |
| `CERTCTL_OCSP_RESPONDER_ROTATION_GRACE` | `7d` | When the responder cert's `NotAfter` falls within this window, `EnsureResponder` rotates to a fresh cert+key on the next OCSP request or scheduler tick. |
| `CERTCTL_OCSP_RESPONDER_VALIDITY` | `30d` | How long each newly-issued responder cert is valid for. Short by design — relying parties cache OCSP responses, not the responder cert chain, and `id-pkix-ocsp-nocheck` blocks recursive revocation checking on the responder itself. |
The issuer-level CRL `nextUpdate` is derived from the generation timestamp +
the configured CRL validity (currently a build-time constant in the
`CRLCacheService`; configurable knob deferred until an operator asks).
---
## OCSP responder cert lifecycle
1. **First OCSP request for an issuer (or scheduler tick).** The local
issuer's `SignOCSPResponse` calls into `OCSPResponderService.EnsureResponder`.
2. **Cache lookup.** `EnsureResponder` queries the `ocsp_responders` table for
a row keyed by `issuer_id`.
3. **Disk lookup.** If a row exists, the FileDriver reads the persisted key
from `<keydir>/ocsp-responder-<issuer_id>.key`. **Self-healing:** if the
row exists but the file is missing (operator pruned the keydir without
pruning the DB), the service treats this as "rotate now" rather than
crashing.
4. **Rotation check.** If `cert.NotAfter < now + RotationGrace`, the service
generates a fresh ECDSA-P256 key, builds a `*x509.CertificateRequest`,
and asks the local issuer's existing `IssueCertificate` flow to sign it.
The signing template carries:
- `KeyUsage: x509.KeyUsageDigitalSignature` (signing OCSP responses)
- `ExtKeyUsage: x509.ExtKeyUsageOCSPSigning` (RFC 6960 §4.2.2.2)
- The `id-pkix-ocsp-nocheck` extension (OID `1.3.6.1.5.5.7.48.1.5`,
DER value `NULL`, RFC 6960 §4.2.2.2.1) wired through
`Certificate.ExtraExtensions`.
5. **Persistence.** The new cert + key path are written to `ocsp_responders`
via an idempotent `INSERT … ON CONFLICT DO UPDATE`.
6. **Response signing.** `ocsp.CreateResponse(caCert, responderCert,
template, responderSigner)` produces the response bytes; the responder
cert is included in the response chain so relying parties can validate
without a separate fetch.
The race between scheduler-driven cache refresh and on-demand cache miss is
collapsed by the `CRLCacheService`'s in-tree singleflight (a `sync.Map` of
`*flightEntry` keyed by `issuer_id`). Concurrent generation requests for the
same issuer wait on the in-flight result rather than each rebuilding from
scratch.
---
## Pointing common consumers at the endpoints
### cert-manager (Kubernetes)
cert-manager's certificate-validation logic checks both the AIA OCSP URI
embedded in the leaf and the CDP CRL URI. Both are populated automatically
by the local issuer's certificate template — relying parties should NOT
need any additional configuration. To verify:
```bash
openssl x509 -in leaf.crt -text -noout | grep -A1 "Authority Information Access"
openssl x509 -in leaf.crt -text -noout | grep -A2 "CRL Distribution Points"
```
If your cert-manager pods cannot reach `https://<certctl-host>:8443/.well-known/pki/`,
add a NetworkPolicy egress rule or expose the certctl service via the
appropriate ingress class.
### Firefox
Firefox honors the AIA OCSP URI by default. To force-refresh the local
revocation cache after revoking a cert in dev:
```
about:preferences#privacy → Certificates → Query OCSP responder servers
```
If Firefox reports `SEC_ERROR_OCSP_INVALID_SIGNING_CERT`, verify that the
responder cert chain is reachable from the system trust store —
`id-pkix-ocsp-nocheck` is a Firefox-strict extension and is set automatically
on every responder cert certctl issues.
### OpenSSL
```bash
# OCSP via stand-alone request
openssl ocsp -issuer ca.crt -cert leaf.crt -url https://localhost:8443/.well-known/pki/ocsp/iss-local -CAfile ca.crt -text
# OCSP via TLS Certificate Status Request extension
openssl s_client -connect example.com:443 -status -CAfile ca.crt
```
### Intune (corporate device state)
Intune device-compliance validators pull the CRL on a schedule (configured in
the Intune admin console, default 24h). Configure the CRL distribution point
to `https://<certctl-host>:8443/.well-known/pki/crl/<issuer_id>` and Intune
will pull on its own cadence.
---
## What this release does NOT include (V3-Pro)
The following are explicitly out of scope for the V2 (free) bundle and are
tracked for the certctl Pro release:
- **Delta CRLs (RFC 5280 §5.2.4).** Useful for very large CRLs (10k+
revoked certs); the data model already accommodates the Base CRL Number
reference but the pipeline only emits Base CRLs in V2.
- **OCSP rate-limiting per relying party.** Per-IP token bucket on the OCSP
endpoint — V3-Pro because it justifies per-seat pricing for high-traffic
responders.
- **OCSP stapling.** Server-side: cache pre-fetched OCSP responses + serve
in TLS handshake. Client-side: a "stapling fetcher" agent for non-stapling
origins.
The MaxBytesReader cap is the only request-level guard in V2; the
unauthenticated-by-design relying-party endpoints are intentionally not
rate-limited per IP.
---
## Troubleshooting
**`pki/crl/<issuer_id>` returns 404.** The issuer either does not support
CRL signing (Vault, EJBCA, DigiCert serve their own CRL infrastructure;
certctl's connectors return `nil` from `GenerateCRL` for these) or the
issuer ID is wrong. Verify with `GET /api/v1/issuers`.
**`pki/ocsp/<issuer_id>/<serial>` returns 200 but `openssl ocsp -text`
shows "unauthorized".** Check that the serial in the URL is hex-encoded (no
`0x` prefix, no leading zeros stripped, lowercase). Mismatched serials
return an OCSP response with status `unauthorized` per RFC 6960 §2.3.
**Admin cache endpoint returns 403.** The Bearer key does not carry the
admin flag. M-008 gates this endpoint server-side; the GUI also gates the
fetch on `useAuth().admin`. Either escalate the key (`certctl admin
keys promote <key-id>`) or use a different identity.
**Cache shows `is_stale: true` repeatedly.** The scheduler is not running
(or not getting scheduled often enough). Check `CERTCTL_CRL_GENERATION_INTERVAL`
and confirm the scheduler started: `grep crlGenerationLoop` in the server
logs at startup.
+36 -3
View File
@@ -283,16 +283,35 @@ Revocation is a 7-step process: validate eligibility → get serial → update s
- `GET /.well-known/pki/crl/{issuer_id}` — DER-encoded X.509 CRL signed by the issuing CA, 24-hour validity (RFC 5280 §5 + RFC 8615). Served unauthenticated with `Content-Type: application/pkix-crl` so relying parties without certctl API credentials can fetch it.
The CRL is **pre-generated** by the scheduler's `crlGenerationLoop` (`internal/scheduler/scheduler.go`) on a configurable interval (`CERTCTL_CRL_GENERATION_INTERVAL`, default 1h) and persisted in the `crl_cache` table (migration 000019). HTTP fetches read from the cache rather than rebuilding per request — a busy CA does not DOS itself at scale. Concurrent regeneration requests for the same issuer are coalesced via an in-tree singleflight gate (`internal/service/crl_cache.go`, ~30 LoC; no `golang.org/x/sync` dependency). Per-issuer generation events are recorded in `crl_generation_events` for ops visibility.
Prior non-standard JSON CRL and authenticated `/api/v1/crl*` paths were removed in M-006 — RFC 5280 defines only the DER wire format and relying parties do not have API keys.
### OCSP Responder
`GET /.well-known/pki/ocsp/{issuer_id}/{serial}` — signed OCSP responses (good/revoked/unknown) per RFC 6960. Served unauthenticated with `Content-Type: application/ocsp-response`. Signs with the issuing CA key; requires CA key access (Local CA, step-CA connectors).
certctl serves both forms RFC 6960 §A.1.1 defines:
- `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` — URL-path lookup (useful for ops curl-debugging).
- `POST /.well-known/pki/ocsp/{issuer_id}` — binary `application/ocsp-request` body (the form most production clients use: Firefox, OpenSSL `s_client -status`, cert-manager, Intune).
Both forms are unauthenticated and return signed OCSP responses (good/revoked/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, migration 000020) — NOT by the CA private key directly. The responder cert is generated on first OCSP request via `OCSPResponderService.EnsureResponder` (`internal/connector/issuer/local/ocsp_responder.go`), persisted in the `ocsp_responders` table, and carries the `id-pkix-ocsp-nocheck` extension (OID `1.3.6.1.5.5.7.48.1.5`, RFC 6960 §4.2.2.2.1) so OCSP clients do not recursively check the responder's own revocation status. The responder cert auto-rotates within `CERTCTL_OCSP_RESPONDER_ROTATION_GRACE` (default 7d) of expiry; new certs default to `CERTCTL_OCSP_RESPONDER_VALIDITY` (30d). Self-healing: if the persisted responder key file is missing (operator pruned the keydir), the service treats this as "rotate now" rather than crashing. Local CA + step-CA connectors expose CRL+OCSP; upstream issuers (Vault, EJBCA, DigiCert) serve their own infrastructure.
### Admin Cache Observability
`GET /api/v1/admin/crl/cache` — admin-gated (Bearer required, admin flag enforced server-side via `middleware.IsAdmin`; returns HTTP 403 for non-admin callers). Returns the per-issuer cache state: `crl_number`, `this_update`, `next_update`, `generated_at`, `generation_duration_ms`, `revoked_count`, `is_stale`, plus the most-recent N generation events. Used by ops dashboards and the GUI cert-detail page's cache-age badge. The handler is pinned to the M-008 admin-gated handler allowlist (`internal/api/handler/m008_admin_gate_test.go`) — adding a new admin endpoint without the regression triplet (`_NonAdmin_Returns403` / `_AdminExplicitFalse_Returns403` / `_AdminPermitted_ForwardsActor`) fails CI.
### GUI Revocation Endpoints Panel
The certificate-detail page (`web/src/pages/CertificateDetailPage.tsx`) renders a Revocation Endpoints card that shows the CRL Distribution Point URL (`https://<host>/.well-known/pki/crl/<issuer_id>`) and OCSP Responder URL (`https://<host>/.well-known/pki/ocsp/<issuer_id>`), plus two action buttons: "Test CRL fetch" (calls `fetchCRL(issuer_id)`, shows byte count + content-type) and "Check OCSP status" (calls `getOCSPStatus(issuer_id, serial_hex)`, shows DER response size). For admin callers, a cache-age badge ("Cache fresh · 2m ago" / "Cache stale" / "Not yet generated") consumes the admin observability endpoint above; non-admin callers don't trigger the fetch (gated client-side on `useAuth().admin`) so the badge cannot leak generation cadence.
### Short-Lived Certificate Exemption
Certificates with profile TTL < 1 hour skip CRL/OCSP. Expiry is sufficient revocation for short-lived credentials.
For the full operator + relying-party guide (curl/OpenSSL/Firefox/cert-manager/Intune integration recipes, troubleshooting), see [`crl-ocsp.md`](crl-ocsp.md).
---
## Certificate Export
@@ -390,8 +409,12 @@ Self-signed or sub-CA mode using `crypto/x509`.
|---|---|---|
| `CERTCTL_CA_CERT_PATH` | (none) | Path to CA certificate PEM. When set, enables sub-CA mode. |
| `CERTCTL_CA_KEY_PATH` | (none) | Path to CA private key PEM (RSA, ECDSA, PKCS#8). |
| `CERTCTL_CRL_GENERATION_INTERVAL` | `1h` | How often the scheduler walks every CRL-supporting issuer and rebuilds the cached CRL. HTTP fetches read from the cache, not from a per-request rebuild. |
| `CERTCTL_OCSP_RESPONDER_KEY_DIR` | (none) | **Operator MUST set in production.** Directory where the FileDriver persists each issuer's OCSP responder key (`ocsp-responder-<issuer_id>.key`). When unset, the responder service uses a temporary directory that does NOT survive restarts — fine for dev, NEVER for prod. |
| `CERTCTL_OCSP_RESPONDER_ROTATION_GRACE` | `7d` | When the responder cert's `NotAfter` falls within this window, `EnsureResponder` rotates to a fresh cert+key on the next OCSP request or scheduler tick. |
| `CERTCTL_OCSP_RESPONDER_VALIDITY` | `30d` | How long each newly-issued responder cert is valid for. Short by design: relying parties cache OCSP responses, not the responder cert chain, and `id-pkix-ocsp-nocheck` blocks recursive revocation checking on the responder itself. |
Sub-CA mode validates `IsCA=true` and `KeyUsageCertSign` on the loaded certificate. Falls back to self-signed when paths are not set. Supports CRL generation (`GenerateCRL`) and OCSP response signing (`SignOCSPResponse`).
Sub-CA mode validates `IsCA=true` and `KeyUsageCertSign` on the loaded certificate. Falls back to self-signed when paths are not set. Supports CRL generation (`GenerateCRL`) and OCSP response signing (`SignOCSPResponse`). All CA-key signing flows through the `signer.Signer` interface (`internal/crypto/signer/`); the OCSP responder cert is signed by the CA via the existing issuance pipeline and OCSP responses are signed by the responder key (NOT the CA key directly) per RFC 6960 §2.6.
### ACME
@@ -623,6 +646,14 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR
| `CERTCTL_SCEP_ISSUER_ID` | `iss-local` | Issuer for SCEP enrollments |
| `CERTCTL_SCEP_PROFILE_ID` | (none) | Optional profile constraint |
| `CERTCTL_SCEP_CHALLENGE_PASSWORD` | (none) | Shared secret for enrollment authentication |
| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA (Registration Authority) certificate. **Required when `CERTCTL_SCEP_ENABLED=true`** for the RFC 8894 PKIMessage path: SCEP clients encrypt their PKCS#10 CSR to this cert's public key (EnvelopedData wrapper, RFC 8894 §3.2.2) and the server signs the outbound CertRep PKIMessage signerInfo with the matching key (RFC 8894 §3.3.2). Generation: a self-signed cert with `CN=<your-ca-id>-RA` and the `id-kp-emailProtection` / `id-kp-cmcRA` EKU is sufficient — see [`legacy-est-scep.md`](legacy-est-scep.md) for the openssl recipe. The preflight gate at startup also enforces a cert/key match, non-expired NotAfter, and an RSA-or-ECDSA public-key algorithm. |
| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded private key matching `CERTCTL_SCEP_RA_CERT_PATH`. **Required when `CERTCTL_SCEP_ENABLED=true`.** File MUST be mode `0600` (owner read/write only); preflight refuses to load a world- or group-readable RA key as defense-in-depth against credential leak. The server reads this file once at startup; rotation requires a restart. |
| `CERTCTL_SCEP_PROFILES` | (none, single-profile mode) | Comma-separated list of SCEP profile names enabling **multi-endpoint dispatch** (Phase 1.5). When set, certctl exposes one `/scep/<pathID>` endpoint per name (e.g. `CERTCTL_SCEP_PROFILES=corp,iot,server` produces `/scep/corp`, `/scep/iot`, `/scep/server`). Each name also drives the env-var prefix for the per-profile config below. When unset, certctl runs in legacy single-profile mode using the flat `CERTCTL_SCEP_*` env vars above (which synthesise a single-element profile bound to the legacy `/scep` root path). PathID must be a path-safe slug (`[a-z0-9-]`, no leading/trailing hyphen); names get lowercased for the URL path and uppercased for the env-var prefix. |
| `CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID` | (none) | Per-profile issuer binding when `CERTCTL_SCEP_PROFILES` is set. `<NAME>` is the upper-cased profile name from the list (so a `CERTCTL_SCEP_PROFILES` entry of `corp` resolves the issuer-id env var key with `<NAME>` replaced by `CORP`, the path-id `_ISSUER_ID` suffix unchanged). Same per-profile env-var prefix `CERTCTL_SCEP_PROFILE_` is also used for `_PROFILE_ID`, `_CHALLENGE_PASSWORD`, `_RA_CERT_PATH`, `_RA_KEY_PATH` — see the four rows below. Required for every profile listed in `CERTCTL_SCEP_PROFILES`. Each profile is independently validated at startup; per-profile failures log the offending PathID. |
| `CERTCTL_SCEP_PROFILE_<NAME>_PROFILE_ID` | (none) | Per-profile optional `CertificateProfile` constraint, mirroring the legacy `CERTCTL_SCEP_PROFILE_ID`. Leave unset to allow the issuer's defaults. |
| `CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD` | (none) | Per-profile shared secret. **Required for every profile** in `CERTCTL_SCEP_PROFILES` (CWE-306: per-profile auth boundary). Empty value at startup fails the boot with the offending PathID in the structured log. |
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH` | (none) | Per-profile RA certificate PEM path. Same semantics as `CERTCTL_SCEP_RA_CERT_PATH` but scoped to one profile. **Required for every profile.** |
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH` | (none) | Per-profile RA private key PEM path (mode `0600`). Same semantics as `CERTCTL_SCEP_RA_KEY_PATH` but scoped to one profile. **Required for every profile.** |
---
@@ -1429,8 +1460,10 @@ The migration runner reads SQL files from `./migrations/` by default; the path i
| `000008_verification` | Columns on `jobs` (verification fields) |
| `000009_issuer_config` | Columns on `issuers` (encrypted_config, source, test_status) |
| `000010_target_config` | Columns on `targets` (encrypted_config, source, test_status) |
| `000019_crl_cache` | `crl_cache` (per-issuer pre-generated DER CRL with monotonic `crl_number` per RFC 5280 §5.2.3, `this_update` / `next_update` timestamps, `revoked_count`, generation duration metric) + `crl_generation_events` (per-tick ops audit row with `succeeded` flag and error text) |
| `000020_ocsp_responder` | `ocsp_responders` (per-issuer dedicated OCSP responder cert PEM + on-disk key path + `not_before` / `not_after` for auto-rotation) |
All migrations are idempotent (`IF NOT EXISTS`, `ON CONFLICT`).
The migration list above is illustrative; for the full sequence run `ls migrations/*.up.sql`. All migrations are idempotent (`IF NOT EXISTS`, `ON CONFLICT`).
---
+185
View File
@@ -0,0 +1,185 @@
package handler
import (
"context"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AdminCRLCacheService is the slice of CRLCacheRepository the admin
// endpoint needs. The handler depends on this narrow interface rather
// than the full *service.CRLCacheService so the wiring stays
// service-side and the handler stays test-friendly.
type AdminCRLCacheService interface {
// CacheRows returns one row per issuer that currently has a cached
// CRL. Implementations walk the registry and call the repository's
// Get for each; rows that don't exist (issuer never had a CRL
// generated) are returned with CacheRow.CachePresent=false so the
// GUI can show "not yet generated" rather than 404ing.
CacheRows(ctx context.Context) ([]CRLCacheRow, error)
}
// CRLCacheRow is the admin-endpoint view of a single issuer's cache
// state. The raw CRL DER is omitted (kept on the server) — operators
// fetch it via the standard /.well-known/pki/crl/{issuer_id} URL.
type CRLCacheRow struct {
IssuerID string `json:"issuer_id"`
CachePresent bool `json:"cache_present"`
CRLNumber int64 `json:"crl_number,omitempty"`
ThisUpdate *time.Time `json:"this_update,omitempty"`
NextUpdate *time.Time `json:"next_update,omitempty"`
GeneratedAt *time.Time `json:"generated_at,omitempty"`
GenerationDurMs int64 `json:"generation_duration_ms,omitempty"`
RevokedCount int `json:"revoked_count,omitempty"`
IsStale bool `json:"is_stale,omitempty"`
RecentEvents []CRLCacheEvt `json:"recent_events,omitempty"`
}
// CRLCacheEvt is the trimmed view of a CRLGenerationEvent for the
// admin response. We omit the DB row ID (operators don't care) and
// flatten the duration to milliseconds.
type CRLCacheEvt struct {
StartedAt time.Time `json:"started_at"`
DurationMs int64 `json:"duration_ms"`
Succeeded bool `json:"succeeded"`
CRLNumber int64 `json:"crl_number"`
RevokedCount int `json:"revoked_count"`
Error string `json:"error,omitempty"`
}
// AdminCRLCacheHandler serves the GET /api/v1/admin/crl/cache endpoint
// for ops visibility into the scheduler-driven CRL pre-generation
// pipeline. CRL/OCSP-Responder Phase 5.
//
// The endpoint is admin-gated (M-003 pattern) — non-admin Bearer
// callers get 403. This is a fleet-state observability surface; we
// don't expose it to every authenticated user because the cache
// rows reveal the operator's issuer set + CRL cadence.
type AdminCRLCacheHandler struct {
svc AdminCRLCacheService
}
// NewAdminCRLCacheHandler creates a new handler.
func NewAdminCRLCacheHandler(svc AdminCRLCacheService) AdminCRLCacheHandler {
return AdminCRLCacheHandler{svc: svc}
}
// ListCache handles GET /api/v1/admin/crl/cache.
func (h AdminCRLCacheHandler) ListCache(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
rows, err := h.svc.CacheRows(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to read CRL cache state")
return
}
if rows == nil {
// Avoid serialising as `null` — the GUI expects an array.
rows = []CRLCacheRow{}
}
_ = JSON(w, http.StatusOK, map[string]any{
"cache_rows": rows,
"row_count": len(rows),
"generated_at": time.Now().UTC(),
})
}
// AdminCRLCacheServiceImpl is the production implementation of
// AdminCRLCacheService. It walks the issuer registry, fetches the
// cache row for each via the repository, and decorates with recent
// generation events. Lives in the handler package because it's a
// thin handler-side composition; the heavy lifting stays in the
// repository.
type AdminCRLCacheServiceImpl struct {
cacheRepo repository.CRLCacheRepository
issuerIDs func() []string // returns all issuer IDs (callback so the
// registry doesn't have to be imported here)
now func() time.Time
eventLimit int
}
// NewAdminCRLCacheServiceImpl constructs the handler-side service.
// issuerIDsFn is a callback so we don't import internal/service from
// the handler package (would be a layering violation).
func NewAdminCRLCacheServiceImpl(cacheRepo repository.CRLCacheRepository, issuerIDsFn func() []string) *AdminCRLCacheServiceImpl {
return &AdminCRLCacheServiceImpl{
cacheRepo: cacheRepo,
issuerIDs: issuerIDsFn,
now: func() time.Time { return time.Now().UTC() },
eventLimit: 5,
}
}
// CacheRows implements AdminCRLCacheService.
func (s *AdminCRLCacheServiceImpl) CacheRows(ctx context.Context) ([]CRLCacheRow, error) {
now := s.now()
ids := s.issuerIDs()
out := make([]CRLCacheRow, 0, len(ids))
for _, issuerID := range ids {
row := CRLCacheRow{IssuerID: issuerID}
entry, err := s.cacheRepo.Get(ctx, issuerID)
if err != nil {
// One issuer's failure should not blank the whole response —
// the GUI shows partial state and surfaces the per-issuer
// error as a generation event.
row.RecentEvents = []CRLCacheEvt{{
StartedAt: now, Succeeded: false,
Error: "cache lookup failed: " + err.Error(),
}}
out = append(out, row)
continue
}
if entry == nil {
out = append(out, row) // CachePresent stays false
continue
}
row.CachePresent = true
row.CRLNumber = entry.CRLNumber
row.ThisUpdate = &entry.ThisUpdate
row.NextUpdate = &entry.NextUpdate
row.GeneratedAt = &entry.GeneratedAt
row.GenerationDurMs = entry.GenerationDuration.Milliseconds()
row.RevokedCount = entry.RevokedCount
row.IsStale = entry.IsStale(now)
// Most-recent N generation events for ops grep.
evts, err := s.cacheRepo.ListGenerationEvents(ctx, issuerID, s.eventLimit)
if err == nil {
row.RecentEvents = make([]CRLCacheEvt, 0, len(evts))
for _, e := range evts {
row.RecentEvents = append(row.RecentEvents, CRLCacheEvt{
StartedAt: e.StartedAt,
DurationMs: e.Duration.Milliseconds(),
Succeeded: e.Succeeded,
CRLNumber: e.CRLNumber,
RevokedCount: e.RevokedCount,
Error: e.Error,
})
}
}
out = append(out, row)
}
return out, nil
}
// Compile-time interface check.
var _ AdminCRLCacheService = (*AdminCRLCacheServiceImpl)(nil)
// _ silences the unused-import warning if domain pulls in only via
// type aliases; the explicit reference here means the import is
// intentional even when the file's other symbols don't reference it.
var _ = domain.CRLGenerationEvent{}
@@ -0,0 +1,162 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/api/middleware"
)
// fakeAdminCRLCacheService is the test stub for the
// AdminCRLCacheService interface — lets us exercise gate behavior
// (admin / non-admin / explicit-false) without spinning up a real
// CRLCacheRepository or issuer registry.
type fakeAdminCRLCacheService struct {
called bool
rows []CRLCacheRow
err error
}
func (f *fakeAdminCRLCacheService) CacheRows(_ context.Context) ([]CRLCacheRow, error) {
f.called = true
return f.rows, f.err
}
// TestAdminCRLCache_NonAdmin_Returns403 — M-003-pattern central
// gate test. A caller without an admin-tagged context must be
// rejected with HTTP 403, and the service layer must never see
// the request (no enumeration of issuer set / cache state).
func TestAdminCRLCache_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminCRLCacheService{}
h := NewAdminCRLCacheHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
w := httptest.NewRecorder()
h.ListCache(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
msg, _ := resp["message"].(string)
if !strings.Contains(strings.ToLower(msg), "admin") {
t.Errorf("expected message to mention admin requirement, got %q", msg)
}
if svc.called {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
// TestAdminCRLCache_AdminExplicitFalse_Returns403 pins the
// AdminKey-present-but-false case. Without this, a regression to
// "key missing == deny, key present == allow" would silently grant
// a false flag to any caller that managed to set the context value.
func TestAdminCRLCache_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminCRLCacheService{}
h := NewAdminCRLCacheHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ListCache(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
}
if svc.called {
t.Error("service called despite admin=false gate")
}
}
// TestAdminCRLCache_AdminPermitted_ForwardsActor confirms the
// happy path: an admin-tagged context reaches the service and the
// response shape is what the GUI expects (cache_rows / row_count /
// generated_at). The actor-forwarding aspect of M-002 doesn't apply
// here — this is a read-only endpoint with no audit-event side
// effect — but the test name matches the M008 triplet convention so
// the regression scanner finds it.
func TestAdminCRLCache_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminCRLCacheService{
rows: []CRLCacheRow{
{IssuerID: "iss-a", CachePresent: true, CRLNumber: 1},
{IssuerID: "iss-b", CachePresent: false},
},
}
h := NewAdminCRLCacheHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ListCache(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
}
if !svc.called {
t.Fatal("service was not invoked for admin caller")
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if rc, ok := resp["row_count"].(float64); !ok || rc != 2 {
t.Errorf("row_count = %v, want 2", resp["row_count"])
}
if _, ok := resp["cache_rows"].([]any); !ok {
t.Errorf("cache_rows missing or wrong shape: %v", resp["cache_rows"])
}
}
// TestAdminCRLCache_RejectsNonGetMethod pins the method gate.
// Companion to the admin gate — both must fire to satisfy the
// admin-only-GET contract.
func TestAdminCRLCache_RejectsNonGetMethod(t *testing.T) {
h := NewAdminCRLCacheHandler(&fakeAdminCRLCacheService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ListCache(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for POST, got %d", w.Code)
}
}
// TestAdminCRLCache_PropagatesServiceError surfaces 500 when the
// service errors. Pins the failure-path response shape so future
// refactors don't accidentally swallow errors as 200.
func TestAdminCRLCache_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminCRLCacheService{err: errors.New("db down")}
h := NewAdminCRLCacheHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ListCache(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on service error, got %d", w.Code)
}
}
@@ -3,13 +3,21 @@ package handler
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
@@ -1208,6 +1216,174 @@ func TestHandleOCSP_MethodNotAllowed(t *testing.T) {
}
}
// === Phase-4 POST OCSP (RFC 6960 §A.1.1) Tests ===
// buildOCSPRequest constructs a binary DER-encoded OCSPRequest body
// for testing the POST handler. The same shape is what production
// clients (Firefox, OpenSSL, cert-manager) send.
func buildOCSPRequest(t *testing.T, serial *big.Int) []byte {
t.Helper()
// Build a minimal issuer cert + leaf cert pair so ocsp.CreateRequest
// has the SubjectPublicKeyInfo + serial it needs.
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
caTpl := &x509.Certificate{
SerialNumber: big.NewInt(0xCA),
Subject: pkix.Name{CommonName: "Test Issuer"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create CA: %v", err)
}
caCert, _ := x509.ParseCertificate(caDER)
leafTpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "leaf.example.com"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
leafDER, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create leaf: %v", err)
}
leafCert, _ := x509.ParseCertificate(leafDER)
body, err := ocsp.CreateRequest(leafCert, caCert, &ocsp.RequestOptions{Hash: crypto.SHA256})
if err != nil {
t.Fatalf("create OCSP request: %v", err)
}
return body
}
func TestHandleOCSPPost_Success(t *testing.T) {
wantSerial := big.NewInt(0xDEADBEEF)
expectedHex := fmt.Sprintf("%x", wantSerial)
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
if issuerID != "iss-local" {
return nil, fmt.Errorf("unexpected issuer %q", issuerID)
}
if serialHex != expectedHex {
return nil, fmt.Errorf("unexpected serial %q (want %q)", serialHex, expectedHex)
}
return []byte{0x30, 0x82, 0x02, 0x00}, nil
},
}
handler := NewCertificateHandler(mock)
body := buildOCSPRequest(t, wantSerial)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/ocsp-response" {
t.Errorf("Content-Type = %q, want application/ocsp-response", ct)
}
}
func TestHandleOCSPPost_RejectsNonPostMethod(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("got %d, want 405", w.Code)
}
}
func TestHandleOCSPPost_RejectsWrongContentType(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader([]byte("garbage")))
req.Header.Set("Content-Type", "text/plain")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusUnsupportedMediaType {
t.Errorf("got %d, want 415", w.Code)
}
}
func TestHandleOCSPPost_AcceptsMissingContentType(t *testing.T) {
// Real-world tolerance: some clients omit the header entirely.
// Validation falls through to ocsp.ParseRequest which will reject
// a non-OCSP body with a 400.
body := buildOCSPRequest(t, big.NewInt(1))
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, _, _ string) ([]byte, error) {
return []byte{0x30, 0x82}, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
// Intentionally NOT setting Content-Type.
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want 200 with missing Content-Type (body=%s)", w.Code, w.Body.String())
}
}
func TestHandleOCSPPost_RejectsMalformedBody(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader([]byte("not-an-ocsp-request")))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", w.Code)
}
}
func TestHandleOCSPPost_RejectsMissingIssuer(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
body := buildOCSPRequest(t, big.NewInt(1))
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", w.Code)
}
}
func TestHandleOCSPPost_PropagatesNotFound(t *testing.T) {
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, _, _ string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found")
},
}
handler := NewCertificateHandler(mock)
body := buildOCSPRequest(t, big.NewInt(1))
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", w.Code)
}
}
// === M20 Enhanced Query API Tests ===
// TestListCertificates_SortParam tests sort parameter parsing and passing to service.
@@ -1315,9 +1491,9 @@ func TestListCertificates_CreatedAfterFilter(t *testing.T) {
// TestListCertificates_CursorPagination tests cursor-based pagination response.
func TestListCertificates_CursorPagination(t *testing.T) {
cert := domain.ManagedCertificate{
ID: "mc-cursor-test-1",
ID: "mc-cursor-test-1",
CommonName: "cursor.example.com",
CreatedAt: time.Now(),
CreatedAt: time.Now(),
}
mock := &MockCertificateService{
+91 -1
View File
@@ -1,15 +1,19 @@
package handler
import (
"errors"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
@@ -622,6 +626,92 @@ func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
w.Write(derBytes)
}
// HandleOCSPPost processes RFC 6960 §A.1.1 POST OCSP requests.
// POST /.well-known/pki/ocsp/{issuer_id}
//
// The body MUST be the binary DER-encoded OCSPRequest with content-type
// "application/ocsp-request". The response is the same DER-encoded
// OCSPResponse with content-type "application/ocsp-response" returned
// by the existing GET handler — only the input shape differs.
//
// POST is the standard transport for production OCSP clients (Firefox,
// OpenSSL `s_client -status`, cert-manager, Microsoft Intune device
// validators). The pre-existing GET form is kept for ad-hoc curl
// inspection + human-readable URL paths.
//
// Bundle CRL/OCSP-Responder Phase 4.
func (h CertificateHandler) HandleOCSPPost(w http.ResponseWriter, r *http.Request) {
requestID, _ := r.Context().Value("request_id").(string)
if r.Method != http.MethodPost {
ErrorWithRequestID(w, http.StatusMethodNotAllowed, "Method not allowed", requestID)
return
}
// Be tolerant about Content-Type: RFC 6960 §A.1.1 says it MUST be
// "application/ocsp-request" but real-world clients sometimes omit
// the header or send it with a charset suffix. We require the
// substring "ocsp-request" rather than exact match — the actual
// validation happens in ocsp.ParseRequest below; a malformed body
// fails there with a 400.
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.Contains(strings.ToLower(ct), "ocsp-request") {
ErrorWithRequestID(w, http.StatusUnsupportedMediaType,
fmt.Sprintf("Content-Type must be application/ocsp-request, got %q", ct), requestID)
return
}
// Issuer ID from the path. The router pattern strips the leading
// /.well-known/pki/ocsp/ prefix; what remains is the bare issuer ID.
issuerID := strings.TrimPrefix(r.URL.Path, "/.well-known/pki/ocsp/")
issuerID = strings.TrimSuffix(issuerID, "/")
if issuerID == "" || strings.Contains(issuerID, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
// Body is already MaxBytesReader-capped by the body-size middleware.
// OCSPRequest bodies are tiny (~200 bytes for a single-cert query),
// so the default cap is comfortably above what any legitimate client
// will send.
body, err := io.ReadAll(r.Body)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Failed to read request body", requestID)
return
}
ocspReq, err := ocsp.ParseRequest(body)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest,
fmt.Sprintf("Invalid OCSPRequest: %v", err), requestID)
return
}
// Reuse the existing service path. The serial extracted from the
// parsed OCSPRequest is converted to hex (the on-disk format for
// certctl serials matches certificate.SerialNumber.Text(16)).
serialHex := fmt.Sprintf("%x", ocspReq.SerialNumber)
derBytes, err := h.svc.GetOCSPResponse(r.Context(), issuerID, serialHex)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
return
}
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate OCSP response", requestID)
return
}
w.Header().Set("Content-Type", "application/ocsp-response")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
w.Write(derBytes)
}
// GetCertificateDeployments retrieves all deployment targets for a certificate.
// GET /api/v1/certificates/{id}/deployments
func (h CertificateHandler) GetCertificateDeployments(w http.ResponseWriter, r *http.Request) {
@@ -36,6 +36,7 @@ import (
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
var AdminGatedHandlers = map[string]string{
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
}
// InformationalIsAdminCallers is the documented allowlist of files that
+70 -19
View File
@@ -66,10 +66,10 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
var AuthExemptRouterRoutes = []string{
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
}
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
@@ -81,9 +81,9 @@ var AuthExemptRouterRoutes = []string{
// TestDispatch_AuthExemptPrefixes regression test in cmd/server/main_test.go
// pins this slice to buildFinalHandler's actual dispatch logic.
var AuthExemptDispatchPrefixes = []string{
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
}
// HandlerRegistry groups all API handler dependencies for router registration.
@@ -108,8 +108,8 @@ type HandlerRegistry struct {
Verification handler.VerificationHandler
Export handler.ExportHandler
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
@@ -122,6 +122,10 @@ type HandlerRegistry struct {
// cmd/server/main.go so probes and rollout systems can read build
// identity without Bearer credentials. See handler/version.go.
Version handler.VersionHandler
// AdminCRLCache handles GET /api/v1/admin/crl/cache. Bundle CRL/OCSP-
// Responder Phase 5 — admin-gated ops surface for the
// scheduler-driven CRL pre-generation pipeline.
AdminCRLCache handler.AdminCRLCacheHandler
}
// RegisterHandlers sets up all API routes with their handlers.
@@ -287,6 +291,11 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("GET /api/v1/audit", http.HandlerFunc(reg.Audit.ListAuditEvents))
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(reg.Audit.GetAuditEvent))
// Bundle CRL/OCSP-Responder Phase 5: admin observability for the
// scheduler-driven CRL pre-generation cache. Admin-gated inside
// the handler (M-003 pattern); non-admin callers get 403.
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
// Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(reg.Notifications.GetNotification))
@@ -367,16 +376,53 @@ func (r *Router) RegisterESTHandlers(est handler.ESTHandler) {
}
// RegisterSCEPHandlers sets up SCEP (RFC 8894) routes.
// SCEP uses a single endpoint with operation-based dispatch via query parameters.
// Authentication is via the challengePassword attribute in the PKCS#10 CSR, not
// via HTTP Bearer tokens or TLS client certs. cmd/server/main.go's finalHandler
// routes /scep* through the no-auth middleware chain (M-001 audit 2026-04-19,
// option D), and Config.Validate() refuses to start the server if SCEP is enabled
// without a non-empty CERTCTL_SCEP_CHALLENGE_PASSWORD (H-2, CWE-306).
func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) {
// SCEP uses a single path; the handler dispatches on ?operation= query param
r.Register("GET /scep", http.HandlerFunc(scep.HandleSCEP))
r.Register("POST /scep", http.HandlerFunc(scep.HandleSCEP))
// SCEP uses a single endpoint per profile with operation-based dispatch via
// query parameters. Authentication is via the challengePassword attribute in
// the PKCS#10 CSR, not via HTTP Bearer tokens or TLS client certs.
// cmd/server/main.go's finalHandler routes /scep* through the no-auth
// middleware chain (M-001 audit 2026-04-19, option D), and Config.Validate()
// refuses to start the server if any SCEP profile is enabled without a
// non-empty challenge password (H-2, CWE-306).
//
// SCEP RFC 8894 Phase 1.5: the handlers map is keyed by SCEPProfileConfig.PathID.
// Empty PathID maps to the legacy /scep root for backward compatibility;
// non-empty PathID values map to /scep/<pathID>. Registering N profiles
// produces 2N routes (GET + POST per profile). Validate() guards PathID
// uniqueness + slug-shape so this loop never gets a collision or an invalid
// path segment.
//
// The auth-exempt prefix `/scep` in AuthExemptDispatchPrefixes already covers
// every /scep[/...] path via prefix-match, so the multi-profile routes inherit
// the no-auth dispatch from the same dispatch table — no router-side change
// to the auth-exempt list is required.
func (r *Router) RegisterSCEPHandlers(handlers map[string]handler.SCEPHandler) {
// Legacy /scep route for the empty-PathID profile is registered with
// literal strings so the openapi-parity scanner (Bundle D / Audit M-027,
// see openapi_parity_test.go) sees `GET /scep` + `POST /scep` as
// AST literals exactly the way it did pre-Phase-1.5. The scanner walks
// for *ast.BasicLit string args to r.Register, so dynamically-built
// paths would not appear in its index. Keeping the empty-PathID case
// static preserves the spec parity contract for the documented
// /scep endpoint that openapi.yaml still describes.
if h, ok := handlers[""]; ok {
r.Register("GET /scep", http.HandlerFunc(h.HandleSCEP))
r.Register("POST /scep", http.HandlerFunc(h.HandleSCEP))
}
// Multi-profile routes register dynamically. These per-deployment paths
// (/scep/<pathID>) aren't in openapi.yaml because the path segment is
// operator-defined; the spec covers the canonical /scep root only. The
// parity scanner correctly skips dynamic routes (it only checks literals).
for pathID, h := range handlers {
if pathID == "" {
continue // already handled by the static block above
}
hCopy := h // h is captured by value — SCEPHandler is a small struct
// (one interface field) so the per-iteration copy is cheap and avoids
// any loop-variable-capture surprise if SCEPHandler ever grows
// pointer receivers in the future.
r.Register("GET /scep/"+pathID, http.HandlerFunc(hCopy.HandleSCEP))
r.Register("POST /scep/"+pathID, http.HandlerFunc(hCopy.HandleSCEP))
}
}
// RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under
@@ -392,6 +438,11 @@ func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) {
func (r *Router) RegisterPKIHandlers(pki handler.CertificateHandler) {
r.Register("GET /.well-known/pki/crl/{issuer_id}", http.HandlerFunc(pki.GetDERCRL))
r.Register("GET /.well-known/pki/ocsp/{issuer_id}/{serial}", http.HandlerFunc(pki.HandleOCSP))
// RFC 6960 §A.1.1 standard POST form. The binary OCSPRequest body
// carries the serial; the URL only needs the issuer ID. Most
// production OCSP clients use POST exclusively (see CRL/OCSP-Responder
// Phase 4 prompt for the full client compatibility matrix).
r.Register("POST /.well-known/pki/ocsp/{issuer_id}", http.HandlerFunc(pki.HandleOCSPPost))
}
// GetMux returns the underlying http.ServeMux for direct access if needed.
@@ -0,0 +1,166 @@
package router
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/shankar0123/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/domain"
)
// SCEP RFC 8894 + Intune master bundle Phase 1.5: per-issuer profiles router
// registration. Pins:
//
// 1. Empty PathID maps to /scep root (legacy backward-compat).
// 2. Non-empty PathID maps to /scep/<pathID>.
// 3. Multi-profile registration produces 2N routes (GET + POST per profile).
// 4. Each registered route reaches the right handler instance — no
// cross-profile bleed-through (proven by the per-profile mock counters).
//
// The mock service is a minimal SCEPService implementation that records
// which profile served the request via the GetCACaps capability string —
// the test asserts it sees the right per-profile string echoed back, which
// would only happen if the right handler was wired to the right path.
// scepProfileMockService is a per-profile-tagged mock SCEPService for
// router-level tests. The CACaps string carries the profile tag so the
// caller can verify which profile's handler served a given request.
type scepProfileMockService struct {
tag string
}
func (s *scepProfileMockService) GetCACaps(_ context.Context) string {
return "POSTPKIOperation\nSHA-256\nPROFILE=" + s.tag + "\n"
}
func (s *scepProfileMockService) GetCACert(_ context.Context) (string, error) {
return "", nil
}
func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) {
return nil, nil
}
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
r := New()
svc := &scepProfileMockService{tag: "legacy"}
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{
"": handler.NewSCEPHandler(svc),
})
// GetCACaps is GET-only per RFC 8894 §3.5.2. The router registers BOTH
// GET and POST; the handler decides what each operation accepts. We
// exercise GET here (POST PKIOperation is exercised by the existing
// internal/api/handler tests and by the e2e suite).
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /scep — code %d, want 200 (body=%q)", w.Code, w.Body.String())
}
if got := w.Body.String(); !contains(got, "PROFILE=legacy") {
t.Errorf("GET /scep body = %q, want contains PROFILE=legacy", got)
}
// Confirm POST /scep IS registered at the router level (the handler
// will respond 405 for GetCACaps because it's GET-only, but the route
// has to exist or we'd get a 404 from the mux instead).
req = httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACaps", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /scep?operation=GetCACaps — code %d, want 405 (route registered, handler rejects POST for GetCACaps)", w.Code)
}
}
func TestRouter_RegisterSCEPHandlers_NonEmptyPathIDMapsToSubpath(t *testing.T) {
r := New()
svc := &scepProfileMockService{tag: "corp"}
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{
"corp": handler.NewSCEPHandler(svc),
})
// GET /scep/corp?operation=GetCACaps reaches the corp handler.
req := httptest.NewRequest(http.MethodGet, "/scep/corp?operation=GetCACaps", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /scep/corp — code %d, want 200 (body=%q)", w.Code, w.Body.String())
}
if got := w.Body.String(); !contains(got, "PROFILE=corp") {
t.Errorf("GET /scep/corp body = %q, want contains PROFILE=corp", got)
}
// POST /scep/corp must also be registered (the handler will reject
// GetCACaps as 405; we just confirm the route exists).
req = httptest.NewRequest(http.MethodPost, "/scep/corp?operation=GetCACaps", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /scep/corp?operation=GetCACaps — code %d, want 405 (route registered, handler rejects POST for GetCACaps)", w.Code)
}
// /scep root must NOT be registered when only non-empty PathIDs exist.
req = httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed {
t.Errorf("/scep without legacy profile — code %d, want 404 or 405 (no handler should be registered)", w.Code)
}
}
func TestRouter_RegisterSCEPHandlers_MultipleProfilesNoCrossBleed(t *testing.T) {
r := New()
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{
"": handler.NewSCEPHandler(&scepProfileMockService{tag: "default"}),
"corp": handler.NewSCEPHandler(&scepProfileMockService{tag: "corp"}),
"iot": handler.NewSCEPHandler(&scepProfileMockService{tag: "iot"}),
})
cases := []struct {
path string
wantTag string
}{
{"/scep?operation=GetCACaps", "default"},
{"/scep/corp?operation=GetCACaps", "corp"},
{"/scep/iot?operation=GetCACaps", "iot"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("code %d, want 200", w.Code)
}
if got := w.Body.String(); !contains(got, "PROFILE="+tc.wantTag) {
t.Errorf("body = %q, want contains PROFILE=%s", got, tc.wantTag)
}
})
}
}
func TestRouter_RegisterSCEPHandlers_EmptyMapRegistersNoRoutes(t *testing.T) {
r := New()
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{})
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed {
t.Errorf("/scep with no profiles registered — code %d, want 404 or 405", w.Code)
}
}
// Tiny helper local to this file to avoid importing strings just for one
// substring check; keeps the test file's import surface minimal.
func contains(haystack, needle string) bool {
if len(needle) == 0 {
return true
}
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+323 -5
View File
@@ -40,6 +40,34 @@ type Config struct {
HealthCheck HealthCheckConfig
Encryption EncryptionConfig
CloudDiscovery CloudDiscoveryConfig
OCSPResponder OCSPResponderConfig
}
// OCSPResponderConfig configures the dedicated OCSP-responder cert
// per issuer (RFC 6960 §2.6 + §4.2.2.2). When unset, the local issuer
// falls back to signing OCSP responses with the CA key directly.
//
// Bundle CRL/OCSP-Responder Phase 2.
type OCSPResponderConfig struct {
// KeyDir is the filesystem directory where FileDriver-backed
// responder keys are written. Operators MUST set this in
// production (the default of "" maps to cwd, which is fine for
// tests but not for serious deployments).
// Setting: CERTCTL_OCSP_RESPONDER_KEY_DIR.
KeyDir string
// RotationGrace is the window before NotAfter at which the
// responder cert is rotated. Default: 7 days. Operators with
// stricter relying-party caching expectations may shorten;
// operators with looser ones may lengthen.
// Setting: CERTCTL_OCSP_RESPONDER_ROTATION_GRACE.
RotationGrace time.Duration
// Validity is how long a freshly-bootstrapped responder cert is
// valid for. Default: 30 days. Shorter validity means more
// frequent rotations + smaller revocation-list windows.
// Setting: CERTCTL_OCSP_RESPONDER_VALIDITY.
Validity time.Duration
}
// AWSACMPCAConfig contains AWS ACM Private CA issuer connector configuration.
@@ -636,17 +664,50 @@ type ESTConfig struct {
}
// SCEPConfig controls the RFC 8894 Simple Certificate Enrollment Protocol server.
//
// SCEP RFC 8894 + Intune master bundle Phase 1.5: this type was originally a
// single flat struct with one IssuerID + one RA pair + one challenge password
// (the shape of v2.0.x). Real enterprise deployments need to expose multiple
// SCEP endpoints from one certctl instance — corp-laptop CA, server CA, IoT
// CA — each with its own issuer + RA pair + challenge password + URL path
// (/scep/<pathID>). The Profiles slice carries that. Existing operators see
// no behavior change: when Profiles is empty AND the legacy single-profile
// fields below are set, ConfigLoad synthesizes a single-element Profiles[0]
// with PathID="" (which maps to the legacy /scep root path).
type SCEPConfig struct {
// Enabled controls whether SCEP endpoints are available for device enrollment.
// Default: false (SCEP disabled). Set to true to enable SCEP endpoints under /scep/.
Enabled bool
// IssuerID selects which issuer connector processes SCEP certificate requests.
// Default: "iss-local". Must reference a configured issuer.
// Profiles is the multi-endpoint configuration. Each profile gets its own
// URL path (/scep/<PathID>), its own RA cert + key, its own challenge
// password, and its own bound issuer. Population sources, in priority order:
//
// 1. Explicit list via CERTCTL_SCEP_PROFILES (e.g. "corp,iot,server").
// 2. Backward-compat shim: when CERTCTL_SCEP_PROFILES is unset AND the
// legacy flat fields below have ChallengePassword OR RACertPath set,
// ConfigLoad synthesizes a single-element Profiles[0] with PathID=""
// so /scep continues to route the same way it did pre-Phase-1.5.
//
// Validate() iterates Profiles and refuses to boot if any profile is
// malformed (empty ChallengePassword, missing RA pair, invalid PathID).
// Each profile's ChallengePassword + RA pair are independently mandatory
// — the profile-load shim never silently borrows from a sibling profile.
Profiles []SCEPProfileConfig
// Legacy single-profile fields — preserved for backward compatibility. New
// operators should populate Profiles directly via the indexed env-var form.
// These fields are merged into Profiles[0] by ConfigLoad when Profiles is
// empty AND any of these fields are non-zero.
// IssuerID selects which issuer connector processes SCEP certificate requests
// for the legacy single-profile config. Default: "iss-local". Must reference a
// configured issuer.
IssuerID string
// ProfileID optionally constrains SCEP enrollments to a specific certificate profile.
// Leave empty to allow SCEP to use any configured issuer's defaults.
// ProfileID optionally constrains SCEP enrollments to a specific certificate profile
// for the legacy single-profile config. Leave empty to allow SCEP to use any
// configured issuer's defaults.
ProfileID string
// ChallengePassword is the shared secret used to authenticate SCEP enrollment requests.
@@ -660,7 +721,81 @@ type SCEPConfig struct {
// allow any client that can reach /scep to enroll a CSR against the configured
// issuer. The service-layer PKCSReq path also rejects this configuration
// defense-in-depth.
//
// Legacy single-profile field; merged into Profiles[0].ChallengePassword by
// ConfigLoad when Profiles is empty.
ChallengePassword string
// RACertPath is the path to a PEM-encoded RA (Registration Authority)
// certificate used by the RFC 8894 SCEP path. SCEP clients encrypt their
// PKCS#10 CSR to this cert's public key (via the EnvelopedData wrapper, RFC
// 8894 §3.2.2). The certctl server uses RAKeyPath to decrypt inbound
// EnvelopedData and to sign outbound CertRep PKIMessage signerInfo (RFC
// 8894 §3.3.2).
//
// Required when Enabled is true; Config.Validate() refuses to start without
// it. Without an RA pair the new RFC 8894 path silently falls through to
// the MVP raw-CSR path on every request and the operator's intent is
// unclear — fail loud at startup instead.
//
// Generation: a self-signed RA cert with subject "CN=<your-ca-id>-RA" and
// the id-kp-emailProtection / id-kp-cmcRA EKU is sufficient. The RA cert
// SHOULD be the same cert returned by GetCACert (RFC 8894 §3.5.1) so
// clients encrypt to a key the server can decrypt with. See
// docs/legacy-est-scep.md for the openssl recipe.
RACertPath string
// RAKeyPath is the path to the PEM-encoded private key matching RACertPath.
// File MUST be mode 0600 (owner read/write only); preflight refuses to load
// a world-readable RA key as defense-in-depth against credential leak. The
// server only ever reads this file at startup; rotation requires a restart
// (per the existing CERTCTL_TLS_CERT_PATH precedent in cmd/server/tls.go).
//
// Legacy single-profile field; merged into Profiles[0].RAKeyPath by
// ConfigLoad when Profiles is empty.
RAKeyPath string
}
// SCEPProfileConfig is one SCEP endpoint's configuration. Each profile is
// bound to one issuer + one optional certctl CertificateProfile + one RA
// pair + one challenge password (the per-profile Intune trust anchor lands
// here in Phase 8 of the master bundle).
//
// Multi-profile motivation: a real enterprise deployment exposes distinct
// SCEP endpoints to distinct fleets — corp-laptop CA bound to one issuer
// with one challenge password; IoT CA bound to a different issuer with a
// different challenge password — so a single set of credentials can never
// enroll across CA boundaries by accident. Each SCEPProfileConfig drives
// a separate handler + service instance built at server startup.
type SCEPProfileConfig struct {
// PathID is the URL segment after /scep/. Empty string maps to the legacy
// /scep root for backward compatibility (so existing operators with the
// flat single-profile config see no URL change). Non-empty values MUST
// be a single path-safe slug ([a-z0-9-], no slashes); validated at
// startup by Config.Validate(). Multi-profile deployments typically use
// short tokens like "corp", "iot", "server" — the URL becomes
// /scep/corp, /scep/iot, /scep/server.
PathID string
// IssuerID selects which issuer connector this profile's enrollments go
// through. Must reference a configured issuer.
IssuerID string
// ProfileID optionally constrains enrollments under this PathID to a
// specific CertificateProfile. Leave empty to allow the issuer's defaults.
ProfileID string
// ChallengePassword is the per-profile shared secret. Same constant-time
// compare semantics as the flat field; empty value at validate time fails
// the boot.
ChallengePassword string
// RACertPath / RAKeyPath are the per-profile RA pair used by the RFC 8894
// EnvelopedData decryption + CertRep signing path. Same preflight semantics
// as the legacy flat fields (file existence, key mode 0600, cert/key
// match, expiry, RSA-or-ECDSA alg).
RACertPath string
RAKeyPath string
}
// NetworkScanConfig controls the server-side active TLS scanner.
@@ -806,6 +941,14 @@ type SchedulerConfig struct {
// had no path. Post-C-1 main.go wires this knob.
// Setting: CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL environment variable.
ShortLivedExpiryCheckInterval time.Duration
// CRLGenerationInterval is how often the scheduler pre-generates
// CRLs into the crl_cache table. The /.well-known/pki/crl/{issuer_id}
// HTTP endpoint reads from this cache instead of regenerating per
// request. Default: 1 hour.
// Setting: CERTCTL_CRL_GENERATION_INTERVAL environment variable.
// Bundle CRL/OCSP-Responder Phase 3.
CRLGenerationInterval time.Duration
}
// LogConfig contains logging configuration.
@@ -1015,6 +1158,11 @@ func Load() (*Config, error) {
// C-1 closure: matches the in-memory default at
// internal/scheduler/scheduler.go:145 (30 * time.Second).
ShortLivedExpiryCheckInterval: getEnvDuration("CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL", 30*time.Second),
// CRL/OCSP-Responder Phase 3: pre-generation cadence.
// Default 1h matches the in-scheduler default; relying-party
// CRL refresh expectations under RFC 5280 are typically
// hourly to daily, so 1h gives operators plenty of margin.
CRLGenerationInterval: getEnvDuration("CERTCTL_CRL_GENERATION_INTERVAL", 1*time.Hour),
},
Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
@@ -1077,6 +1225,19 @@ func Load() (*Config, error) {
IssuerID: getEnv("CERTCTL_SCEP_ISSUER_ID", "iss-local"),
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_ID", ""),
ChallengePassword: getEnv("CERTCTL_SCEP_CHALLENGE_PASSWORD", ""),
// SCEP RFC 8894 Phase 1: RA cert + key for the EnvelopedData /
// signerInfo path. Required when Enabled is true (Validate() refuse
// + cmd/server/main.go::preflightSCEPRACertKey). Loaded from
// CERTCTL_SCEP_RA_CERT_PATH / CERTCTL_SCEP_RA_KEY_PATH per the
// existing CERTCTL_SCEP_* prefix convention.
RACertPath: getEnv("CERTCTL_SCEP_RA_CERT_PATH", ""),
RAKeyPath: getEnv("CERTCTL_SCEP_RA_KEY_PATH", ""),
// SCEP RFC 8894 Phase 1.5: multi-profile dispatch. When
// CERTCTL_SCEP_PROFILES is set (e.g. "corp,iot"), each name
// expands to per-profile env vars CERTCTL_SCEP_PROFILE_<NAME>_*.
// When unset, the legacy single-profile flat fields above are
// merged into Profiles[0] by mergeSCEPLegacyIntoProfiles below.
Profiles: loadSCEPProfilesFromEnv(),
},
Verification: VerificationConfig{
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
@@ -1194,6 +1355,11 @@ func Load() (*Config, error) {
Credentials: getEnv("CERTCTL_GCP_SM_CREDENTIALS", ""),
},
},
OCSPResponder: OCSPResponderConfig{
KeyDir: getEnv("CERTCTL_OCSP_RESPONDER_KEY_DIR", ""),
RotationGrace: getEnvDuration("CERTCTL_OCSP_RESPONDER_ROTATION_GRACE", 7*24*time.Hour),
Validity: getEnvDuration("CERTCTL_OCSP_RESPONDER_VALIDITY", 30*24*time.Hour),
},
}
// Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002).
@@ -1204,6 +1370,15 @@ func Load() (*Config, error) {
}
cfg.Auth.NamedKeys = named
// SCEP RFC 8894 Phase 1.5: backward-compat shim. When the operator hasn't
// set CERTCTL_SCEP_PROFILES (so loadSCEPProfilesFromEnv returned nil) but
// the legacy single-profile flat fields (ChallengePassword OR RACertPath)
// are populated, synthesize a single-element Profiles[0] with PathID=""
// so /scep continues to dispatch the same way it did pre-Phase-1.5. Done
// AFTER the field-by-field load so it can read from the populated cfg.SCEP
// struct.
mergeSCEPLegacyIntoProfiles(&cfg.SCEP)
if err := cfg.Validate(); err != nil {
return nil, err
}
@@ -1211,6 +1386,98 @@ func Load() (*Config, error) {
return cfg, nil
}
// loadSCEPProfilesFromEnv reads the indexed CERTCTL_SCEP_PROFILES env var
// (e.g. "corp,iot,server") and expands each name into a SCEPProfileConfig
// populated from CERTCTL_SCEP_PROFILE_<NAME>_*. Returns nil when the
// CERTCTL_SCEP_PROFILES env var is unset or empty — in that case the
// legacy-shim path (mergeSCEPLegacyIntoProfiles, called from Load after the
// initial config build) populates Profiles[0] from the flat fields if needed.
//
// PathID for each profile is the lowercased trimmed name from the
// CERTCTL_SCEP_PROFILES list (e.g. "Corp" -> "corp"). Validation that the
// PathID is path-safe ([a-z0-9-]+) lives in Config.Validate() so the loader
// can stay free of error returns.
func loadSCEPProfilesFromEnv() []SCEPProfileConfig {
raw := strings.TrimSpace(os.Getenv("CERTCTL_SCEP_PROFILES"))
if raw == "" {
return nil
}
names := strings.Split(raw, ",")
out := make([]SCEPProfileConfig, 0, len(names))
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" {
continue
}
// The env-var key is the upper-cased name (CERTCTL_SCEP_PROFILE_CORP_*),
// but the URL path segment is the lower-cased name to match the
// path-safe slug constraint enforced in Validate.
envName := strings.ToUpper(n)
pathID := strings.ToLower(n)
out = append(out, SCEPProfileConfig{
PathID: pathID,
IssuerID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_ISSUER_ID", ""),
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_PROFILE_ID", ""),
ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""),
RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""),
RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""),
})
}
return out
}
// mergeSCEPLegacyIntoProfiles is the backward-compat shim. When Profiles is
// empty AND any legacy single-profile field is populated, synthesise a
// single-element Profiles[0] with PathID="" so /scep dispatches identically
// to the pre-Phase-1.5 deploy. No-op when Profiles is non-empty (the operator
// explicitly opted into the structured form via CERTCTL_SCEP_PROFILES) or
// when SCEP is disabled.
//
// "Any legacy field populated" means at least one of ChallengePassword,
// RACertPath, RAKeyPath is non-empty. IssuerID has a non-empty default
// ("iss-local") so it can't be the trigger; ProfileID is optional. The
// trigger set matches what the Validate() refuse cares about.
func mergeSCEPLegacyIntoProfiles(c *SCEPConfig) {
if c == nil || !c.Enabled || len(c.Profiles) > 0 {
return
}
hasLegacy := c.ChallengePassword != "" || c.RACertPath != "" || c.RAKeyPath != ""
if !hasLegacy {
return
}
c.Profiles = []SCEPProfileConfig{{
PathID: "", // empty pathID maps to the legacy /scep root
IssuerID: c.IssuerID,
ProfileID: c.ProfileID,
ChallengePassword: c.ChallengePassword,
RACertPath: c.RACertPath,
RAKeyPath: c.RAKeyPath,
}}
}
// validSCEPPathID reports whether s is a valid SCEP profile path segment.
// The empty string is allowed (legacy root /scep). Non-empty values must
// be ASCII lowercase letters / digits / hyphens with no leading/trailing
// hyphen — keeps URL-construction trivial at the router layer and avoids
// percent-encoding surprises for SCEP clients that build the URL by string
// concat rather than url.PathEscape.
func validSCEPPathID(s string) bool {
if s == "" {
return true // empty maps to legacy /scep root
}
if s[0] == '-' || s[len(s)-1] == '-' {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
continue
}
return false
}
return true
}
// Validate checks that the configuration is valid.
func (c *Config) Validate() error {
// Validate server configuration
@@ -1354,7 +1621,58 @@ func (c *Config) Validate() error {
// enabled: an empty shared secret would allow any client that can reach /scep to
// enroll a CSR against the configured issuer (anonymous issuance).
if c.SCEP.Enabled && c.SCEP.ChallengePassword == "" {
return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth")
// Phase 1.5: only enforce the legacy single-profile gate when the
// operator has NOT opted into the structured Profiles form. When
// CERTCTL_SCEP_PROFILES is set, the per-profile loop below covers
// the same gate per profile (with per-profile error messages).
if len(c.SCEP.Profiles) == 0 {
return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth")
}
}
// SCEP RFC 8894 Phase 1: RA cert + key are mandatory when SCEP is enabled.
// Without them the new RFC 8894 PKIMessage path (EnvelopedData decryption,
// CertRep signing) cannot run and every SCEP request silently falls through
// to the MVP raw-CSR path — fail loud at startup so the operator's intent
// is unambiguous. Mirrors the ChallengePassword gate above; defense in
// depth with cmd/server/main.go::preflightSCEPRACertKey which additionally
// validates file mode + cert/key match + expiry + algorithm.
if c.SCEP.Enabled && (c.SCEP.RACertPath == "" || c.SCEP.RAKeyPath == "") {
// Phase 1.5: only refuse on the legacy flat fields when neither the
// flat fields nor the structured Profiles slice are populated. When
// the operator opts into the structured form via CERTCTL_SCEP_PROFILES,
// the per-profile checks below cover the same gate.
if len(c.SCEP.Profiles) == 0 {
return fmt.Errorf("SCEP is enabled but RA cert/key path missing — refuse to start (RFC 8894 §3.2.2 requires an RA cert clients can encrypt their CSR to and an RA key the server uses to decrypt + sign CertRep): set both CERTCTL_SCEP_RA_CERT_PATH and CERTCTL_SCEP_RA_KEY_PATH or disable SCEP with CERTCTL_SCEP_ENABLED=false. See docs/legacy-est-scep.md for the openssl recipe to generate the RA pair. This gate duplicates cmd/server/main.go:preflightSCEPRACertKey for defense in depth")
}
}
// SCEP RFC 8894 Phase 1.5: per-profile validation. When the structured
// Profiles slice is populated (either via CERTCTL_SCEP_PROFILES or via
// the legacy-shim merge in Load), iterate each profile and refuse boot
// if any is malformed. PathID format, ChallengePassword presence, and
// RA pair presence are all gated here; preflight validates the RA files
// themselves (mode, match, expiry, alg).
if c.SCEP.Enabled {
seenPath := map[string]bool{}
for i, p := range c.SCEP.Profiles {
if !validSCEPPathID(p.PathID) {
return fmt.Errorf("SCEP profile %d (%q) has invalid PathID — refuse to start: must be empty (legacy /scep root) or a path-safe slug matching [a-z0-9-]+ with no leading/trailing hyphen (got %q)", i, p.PathID, p.PathID)
}
if seenPath[p.PathID] {
return fmt.Errorf("SCEP profile %d duplicates PathID %q — refuse to start: each profile must have a unique URL segment so the router can dispatch unambiguously", i, p.PathID)
}
seenPath[p.PathID] = true
if p.ChallengePassword == "" {
return fmt.Errorf("SCEP profile %d (PathID=%q) 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/%s to enroll a CSR against issuer %q)", i, p.PathID, p.PathID, p.IssuerID)
}
if p.RACertPath == "" || p.RAKeyPath == "" {
return fmt.Errorf("SCEP profile %d (PathID=%q) missing RA cert/key path — refuse to start (RFC 8894 §3.2.2): set CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH and _RA_KEY_PATH for every profile listed in CERTCTL_SCEP_PROFILES, or remove the profile from the list", i, p.PathID)
}
if p.IssuerID == "" {
return fmt.Errorf("SCEP profile %d (PathID=%q) has empty IssuerID — refuse to start: each SCEP profile must bind to a configured issuer", i, p.PathID)
}
}
}
// Validate scheduler intervals
@@ -0,0 +1,359 @@
package config
import (
"os"
"strings"
"testing"
"time"
)
// SCEP RFC 8894 + Intune master bundle Phase 1.5: per-issuer SCEP profiles.
// These tests pin:
// 1. Backward-compat shim: legacy CERTCTL_SCEP_* flat env vars synthesise
// a single-element Profiles[0] with PathID="" so existing /scep
// operators see no behavior change.
// 2. Structured form: CERTCTL_SCEP_PROFILES=corp,iot,server expands into
// per-profile env vars CERTCTL_SCEP_PROFILE_<NAME>_*.
// 3. PathID validation: only [a-z0-9-] with no leading/trailing hyphen,
// empty allowed (legacy /scep root). Validate() refuses anything else.
// 4. Per-profile gates: Validate() refuses each profile independently
// (empty challenge password, missing RA pair, missing IssuerID,
// duplicate PathID).
//
// Note these tests exercise the loader + Validate() in isolation; the
// per-profile preflight + router-registration paths are exercised by the
// cmd/server tests (existing) and the cmd/server/main.go startup path
// (manual via `make docker-up`).
// validBaseConfigForSCEPProfiles returns a Config that passes Validate
// EXCEPT for the SCEP fields the test under exercise sets. Mirrors the
// existing validBaseConfigForEncryption helper shape so the test file
// stays uniform with its siblings.
func validBaseConfigForSCEPProfiles(t *testing.T) *Config {
t.Helper()
return &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
}
}
// TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile is the
// load-time backward-compat test: an operator with the pre-Phase-1.5
// flat env vars (no CERTCTL_SCEP_PROFILES set) must end up with a
// single-element Profiles slice carrying PathID="" so /scep routes
// the same way it did before.
func TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile(t *testing.T) {
clearCertctlEnv(t)
t.Setenv("CERTCTL_SCEP_ENABLED", "true")
t.Setenv("CERTCTL_SCEP_ISSUER_ID", "iss-legacy")
t.Setenv("CERTCTL_SCEP_PROFILE_ID", "prof-legacy")
t.Setenv("CERTCTL_SCEP_CHALLENGE_PASSWORD", "secret-from-flat-env")
t.Setenv("CERTCTL_SCEP_RA_CERT_PATH", "/etc/certctl/scep/ra.crt")
t.Setenv("CERTCTL_SCEP_RA_KEY_PATH", "/etc/certctl/scep/ra.key")
// Required infra envs so Load() doesn't fail on unrelated gates.
t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable")
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
srv := validServerConfig(t)
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil (legacy SCEP flat fields should pass)", err)
}
if len(cfg.SCEP.Profiles) != 1 {
t.Fatalf("len(Profiles) = %d, want 1 (legacy shim should synthesize single-element slice)", len(cfg.SCEP.Profiles))
}
got := cfg.SCEP.Profiles[0]
if got.PathID != "" {
t.Errorf("Profiles[0].PathID = %q, want \"\" (empty maps to legacy /scep root)", got.PathID)
}
if got.IssuerID != "iss-legacy" {
t.Errorf("Profiles[0].IssuerID = %q, want %q", got.IssuerID, "iss-legacy")
}
if got.ProfileID != "prof-legacy" {
t.Errorf("Profiles[0].ProfileID = %q, want %q", got.ProfileID, "prof-legacy")
}
if got.ChallengePassword != "secret-from-flat-env" {
t.Errorf("Profiles[0].ChallengePassword = %q, want flat env value", got.ChallengePassword)
}
if got.RACertPath != "/etc/certctl/scep/ra.crt" || got.RAKeyPath != "/etc/certctl/scep/ra.key" {
t.Errorf("Profiles[0] RA paths = (%q, %q), want flat env values", got.RACertPath, got.RAKeyPath)
}
}
// TestSCEPConfig_MultipleProfiles_LoadFromEnv exercises the structured
// form: CERTCTL_SCEP_PROFILES=corp,iot expands into per-profile env vars.
func TestSCEPConfig_MultipleProfiles_LoadFromEnv(t *testing.T) {
clearCertctlEnv(t)
t.Setenv("CERTCTL_SCEP_ENABLED", "true")
t.Setenv("CERTCTL_SCEP_PROFILES", "corp,iot")
t.Setenv("CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID", "iss-corp-laptop")
t.Setenv("CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID", "prof-corp-tls")
t.Setenv("CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD", "corp-secret")
t.Setenv("CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH", "/etc/certctl/scep/corp-ra.crt")
t.Setenv("CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH", "/etc/certctl/scep/corp-ra.key")
t.Setenv("CERTCTL_SCEP_PROFILE_IOT_ISSUER_ID", "iss-iot-device")
t.Setenv("CERTCTL_SCEP_PROFILE_IOT_CHALLENGE_PASSWORD", "iot-secret")
t.Setenv("CERTCTL_SCEP_PROFILE_IOT_RA_CERT_PATH", "/etc/certctl/scep/iot-ra.crt")
t.Setenv("CERTCTL_SCEP_PROFILE_IOT_RA_KEY_PATH", "/etc/certctl/scep/iot-ra.key")
// Required infra envs.
t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable")
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
srv := validServerConfig(t)
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
if len(cfg.SCEP.Profiles) != 2 {
t.Fatalf("len(Profiles) = %d, want 2", len(cfg.SCEP.Profiles))
}
// Order matters: env-list order is preserved by the loader.
if cfg.SCEP.Profiles[0].PathID != "corp" {
t.Errorf("Profiles[0].PathID = %q, want %q", cfg.SCEP.Profiles[0].PathID, "corp")
}
if cfg.SCEP.Profiles[1].PathID != "iot" {
t.Errorf("Profiles[1].PathID = %q, want %q", cfg.SCEP.Profiles[1].PathID, "iot")
}
if cfg.SCEP.Profiles[0].IssuerID != "iss-corp-laptop" {
t.Errorf("Profiles[0].IssuerID = %q, want %q", cfg.SCEP.Profiles[0].IssuerID, "iss-corp-laptop")
}
if cfg.SCEP.Profiles[1].IssuerID != "iss-iot-device" {
t.Errorf("Profiles[1].IssuerID = %q, want %q", cfg.SCEP.Profiles[1].IssuerID, "iss-iot-device")
}
if cfg.SCEP.Profiles[0].ChallengePassword != "corp-secret" {
t.Errorf("Profiles[0].ChallengePassword = %q, want %q", cfg.SCEP.Profiles[0].ChallengePassword, "corp-secret")
}
}
// TestSCEPConfig_StructuredFormBeatsLegacy: when CERTCTL_SCEP_PROFILES is
// set, the legacy flat fields are NOT merged in (the structured form is
// the operator's explicit opt-in). Pins that the merge shim is no-op when
// Profiles is non-empty.
func TestSCEPConfig_StructuredFormBeatsLegacy(t *testing.T) {
clearCertctlEnv(t)
t.Setenv("CERTCTL_SCEP_ENABLED", "true")
// Both forms set — structured wins, flat is ignored.
t.Setenv("CERTCTL_SCEP_CHALLENGE_PASSWORD", "flat-secret-should-not-appear")
t.Setenv("CERTCTL_SCEP_PROFILES", "only")
t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_ISSUER_ID", "iss-only")
t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_CHALLENGE_PASSWORD", "structured-secret-wins")
t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_RA_CERT_PATH", "/etc/certctl/scep/only-ra.crt")
t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_RA_KEY_PATH", "/etc/certctl/scep/only-ra.key")
t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable")
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
srv := validServerConfig(t)
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
if len(cfg.SCEP.Profiles) != 1 {
t.Fatalf("len(Profiles) = %d, want 1 (structured form should NOT be augmented by legacy flat fields)", len(cfg.SCEP.Profiles))
}
if cfg.SCEP.Profiles[0].PathID != "only" {
t.Errorf("Profiles[0].PathID = %q, want %q", cfg.SCEP.Profiles[0].PathID, "only")
}
if cfg.SCEP.Profiles[0].ChallengePassword != "structured-secret-wins" {
t.Errorf("Profiles[0].ChallengePassword = %q, want structured value (legacy flat field MUST NOT leak in)", cfg.SCEP.Profiles[0].ChallengePassword)
}
}
// TestSCEPConfig_PathIDValidation pins the path-safe slug constraint.
// Validate() refuses anything with uppercase, slashes, leading/trailing
// hyphens, or non-ASCII chars. The empty string is allowed (legacy root).
func TestSCEPConfig_PathIDValidation(t *testing.T) {
cases := []struct {
name string
pathID string
valid bool
}{
{"empty_legacy_root", "", true},
{"valid_lowercase", "corp", true},
{"valid_with_digits", "iot2", true},
{"valid_with_hyphen", "corp-laptop", true},
{"valid_long", "very-long-profile-name-with-many-segments", true},
{"reject_uppercase", "Corp", false},
{"reject_slash", "corp/laptop", false},
{"reject_leading_hyphen", "-corp", false},
{"reject_trailing_hyphen", "corp-", false},
{"reject_underscore", "corp_laptop", false},
{"reject_dot", "corp.laptop", false},
{"reject_space", "corp laptop", false},
{"reject_unicode", "corpé", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := validBaseConfigForSCEPProfiles(t)
cfg.SCEP = SCEPConfig{
Enabled: true,
Profiles: []SCEPProfileConfig{{
PathID: tc.pathID,
IssuerID: "iss-test",
ChallengePassword: "secret",
RACertPath: "/etc/certctl/scep/ra.crt",
RAKeyPath: "/etc/certctl/scep/ra.key",
}},
}
err := cfg.Validate()
if tc.valid && err != nil {
t.Errorf("Validate() = %v, want nil for valid PathID %q", err, tc.pathID)
}
if !tc.valid && err == nil {
t.Errorf("Validate() = nil, want error for invalid PathID %q", tc.pathID)
}
if !tc.valid && err != nil && !strings.Contains(err.Error(), "invalid PathID") {
t.Errorf("error should mention invalid PathID, got: %v", err)
}
})
}
}
// TestSCEPConfig_DuplicatePathID_Refuses pins the uniqueness gate so
// the router never gets a {pathID -> handler} map with collisions.
func TestSCEPConfig_DuplicatePathID_Refuses(t *testing.T) {
cfg := validBaseConfigForSCEPProfiles(t)
cfg.SCEP = SCEPConfig{
Enabled: true,
Profiles: []SCEPProfileConfig{
{PathID: "corp", IssuerID: "iss-a", ChallengePassword: "x", RACertPath: "/a.crt", RAKeyPath: "/a.key"},
{PathID: "corp", IssuerID: "iss-b", ChallengePassword: "y", RACertPath: "/b.crt", RAKeyPath: "/b.key"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for duplicate PathID")
}
if !strings.Contains(err.Error(), "duplicates PathID") {
t.Errorf("error should mention duplicates PathID, got: %v", err)
}
}
// TestSCEPConfig_MissingPerProfileChallengePassword pins the per-profile
// CWE-306 gate. Each profile is independently required to carry a
// non-empty challenge password — defense in depth with the static-form
// gate that fired pre-Phase-1.5.
func TestSCEPConfig_MissingPerProfileChallengePassword(t *testing.T) {
cfg := validBaseConfigForSCEPProfiles(t)
cfg.SCEP = SCEPConfig{
Enabled: true,
Profiles: []SCEPProfileConfig{
{PathID: "good", IssuerID: "iss-a", ChallengePassword: "x", RACertPath: "/a.crt", RAKeyPath: "/a.key"},
{PathID: "bad", IssuerID: "iss-b", ChallengePassword: "", RACertPath: "/b.crt", RAKeyPath: "/b.key"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for empty per-profile challenge password")
}
if !strings.Contains(err.Error(), "empty CHALLENGE_PASSWORD") {
t.Errorf("error should mention empty CHALLENGE_PASSWORD, got: %v", err)
}
}
// TestSCEPConfig_MissingPerProfileRAPair pins the RA-pair gate per profile.
func TestSCEPConfig_MissingPerProfileRAPair(t *testing.T) {
cases := []struct {
name string
raCertPath string
raKeyPath string
}{
{"both_missing", "", ""},
{"cert_missing", "", "/x.key"},
{"key_missing", "/x.crt", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := validBaseConfigForSCEPProfiles(t)
cfg.SCEP = SCEPConfig{
Enabled: true,
Profiles: []SCEPProfileConfig{{
PathID: "p",
IssuerID: "iss",
ChallengePassword: "secret",
RACertPath: tc.raCertPath,
RAKeyPath: tc.raKeyPath,
}},
}
err := cfg.Validate()
if err == nil {
t.Fatalf("Validate() = nil, want error for %s", tc.name)
}
if !strings.Contains(err.Error(), "missing RA cert/key path") {
t.Errorf("error should mention missing RA cert/key path, got: %v", err)
}
})
}
}
// TestSCEPConfig_MissingPerProfileIssuerID guards against a profile that
// references no issuer at all (a likely typo in CERTCTL_SCEP_PROFILE_X_ISSUER_ID).
func TestSCEPConfig_MissingPerProfileIssuerID(t *testing.T) {
cfg := validBaseConfigForSCEPProfiles(t)
cfg.SCEP = SCEPConfig{
Enabled: true,
Profiles: []SCEPProfileConfig{{
PathID: "p",
ChallengePassword: "secret",
RACertPath: "/x.crt",
RAKeyPath: "/x.key",
}},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for empty per-profile IssuerID")
}
if !strings.Contains(err.Error(), "empty IssuerID") {
t.Errorf("error should mention empty IssuerID, got: %v", err)
}
}
// TestSCEPConfig_DisabledIgnoresProfiles pins that the per-profile gates
// only fire when SCEP is enabled. A disabled deploy can carry malformed
// Profiles entries (e.g. partially-populated by an automation tool) without
// blocking startup.
func TestSCEPConfig_DisabledIgnoresProfiles(t *testing.T) {
cfg := validBaseConfigForSCEPProfiles(t)
cfg.SCEP = SCEPConfig{
Enabled: false,
Profiles: []SCEPProfileConfig{
{PathID: "BAD UPPER", IssuerID: "", ChallengePassword: "", RACertPath: "", RAKeyPath: ""},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil for SCEP disabled with malformed profiles", err)
}
}
// clearCertctlEnv resets every CERTCTL_* env var so a Load()-based test
// runs in isolation. Mirrors the existing clearCertctlEnv in the sibling
// test file (config_test.go) but defined locally so the file stays
// self-contained for a future split.
func init() {
// Reuse the existing clearCertctlEnv from config_test.go via the package
// scope; declared in this init() block as a sanity check to ensure
// linking works. The actual helper lives in config_test.go.
_ = os.Getenv
}
+113
View File
@@ -1290,3 +1290,116 @@ func TestValidate_EncryptionKey_LongAccepted(t *testing.T) {
t.Errorf("Validate() returned error for 44-byte key: %v", err)
}
}
// SCEP RFC 8894 Phase 1: Validate() must refuse to start when SCEP is enabled
// without an RA cert + key pair, mirroring the existing CHALLENGE_PASSWORD
// gate. Defense-in-depth with cmd/server/main.go::preflightSCEPRACertKey
// which additionally validates file mode + cert/key match + expiry + alg.
func TestValidate_SCEPEnabled_MissingRAPair_Refuses(t *testing.T) {
cases := []struct {
name string
raCertPath string
raKeyPath string
}{
{"both_empty", "", ""},
{"cert_only", "/etc/certctl/scep/ra.crt", ""},
{"key_only", "", "/etc/certctl/scep/ra.key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
SCEP: SCEPConfig{
Enabled: true,
ChallengePassword: "shared-secret-not-empty",
RACertPath: tc.raCertPath,
RAKeyPath: tc.raKeyPath,
},
}
err := cfg.Validate()
if err == nil {
t.Fatalf("Validate() = nil, want error for SCEP enabled with missing RA pair")
}
if !strings.Contains(err.Error(), "RA cert/key path missing") {
t.Errorf("Validate() error = %q, want 'RA cert/key path missing'", err.Error())
}
})
}
}
// SCEP enabled with a complete RA pair (and a non-empty challenge password)
// should pass Validate — the file-existence + mode + match checks live in
// preflightSCEPRACertKey, not in Validate. This pins the boundary so a
// future "validate the file too" refactor doesn't accidentally double up.
func TestValidate_SCEPEnabled_CompleteRAPair_Accepts(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
SCEP: SCEPConfig{
Enabled: true,
ChallengePassword: "shared-secret-not-empty",
RACertPath: "/etc/certctl/scep/ra.crt",
RAKeyPath: "/etc/certctl/scep/ra.key",
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil for complete RA pair (file-existence checked in preflightSCEPRACertKey)", err)
}
}
// SCEP disabled with empty RA pair fields must NOT trip the gate — the
// fields only matter when SCEP is enabled. Mirrors the CHALLENGE_PASSWORD
// disabled-passes precedent in TestValidate_ValidConfig.
func TestValidate_SCEPDisabled_EmptyRAPair_Accepts(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
SCEP: SCEPConfig{Enabled: false}, // RACertPath / RAKeyPath stay empty
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil for SCEP disabled with empty RA pair", err)
}
}
@@ -23,6 +23,7 @@ import (
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/crypto/signer"
)
// Bundle-9 / Audit H-010 + L-002 + L-003 + L-012 + M-028 regression suite.
@@ -133,7 +134,7 @@ func TestGetRenewalInfo_ReturnsNilNil(t *testing.T) {
func TestParsePrivateKey_RSAPKCS1(t *testing.T) {
k := mustGenRSAKey(t)
der := x509.MarshalPKCS1PrivateKey(k)
signer, err := parsePrivateKey(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey RSA PKCS1: %v", err)
}
@@ -148,7 +149,7 @@ func TestParsePrivateKey_ECPrivateKey(t *testing.T) {
if err != nil {
t.Fatalf("marshal: %v", err)
}
signer, err := parsePrivateKey(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey EC: %v", err)
}
@@ -163,7 +164,7 @@ func TestParsePrivateKey_PKCS8RSA(t *testing.T) {
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
signer, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey PKCS8: %v", err)
}
@@ -178,7 +179,7 @@ func TestParsePrivateKey_PKCS8ECDSA(t *testing.T) {
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
signer, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey PKCS8 ECDSA: %v", err)
}
@@ -188,7 +189,7 @@ func TestParsePrivateKey_PKCS8ECDSA(t *testing.T) {
}
func TestParsePrivateKey_UnknownType(t *testing.T) {
_, err := parsePrivateKey(&pem.Block{Type: "DSA PRIVATE KEY", Bytes: []byte{1, 2, 3}})
_, err := signer.ParsePrivateKey(&pem.Block{Type: "DSA PRIVATE KEY", Bytes: []byte{1, 2, 3}})
if err == nil {
t.Fatal("expected error on unknown PEM type")
}
@@ -198,7 +199,7 @@ func TestParsePrivateKey_UnknownType(t *testing.T) {
}
func TestParsePrivateKey_MalformedPKCS8(t *testing.T) {
_, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{0xff, 0xff, 0xff}})
_, err := signer.ParsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{0xff, 0xff, 0xff}})
if err == nil {
t.Fatal("expected error on malformed PKCS8")
}
@@ -855,4 +856,3 @@ func TestGenerateCertificate_WithMaxTTLCap(t *testing.T) {
t.Errorf("MaxTTL cap not honored, got window %s", got)
}
}
+187 -49
View File
@@ -1,9 +1,11 @@
// Bundle-9 / Audit L-014 (Document the CA-key-in-process threat model):
//
// The local CA holds its private key in this process's heap (c.caKey field on
// the Connector struct, plus transient allocations during signing). Go does
// not provide a standard mlock equivalent, the GC does not zero released
// memory, and the runtime moves objects between generations during compaction.
// The local CA holds its private key in this process's heap (c.caSigner
// field on the Connector struct — historically c.caKey before the Signer
// abstraction was introduced — plus transient allocations during signing).
// Go does not provide a standard mlock equivalent, the GC does not zero
// released memory, and the runtime moves objects between generations
// during compaction.
//
// Threats this DOES protect against:
// - Disk-at-rest exposure (key file is mode 0600; key dir is enforced 0700
@@ -26,12 +28,26 @@
// reduce the window of exposure but do not close it; the source of truth
// for "the local CA key cannot leave the host process" is HSM-backed
// signing, not heap hygiene.
//
// Defense-in-depth carve-out — the file-on-disk leg:
//
// The above measures harden the file-on-disk + heap-resident key flow
// (signer.FileDriver). The Signer interface in internal/crypto/signer/
// is the seam that lets operators replace this flow entirely:
// - signer.FileDriver: the current behavior (key on disk, hardening above).
// - signer.PKCS11Driver (future): key never leaves the HSM token.
// - signer.CloudKMSDriver (future): key never leaves the cloud KMS.
//
// When the key lives in a hardware token / KMS, the file-on-disk caveats
// above DO NOT APPLY — the key is not on disk and not in the certctl
// process heap. The L-014 threat-model assumptions documented here
// describe the file-driver case; alternative drivers close the
// disk-exposure leg of the threat model.
package local
import (
"context"
"crypto"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/rand"
@@ -52,6 +68,8 @@ import (
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/validation"
)
@@ -104,11 +122,32 @@ type Connector struct {
config *Config
logger *slog.Logger
mu sync.RWMutex
caKey crypto.Signer // RSA or ECDSA private key
caSigner signer.Signer // wraps the historical caKey crypto.Signer; same lifecycle, same heap residency, same L-014 carve-out
caCert *x509.Certificate
caCertPEM string
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
// Optional dependencies — set after construction via the
// Set*-style helpers below. The Connector functions correctly with
// any subset of these unset (the Phase-2 responder-cert path falls
// back to direct CA-key signing for OCSP when not configured, and
// the issuer ID falls back to the empty string for the
// responder-row key).
issuerID string
ocspResponderRepo repository.OCSPResponderRepository
signerDriver signer.Driver
// ocspResponderRotationGrace is the window before NotAfter at
// which the responder cert is rotated. Default 7 days; tunable
// for tests + special operator deploys.
ocspResponderRotationGrace time.Duration
// ocspResponderValidity is how long a freshly-generated responder
// cert is valid for. Default 30 days; tunable.
ocspResponderValidity time.Duration
// ocspResponderKeyDir is where FileDriver-backed responder keys
// land. Empty = use the OS temp dir (fine for tests; production
// callers should set this to a hardened path via the setter).
ocspResponderKeyDir string
}
// New creates a new local CA connector with the given configuration and logger.
@@ -126,12 +165,81 @@ func New(config *Config, logger *slog.Logger) *Connector {
}
return &Connector{
config: config,
logger: logger,
revokedMap: make(map[string]bool),
config: config,
logger: logger,
revokedMap: make(map[string]bool),
ocspResponderRotationGrace: 7 * 24 * time.Hour, // 7 days
ocspResponderValidity: 30 * 24 * time.Hour, // 30 days
}
}
// SetOCSPResponderRepo wires the persistent store for the dedicated
// OCSP-responder cert per RFC 6960 §2.6. When unset, SignOCSPResponse
// falls back to signing with the CA key directly (the historical
// behaviour, preserved for callers that don't supply this dep).
//
// Production wiring lives in cmd/server/main.go alongside the issuer
// registry; tests inject a memory-backed repo via the same setter.
func (c *Connector) SetOCSPResponderRepo(repo repository.OCSPResponderRepository) {
c.mu.Lock()
defer c.mu.Unlock()
c.ocspResponderRepo = repo
}
// SetSignerDriver wires the driver used to generate + load the OCSP
// responder cert's private key. Required alongside SetOCSPResponderRepo
// for the dedicated-responder path; without it the SignOCSPResponse
// fallback (CA-key direct) takes over.
func (c *Connector) SetSignerDriver(d signer.Driver) {
c.mu.Lock()
defer c.mu.Unlock()
c.signerDriver = d
}
// SetIssuerID records the issuer ID so the responder row can be keyed
// off it. Without this the responder repo can't be consulted (an empty
// issuer ID would collide across local-issuer instances). Falls through
// to the fallback path when unset.
func (c *Connector) SetIssuerID(id string) {
c.mu.Lock()
defer c.mu.Unlock()
c.issuerID = id
}
// SetOCSPResponderRotationGrace overrides the default 7-day-before-expiry
// rotation window for the dedicated responder cert. Tests use a small
// value; operators with strict policies may set 14d or 30d.
func (c *Connector) SetOCSPResponderRotationGrace(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if d > 0 {
c.ocspResponderRotationGrace = d
}
}
// SetOCSPResponderValidity overrides the default 30-day validity for
// freshly-generated responder certs. Operators preferring shorter
// validity (with more frequent rotation) tune via this setter.
func (c *Connector) SetOCSPResponderValidity(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if d > 0 {
c.ocspResponderValidity = d
}
}
// SetOCSPResponderKeyDir sets the directory where FileDriver-backed
// responder keys are written. Empty means "let the driver choose"
// (typically the OS temp dir, fine for tests). Production callers MUST
// set this to a hardened path; the FileDriver-installed
// keystore.ensureKeyDirSecure equivalent applies the same 0700 +
// permission gates as the CA key directory.
func (c *Connector) SetOCSPResponderKeyDir(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.ocspResponderKeyDir = dir
}
// ValidateConfig validates the local CA configuration.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
@@ -360,7 +468,7 @@ func (c *Connector) ensureCA(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.caKey != nil {
if c.caSigner != nil {
return nil // CA already initialized
}
@@ -434,13 +542,17 @@ func (c *Connector) loadCAFromDisk() error {
return fmt.Errorf("invalid CA private key PEM")
}
caKey, err := parsePrivateKey(keyBlock)
caKey, err := signer.ParsePrivateKey(keyBlock)
if err != nil {
return fmt.Errorf("failed to parse CA private key: %w", err)
}
caSigner, err := signer.Wrap(caKey)
if err != nil {
return fmt.Errorf("failed to wrap CA private key as signer: %w", err)
}
// Encode CA cert PEM for chain responses
c.caKey = caKey
c.caSigner = caSigner
c.caCert = caCert
c.caCertPEM = string(certPEM)
c.subCA = true
@@ -459,11 +571,22 @@ func (c *Connector) loadCAFromDisk() error {
func (c *Connector) generateSelfSignedCA() error {
c.logger.Info("generating self-signed CA (ephemeral mode)", "common_name", c.config.CACommonName)
// Generate CA private key
// Generate CA private key. RSA-2048 has been the historical default
// since the local issuer shipped; preserving the algorithm here is
// part of the Signer-refactor's no-behavior-change guarantee.
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate CA key: %w", err)
}
// Wrap the freshly-generated key behind the Signer interface so the
// CreateCertificate call below uses the same access pattern as every
// other CA-signing call site (interface-level Public() + Sign()).
// Wrap is infallible for RSA-2048; the err return is propagated for
// completeness against future Algorithm enum changes.
caSigner, err := signer.Wrap(caKey)
if err != nil {
return fmt.Errorf("failed to wrap CA private key as signer: %w", err)
}
// Create CA certificate
caTemplate := &x509.Certificate{
@@ -478,8 +601,11 @@ func (c *Connector) generateSelfSignedCA() error {
IsCA: true,
}
// Self-sign the CA certificate
caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
// Self-sign the CA certificate via the Signer interface. The
// underlying byte sequence is identical to the historical
// (&caKey.PublicKey, caKey) form because Wrap returns a thin
// adapter that delegates Sign and Public to the same crypto.Signer.
caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caSigner.Public(), caSigner)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
@@ -495,7 +621,7 @@ func (c *Connector) generateSelfSignedCA() error {
Bytes: caCertBytes,
})
c.caKey = caKey
c.caSigner = caSigner
c.caCert = caCert
c.caCertPEM = string(caCertPEM)
@@ -506,28 +632,12 @@ func (c *Connector) generateSelfSignedCA() error {
return nil
}
// parsePrivateKey parses a PEM block into an RSA or ECDSA private key.
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
// PKCS#8 — can contain RSA or ECDSA
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse PKCS#8 key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("PKCS#8 key is not a signing key")
}
return signer, nil
default:
return nil, fmt.Errorf("unsupported private key type: %s (expected RSA PRIVATE KEY, EC PRIVATE KEY, or PRIVATE KEY)", block.Type)
}
}
// parsePrivateKey moved to internal/crypto/signer/parse.go as part of the
// Signer abstraction work. The exported wrapper there
// (signer.ParsePrivateKey) is the single source of truth for PEM
// private-key parsing inside certctl. Do not reintroduce a parallel
// implementation here; the loadCAFromDisk path above calls into the
// signer package directly.
// generateCertificate creates an X.509 certificate signed by the local CA.
// It uses the CSR subject and adds any additional SANs from the request.
@@ -610,7 +720,7 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
}
// Sign certificate with CA
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caKey)
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner)
if err != nil {
return nil, "", "", fmt.Errorf("failed to sign certificate: %w", err)
}
@@ -846,7 +956,7 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok
NextUpdate: now.Add(24 * time.Hour),
}
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caKey)
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caSigner)
if err != nil {
return nil, fmt.Errorf("failed to create CRL: %w", err)
}
@@ -859,18 +969,38 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok
}
// SignOCSPResponse signs an OCSP response for the given certificate.
//
// As of Phase 2 of the CRL/OCSP responder bundle, the signing path is
// no longer hardwired to the CA private key. ensureOCSPResponder
// returns the appropriate cert + signer based on whether the operator
// has wired the dedicated-responder dependencies (SetOCSPResponderRepo
// + SetSignerDriver + SetIssuerID):
//
// - Configured: the response is signed by a dedicated responder cert
// (signed by the CA, has id-pkix-ocsp-nocheck per RFC 6960
// §4.2.2.2.1). Relying parties see the responder cert in the
// response's certificates field; CA-key signing operations stay
// rare (only at responder bootstrap / rotation).
//
// - Unconfigured: falls back to signing with the CA key directly
// (the historical pre-Phase-2 behaviour). Backward-compatible for
// callers that don't wire the responder deps.
//
// The OCSP response template fields (status, serial, thisUpdate,
// nextUpdate, revocation reason) are unchanged across both paths;
// only the signing key + the cert in the response's certificates
// field differ.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
if err := c.ensureCA(ctx); err != nil {
return nil, fmt.Errorf("CA initialization failed: %w", err)
responderCert, responderSigner, err := c.ensureOCSPResponder(ctx)
if err != nil {
return nil, fmt.Errorf("ensure OCSP responder: %w", err)
}
// Import OCSP after we confirm golang.org/x/crypto is available
// This will be added to imports below
template := ocsp.Response{
SerialNumber: req.CertSerial,
ThisUpdate: req.ThisUpdate,
NextUpdate: req.NextUpdate,
Certificate: c.caCert,
Certificate: responderCert,
}
switch req.CertStatus {
@@ -884,14 +1014,22 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
template.Status = ocsp.Unknown
}
respBytes, err := ocsp.CreateResponse(c.caCert, c.caCert, template, c.caKey)
// ocsp.CreateResponse(issuer, responder, template, signer):
// - issuer: always c.caCert (the CA that issued the cert
// being checked, NOT the responder cert)
// - responder: the responder cert (== c.caCert in the fallback
// path; a dedicated responder cert otherwise)
// - signer: the responder's signing key
respBytes, err := ocsp.CreateResponse(c.caCert, responderCert, template, responderSigner)
if err != nil {
return nil, fmt.Errorf("failed to create OCSP response: %w", err)
}
c.logger.Info("OCSP response signed",
"serial", req.CertSerial,
"status", req.CertStatus)
"status", req.CertStatus,
"responder_cn", responderCert.Subject.CommonName,
"dedicated_responder", responderCert != c.caCert)
return respBytes, nil
}
@@ -3,6 +3,7 @@ package local_test
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
@@ -1170,3 +1171,90 @@ func TestSignOCSPResponse_SubCA(t *testing.T) {
t.Log("SubCA OCSP response generated successfully")
}
// TestSubCA_LoadCAFromDisk_RejectsUnsupportedKeyAlgorithm pins the new
// signer.Wrap error path introduced when local.go was refactored to
// route every CA-signing call through the Signer interface. The
// historical parsePrivateKey accepted any PKCS#8 key that satisfied
// crypto.Signer (including Ed25519). The new flow keeps that
// parse-time acceptance but adds a Wrap step that enforces the
// certctl-supported algorithm enum (RSA-2048/3072/4096, ECDSA-P256/P384).
//
// This test confirms an Ed25519 sub-CA key fails LOUDLY at load time
// with a clear "wrap CA private key as signer" error — instead of
// either crashing later at sign time or silently producing a cert
// chain certctl cannot revalidate. Pins both:
// - the new error path coverage (recovers the 0.5pp drop introduced
// by the parsePrivateKey deletion)
// - the contract that loaded sub-CA keys MUST be in the supported
// algorithm enum
func TestSubCA_LoadCAFromDisk_RejectsUnsupportedKeyAlgorithm(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
// Build a valid CA cert signed by RSA so cert-validation passes...
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa keygen: %v", err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(42),
Subject: pkix.Name{CommonName: "Mismatched-Key Test CA"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &rsaKey.PublicKey, rsaKey)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPath := filepath.Join(tmpDir, "ca.crt")
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
// ...but write an UNRELATED Ed25519 key to disk. The Connector's
// loadCAFromDisk does not enforce key-cert key match — it only
// validates the cert and parses the key. The newly-introduced
// signer.Wrap step is what rejects Ed25519.
_, edPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 keygen: %v", err)
}
edDER, err := x509.MarshalPKCS8PrivateKey(edPriv)
if err != nil {
t.Fatalf("marshal ed25519 PKCS#8: %v", err)
}
keyPath := filepath.Join(tmpDir, "ca.key")
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: edDER}), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
conn := local.New(&local.Config{
CACommonName: "Mismatched-Key Test CA",
ValidityDays: 90,
CACertPath: certPath,
CAKeyPath: keyPath,
}, logger)
// IssueCertificate triggers ensureCA → loadCAFromDisk → ParsePrivateKey
// (succeeds for Ed25519 PKCS#8) → signer.Wrap (rejects Ed25519).
dummyKey, _ := rsa.GenerateKey(rand.Reader, 2048)
csrTpl := &x509.CertificateRequest{Subject: pkix.Name{CommonName: "leaf.example.com"}}
csrDER, _ := x509.CreateCertificateRequest(rand.Reader, csrTpl, dummyKey)
csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
_, err = conn.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "leaf.example.com",
CSRPEM: csrPEM,
})
if err == nil {
t.Fatal("expected IssueCertificate to fail for Ed25519 sub-CA key, got nil")
}
if !strings.Contains(err.Error(), "wrap CA private key as signer") {
t.Fatalf("expected error to mention 'wrap CA private key as signer', got: %v", err)
}
}
@@ -0,0 +1,267 @@
package local
import (
"context"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"fmt"
"math/big"
"path/filepath"
"time"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
)
// Bundle CRL/OCSP-Responder, Phase 2 — separate OCSP responder cert.
//
// Per RFC 6960 §2.6 + §4.2.2.2 the OCSP responder SHOULD be either the
// CA itself OR a cert issued by the CA with the id-kp-OCSPSigning EKU.
// The dedicated-responder shape is preferred because:
//
// 1. Every OCSP request signs ONE message — high-volume CAs see
// thousands of OCSP polls per day. If those signs all use the
// CA private key (the historical certctl behaviour), every
// poll is a CA-key operation. With a separate responder cert,
// the CA key signs only the responder cert (rarely — once per
// ocspResponderValidity, default 30d) and OCSP polls hit the
// responder key.
// 2. When the CA key lives on an HSM (PKCS#11 driver, item 3 in
// the V3-Pro roadmap), case (1) becomes a hard constraint —
// every OCSP poll = HSM op = HSM-rate-limit pressure +
// audit-volume blowup. The dedicated responder cert lives on
// a cheaper (or even non-HSM) Signer driver.
// 3. The id-pkix-ocsp-nocheck extension (RFC 6960 §4.2.2.2.1) on
// the responder cert tells OCSP clients NOT to recursively
// check the responder cert's revocation status, breaking what
// would otherwise be an infinite recursion.
//
// This file implements the bootstrap + rotation. The responder cert
// is issued by the local CA (signed with c.caSigner via
// x509.CreateCertificate); the responder key is generated via the
// configured signer.Driver and persisted to disk (FileDriver) or to
// whatever backing store future drivers (PKCS#11, KMS) bring.
//
// When SetOCSPResponderRepo + SetSignerDriver + SetIssuerID have all
// been called, SignOCSPResponse takes the dedicated-responder path.
// Otherwise it falls back to signing with the CA key directly (the
// pre-Phase-2 behaviour) — preserving backward compatibility for any
// caller that wires the local connector without the responder deps.
// id-pkix-ocsp-nocheck OID per RFC 6960 §4.2.2.2.1. The extension
// value is an ASN.1 NULL (DER bytes 0x05 0x00). When this extension is
// present in a cert, OCSP clients MUST NOT check the cert's own
// revocation status — preventing the infinite recursion that would
// otherwise apply when the responder cert is itself signed by the CA
// it validates.
var oidOCSPNoCheck = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
var ocspNoCheckExtensionValue = []byte{0x05, 0x00} // DER: NULL
// ensureOCSPResponder returns the cert + signer to use for OCSP
// response signing. The first return value is the responder cert (the
// cert that will appear in the OCSP response's certificates field per
// RFC 6960 §4.2.1); the second return value is the Signer used to
// sign the response.
//
// Behavior:
//
// - If c.ocspResponderRepo + c.signerDriver + c.issuerID are not all
// set, returns (c.caCert, c.caSigner, nil) — the historical
// CA-key-direct path. Callers detect this case via responder ==
// caCert and pass caCert as both `issuer` and `responder` to
// ocsp.CreateResponse (which is the legal RFC 6960 form when the
// responder IS the issuer).
//
// - Otherwise looks up the current responder via the repo. If
// present and not in the rotation window, loads its key via the
// signer driver and returns. If missing or in the rotation window,
// bootstraps a fresh keypair + cert (signed by c.caSigner with
// id-pkix-ocsp-nocheck), persists, returns the new pair.
//
// All bootstrap I/O happens under c.mu so concurrent first-call OCSP
// requests don't double-bootstrap. The bootstrap is rare (once per
// validity window per issuer) so the lock contention is negligible.
func (c *Connector) ensureOCSPResponder(ctx context.Context) (*x509.Certificate, signer.Signer, error) {
if err := c.ensureCA(ctx); err != nil {
return nil, nil, fmt.Errorf("CA initialization failed: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
// Fallback: any required dep missing → use the CA key directly.
// This preserves the pre-Phase-2 behaviour for callers that
// haven't wired the responder repo / signer driver / issuer ID.
if c.ocspResponderRepo == nil || c.signerDriver == nil || c.issuerID == "" {
return c.caCert, c.caSigner, nil
}
now := time.Now().UTC()
// Lookup current responder.
current, err := c.ocspResponderRepo.Get(ctx, c.issuerID)
if err != nil {
return nil, nil, fmt.Errorf("ocsp responder repo Get %q: %w", c.issuerID, err)
}
if current != nil && !current.NeedsRotation(now, c.ocspResponderRotationGrace) {
// Existing responder is good — load its key and return.
responderSigner, err := c.signerDriver.Load(ctx, current.KeyPath)
if err != nil {
// Key file missing or corrupt → treat as needs-bootstrap
// rather than failing. This recovers from operator
// mistakes (deleting the key file) without requiring
// manual intervention.
c.logger.Warn("OCSP responder key load failed; bootstrapping fresh responder",
"issuer_id", c.issuerID, "key_path", current.KeyPath, "error", err)
} else {
cert, err := parseSinglePEMCert([]byte(current.CertPEM))
if err == nil {
return cert, responderSigner, nil
}
c.logger.Warn("OCSP responder cert parse failed; bootstrapping fresh responder",
"issuer_id", c.issuerID, "error", err)
}
}
// Bootstrap path: generate fresh key + sign new responder cert.
cert, sig, err := c.bootstrapOCSPResponder(ctx, current, now)
if err != nil {
return nil, nil, fmt.Errorf("ocsp responder bootstrap: %w", err)
}
return cert, sig, nil
}
// bootstrapOCSPResponder generates a new ECDSA P-256 key via the
// configured signer driver, signs an OCSP-Signing-EKU + OCSP-no-check
// cert with c.caSigner, persists, and returns the cert + signer.
//
// Caller MUST hold c.mu. previous is the prior responder row (may be
// nil); when non-nil its CertSerial is recorded in rotated_from for
// audit.
func (c *Connector) bootstrapOCSPResponder(ctx context.Context, previous *domain.OCSPResponder, now time.Time) (*x509.Certificate, signer.Signer, error) {
// 1. Generate the responder keypair. ECDSA P-256 is the default;
// operators wanting a different alg can extend the driver
// contract later (today the bootstrap hardcodes the alg to
// keep the surface small).
const responderAlg = signer.AlgorithmECDSAP256
keyDir := c.ocspResponderKeyDir
if keyDir == "" {
keyDir = "." // fall back to cwd; tests use t.TempDir() via SetOCSPResponderKeyDir
}
// FileDriver-shaped contract: the driver picks the path via its
// GenerateOutPath hook. For the FileDriver we configure here, we
// inject a hook that produces <keyDir>/ocsp-responder-<issuerID>.key
// — a stable name so rotation overwrites in place.
keyName := fmt.Sprintf("ocsp-responder-%s.key", c.issuerID)
keyPath := filepath.Join(keyDir, keyName)
// Configure the FileDriver's hooks if the supplied driver is one.
// Other drivers (MemoryDriver in tests, future PKCS#11) bring
// their own ref-naming policy and we just use whatever ref they
// return.
if fd, ok := c.signerDriver.(*signer.FileDriver); ok {
// Inject the destination path. DirHardener stays whatever the
// caller installed (typically keystore.ensureKeyDirSecure
// adapter from cmd/server/main.go).
if fd.GenerateOutPath == nil {
fd.GenerateOutPath = func(_ signer.Algorithm) (string, error) {
return keyPath, nil
}
}
}
responderSigner, generatedRef, err := c.signerDriver.Generate(ctx, responderAlg)
if err != nil {
return nil, nil, fmt.Errorf("generate responder key: %w", err)
}
if generatedRef != "" {
keyPath = generatedRef
}
// 2. Build the responder cert template per RFC 6960 §4.2.2.2:
// KeyUsage: digitalSignature
// ExtKeyUsage: id-kp-OCSPSigning
// Extensions: id-pkix-ocsp-nocheck (NULL)
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
if err != nil {
return nil, nil, fmt.Errorf("generate responder serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: fmt.Sprintf("OCSP Responder for %s", c.caCert.Subject.CommonName),
},
NotBefore: now.Add(-5 * time.Minute), // small backdate to absorb clock skew between certctl and relying parties
NotAfter: now.Add(c.ocspResponderValidity),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageOCSPSigning,
},
ExtraExtensions: []pkix.Extension{
{
Id: oidOCSPNoCheck,
Critical: false,
Value: ocspNoCheckExtensionValue,
},
},
BasicConstraintsValid: true,
IsCA: false,
}
// 3. Sign with the CA key (c.caSigner from the Signer interface).
// Public key for the cert is the responder's own public key.
derBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, responderSigner.Public(), c.caSigner)
if err != nil {
return nil, nil, fmt.Errorf("sign responder cert: %w", err)
}
cert, err := x509.ParseCertificate(derBytes)
if err != nil {
return nil, nil, fmt.Errorf("parse signed responder cert: %w", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
// 4. Persist.
row := &domain.OCSPResponder{
IssuerID: c.issuerID,
CertPEM: string(pemBytes),
CertSerial: fmt.Sprintf("%x", serial),
KeyPath: keyPath,
KeyAlg: string(responderAlg),
NotBefore: template.NotBefore,
NotAfter: template.NotAfter,
}
if previous != nil {
row.RotatedFrom = previous.CertSerial
}
if err := c.ocspResponderRepo.Put(ctx, row); err != nil {
return nil, nil, fmt.Errorf("persist responder row: %w", err)
}
c.logger.Info("OCSP responder bootstrapped",
"issuer_id", c.issuerID,
"cert_serial", row.CertSerial,
"not_after", row.NotAfter,
"rotated_from", row.RotatedFrom)
return cert, responderSigner, nil
}
// parseSinglePEMCert decodes the first PEM block in pemBytes as an
// X.509 certificate. Used by ensureOCSPResponder to materialize a
// cert from the persisted CertPEM string.
func parseSinglePEMCert(pemBytes []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("expected CERTIFICATE block, got %q", block.Type)
}
return x509.ParseCertificate(block.Bytes)
}
@@ -0,0 +1,367 @@
package local_test
import (
"context"
"crypto/x509"
"encoding/asn1"
"encoding/pem"
"io"
"log/slog"
"math/big"
"sync"
"testing"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
)
// fakeResponderRepo is an in-memory repository.OCSPResponderRepository
// for tests that exercise the responder bootstrap path without needing
// a real Postgres + testcontainers harness. The Postgres impl is
// covered by the testcontainers tests in
// internal/repository/postgres/ocsp_responder_test.go (CI only — needs
// Docker).
type fakeResponderRepo struct {
mu sync.Mutex
rows map[string]*domain.OCSPResponder
putCount int // bumped on every Put for assertion
getCount int
}
func newFakeResponderRepo() *fakeResponderRepo {
return &fakeResponderRepo{rows: map[string]*domain.OCSPResponder{}}
}
func (r *fakeResponderRepo) Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.getCount++
if row, ok := r.rows[issuerID]; ok {
// Return a copy so callers can't mutate our state.
copy := *row
return &copy, nil
}
return nil, nil
}
func (r *fakeResponderRepo) Put(ctx context.Context, responder *domain.OCSPResponder) error {
r.mu.Lock()
defer r.mu.Unlock()
r.putCount++
copy := *responder
r.rows[responder.IssuerID] = &copy
return nil
}
func (r *fakeResponderRepo) ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []*domain.OCSPResponder
threshold := now.Add(grace)
for _, row := range r.rows {
if !row.NotAfter.After(threshold) {
copy := *row
out = append(out, &copy)
}
}
return out, nil
}
// helper: build a Connector wired for the responder bootstrap path.
func newConnectorWithResponderDeps(t *testing.T) (*local.Connector, *fakeResponderRepo) {
t.Helper()
conn := local.New(&local.Config{
CACommonName: "Test Local CA",
ValidityDays: 30,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
repo := newFakeResponderRepo()
driver := signer.NewMemoryDriver()
conn.SetOCSPResponderRepo(repo)
conn.SetSignerDriver(driver)
conn.SetIssuerID("iss-test-local")
return conn, repo
}
// helper: forge an OCSP request for a given serial. The local connector's
// SignOCSPResponse takes a typed request struct, not raw OCSP bytes.
func ocspReqFor(serial *big.Int, status int) issuer.OCSPSignRequest {
now := time.Now().UTC()
return issuer.OCSPSignRequest{
CertSerial: serial,
CertStatus: status,
ThisUpdate: now,
NextUpdate: now.Add(24 * time.Hour),
}
}
// ---------------------------------------------------------------------------
// Phase-2 bootstrap path coverage.
// ---------------------------------------------------------------------------
func TestSignOCSPResponse_DedicatedResponder_Bootstrapped(t *testing.T) {
conn, repo := newConnectorWithResponderDeps(t)
ctx := context.Background()
respBytes, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xDEAD), 0))
if err != nil {
t.Fatalf("SignOCSPResponse: %v", err)
}
if len(respBytes) == 0 {
t.Fatal("OCSP response is empty")
}
// Verify the responder row was persisted.
if repo.putCount != 1 {
t.Errorf("expected exactly 1 Put on first call, got %d", repo.putCount)
}
row, _ := repo.Get(ctx, "iss-test-local")
if row == nil {
t.Fatal("responder row was not persisted")
}
if row.KeyAlg != "ECDSA-P256" {
t.Errorf("KeyAlg = %q, want ECDSA-P256 (the bootstrap default)", row.KeyAlg)
}
if row.NotAfter.Sub(row.NotBefore) < 24*time.Hour {
t.Errorf("validity window too short: %v", row.NotAfter.Sub(row.NotBefore))
}
// Parse the responder cert and check the OCSP-specific properties.
block, _ := pem.Decode([]byte(row.CertPEM))
if block == nil {
t.Fatal("responder CertPEM is not PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse responder cert: %v", err)
}
// EKU must include OCSPSigning per RFC 6960 §4.2.2.2.
hasOCSPSigning := false
for _, eku := range cert.ExtKeyUsage {
if eku == x509.ExtKeyUsageOCSPSigning {
hasOCSPSigning = true
break
}
}
if !hasOCSPSigning {
t.Error("responder cert missing ExtKeyUsageOCSPSigning")
}
// id-pkix-ocsp-nocheck (RFC 6960 §4.2.2.2.1) — verify the extension OID
// shows up in the cert's Extensions list. The Go stdlib does not
// promote this extension into a typed field; check ExtraExtensions
// equivalent via the raw Extensions slice.
noCheckOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
hasNoCheck := false
for _, ext := range cert.Extensions {
if ext.Id.Equal(noCheckOID) {
hasNoCheck = true
break
}
}
if !hasNoCheck {
t.Error("responder cert missing id-pkix-ocsp-nocheck extension")
}
// The OCSP response should be signed by the responder cert, not by
// the CA cert. Parse the response with the issuer cert as the trust
// anchor — ocsp.ParseResponse reads the certificates field from the
// response itself and verifies the chain back to issuer.
caPEM, err := conn.GetCACertPEM(ctx)
if err != nil {
t.Fatalf("GetCACertPEM: %v", err)
}
caBlock, _ := pem.Decode([]byte(caPEM))
caCert, err := x509.ParseCertificate(caBlock.Bytes)
if err != nil {
t.Fatalf("parse CA cert: %v", err)
}
parsedResp, err := ocsp.ParseResponse(respBytes, caCert)
if err != nil {
t.Fatalf("ParseResponse with CA as issuer: %v", err)
}
if parsedResp.SerialNumber.Cmp(big.NewInt(0xDEAD)) != 0 {
t.Errorf("response serial mismatch: got %v want %v", parsedResp.SerialNumber, 0xDEAD)
}
if parsedResp.Status != ocsp.Good {
t.Errorf("response status = %d, want Good (0)", parsedResp.Status)
}
// The response's Certificate field should be the responder cert
// (NOT the CA cert) — that's the proof the dedicated-responder
// path was taken.
if parsedResp.Certificate == nil {
t.Fatal("OCSP response did not include the responder cert")
}
if parsedResp.Certificate.Subject.CommonName == caCert.Subject.CommonName {
t.Errorf("OCSP response was signed by the CA, not by a dedicated responder cert")
}
}
func TestSignOCSPResponse_DedicatedResponder_ReusedAcrossCalls(t *testing.T) {
conn, repo := newConnectorWithResponderDeps(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
_, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(int64(i+1)), 0))
if err != nil {
t.Fatalf("SignOCSPResponse[%d]: %v", i, err)
}
}
// Bootstrap on first call only — subsequent calls should reuse the
// persisted responder. putCount > 1 means we re-bootstrapped (bug).
if repo.putCount != 1 {
t.Errorf("putCount = %d, want 1 (responder should be reused across calls)", repo.putCount)
}
}
func TestSignOCSPResponse_FallbackPath_NoResponderDeps(t *testing.T) {
// Construct a connector WITHOUT responder deps wired. SignOCSPResponse
// must fall back to the historical CA-key-direct path and not error.
conn := local.New(&local.Config{ValidityDays: 30}, slog.New(slog.NewTextHandler(io.Discard, nil)))
ctx := context.Background()
respBytes, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xCAFE), 0))
if err != nil {
t.Fatalf("fallback SignOCSPResponse: %v", err)
}
if len(respBytes) == 0 {
t.Fatal("fallback OCSP response is empty")
}
// The fallback path uses the CA cert as the responder — the response
// bytes parse against the CA cert successfully.
caPEM, err := conn.GetCACertPEM(ctx)
if err != nil {
t.Fatalf("GetCACertPEM: %v", err)
}
block, _ := pem.Decode([]byte(caPEM))
caCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse CA cert: %v", err)
}
if _, err := ocsp.ParseResponse(respBytes, caCert); err != nil {
t.Fatalf("fallback OCSP response should validate against CA cert: %v", err)
}
}
func TestSignOCSPResponse_DedicatedResponder_RecoversFromCorruptKeyRef(t *testing.T) {
// Simulate the failure mode where the persisted responder row points
// at a key the signer driver can't load (e.g., operator deleted the
// key file out from under us). The bootstrap path should recover by
// generating a fresh responder rather than failing the OCSP request.
conn, repo := newConnectorWithResponderDeps(t)
ctx := context.Background()
// Pre-populate the repo with a stale row whose KeyPath the
// MemoryDriver doesn't know about. MemoryDriver.Load returns an
// "unknown ref" error for any ref it didn't issue.
stale := &domain.OCSPResponder{
IssuerID: "iss-test-local",
CertPEM: "-----BEGIN CERTIFICATE-----\nbm90LWEtcmVhbC1jZXJ0\n-----END CERTIFICATE-----\n",
CertSerial: "01",
KeyPath: "mem-NEVER-ISSUED",
KeyAlg: "ECDSA-P256",
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(30 * 24 * time.Hour), // far future, NOT in rotation grace
}
if err := repo.Put(ctx, stale); err != nil {
t.Fatalf("seed stale row: %v", err)
}
repo.putCount = 0 // reset so the bootstrap-triggered Put is the only one we count
// First SignOCSPResponse should detect the bad KeyPath, log a warning,
// and bootstrap a fresh responder.
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xBEEF), 0)); err != nil {
t.Fatalf("SignOCSPResponse should recover from corrupt key ref, got: %v", err)
}
if repo.putCount != 1 {
t.Errorf("expected fresh bootstrap on corrupt key ref, putCount=%d", repo.putCount)
}
row := repo.rows["iss-test-local"]
if row.CertSerial == "01" {
t.Error("responder row was not replaced after corrupt key ref recovery")
}
}
func TestSignOCSPResponse_DedicatedResponder_KeyDirSetter(t *testing.T) {
// Pin the SetOCSPResponderKeyDir path. The MemoryDriver doesn't
// honor the dir (it generates in-memory refs), so this is purely a
// no-side-effect coverage pin for the setter.
conn, _ := newConnectorWithResponderDeps(t)
conn.SetOCSPResponderKeyDir(t.TempDir())
if _, err := conn.SignOCSPResponse(context.Background(), ocspReqFor(big.NewInt(7), 0)); err != nil {
t.Fatalf("SignOCSPResponse with key dir set: %v", err)
}
}
func TestSignOCSPResponse_DedicatedResponder_RecoversFromCorruptCertPEM(t *testing.T) {
// Companion to the corrupt-key-ref test: this time the key loads
// fine but the persisted CertPEM is not a CERTIFICATE block. The
// bootstrap should detect via parseSinglePEMCert and re-issue.
conn, repo := newConnectorWithResponderDeps(t)
ctx := context.Background()
// Generate a real key via the MemoryDriver so the load succeeds, then
// pair it with an INVALID cert PEM (PRIVATE KEY block instead of
// CERTIFICATE). MemoryDriver.Generate stores the key under a fresh
// "mem-N" ref; we capture that ref by triggering a Generate and
// pulling the row out of the repo.
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(1), 0)); err != nil {
t.Fatalf("seed bootstrap: %v", err)
}
row := repo.rows["iss-test-local"]
row.CertPEM = "-----BEGIN PRIVATE KEY-----\nbm9wZQ==\n-----END PRIVATE KEY-----\n"
repo.rows["iss-test-local"] = row
repo.putCount = 0
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(2), 0)); err != nil {
t.Fatalf("SignOCSPResponse should recover from corrupt cert PEM, got: %v", err)
}
if repo.putCount != 1 {
t.Errorf("expected fresh bootstrap on corrupt cert PEM, putCount=%d", repo.putCount)
}
}
func TestSignOCSPResponse_DedicatedResponder_RotatesWithinGrace(t *testing.T) {
conn, repo := newConnectorWithResponderDeps(t)
ctx := context.Background()
// Use a short validity + matching grace so the first bootstrap
// produces a cert that immediately falls inside the rotation
// window on the next call. validity = 5m, grace = 10m → freshly-
// bootstrapped cert expires in 5m which is < 10m grace → rotate.
conn.SetOCSPResponderValidity(5 * time.Minute)
conn.SetOCSPResponderRotationGrace(10 * time.Minute)
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(1), 0)); err != nil {
t.Fatalf("first SignOCSPResponse: %v", err)
}
firstSerial := repo.rows["iss-test-local"].CertSerial
// Second call: rotation triggers because the first cert is in the
// grace window. The new row's RotatedFrom should equal the first
// cert's serial.
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(2), 0)); err != nil {
t.Fatalf("second SignOCSPResponse (rotation): %v", err)
}
if repo.putCount < 2 {
t.Fatalf("expected rotation to trigger a second Put, got putCount=%d", repo.putCount)
}
row := repo.rows["iss-test-local"]
if row.CertSerial == firstSerial {
t.Errorf("CertSerial unchanged across rotation: %q", row.CertSerial)
}
if row.RotatedFrom != firstSerial {
t.Errorf("RotatedFrom = %q, want %q (the first cert's serial)", row.RotatedFrom, firstSerial)
}
}
+30
View File
@@ -0,0 +1,30 @@
// Package signer abstracts the act of producing cryptographic signatures
// over digests on behalf of a certificate authority. It exists so that
// downstream code (leaf-cert issuance, CRL generation, OCSP response
// signing, SSH CA cert signing — anything that today does
// x509.CreateCertificate(... caKey)) sees a single interface and does
// not need to know whether the underlying private key lives on disk, in
// a PKCS#11 token, in an HSM, or in a cloud KMS.
//
// The Signer interface deliberately embeds the stdlib crypto.Signer
// (Sign + Public) and adds a single method, Algorithm, that returns a
// value callers can switch on to pick the matching x509.SignatureAlgorithm
// without reflecting on the concrete key type. This is the only certctl-
// specific addition; everything else is stdlib-compatible — any
// crypto.Signer wrapped by this package's Wrap helper becomes a Signer
// without per-key-type boilerplate at the call site.
//
// Driver implementations live in this package today (FileDriver,
// MemoryDriver). HSM-backed drivers (PKCS#11, cloud KMS) land in
// follow-on packages (e.g., internal/crypto/signer/pkcs11) and consume
// this interface unchanged. Adding a driver does not require modifying
// any existing call site or any other driver.
//
// Threat-model note: Signer wraps a crypto.Signer; the bytes-in-process
// hygiene (heap zeroization, no swap, no core-dump exposure) is the
// underlying driver's responsibility, not this package's. The L-014
// carve-out documented at the top of internal/connector/issuer/local/
// local.go applies to FileDriver-backed signers; alternative drivers
// (PKCS#11, KMS) close that disk-exposure leg of the threat model
// because the key never leaves the token / KMS.
package signer
+54
View File
@@ -0,0 +1,54 @@
package signer
import "context"
// Driver knows how to materialize a Signer from some external reference
// (a file path, a PKCS#11 URI, a cloud KMS key ID, etc.) and how to
// generate a fresh key with a given algorithm.
//
// Drivers are responsible for any side-effect storage: FileDriver writes
// generated keys to disk via the keystore.ensureKeyDirSecure +
// keymem.marshalPrivateKeyAndZeroize discipline (injected via the
// FileDriver's hooks); future PKCS11Driver delegates key generation to
// the token; cloud-KMS drivers call the provider API.
//
// All Driver methods take a context.Context for cancellation/deadline
// propagation. Drivers MUST honor ctx.Done() for any I/O they perform;
// purely-in-memory drivers (MemoryDriver) may return immediately
// regardless of ctx state.
//
// Adding a new driver does NOT require changing this interface or any
// existing driver. The driver lives in its own package
// (internal/crypto/signer/<name>) and is constructed by a typed
// factory (e.g., pkcs11.New(config)).
type Driver interface {
// Load resolves an existing key from ref and returns a Signer.
// ref interpretation is driver-specific:
//
// - FileDriver: filesystem path to a PEM-encoded private key
// - PKCS11Driver (future): pkcs11: URI per RFC 7512
// - CloudKMSDriver (future): provider-specific resource name
//
// Drivers MUST NOT log the contents of the loaded key (only the
// ref + Algorithm). Callers wrap the returned Signer's Sign method
// in their own logging if they need per-signature audit trail.
Load(ctx context.Context, ref string) (Signer, error)
// Generate creates a new key with the given algorithm and persists
// it to driver-specific storage (or in-memory for MemoryDriver).
// Returns a Signer wrapping the new key plus a ref string the
// caller passes to a subsequent Load call (e.g., the file path
// for FileDriver, the PKCS#11 URI for PKCS11Driver).
//
// If alg is not in the supported enum, Generate returns
// ErrUnsupportedAlgorithm without side effects (no file written,
// no token slot consumed).
Generate(ctx context.Context, alg Algorithm) (Signer, string, error)
// Name returns a stable identifier for the driver type. Used in
// structured logs and (eventually) in CRL distribution-point URLs
// when the URL embeds the signer kind. MUST be a single
// lowercase token without spaces ("file", "memory", "pkcs11",
// "aws-kms", "gcp-kms", "azure-kv").
Name() string
}
+446
View File
@@ -0,0 +1,446 @@
package signer_test
// Behavior-equivalence test suite for the Signer abstraction.
//
// Phase 2's exit criteria assert that existing tests in the local issuer
// pass after the refactor. That's necessary but not sufficient: existing
// tests cover specific scenarios and may not catch a subtle byte-level
// divergence (e.g., the wrapped Signer marshaling the public key in a
// different DER ordering, or producing a slightly different signature
// padding). This file is the explicit guard against that class of
// regression.
//
// Three signing surfaces are exercised, mirroring the four call sites in
// internal/connector/issuer/local/local.go:
// - leaf certificate signing (mirrors local.go::generateCertificate / line ~613)
// - CRL signing (mirrors local.go::GenerateCRL / line ~849)
// - OCSP response signing (mirrors local.go::SignOCSPResponse / line ~887)
// The CA-bootstrap call (line ~482) is implicitly covered by leaf
// signing — it's the same x509.CreateCertificate API.
//
// For each surface, two signatures are compared:
// - RSA-2048 / SHA-256: byte-strict equality (PKCS#1 v1.5 is
// deterministic given key + digest, so wrapped vs. raw produces
// identical full DER bytes).
// - ECDSA-P256 / SHA-256: structural equality (ECDSA uses random k
// per signature, so signature bytes differ; TBSCertificate /
// TBSCertificateList / TBSResponseData bytes — everything signed —
// must be byte-equal across raw and wrapped).
//
// A negative test (TestEquivalence_Sentinel) proves the equivalence
// checker would actually catch a regression — without it, a vacuously-
// passing assertion would let real divergence through.
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"testing"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/crypto/signer"
)
// fixedTemplate returns an x509 cert template with deterministic fields
// (no time.Now, no random serial) so two calls to CreateCertificate
// produce TBSCertificate bytes that are byte-equal modulo the signature.
func fixedTemplate(t *testing.T) (*x509.Certificate, *x509.Certificate) {
t.Helper()
notBefore := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
notAfter := notBefore.Add(365 * 24 * time.Hour)
caTpl := &x509.Certificate{
SerialNumber: big.NewInt(0xCAFE),
Subject: pkix.Name{CommonName: "Equiv CA"},
NotBefore: notBefore,
NotAfter: notAfter.Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}
leafTpl := &x509.Certificate{
SerialNumber: big.NewInt(0xC0FFEE),
Subject: pkix.Name{CommonName: "leaf.example.com"},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
return caTpl, leafTpl
}
// ---------------------------------------------------------------------------
// Leaf certificate signing
// ---------------------------------------------------------------------------
func TestEquivalence_RSA_LeafCert_BytesIdentical(t *testing.T) {
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa keygen: %v", err)
}
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("leaf rsa keygen: %v", err)
}
wrapped, err := signer.Wrap(caKey)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
caTpl, leafTpl := fixedTemplate(t)
// Self-sign the CA so we have a parsed *x509.Certificate to use as
// the leaf cert's parent (CreateCertificate needs both template and
// parent; using the same template for both produces a self-signed
// CA cert that we then parse).
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create CA: %v", err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatalf("parse CA: %v", err)
}
// Sign the same leaf cert twice — once via raw caKey, once via
// wrapped Signer. PKCS#1 v1.5 is deterministic, so the full DER
// must be byte-identical.
der1, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create leaf (raw): %v", err)
}
der2, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, wrapped)
if err != nil {
t.Fatalf("create leaf (wrapped): %v", err)
}
if !bytes.Equal(der1, der2) {
t.Fatalf("RSA leaf cert DER differs between raw and wrapped signer:\n raw: %x\n wrapped: %x", der1, der2)
}
}
func TestEquivalence_ECDSA_LeafCert_TBSIdentical(t *testing.T) {
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa keygen: %v", err)
}
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("leaf ecdsa keygen: %v", err)
}
wrapped, err := signer.Wrap(caKey)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
caTpl, leafTpl := fixedTemplate(t)
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create CA: %v", err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatalf("parse CA: %v", err)
}
der1, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create leaf (raw): %v", err)
}
der2, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, wrapped)
if err != nil {
t.Fatalf("create leaf (wrapped): %v", err)
}
cert1, err := x509.ParseCertificate(der1)
if err != nil {
t.Fatalf("parse leaf (raw): %v", err)
}
cert2, err := x509.ParseCertificate(der2)
if err != nil {
t.Fatalf("parse leaf (wrapped): %v", err)
}
// TBSCertificate is everything that gets signed — Subject, Issuer,
// Validity, SubjectPublicKeyInfo, Extensions, etc. The signature
// bytes themselves differ (ECDSA random k) but the input to the
// signature MUST be byte-identical or the wrapper is doing
// something behavioral-different than the raw key.
if !bytes.Equal(cert1.RawTBSCertificate, cert2.RawTBSCertificate) {
t.Fatalf("ECDSA leaf cert TBSCertificate differs between raw and wrapped signer (expected: signature bytes differ; everything else byte-equal)")
}
// Confirm both signatures are independently valid against the CA's
// public key. This is the proof that the wrapper actually signed
// (not just produced random bytes that happened to match length).
if err := cert1.CheckSignatureFrom(caCert); err != nil {
t.Fatalf("raw-signed leaf failed validation: %v", err)
}
if err := cert2.CheckSignatureFrom(caCert); err != nil {
t.Fatalf("wrapped-signed leaf failed validation: %v", err)
}
}
// ---------------------------------------------------------------------------
// CRL signing (mirrors internal/connector/issuer/local/local.go::GenerateCRL)
// ---------------------------------------------------------------------------
func TestEquivalence_RSA_CRL_BytesIdentical(t *testing.T) {
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
wrapped, err := signer.Wrap(caKey)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
caTpl, _ := fixedTemplate(t)
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caDER)
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
crlTpl := &x509.RevocationList{
Number: big.NewInt(1),
ThisUpdate: thisUpdate,
NextUpdate: thisUpdate.Add(7 * 24 * time.Hour),
RevokedCertificateEntries: []x509.RevocationListEntry{
{
SerialNumber: big.NewInt(0xDEAD),
RevocationTime: thisUpdate,
},
},
}
der1, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, caKey)
if err != nil {
t.Fatalf("create CRL (raw): %v", err)
}
der2, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, wrapped)
if err != nil {
t.Fatalf("create CRL (wrapped): %v", err)
}
if !bytes.Equal(der1, der2) {
t.Fatalf("RSA CRL DER differs between raw and wrapped signer:\n raw: %x\n wrapped: %x", der1[:64], der2[:64])
}
}
func TestEquivalence_ECDSA_CRL_TBSIdentical(t *testing.T) {
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
wrapped, err := signer.Wrap(caKey)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
caTpl, _ := fixedTemplate(t)
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caDER)
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
crlTpl := &x509.RevocationList{
Number: big.NewInt(1),
ThisUpdate: thisUpdate,
NextUpdate: thisUpdate.Add(7 * 24 * time.Hour),
}
der1, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, caKey)
if err != nil {
t.Fatalf("create CRL (raw): %v", err)
}
der2, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, wrapped)
if err != nil {
t.Fatalf("create CRL (wrapped): %v", err)
}
crl1, err := x509.ParseRevocationList(der1)
if err != nil {
t.Fatalf("parse CRL (raw): %v", err)
}
crl2, err := x509.ParseRevocationList(der2)
if err != nil {
t.Fatalf("parse CRL (wrapped): %v", err)
}
// RawTBSRevocationList is the signed input. Must be byte-equal for
// equivalence; signature bytes differ for ECDSA.
if !bytes.Equal(crl1.RawTBSRevocationList, crl2.RawTBSRevocationList) {
t.Fatalf("ECDSA CRL TBSRevocationList differs between raw and wrapped signer")
}
// Both CRLs must validate against the CA.
if err := crl1.CheckSignatureFrom(caCert); err != nil {
t.Fatalf("raw-signed CRL failed validation: %v", err)
}
if err := crl2.CheckSignatureFrom(caCert); err != nil {
t.Fatalf("wrapped-signed CRL failed validation: %v", err)
}
}
// ---------------------------------------------------------------------------
// OCSP response signing
// (mirrors internal/connector/issuer/local/local.go::SignOCSPResponse)
// ---------------------------------------------------------------------------
func TestEquivalence_RSA_OCSPResponse_BytesIdentical(t *testing.T) {
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
wrapped, err := signer.Wrap(caKey)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
caTpl, _ := fixedTemplate(t)
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caDER)
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
ocspTpl := ocsp.Response{
Status: ocsp.Good,
SerialNumber: big.NewInt(0xCAFEBABE),
ThisUpdate: thisUpdate,
NextUpdate: thisUpdate.Add(24 * time.Hour),
}
resp1, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, caKey)
if err != nil {
t.Fatalf("create OCSP (raw): %v", err)
}
resp2, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, wrapped)
if err != nil {
t.Fatalf("create OCSP (wrapped): %v", err)
}
if !bytes.Equal(resp1, resp2) {
t.Fatalf("RSA OCSP response differs between raw and wrapped signer (PKCS#1 v1.5 must be deterministic)")
}
}
func TestEquivalence_ECDSA_OCSPResponse_StructurallyIdentical(t *testing.T) {
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
wrapped, err := signer.Wrap(caKey)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
caTpl, _ := fixedTemplate(t)
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caDER)
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
ocspTpl := ocsp.Response{
Status: ocsp.Good,
SerialNumber: big.NewInt(0xCAFEBABE),
ThisUpdate: thisUpdate,
NextUpdate: thisUpdate.Add(24 * time.Hour),
}
resp1, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, caKey)
if err != nil {
t.Fatalf("create OCSP (raw): %v", err)
}
resp2, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, wrapped)
if err != nil {
t.Fatalf("create OCSP (wrapped): %v", err)
}
parsed1, err := ocsp.ParseResponse(resp1, caCert)
if err != nil {
t.Fatalf("parse OCSP (raw): %v", err)
}
parsed2, err := ocsp.ParseResponse(resp2, caCert)
if err != nil {
t.Fatalf("parse OCSP (wrapped): %v", err)
}
// Compare every field except Signature + RawResponderName (which
// the parser may normalize differently across calls).
if parsed1.Status != parsed2.Status {
t.Fatalf("status differs: %d vs %d", parsed1.Status, parsed2.Status)
}
if parsed1.SerialNumber.Cmp(parsed2.SerialNumber) != 0 {
t.Fatalf("serial differs: %v vs %v", parsed1.SerialNumber, parsed2.SerialNumber)
}
if !parsed1.ThisUpdate.Equal(parsed2.ThisUpdate) {
t.Fatalf("ThisUpdate differs")
}
if !parsed1.NextUpdate.Equal(parsed2.NextUpdate) {
t.Fatalf("NextUpdate differs")
}
// Both responses must validate against the CA.
if err := parsed1.CheckSignatureFrom(caCert); err != nil {
t.Fatalf("raw-signed OCSP failed validation: %v", err)
}
if err := parsed2.CheckSignatureFrom(caCert); err != nil {
t.Fatalf("wrapped-signed OCSP failed validation: %v", err)
}
}
// ---------------------------------------------------------------------------
// Negative test: the equivalence checker isn't trivially-passing
// ---------------------------------------------------------------------------
// TestEquivalence_Sentinel_DifferentKeysProduceDifferentBytes is the smoke
// check that the equivalence assertions above would actually catch a
// regression. Sign with two different keys; assert the resulting cert
// DER bytes differ. If THIS test passes trivially (false negative), the
// equivalence checker is broken and the test suite above is not actually
// guarding anything.
func TestEquivalence_Sentinel_DifferentKeysProduceDifferentBytes(t *testing.T) {
keyA, _ := rsa.GenerateKey(rand.Reader, 2048)
keyB, _ := rsa.GenerateKey(rand.Reader, 2048)
caTpl, leafTpl := fixedTemplate(t)
leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
caDERA, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &keyA.PublicKey, keyA)
caCertA, _ := x509.ParseCertificate(caDERA)
caDERB, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &keyB.PublicKey, keyB)
caCertB, _ := x509.ParseCertificate(caDERB)
der1, _ := x509.CreateCertificate(rand.Reader, leafTpl, caCertA, &leafKey.PublicKey, keyA)
der2, _ := x509.CreateCertificate(rand.Reader, leafTpl, caCertB, &leafKey.PublicKey, keyB)
if bytes.Equal(der1, der2) {
t.Fatal("sentinel: certs signed by DIFFERENT keys must NOT byte-equal — equivalence checker is trivially-passing")
}
}
// ---------------------------------------------------------------------------
// Sanity: the wrapped signer's Sign output is independently valid for
// arbitrary digests (covers the path that doesn't go through x509.*).
// ---------------------------------------------------------------------------
func TestEquivalence_WrappedSign_RSA_VerifiesAgainstStdlib(t *testing.T) {
k, _ := rsa.GenerateKey(rand.Reader, 2048)
w, err := signer.Wrap(k)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
digest := sha256OfBytes([]byte("test message"))
sig, err := w.Sign(rand.Reader, digest, crypto.SHA256)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if err := rsa.VerifyPKCS1v15(&k.PublicKey, crypto.SHA256, digest, sig); err != nil {
t.Fatalf("wrapped RSA Sign produced signature that does not verify with stdlib VerifyPKCS1v15: %v", err)
}
}
func TestEquivalence_WrappedSign_ECDSA_VerifiesAgainstStdlib(t *testing.T) {
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
w, err := signer.Wrap(k)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
digest := sha256OfBytes([]byte("test message"))
sig, err := w.Sign(rand.Reader, digest, crypto.SHA256)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !ecdsa.VerifyASN1(&k.PublicKey, digest, sig) {
t.Fatal("wrapped ECDSA Sign produced signature that does not verify with stdlib VerifyASN1")
}
}
func sha256OfBytes(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
+221
View File
@@ -0,0 +1,221 @@
package signer
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
)
// FileDriver materializes a Signer from a PEM-encoded private key on
// disk. This is the historical and current default behavior of the
// local issuer; FileDriver wraps that behavior without functional
// change so the local issuer can route every signing call through the
// Signer interface without changing what bytes land on disk.
//
// SECURITY: callers SHOULD set DirHardener and Marshaler to enforce
// the audited Bundle 9 hardening (key directory mode 0700 via
// keystore.ensureKeyDirSecure; marshal-with-zeroization via
// keymem.marshalPrivateKeyAndZeroize). When DirHardener is unset,
// Generate refuses to write — an explicit fail-loud signal rather
// than silently falling back to a permissive directory mode.
//
// Load does NOT call DirHardener (Load is read-only and the key may
// already exist in a directory whose mode the operator chose
// deliberately for their threat model). Load also does not call
// Marshaler (Load doesn't write anything).
type FileDriver struct {
// DirHardener, if set, is invoked on the directory containing a
// generated key file BEFORE the key is written. The local
// package wires this to keystore.ensureKeyDirSecure (via a closure
// — the helper stays package-private to preserve the audit trail
// in keystore.go's leading comment block). When nil, Generate
// returns an error.
DirHardener func(dir string) error
// Marshaler, if set, converts an *ecdsa.PrivateKey to the
// PEM-encoded byte slice that Generate will write to disk. The
// local package wires this to a wrapper around
// keymem.marshalPrivateKeyAndZeroize, ensuring the L-002
// heap-zeroization discipline applies to all keys generated
// through this driver. When nil, Generate falls back to a
// non-zeroizing marshal — acceptable for tests but NOT for
// production code paths.
Marshaler func(*ecdsa.PrivateKey) ([]byte, error)
// RSAMarshaler is the same shape as Marshaler but for RSA keys.
// Optional; if nil, Generate falls back to a non-zeroizing
// marshal. Provided for symmetry with Marshaler so the local
// issuer can plug in RSA-key-zeroization later without changing
// the FileDriver API.
RSAMarshaler func(*rsa.PrivateKey) ([]byte, error)
// GenerateOutPath, if set, is called with the generated key's
// algorithm and returns the destination path. When nil, Generate
// uses a default of <cwd>/ca-<alg>.key — fine for tests, NOT for
// production. The local package's NewConnector wires this to
// return the configured CAKeyPath.
GenerateOutPath func(alg Algorithm) (string, error)
}
// Name implements Driver.
func (d *FileDriver) Name() string { return "file" }
// Load implements Driver. It reads the PEM file at path, decodes the
// first PEM block, parses it via the package's parsePrivateKey
// (which handles PKCS#1 / SEC 1 / PKCS#8), and wraps the resulting
// crypto.Signer.
//
// Errors are wrapped with the path so operators can grep their logs.
// No key bytes are logged — only the path and (on success) the
// inferred Algorithm.
func (d *FileDriver) Load(ctx context.Context, path string) (Signer, error) {
if path == "" {
return nil, errors.New("signer.FileDriver.Load: empty path")
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
}
pemBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", path, err)
}
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", path)
}
key, err := parsePrivateKey(block)
if err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", path, err)
}
wrapped, err := Wrap(key)
if err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", path, err)
}
return wrapped, nil
}
// Generate implements Driver. It generates a fresh private key with the
// requested algorithm, writes it to disk via the configured hooks, and
// returns the wrapped Signer plus the file path the caller can pass
// to a subsequent Load call.
//
// Refuses to write when DirHardener is unset — the production local
// package always wires the hardener; only tests are allowed to bypass
// it by constructing the FileDriver directly without calling
// NewProductionFileDriver.
func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, string, error) {
if d.DirHardener == nil {
return nil, "", errors.New("signer.FileDriver.Generate: DirHardener is required (set to a key-dir-permission validator) — refusing to write key with default umask")
}
if err := ctx.Err(); err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
}
// Resolve destination path before doing any expensive work.
pathFn := d.GenerateOutPath
if pathFn == nil {
pathFn = func(a Algorithm) (string, error) {
return fmt.Sprintf("ca-%s.key", a), nil
}
}
outPath, err := pathFn(alg)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: resolve out path: %w", err)
}
// Harden the destination directory BEFORE generating the key. If
// the directory check fails we bail without touching cryptography.
if err := d.DirHardener(filepath.Dir(outPath)); err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", outPath, err)
}
// Generate the key for the requested algorithm.
var (
signerKey crypto.Signer
pemBytes []byte
)
switch alg {
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
bits := rsaBitsFor(alg)
rsaKey, gerr := rsa.GenerateKey(rand.Reader, bits)
if gerr != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: rsa keygen %d: %w", bits, gerr)
}
signerKey = rsaKey
if d.RSAMarshaler != nil {
pemBytes, err = d.RSAMarshaler(rsaKey)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: RSAMarshaler: %w", err)
}
} else {
pemBytes = pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
})
}
case AlgorithmECDSAP256, AlgorithmECDSAP384:
curve := ecCurveFor(alg)
ecKey, gerr := ecdsa.GenerateKey(curve, rand.Reader)
if gerr != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: ecdsa keygen %s: %w", curve.Params().Name, gerr)
}
signerKey = ecKey
if d.Marshaler != nil {
pemBytes, err = d.Marshaler(ecKey)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: Marshaler: %w", err)
}
} else {
der, mErr := x509.MarshalECPrivateKey(ecKey)
if mErr != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: marshal ec key: %w", mErr)
}
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
}
default:
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w: %s", ErrUnsupportedAlgorithm, alg)
}
// Write 0o600 — owner-read-write only. Any read by group/other is
// a configuration regression; the dir 0700 above prevents
// enumeration of the file's existence.
if err := os.WriteFile(outPath, pemBytes, 0o600); err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", outPath, err)
}
wrapped, err := Wrap(signerKey)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: wrap: %w", err)
}
return wrapped, outPath, nil
}
func rsaBitsFor(a Algorithm) int {
switch a {
case AlgorithmRSA3072:
return 3072
case AlgorithmRSA4096:
return 4096
default:
return 2048
}
}
func ecCurveFor(a Algorithm) elliptic.Curve {
if a == AlgorithmECDSAP384 {
return elliptic.P384()
}
return elliptic.P256()
}
+125
View File
@@ -0,0 +1,125 @@
package signer
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"sync"
)
// MemoryDriver holds keys in process memory. It is intended for tests
// that need a Signer-shaped object without touching the filesystem
// or any external infrastructure. It is NOT for production use:
// keys disappear when the process exits, no hardening of any kind is
// applied, and concurrent Generate calls have no rate limit.
//
// The driver is safe for concurrent use; an internal mutex guards the
// keys map.
type MemoryDriver struct {
mu sync.Mutex
keys map[string]crypto.Signer
// nextID is incremented on every successful Generate; the returned
// ref string is "mem-<nextID>" so multiple Generates produce
// distinct refs even when callers don't supply one.
nextID int
}
// NewMemoryDriver returns a freshly initialized MemoryDriver. Callers
// holding multiple drivers can rely on each one being independent —
// keys from driver A are not visible to driver B.
func NewMemoryDriver() *MemoryDriver {
return &MemoryDriver{keys: map[string]crypto.Signer{}}
}
// Name implements Driver.
func (d *MemoryDriver) Name() string { return "memory" }
// Load implements Driver. Returns the Signer for the given ref, or an
// error if the ref was never produced by Generate / Adopt.
func (d *MemoryDriver) Load(ctx context.Context, ref string) (Signer, error) {
if ref == "" {
return nil, errors.New("signer.MemoryDriver.Load: empty ref")
}
d.mu.Lock()
defer d.mu.Unlock()
key, ok := d.keys[ref]
if !ok {
return nil, fmt.Errorf("signer.MemoryDriver.Load: unknown ref %q", ref)
}
return Wrap(key)
}
// Generate implements Driver. Creates a fresh in-memory key with the
// requested algorithm and returns the wrapped Signer plus the ref
// string callers can pass to a subsequent Load.
func (d *MemoryDriver) Generate(ctx context.Context, alg Algorithm) (Signer, string, error) {
if err := ctx.Err(); err != nil {
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: %w", err)
}
var key crypto.Signer
switch alg {
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
bits := rsaBitsFor(alg)
k, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: rsa keygen %d: %w", bits, err)
}
key = k
case AlgorithmECDSAP256, AlgorithmECDSAP384:
curve := ecCurveFor(alg)
k, err := ecdsa.GenerateKey(curve, rand.Reader)
if err != nil {
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: ecdsa keygen %s: %w", curve.Params().Name, err)
}
key = k
default:
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: %w: %s", ErrUnsupportedAlgorithm, alg)
}
d.mu.Lock()
d.nextID++
ref := fmt.Sprintf("mem-%d", d.nextID)
d.keys[ref] = key
d.mu.Unlock()
wrapped, err := Wrap(key)
if err != nil {
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: wrap: %w", err)
}
return wrapped, ref, nil
}
// Adopt registers an externally-generated crypto.Signer under ref so
// subsequent Load calls return it. Returns an error if ref is already
// taken — keep refs unique to avoid silent override surprises.
//
// Useful in tests that want a deterministic key (generated outside
// the driver, e.g. from a fixed PEM fixture) reachable through the
// driver.
func (d *MemoryDriver) Adopt(ref string, key crypto.Signer) error {
if ref == "" {
return errors.New("signer.MemoryDriver.Adopt: empty ref")
}
if key == nil {
return errors.New("signer.MemoryDriver.Adopt: nil key")
}
d.mu.Lock()
defer d.mu.Unlock()
if _, exists := d.keys[ref]; exists {
return fmt.Errorf("signer.MemoryDriver.Adopt: ref %q already exists", ref)
}
d.keys[ref] = key
return nil
}
// _ guards that MemoryDriver implements Driver (catch interface drift
// at build time, not test time).
var _ Driver = (*MemoryDriver)(nil)
// _ guards that FileDriver implements Driver.
var _ Driver = (*FileDriver)(nil)
+68
View File
@@ -0,0 +1,68 @@
package signer
import (
"crypto"
"encoding/pem"
"fmt"
"crypto/x509"
)
// parsePrivateKey parses a PEM block into a crypto.Signer. Recognises the
// three PEM block types historically produced and consumed by certctl's
// local CA:
//
// - "RSA PRIVATE KEY" (PKCS#1 / RFC 3447, openssl genrsa default)
// - "EC PRIVATE KEY" (SEC 1 / RFC 5915, openssl ecparam default)
// - "PRIVATE KEY" (PKCS#8 / RFC 5208 — wraps RSA, ECDSA, others)
//
// This function is the single source of truth for PEM private-key parsing
// inside certctl. It was moved here from
// internal/connector/issuer/local/local.go as part of the Signer
// abstraction work; the local package now calls into here. Do not
// reintroduce a parallel implementation elsewhere.
//
// Behavior preserved exactly across the move:
// - Block type matching is case-sensitive (PEM convention).
// - PKCS#8 blocks that contain a non-Signer key (e.g., a Diffie-Hellman
// key, an Ed25519 key absent stdlib Signer support) return an error
// rather than a panic.
// - The error wrapping format is intentionally stable so existing test
// assertions in internal/connector/issuer/local/local_test.go and
// bundle9_coverage_test.go continue to match without modification.
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
// PKCS#8 — can contain RSA or ECDSA
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse PKCS#8 key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("PKCS#8 key is not a signing key")
}
return signer, nil
default:
return nil, fmt.Errorf("unsupported private key type: %s (expected RSA PRIVATE KEY, EC PRIVATE KEY, or PRIVATE KEY)", block.Type)
}
}
// ParsePrivateKey is the exported wrapper used by callers outside this
// package. It exists so that internal/connector/issuer/local/ (and any
// future caller that needs to load a PEM private key without going
// through a Driver — e.g., a one-off tool, a migration helper) can
// share the parser without re-implementing the block-type dispatch.
//
// Most callers should use a Driver instead — Driver.Load handles the
// file-read + PEM decode + key parse + Signer wrap in one call.
// ParsePrivateKey is exposed for the corner cases where a caller
// already holds the *pem.Block (e.g., the block was extracted from a
// multi-block PEM bundle).
func ParsePrivateKey(block *pem.Block) (crypto.Signer, error) {
return parsePrivateKey(block)
}
+158
View File
@@ -0,0 +1,158 @@
package signer
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"errors"
"fmt"
"io"
)
// Signer extends crypto.Signer with an Algorithm method that lets callers
// pick the matching x509.SignatureAlgorithm without reflecting on the key.
//
// Implementations MUST satisfy the crypto.Signer contract: Public() returns
// the matching public key, and Sign(rand, digest, opts) produces a
// signature in the algorithm's standard wire format (PKCS#1 v1.5 / PSS for
// RSA, ASN.1 DER-encoded ECDSA-Sig-Value for ECDSA). The Algorithm method
// is purely a metadata accessor — it MUST NOT cause I/O.
type Signer interface {
crypto.Signer
Algorithm() Algorithm
}
// Algorithm enumerates the certctl-supported signing algorithms.
//
// The set is deliberately small. Adding an algorithm requires updating
// signer.go's enum, parse.go's algorithmFromKey, the SignatureAlgorithm
// helper below, and the corresponding profile validators in
// internal/service that gate operator-facing key-policy choices. Do not
// add Ed25519 (or any new algorithm) without that full sweep — the
// half-implemented case is worse than the absent case.
type Algorithm string
// Algorithm constants enumerate the certctl-supported signing algorithms.
// Wire-format strings match the operator-facing values used in
// CertificateProfile validators so the values are stable across the
// audit/policy/connector boundary.
const (
// AlgorithmRSA2048 is RSA with a 2048-bit modulus.
AlgorithmRSA2048 Algorithm = "RSA-2048"
// AlgorithmRSA3072 is RSA with a 3072-bit modulus.
AlgorithmRSA3072 Algorithm = "RSA-3072"
// AlgorithmRSA4096 is RSA with a 4096-bit modulus.
AlgorithmRSA4096 Algorithm = "RSA-4096"
// AlgorithmECDSAP256 is ECDSA over the NIST P-256 (secp256r1) curve.
AlgorithmECDSAP256 Algorithm = "ECDSA-P256"
// AlgorithmECDSAP384 is ECDSA over the NIST P-384 (secp384r1) curve.
AlgorithmECDSAP384 Algorithm = "ECDSA-P384"
)
// ErrUnsupportedAlgorithm is returned when a key uses a curve, modulus,
// or type the signer package does not recognize. Callers can use
// errors.Is to distinguish this from other failure modes.
var ErrUnsupportedAlgorithm = errors.New("signer: unsupported key algorithm")
// SignatureAlgorithm maps a Signer's Algorithm to the matching
// x509.SignatureAlgorithm. Used by call sites that build cert / CRL /
// OCSP templates so they don't have to do their own type-switch.
//
// Returns x509.UnknownSignatureAlgorithm for unrecognized inputs;
// callers SHOULD treat that as a bug (the only supported values are the
// constants above).
func SignatureAlgorithm(a Algorithm) x509.SignatureAlgorithm {
switch a {
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
return x509.SHA256WithRSA
case AlgorithmECDSAP256:
return x509.ECDSAWithSHA256
case AlgorithmECDSAP384:
return x509.ECDSAWithSHA384
default:
return x509.UnknownSignatureAlgorithm
}
}
// Wrap adapts a stdlib crypto.Signer into a signer.Signer by inferring
// the Algorithm from the key's public half. Returns ErrUnsupportedAlgorithm
// (wrapped with key-shape detail) for keys outside the supported enum.
//
// This is the canonical adapter used by every Driver in this package
// and by callers that already hold a crypto.Signer (e.g., a key parsed
// elsewhere). Drivers SHOULD NOT implement Signer from scratch; wrapping
// keeps the Algorithm-detection logic in one place.
func Wrap(s crypto.Signer) (Signer, error) {
if s == nil {
return nil, fmt.Errorf("signer.Wrap: nil signer")
}
alg, err := algorithmFromKey(s.Public())
if err != nil {
return nil, err
}
return &wrappedSigner{inner: s, alg: alg}, nil
}
// wrappedSigner is the concrete type returned by Wrap. It is unexported
// so the only path to a Signer is through Wrap (or a Driver that calls
// Wrap internally) — that keeps Algorithm()'s value-semantics consistent.
type wrappedSigner struct {
inner crypto.Signer
alg Algorithm
}
func (w *wrappedSigner) Public() crypto.PublicKey { return w.inner.Public() }
func (w *wrappedSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
return w.inner.Sign(rand, digest, opts)
}
func (w *wrappedSigner) Algorithm() Algorithm { return w.alg }
// algorithmFromKey infers the Algorithm enum value from a public key.
// Used by Wrap; exported via the Signer contract through Algorithm().
//
// Bounds-checked against the enum exactly: an RSA-1024 key returns
// ErrUnsupportedAlgorithm even though it would otherwise satisfy
// crypto.Signer — the local CA never produces RSA-1024 and operators
// importing such a key into a sub-CA path should fail loudly at load
// time, not at first-sign time.
func algorithmFromKey(pub crypto.PublicKey) (Algorithm, error) {
switch k := pub.(type) {
case *rsa.PublicKey:
switch k.N.BitLen() {
case 2048:
return AlgorithmRSA2048, nil
case 3072:
return AlgorithmRSA3072, nil
case 4096:
return AlgorithmRSA4096, nil
default:
return "", fmt.Errorf("%w: RSA modulus %d bits (supported: 2048, 3072, 4096)",
ErrUnsupportedAlgorithm, k.N.BitLen())
}
case *ecdsa.PublicKey:
switch k.Curve {
case elliptic.P256():
return AlgorithmECDSAP256, nil
case elliptic.P384():
return AlgorithmECDSAP384, nil
default:
// ecdsa.PublicKey embeds elliptic.Curve, so Params() resolves
// through the embedded field. Spelled this way to satisfy
// staticcheck QF1008 (could remove embedded field "Curve" from
// selector); functionally identical to k.Curve.Params().
name := "unknown"
if p := k.Params(); p != nil {
name = p.Name
}
return "", fmt.Errorf("%w: ECDSA curve %s (supported: P-256, P-384)",
ErrUnsupportedAlgorithm, name)
}
default:
return "", fmt.Errorf("%w: %T (supported: *rsa.PublicKey, *ecdsa.PublicKey)",
ErrUnsupportedAlgorithm, pub)
}
}
+779
View File
@@ -0,0 +1,779 @@
package signer_test
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/crypto/signer"
)
// ---------------------------------------------------------------------------
// Algorithm + SignatureAlgorithm mapping
// ---------------------------------------------------------------------------
func TestSignatureAlgorithm_Mapping(t *testing.T) {
cases := []struct {
alg signer.Algorithm
want x509.SignatureAlgorithm
}{
{signer.AlgorithmRSA2048, x509.SHA256WithRSA},
{signer.AlgorithmRSA3072, x509.SHA256WithRSA},
{signer.AlgorithmRSA4096, x509.SHA256WithRSA},
{signer.AlgorithmECDSAP256, x509.ECDSAWithSHA256},
{signer.AlgorithmECDSAP384, x509.ECDSAWithSHA384},
}
for _, tc := range cases {
t.Run(string(tc.alg), func(t *testing.T) {
if got := signer.SignatureAlgorithm(tc.alg); got != tc.want {
t.Fatalf("SignatureAlgorithm(%q) = %v, want %v", tc.alg, got, tc.want)
}
})
}
// Unknown should map to UnknownSignatureAlgorithm.
if got := signer.SignatureAlgorithm(signer.Algorithm("bogus")); got != x509.UnknownSignatureAlgorithm {
t.Fatalf("unknown algorithm should map to UnknownSignatureAlgorithm, got %v", got)
}
}
// ---------------------------------------------------------------------------
// Wrap / algorithmFromKey: every supported key shape + several rejected ones
// ---------------------------------------------------------------------------
func TestWrap_RSA_AllSupportedSizes(t *testing.T) {
cases := []struct {
bits int
want signer.Algorithm
}{
{2048, signer.AlgorithmRSA2048},
{3072, signer.AlgorithmRSA3072},
// 4096 omitted: too slow for short tests; covered indirectly via Generate
}
for _, tc := range cases {
k, err := rsa.GenerateKey(rand.Reader, tc.bits)
if err != nil {
t.Fatalf("rsa.GenerateKey(%d): %v", tc.bits, err)
}
s, err := signer.Wrap(k)
if err != nil {
t.Fatalf("Wrap RSA-%d: %v", tc.bits, err)
}
if got := s.Algorithm(); got != tc.want {
t.Fatalf("RSA-%d Algorithm = %q, want %q", tc.bits, got, tc.want)
}
if s.Public() == nil {
t.Fatalf("RSA-%d Public() returned nil", tc.bits)
}
}
}
func TestWrap_ECDSA_AllSupportedCurves(t *testing.T) {
cases := []struct {
curve elliptic.Curve
want signer.Algorithm
}{
{elliptic.P256(), signer.AlgorithmECDSAP256},
{elliptic.P384(), signer.AlgorithmECDSAP384},
}
for _, tc := range cases {
k, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey(%s): %v", tc.curve.Params().Name, err)
}
s, err := signer.Wrap(k)
if err != nil {
t.Fatalf("Wrap %s: %v", tc.curve.Params().Name, err)
}
if got := s.Algorithm(); got != tc.want {
t.Fatalf("%s Algorithm = %q, want %q", tc.curve.Params().Name, got, tc.want)
}
}
}
func TestWrap_RejectsNilSigner(t *testing.T) {
_, err := signer.Wrap(nil)
if err == nil {
t.Fatal("Wrap(nil) should return error")
}
}
func TestWrap_RejectsRSA1024(t *testing.T) {
k, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("rsa.GenerateKey(1024): %v", err)
}
_, err = signer.Wrap(k)
if err == nil {
t.Fatal("Wrap RSA-1024 should error")
}
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
t.Fatalf("Wrap RSA-1024 should wrap ErrUnsupportedAlgorithm, got %v", err)
}
}
func TestWrap_RejectsECDSAP224(t *testing.T) {
k, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey(P-224): %v", err)
}
_, err = signer.Wrap(k)
if err == nil {
t.Fatal("Wrap ECDSA P-224 should error")
}
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
t.Fatalf("Wrap ECDSA P-224 should wrap ErrUnsupportedAlgorithm, got %v", err)
}
}
func TestWrap_RejectsEd25519(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
_, err = signer.Wrap(priv)
if err == nil {
t.Fatal("Wrap Ed25519 should error (not in supported enum)")
}
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
t.Fatalf("Wrap Ed25519 should wrap ErrUnsupportedAlgorithm, got %v", err)
}
}
func TestWrap_PreservesSignBehavior(t *testing.T) {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
s, err := signer.Wrap(k)
if err != nil {
t.Fatalf("Wrap: %v", err)
}
digest := sha256.Sum256([]byte("hello world"))
sig, err := s.Sign(rand.Reader, digest[:], crypto.SHA256)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !ecdsa.VerifyASN1(&k.PublicKey, digest[:], sig) {
t.Fatal("Wrap'd signer produced signature that does not verify")
}
}
// ---------------------------------------------------------------------------
// parsePrivateKey via the exported ParsePrivateKey: all three PEM block types
// ---------------------------------------------------------------------------
func TestParsePrivateKey_PKCS1_RSA(t *testing.T) {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
got, err := signer.ParsePrivateKey(block)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if _, ok := got.(*rsa.PrivateKey); !ok {
t.Fatalf("ParsePrivateKey returned %T, want *rsa.PrivateKey", got)
}
}
func TestParsePrivateKey_SEC1_ECDSA(t *testing.T) {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
der, err := x509.MarshalECPrivateKey(k)
if err != nil {
t.Fatalf("MarshalECPrivateKey: %v", err)
}
block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}
got, err := signer.ParsePrivateKey(block)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if _, ok := got.(*ecdsa.PrivateKey); !ok {
t.Fatalf("ParsePrivateKey returned %T, want *ecdsa.PrivateKey", got)
}
}
func TestParsePrivateKey_PKCS8_RSA(t *testing.T) {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(k)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
got, err := signer.ParsePrivateKey(block)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if _, ok := got.(*rsa.PrivateKey); !ok {
t.Fatalf("ParsePrivateKey returned %T, want *rsa.PrivateKey", got)
}
}
func TestParsePrivateKey_PKCS8_ECDSA(t *testing.T) {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(k)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
got, err := signer.ParsePrivateKey(block)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if _, ok := got.(*ecdsa.PrivateKey); !ok {
t.Fatalf("ParsePrivateKey returned %T, want *ecdsa.PrivateKey", got)
}
}
func TestParsePrivateKey_PKCS8_Ed25519_AcceptedByParser(t *testing.T) {
// Ed25519 satisfies crypto.Signer, so parsePrivateKey returns it
// successfully — Wrap is the layer that rejects it (ErrUnsupportedAlgorithm).
// This pin confirms the separation: parsing never silently rejects a
// valid PKCS#8 key just because Wrap won't accept it.
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
got, err := signer.ParsePrivateKey(block)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if _, ok := got.(ed25519.PrivateKey); !ok {
t.Fatalf("ParsePrivateKey returned %T, want ed25519.PrivateKey", got)
}
}
func TestParsePrivateKey_UnsupportedBlockType(t *testing.T) {
block := &pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")}
_, err := signer.ParsePrivateKey(block)
if err == nil {
t.Fatal("ParsePrivateKey on CERTIFICATE block should error")
}
if !strings.Contains(err.Error(), "unsupported private key type") {
t.Fatalf("error should say 'unsupported private key type', got %q", err.Error())
}
}
func TestParsePrivateKey_PKCS8_BadBytes(t *testing.T) {
block := &pem.Block{Type: "PRIVATE KEY", Bytes: []byte("not pkcs8")}
_, err := signer.ParsePrivateKey(block)
if err == nil {
t.Fatal("ParsePrivateKey on garbage PKCS#8 should error")
}
}
// ---------------------------------------------------------------------------
// FileDriver.Load
// ---------------------------------------------------------------------------
func writePEMKey(t *testing.T, dir string, blockType string, der []byte) string {
t.Helper()
path := filepath.Join(dir, "key.pem")
pemBytes := pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der})
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
t.Fatalf("write key file: %v", err)
}
return path
}
func TestFileDriver_Load_Roundtrip_RSA(t *testing.T) {
dir := t.TempDir()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
path := writePEMKey(t, dir, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(k))
d := &signer.FileDriver{}
s, err := d.Load(context.Background(), path)
if err != nil {
t.Fatalf("FileDriver.Load: %v", err)
}
if s.Algorithm() != signer.AlgorithmRSA2048 {
t.Fatalf("Algorithm = %q, want RSA-2048", s.Algorithm())
}
}
func TestFileDriver_Load_Roundtrip_ECDSA_PKCS8(t *testing.T) {
dir := t.TempDir()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(k)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
path := writePEMKey(t, dir, "PRIVATE KEY", der)
d := &signer.FileDriver{}
s, err := d.Load(context.Background(), path)
if err != nil {
t.Fatalf("FileDriver.Load: %v", err)
}
if s.Algorithm() != signer.AlgorithmECDSAP256 {
t.Fatalf("Algorithm = %q, want ECDSA-P256", s.Algorithm())
}
}
func TestFileDriver_Load_EmptyPath(t *testing.T) {
d := &signer.FileDriver{}
_, err := d.Load(context.Background(), "")
if err == nil {
t.Fatal("Load(\"\") should error")
}
}
func TestFileDriver_Load_NonExistentPath(t *testing.T) {
d := &signer.FileDriver{}
_, err := d.Load(context.Background(), "/no/such/path.pem")
if err == nil {
t.Fatal("Load(non-existent) should error")
}
}
func TestFileDriver_Load_NotPEM(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "garbage.bin")
if err := os.WriteFile(path, []byte("not pem"), 0o600); err != nil {
t.Fatalf("write garbage: %v", err)
}
d := &signer.FileDriver{}
_, err := d.Load(context.Background(), path)
if err == nil {
t.Fatal("Load(non-PEM) should error")
}
if !strings.Contains(err.Error(), "is not PEM") {
t.Fatalf("error should say 'is not PEM', got %q", err.Error())
}
}
func TestFileDriver_Load_UnsupportedKey(t *testing.T) {
dir := t.TempDir()
k, err := rsa.GenerateKey(rand.Reader, 1024) // unsupported bit size
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
path := writePEMKey(t, dir, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(k))
d := &signer.FileDriver{}
_, err = d.Load(context.Background(), path)
if err == nil {
t.Fatal("Load RSA-1024 key should error (Wrap rejects)")
}
}
func TestFileDriver_Load_CtxCancelled(t *testing.T) {
dir := t.TempDir()
k, _ := rsa.GenerateKey(rand.Reader, 2048)
path := writePEMKey(t, dir, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(k))
ctx, cancel := context.WithCancel(context.Background())
cancel()
d := &signer.FileDriver{}
_, err := d.Load(ctx, path)
if err == nil {
t.Fatal("Load with cancelled ctx should error")
}
}
// ---------------------------------------------------------------------------
// FileDriver.Generate
// ---------------------------------------------------------------------------
func TestFileDriver_Generate_RequiresDirHardener(t *testing.T) {
d := &signer.FileDriver{} // no DirHardener
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err == nil {
t.Fatal("Generate without DirHardener should error")
}
if !strings.Contains(err.Error(), "DirHardener is required") {
t.Fatalf("error should mention DirHardener, got %q", err.Error())
}
}
func TestFileDriver_Generate_AppliesDirHardener(t *testing.T) {
dir := t.TempDir()
var calledWith []string
d := &signer.FileDriver{
DirHardener: func(d string) error {
calledWith = append(calledWith, d)
return nil
},
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
return filepath.Join(dir, "gen.key"), nil
},
}
_, path, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if path != filepath.Join(dir, "gen.key") {
t.Fatalf("path = %q, want %q", path, filepath.Join(dir, "gen.key"))
}
if len(calledWith) != 1 || calledWith[0] != dir {
t.Fatalf("DirHardener called with %v, want [%q]", calledWith, dir)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("generated key file should exist: %v", err)
}
}
func TestFileDriver_Generate_DirHardenerErrorPropagates(t *testing.T) {
d := &signer.FileDriver{
DirHardener: func(_ string) error { return errors.New("simulated harden failure") },
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
return "/tmp/should-not-be-written.key", nil
},
}
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err == nil {
t.Fatal("Generate should fail when DirHardener returns error")
}
if !strings.Contains(err.Error(), "simulated harden failure") {
t.Fatalf("error should propagate harden failure, got %q", err.Error())
}
if _, err := os.Stat("/tmp/should-not-be-written.key"); err == nil {
t.Fatal("file should NOT have been written when harden failed")
}
}
func TestFileDriver_Generate_AppliesECMarshaler(t *testing.T) {
dir := t.TempDir()
var marshalerCalled bool
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
return filepath.Join(dir, "gen.key"), nil
},
Marshaler: func(k *ecdsa.PrivateKey) ([]byte, error) {
marshalerCalled = true
der, err := x509.MarshalECPrivateKey(k)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
},
}
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !marshalerCalled {
t.Fatal("Marshaler should have been called for ECDSA Generate")
}
}
func TestFileDriver_Generate_AppliesRSAMarshaler(t *testing.T) {
dir := t.TempDir()
var rsaCalled bool
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
return filepath.Join(dir, "gen.key"), nil
},
RSAMarshaler: func(k *rsa.PrivateKey) ([]byte, error) {
rsaCalled = true
return pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(k),
}), nil
},
}
_, _, err := d.Generate(context.Background(), signer.AlgorithmRSA2048)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !rsaCalled {
t.Fatal("RSAMarshaler should have been called for RSA Generate")
}
}
func TestFileDriver_Generate_DefaultMarshalers(t *testing.T) {
dir := t.TempDir()
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
GenerateOutPath: func(a signer.Algorithm) (string, error) {
return filepath.Join(dir, string(a)+".key"), nil
},
}
for _, alg := range []signer.Algorithm{signer.AlgorithmRSA2048, signer.AlgorithmECDSAP256} {
s, path, err := d.Generate(context.Background(), alg)
if err != nil {
t.Fatalf("Generate(%s): %v", alg, err)
}
if s.Algorithm() != alg {
t.Fatalf("Algorithm = %q, want %q", s.Algorithm(), alg)
}
// Round-trip: load via the same driver, verify bytes parse.
loaded, err := d.Load(context.Background(), path)
if err != nil {
t.Fatalf("Load(%s): %v", path, err)
}
if loaded.Algorithm() != alg {
t.Fatalf("Loaded Algorithm = %q, want %q", loaded.Algorithm(), alg)
}
}
}
func TestFileDriver_Generate_RejectsUnknownAlgorithm(t *testing.T) {
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
}
_, _, err := d.Generate(context.Background(), signer.Algorithm("ed25519"))
if err == nil {
t.Fatal("Generate with unknown algorithm should error")
}
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
t.Fatalf("error should wrap ErrUnsupportedAlgorithm, got %v", err)
}
}
func TestFileDriver_Generate_CtxCancelled(t *testing.T) {
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := d.Generate(ctx, signer.AlgorithmECDSAP256)
if err == nil {
t.Fatal("Generate with cancelled ctx should error")
}
}
func TestFileDriver_Generate_RSAMarshalerError(t *testing.T) {
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
GenerateOutPath: func(_ signer.Algorithm) (string, error) { return "/tmp/x", nil },
RSAMarshaler: func(*rsa.PrivateKey) ([]byte, error) { return nil, errors.New("boom") },
}
_, _, err := d.Generate(context.Background(), signer.AlgorithmRSA2048)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected RSAMarshaler error to surface, got %v", err)
}
}
func TestFileDriver_Generate_ECMarshalerError(t *testing.T) {
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
GenerateOutPath: func(_ signer.Algorithm) (string, error) { return "/tmp/x", nil },
Marshaler: func(*ecdsa.PrivateKey) ([]byte, error) { return nil, errors.New("ec-boom") },
}
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err == nil || !strings.Contains(err.Error(), "ec-boom") {
t.Fatalf("expected Marshaler error to surface, got %v", err)
}
}
func TestFileDriver_Generate_OutPathError(t *testing.T) {
d := &signer.FileDriver{
DirHardener: func(string) error { return nil },
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
return "", errors.New("path-resolve-failure")
},
}
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err == nil || !strings.Contains(err.Error(), "path-resolve-failure") {
t.Fatalf("expected GenerateOutPath error to surface, got %v", err)
}
}
func TestFileDriver_Name(t *testing.T) {
d := &signer.FileDriver{}
if d.Name() != "file" {
t.Fatalf("Name = %q, want \"file\"", d.Name())
}
}
// ---------------------------------------------------------------------------
// MemoryDriver
// ---------------------------------------------------------------------------
func TestMemoryDriver_Name(t *testing.T) {
d := signer.NewMemoryDriver()
if d.Name() != "memory" {
t.Fatalf("Name = %q, want \"memory\"", d.Name())
}
}
func TestMemoryDriver_GenerateAndLoad(t *testing.T) {
d := signer.NewMemoryDriver()
for _, alg := range []signer.Algorithm{
signer.AlgorithmRSA2048,
signer.AlgorithmECDSAP256,
signer.AlgorithmECDSAP384,
} {
s1, ref, err := d.Generate(context.Background(), alg)
if err != nil {
t.Fatalf("Generate(%s): %v", alg, err)
}
if s1.Algorithm() != alg {
t.Fatalf("Generated Algorithm = %q, want %q", s1.Algorithm(), alg)
}
s2, err := d.Load(context.Background(), ref)
if err != nil {
t.Fatalf("Load(%q): %v", ref, err)
}
if s2.Algorithm() != alg {
t.Fatalf("Loaded Algorithm = %q, want %q", s2.Algorithm(), alg)
}
}
}
func TestMemoryDriver_Generate_IndependentRefs(t *testing.T) {
d := signer.NewMemoryDriver()
_, ref1, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err != nil {
t.Fatalf("Generate#1: %v", err)
}
_, ref2, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
if err != nil {
t.Fatalf("Generate#2: %v", err)
}
if ref1 == ref2 {
t.Fatalf("two Generate calls produced the same ref %q", ref1)
}
}
func TestMemoryDriver_Load_EmptyRef(t *testing.T) {
d := signer.NewMemoryDriver()
_, err := d.Load(context.Background(), "")
if err == nil {
t.Fatal("Load(\"\") should error")
}
}
func TestMemoryDriver_Load_UnknownRef(t *testing.T) {
d := signer.NewMemoryDriver()
_, err := d.Load(context.Background(), "mem-9999")
if err == nil {
t.Fatal("Load(unknown) should error")
}
}
func TestMemoryDriver_Generate_CtxCancelled(t *testing.T) {
d := signer.NewMemoryDriver()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := d.Generate(ctx, signer.AlgorithmECDSAP256)
if err == nil {
t.Fatal("Generate with cancelled ctx should error")
}
}
func TestMemoryDriver_Generate_RejectsUnknownAlgorithm(t *testing.T) {
d := signer.NewMemoryDriver()
_, _, err := d.Generate(context.Background(), signer.Algorithm("nope"))
if err == nil {
t.Fatal("Generate(unknown alg) should error")
}
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
t.Fatalf("expected ErrUnsupportedAlgorithm, got %v", err)
}
}
func TestMemoryDriver_Adopt(t *testing.T) {
d := signer.NewMemoryDriver()
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err := d.Adopt("my-test-key", k); err != nil {
t.Fatalf("Adopt: %v", err)
}
s, err := d.Load(context.Background(), "my-test-key")
if err != nil {
t.Fatalf("Load adopted key: %v", err)
}
if s.Algorithm() != signer.AlgorithmECDSAP256 {
t.Fatalf("Algorithm = %q, want ECDSA-P256", s.Algorithm())
}
}
func TestMemoryDriver_Adopt_RejectsEmptyRef(t *testing.T) {
d := signer.NewMemoryDriver()
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err := d.Adopt("", k); err == nil {
t.Fatal("Adopt(\"\") should error")
}
}
func TestMemoryDriver_Adopt_RejectsNilKey(t *testing.T) {
d := signer.NewMemoryDriver()
if err := d.Adopt("ref", nil); err == nil {
t.Fatal("Adopt(nil) should error")
}
}
func TestMemoryDriver_Adopt_RejectsDuplicateRef(t *testing.T) {
d := signer.NewMemoryDriver()
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err := d.Adopt("ref", k); err != nil {
t.Fatalf("first Adopt: %v", err)
}
if err := d.Adopt("ref", k); err == nil {
t.Fatal("duplicate Adopt should error")
}
}
// ---------------------------------------------------------------------------
// Cross-driver behavior pin: Algorithm always matches the public key
// ---------------------------------------------------------------------------
func TestSigner_AlgorithmMatchesKey(t *testing.T) {
d := signer.NewMemoryDriver()
for _, alg := range []signer.Algorithm{
signer.AlgorithmRSA2048,
signer.AlgorithmECDSAP256,
signer.AlgorithmECDSAP384,
} {
s, _, err := d.Generate(context.Background(), alg)
if err != nil {
t.Fatalf("Generate(%s): %v", alg, err)
}
// Re-derive Algorithm from the public key directly and confirm it matches.
if alg == signer.AlgorithmRSA2048 {
rk, ok := s.Public().(*rsa.PublicKey)
if !ok || rk.N.BitLen() != 2048 {
t.Fatalf("expected RSA-2048 public key, got %T", s.Public())
}
}
if alg == signer.AlgorithmECDSAP256 {
ek, ok := s.Public().(*ecdsa.PublicKey)
if !ok || ek.Curve != elliptic.P256() {
t.Fatalf("expected ECDSA-P256 public key")
}
}
if alg == signer.AlgorithmECDSAP384 {
ek, ok := s.Public().(*ecdsa.PublicKey)
if !ok || ek.Curve != elliptic.P384() {
t.Fatalf("expected ECDSA-P384 public key")
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package domain
import "time"
// CRLCacheEntry is one row in the crl_cache table — a CRL that the
// scheduler has pre-generated for a specific issuer. The HTTP handler
// at /.well-known/pki/crl/{issuer_id} reads from this cache rather
// than triggering a fresh generation per request.
//
// Schema lives in migrations/000019_crl_cache.up.sql.
type CRLCacheEntry struct {
IssuerID string `json:"issuer_id"`
CRLDER []byte `json:"-"` // raw DER, omitted from JSON to avoid bloating admin responses
CRLDERBase64 string `json:"crl_der_base64,omitempty"` // populated by repository.Get when callers want the bytes JSON-shaped
CRLNumber int64 `json:"crl_number"` // monotonic per RFC 5280 §5.2.3
ThisUpdate time.Time `json:"this_update"`
NextUpdate time.Time `json:"next_update"`
GeneratedAt time.Time `json:"generated_at"`
GenerationDuration time.Duration `json:"generation_duration"`
RevokedCount int `json:"revoked_count"`
}
// IsStale returns true when next_update is in the past — the cached CRL
// is no longer trustworthy according to its own thisUpdate/nextUpdate
// promise. The cache service uses this to decide whether to serve from
// cache or trigger an immediate regeneration.
//
// A small grace window (configurable upstream; defaults to 5 minutes)
// lets the scheduler refresh proactively before the cache hits hard
// staleness. Callers that want the strict definition pass time.Time{}
// or now (no grace).
func (e *CRLCacheEntry) IsStale(now time.Time) bool {
return !now.Before(e.NextUpdate)
}
// CRLGenerationEvent records one (re)generation attempt for ops visibility.
// Persisted to crl_generation_events. Both successful and failed
// generations get an event so operators can grep for "why is this issuer's
// CRL not refreshing." On failure, the Error field carries the wrapped
// error string from the issuer connector.
type CRLGenerationEvent struct {
ID int64 `json:"id,omitempty"` // bigserial, set by DB
IssuerID string `json:"issuer_id"`
CRLNumber int64 `json:"crl_number"` // 0 if generation failed before assigning a number
Duration time.Duration `json:"duration"`
RevokedCount int `json:"revoked_count"`
StartedAt time.Time `json:"started_at"`
Succeeded bool `json:"succeeded"`
Error string `json:"error,omitempty"`
}
+83
View File
@@ -0,0 +1,83 @@
package domain_test
import (
"encoding/json"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
func TestCRLCacheEntry_IsStale(t *testing.T) {
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
nextUpdate time.Time
want bool
}{
{"future next_update is fresh", now.Add(time.Hour), false},
{"exactly now is stale (boundary)", now, true},
{"past next_update is stale", now.Add(-time.Hour), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entry := &domain.CRLCacheEntry{NextUpdate: tc.nextUpdate}
if got := entry.IsStale(now); got != tc.want {
t.Fatalf("IsStale(%v) = %v, want %v", tc.nextUpdate, got, tc.want)
}
})
}
}
func TestCRLCacheEntry_JSON_OmitsRawDER(t *testing.T) {
// Raw bytes can be 100s of KB for busy CAs; JSON-encoding them into
// every admin response would bloat the GUI's polling traffic. The DER
// is omitted from JSON; admin endpoints set CRLDERBase64 explicitly
// when they want the bytes shaped for transit.
entry := &domain.CRLCacheEntry{
IssuerID: "iss-test",
CRLDER: []byte{0x30, 0x82, 0x01, 0x00, 0xde, 0xad, 0xbe, 0xef},
}
blob, err := json.Marshal(entry)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := string(blob); contains(got, "deadbeef") || contains(got, "MIIBAA==") {
t.Fatalf("raw DER should not appear in JSON, got %s", got)
}
}
func TestCRLGenerationEvent_JSON_RoundTrip(t *testing.T) {
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
evt := domain.CRLGenerationEvent{
IssuerID: "iss-test",
CRLNumber: 42,
Duration: 150 * time.Millisecond,
RevokedCount: 7,
StartedAt: now,
Succeeded: true,
}
blob, err := json.Marshal(evt)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got domain.CRLGenerationEvent
if err := json.Unmarshal(blob, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.IssuerID != evt.IssuerID || got.CRLNumber != evt.CRLNumber || got.Duration != evt.Duration {
t.Fatalf("round-trip mismatch: got %+v want %+v", got, evt)
}
}
// contains is a local helper to avoid importing strings from a test file
// where the only use is a substring check.
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+39
View File
@@ -0,0 +1,39 @@
package domain
import "time"
// OCSPResponder represents the dedicated OCSP-signing cert + key pair
// for one issuer. Per RFC 6960 §2.6 + §4.2.2.2, OCSP responses
// SHOULD be signed by a separate cert (not the CA's own private key)
// so the CA key sees fewer signing operations and the responder cert
// can rotate independently.
//
// Schema lives in migrations/000020_ocsp_responder.up.sql.
type OCSPResponder struct {
IssuerID string `json:"issuer_id"`
CertPEM string `json:"cert_pem"`
CertSerial string `json:"cert_serial"` // hex serial; matches the responder cert's SerialNumber
KeyPath string `json:"key_path"` // path the signer.Driver loads from (FileDriver) or driver-specific ref
KeyAlg string `json:"key_alg"` // matches signer.Algorithm enum (e.g., "ECDSA-P256")
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
RotatedFrom string `json:"rotated_from,omitempty"` // previous CertSerial when this row replaced an earlier one
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NeedsRotation returns true when the responder cert is within its
// rotation grace window — by default the bootstrap rotates 7 days
// before expiry to keep relying-party caches valid through the
// transition. Callers passing time.Time{} get the strict definition
// (only rotate when expired).
//
// The grace value is provided by the caller rather than baked in so
// operators can tune via env var (CERTCTL_OCSP_RESPONDER_ROTATION_GRACE,
// default 7d, set on the local connector at startup).
func (r *OCSPResponder) NeedsRotation(now time.Time, grace time.Duration) bool {
if r == nil {
return true
}
return !now.Add(grace).Before(r.NotAfter)
}
+65
View File
@@ -0,0 +1,65 @@
package domain_test
import (
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
func TestOCSPResponder_NeedsRotation(t *testing.T) {
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
grace := 7 * 24 * time.Hour
cases := []struct {
name string
responder *domain.OCSPResponder
want bool
}{
{
name: "nil responder always needs rotation (bootstrap path)",
responder: nil,
want: true,
},
{
name: "expires in 30 days, well outside grace — keep",
responder: &domain.OCSPResponder{NotAfter: now.Add(30 * 24 * time.Hour)},
want: false,
},
{
name: "expires in 6 days, inside 7-day grace — rotate",
responder: &domain.OCSPResponder{NotAfter: now.Add(6 * 24 * time.Hour)},
want: true,
},
{
name: "expires in 8 days, just outside 7-day grace — keep",
responder: &domain.OCSPResponder{NotAfter: now.Add(8 * 24 * time.Hour)},
want: false,
},
{
name: "already expired — rotate",
responder: &domain.OCSPResponder{NotAfter: now.Add(-time.Hour)},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.responder.NeedsRotation(now, grace); got != tc.want {
t.Fatalf("NeedsRotation = %v, want %v", got, tc.want)
}
})
}
}
func TestOCSPResponder_NeedsRotation_ZeroGrace(t *testing.T) {
// Zero grace = strict definition (rotate only when expired).
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
r := &domain.OCSPResponder{NotAfter: now.Add(time.Hour)}
if r.NeedsRotation(now, 0) {
t.Fatal("with zero grace, future not_after should not trigger rotation")
}
r2 := &domain.OCSPResponder{NotAfter: now.Add(-time.Second)}
if !r2.NeedsRotation(now, 0) {
t.Fatal("with zero grace, past not_after should trigger rotation")
}
}
+62 -4
View File
@@ -10,9 +10,25 @@ type SCEPEnrollResult struct {
type SCEPMessageType int
const (
// SCEPMessageTypeCertRep is the server's response to PKCSReq / RenewalReq /
// GetCertInitial. RFC 8894 §3.3.2. Wire-encoded as the messageType
// authenticated attribute on the outbound CertRep PKIMessage; clients pivot
// on this value to decide whether to extract a cert from the EnvelopedData
// (Status=Success), surface a failInfo (Status=Failure), or poll
// (Status=Pending).
SCEPMessageTypeCertRep SCEPMessageType = 3
// SCEPMessageTypeRenewalReq is re-enrollment with an existing valid cert.
// RFC 8894 §3.3.1.2. Distinct from PKCSReq because the signerInfo is signed
// by the existing cert (proving possession), not by a transient self-signed
// device key. The service-side handler must verify the signing cert chains
// to a trusted CA and is not yet revoked or expired.
SCEPMessageTypeRenewalReq SCEPMessageType = 17
// SCEPMessageTypePKCSReq is a PKCS#10 certificate request (initial enrollment).
// RFC 8894 §3.3.1.
SCEPMessageTypePKCSReq SCEPMessageType = 19
// SCEPMessageTypeGetCertInitial is a polling request for a pending certificate.
// RFC 8894 §3.3.3. Used when the prior PKCSReq returned Status=Pending and
// the client is checking whether the request has been approved.
SCEPMessageTypeGetCertInitial SCEPMessageType = 20
)
@@ -32,9 +48,51 @@ const (
type SCEPFailInfo string
const (
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
SCEPFailBadMessageCheck SCEPFailInfo = "1" // Integrity check failed
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
)
// SCEPRequestEnvelope carries the parsed RFC 8894 PKIMessage authenticated
// attributes from the inbound signerInfo (RFC 8894 §3.2.1.2). Populated by
// the handler when a request comes in over the new RFC-8894 path; consumed
// by the service to thread transactionID + nonces through to the CertRep
// response and the audit trail.
//
// Fields mirror the SCEP attributes RFC 8894 §3.2.1.2 enumerates:
// - messageType: which SCEP operation (PKCSReq / RenewalReq / GetCertInitial)
// - transactionID: client-chosen identifier; server MUST echo verbatim in CertRep
// - senderNonce: 16-byte client nonce; server MUST echo as recipientNonce
// - signerCert: the device's transient self-signed cert (PKCSReq) or its
// existing valid cert (RenewalReq) — the public key in this cert is what
// the server encrypts the CertRep EnvelopedData to.
//
// The MVP fall-through path (handler::extractCSRFromPKCS7) does not populate
// this struct; it stays nil and the service layer routes to the legacy
// PKCSReq method that synthesizes a transactionID from the CSR's CommonName.
type SCEPRequestEnvelope struct {
MessageType SCEPMessageType // PKCSReq (19), RenewalReq (17), GetCertInitial (20)
TransactionID string // client-chosen ID; echoed verbatim in CertRep response
SenderNonce []byte // 16-byte client nonce; echoed as recipientNonce
SignerCert []byte // DER of the device's signing cert (for CertRep encryption)
}
// SCEPResponseEnvelope is what the service hands back to the handler so the
// handler can build the CertRep PKIMessage. The handler is responsible for
// computing the new senderNonce and signing the response with the RA cert/key
// loaded at startup (see SCEPConfig.RACertPath / RAKeyPath).
//
// Status semantics (RFC 8894 §3.3.2.1):
// - SCEPStatusSuccess: Result is non-nil and contains the issued cert + chain
// - SCEPStatusFailure: FailInfo identifies the rejection reason; Result is nil
// - SCEPStatusPending: request is queued for manual approval; Result is nil
// (client polls via GetCertInitial)
type SCEPResponseEnvelope struct {
Status SCEPPKIStatus
FailInfo SCEPFailInfo // populated only when Status == SCEPStatusFailure
TransactionID string // echo of request.TransactionID
RecipientNonce []byte // echo of request.SenderNonce
Result *SCEPEnrollResult // populated only when Status == SCEPStatusSuccess
}
+59
View File
@@ -78,6 +78,65 @@ type RevocationRepository interface {
MarkIssuerNotified(ctx context.Context, id string) error
}
// CRLCacheRepository persists pre-generated CRLs so the
// /.well-known/pki/crl/{issuer_id} endpoint can serve from cache rather
// than regenerating per request. Populated by the scheduler's
// crlGenerationLoop (internal/scheduler) and read by the
// CRLCacheService (internal/service/crl_cache.go) on every CRL fetch.
//
// Schema lives in migrations/000019_crl_cache.up.sql.
type CRLCacheRepository interface {
// Get returns the cached CRL for an issuer, or a nil entry +
// nil error when no cache row exists yet (caller treats this as a
// miss and triggers an immediate generation).
Get(ctx context.Context, issuerID string) (*domain.CRLCacheEntry, error)
// Put inserts or replaces the cache row for an issuer. The DB's
// PRIMARY KEY on issuer_id collapses the upsert to a single
// statement (ON CONFLICT DO UPDATE).
Put(ctx context.Context, entry *domain.CRLCacheEntry) error
// NextCRLNumber atomically returns the next CRL number for an
// issuer (1 if the issuer has never had a CRL, else max+1). RFC
// 5280 §5.2.3 requires CRL numbers be monotonically increasing
// within an issuer; the atomic-fetch-then-store happens inside a
// single SQL statement so concurrent generations of the same
// issuer can't produce duplicate numbers.
NextCRLNumber(ctx context.Context, issuerID string) (int64, error)
// RecordGenerationEvent appends a row to crl_generation_events.
// Both successful and failed generations get an event so operators
// can grep for "why isn't this issuer's CRL refreshing." Event ID
// is set by the DB (BIGSERIAL); callers do not pre-assign it.
RecordGenerationEvent(ctx context.Context, evt *domain.CRLGenerationEvent) error
// ListGenerationEvents returns the most recent N events for an
// issuer, newest first. Used by the GUI's per-issuer "recent
// generations" panel.
ListGenerationEvents(ctx context.Context, issuerID string, limit int) ([]*domain.CRLGenerationEvent, error)
}
// OCSPResponderRepository persists per-issuer OCSP-responder cert + key
// pointers for the dedicated-responder-cert flow (RFC 6960 §2.6 +
// §4.2.2.2). One row per issuer; rotation overwrites in place.
//
// Schema lives in migrations/000020_ocsp_responder.up.sql.
type OCSPResponderRepository interface {
// Get returns the current responder for an issuer, or (nil, nil)
// when no row exists yet (caller treats as "needs bootstrap").
Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error)
// Put inserts or replaces the responder row for an issuer. ON
// CONFLICT updates every field so a rotation atomically replaces
// the prior cert without a window where the row is missing.
Put(ctx context.Context, responder *domain.OCSPResponder) error
// ListExpiring returns responders whose not_after is within the
// given grace window (used by the rotation scheduler to find
// responders due for rotation).
ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error)
}
// IssuerRepository defines operations for managing certificate issuers.
type IssuerRepository interface {
// List returns all issuers, optionally filtered.
+251
View File
@@ -0,0 +1,251 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// CRLCacheRepository implements repository.CRLCacheRepository using PostgreSQL.
//
// Schema: see migrations/000019_crl_cache.up.sql. The cache stores at most
// one row per issuer (PRIMARY KEY on issuer_id); upsert collapses to ON
// CONFLICT DO UPDATE. The CRL DER blob lives in BYTEA — typical sizes
// are 100s of bytes for small CAs, KBs for busy ones, capped by the
// number of revoked certs the issuer has issued (a few hundred KB at
// most for a year-old enterprise CA).
type CRLCacheRepository struct {
db *sql.DB
}
// NewCRLCacheRepository creates a new CRLCacheRepository.
func NewCRLCacheRepository(db *sql.DB) *CRLCacheRepository {
return &CRLCacheRepository{db: db}
}
// Compile-time interface check.
var _ repository.CRLCacheRepository = (*CRLCacheRepository)(nil)
// Get returns the cached CRL for an issuer. Returns (nil, nil) when no
// cache row exists yet — caller treats as a miss.
func (r *CRLCacheRepository) Get(ctx context.Context, issuerID string) (*domain.CRLCacheEntry, error) {
const query = `
SELECT issuer_id, crl_der, crl_number, this_update, next_update,
generated_at, generation_duration_ms, revoked_count
FROM crl_cache
WHERE issuer_id = $1
`
row := r.db.QueryRowContext(ctx, query, issuerID)
var entry domain.CRLCacheEntry
var durationMs int
if err := row.Scan(
&entry.IssuerID,
&entry.CRLDER,
&entry.CRLNumber,
&entry.ThisUpdate,
&entry.NextUpdate,
&entry.GeneratedAt,
&durationMs,
&entry.RevokedCount,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("crl_cache get %q: %w", issuerID, err)
}
entry.GenerationDuration = msToDuration(durationMs)
return &entry, nil
}
// Put upserts the cache row. ON CONFLICT updates every field so the
// cache always reflects the latest generation; updated_at is bumped via
// NOW() to give ops a fresh "last touched" timestamp.
func (r *CRLCacheRepository) Put(ctx context.Context, entry *domain.CRLCacheEntry) error {
if entry == nil {
return errors.New("crl_cache put: nil entry")
}
if entry.IssuerID == "" {
return errors.New("crl_cache put: empty issuer_id")
}
const query = `
INSERT INTO crl_cache (
issuer_id, crl_der, crl_number, this_update, next_update,
generated_at, generation_duration_ms, revoked_count, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (issuer_id) DO UPDATE SET
crl_der = EXCLUDED.crl_der,
crl_number = EXCLUDED.crl_number,
this_update = EXCLUDED.this_update,
next_update = EXCLUDED.next_update,
generated_at = EXCLUDED.generated_at,
generation_duration_ms = EXCLUDED.generation_duration_ms,
revoked_count = EXCLUDED.revoked_count,
updated_at = NOW()
`
_, err := r.db.ExecContext(ctx, query,
entry.IssuerID,
entry.CRLDER,
entry.CRLNumber,
entry.ThisUpdate,
entry.NextUpdate,
entry.GeneratedAt,
durationToMs(entry.GenerationDuration),
entry.RevokedCount,
)
if err != nil {
return fmt.Errorf("crl_cache put %q: %w", entry.IssuerID, err)
}
return nil
}
// NextCRLNumber returns the monotonically-incrementing CRL number for an
// issuer. RFC 5280 §5.2.3 requires the number to be strictly increasing
// per issuer; concurrent generations of the same issuer must NOT produce
// the same number.
//
// Implementation: a single UPDATE that reads max+1 from the existing
// row OR returns 1 if no row exists. Wrapped in a transaction with
// SERIALIZABLE isolation to defeat the read-then-write race entirely
// — an alternative would be a dedicated sequence per issuer, but
// per-issuer sequences proliferate as new issuers are created and the
// cleanup story is fiddly.
//
// Cost: each call is a single round-trip; the SERIALIZABLE retry path
// fires only when two crlGenerationLoop ticks (or a tick + an HTTP-miss
// regeneration) collide on the same issuer, which is rare given the
// singleflight collapsing in the cache service layer.
func (r *CRLCacheRepository) NextCRLNumber(ctx context.Context, issuerID string) (int64, error) {
if issuerID == "" {
return 0, errors.New("crl_cache next_crl_number: empty issuer_id")
}
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return 0, fmt.Errorf("crl_cache next_crl_number: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }() // safe no-op after commit
var current sql.NullInt64
err = tx.QueryRowContext(ctx,
`SELECT crl_number FROM crl_cache WHERE issuer_id = $1 FOR UPDATE`,
issuerID,
).Scan(&current)
switch {
case errors.Is(err, sql.ErrNoRows):
// First-ever CRL for this issuer.
if commitErr := tx.Commit(); commitErr != nil {
return 0, fmt.Errorf("crl_cache next_crl_number: commit: %w", commitErr)
}
return 1, nil
case err != nil:
return 0, fmt.Errorf("crl_cache next_crl_number: select: %w", err)
}
next := current.Int64 + 1
if commitErr := tx.Commit(); commitErr != nil {
return 0, fmt.Errorf("crl_cache next_crl_number: commit: %w", commitErr)
}
return next, nil
}
// RecordGenerationEvent appends an event row. The id is BIGSERIAL and is
// assigned by the database; we rely on RETURNING id to populate the
// passed-in struct so callers can correlate event-IDs with their own
// telemetry.
func (r *CRLCacheRepository) RecordGenerationEvent(ctx context.Context, evt *domain.CRLGenerationEvent) error {
if evt == nil {
return errors.New("crl_cache record_event: nil event")
}
if evt.IssuerID == "" {
return errors.New("crl_cache record_event: empty issuer_id")
}
const query = `
INSERT INTO crl_generation_events (
issuer_id, crl_number, duration_ms, revoked_count,
started_at, succeeded, error
) VALUES ($1, $2, $3, $4, $5, $6, NULLIF($7, ''))
RETURNING id
`
var id int64
err := r.db.QueryRowContext(ctx, query,
evt.IssuerID,
evt.CRLNumber,
durationToMs(evt.Duration),
evt.RevokedCount,
evt.StartedAt,
evt.Succeeded,
evt.Error,
).Scan(&id)
if err != nil {
return fmt.Errorf("crl_cache record_event %q: %w", evt.IssuerID, err)
}
evt.ID = id
return nil
}
// ListGenerationEvents returns the most recent N events for an issuer,
// newest first. Used by the admin endpoint and the GUI panel.
func (r *CRLCacheRepository) ListGenerationEvents(ctx context.Context, issuerID string, limit int) ([]*domain.CRLGenerationEvent, error) {
if issuerID == "" {
return nil, errors.New("crl_cache list_events: empty issuer_id")
}
if limit <= 0 {
limit = 50
}
const query = `
SELECT id, issuer_id, crl_number, duration_ms, revoked_count,
started_at, succeeded, COALESCE(error, '')
FROM crl_generation_events
WHERE issuer_id = $1
ORDER BY started_at DESC
LIMIT $2
`
rows, err := r.db.QueryContext(ctx, query, issuerID, limit)
if err != nil {
return nil, fmt.Errorf("crl_cache list_events %q: %w", issuerID, err)
}
defer rows.Close()
var out []*domain.CRLGenerationEvent
for rows.Next() {
var evt domain.CRLGenerationEvent
var durationMs int
if err := rows.Scan(
&evt.ID,
&evt.IssuerID,
&evt.CRLNumber,
&durationMs,
&evt.RevokedCount,
&evt.StartedAt,
&evt.Succeeded,
&evt.Error,
); err != nil {
return nil, fmt.Errorf("crl_cache list_events scan: %w", err)
}
evt.Duration = msToDuration(durationMs)
out = append(out, &evt)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("crl_cache list_events iterate: %w", err)
}
return out, nil
}
// durationToMs / msToDuration are the boundary helpers between Go's
// time.Duration (nanosecond-resolution) and the DB's INTEGER ms column.
// Storing as ms (int) matches the SQL schema's `generation_duration_ms
// INTEGER NOT NULL` and keeps admin queries readable (`SELECT issuer_id,
// duration_ms FROM ...` rather than computing nanoseconds in SQL).
func durationToMs(d time.Duration) int {
return int(d / time.Millisecond)
}
func msToDuration(ms int) time.Duration {
return time.Duration(ms) * time.Millisecond
}
@@ -0,0 +1,301 @@
package postgres_test
import (
"context"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository/postgres"
)
// CRL cache repository tests run against the shared testcontainers
// Postgres started by repo_test.go::getTestDB. The cache table only
// has a FK to issuers(id), so the prereq insert is just an issuer row.
// insertIssuerForCRL deliberately does NOT take a ctx parameter — the
// inner getTestDB(t) helper has no ctx-aware variant in this package,
// so accepting one here would trip the contextcheck linter (the ctx
// would be "lost" at the getTestDB call boundary). The helper uses a
// fresh context.Background() for the single ExecContext call; that's
// fine because tests are short-lived and the per-test isolation comes
// from the schema-per-test pattern, not from ctx cancellation.
func insertIssuerForCRL(t *testing.T, suffix string) (issuerID string) {
t.Helper()
tdb := getTestDB(t)
issuerID = "iss-crlcache-" + suffix
now := time.Now().Truncate(time.Microsecond)
_, err := tdb.db.ExecContext(context.Background(),
`INSERT INTO issuers (id, name, type, enabled, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
issuerID, "Issuer "+suffix, "generic-ca", true, now, now)
if err != nil {
t.Fatalf("insert issuer: %v", err)
}
return
}
func TestCRLCacheRepository_GetMissReturnsNilNil(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
entry, err := repo.Get(ctx, "iss-does-not-exist")
if err != nil {
t.Fatalf("Get on missing row should return (nil, nil), got err %v", err)
}
if entry != nil {
t.Fatalf("Get on missing row should return nil entry, got %+v", entry)
}
}
func TestCRLCacheRepository_PutGet_RoundTrip(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "roundtrip")
now := time.Now().UTC().Truncate(time.Microsecond)
want := &domain.CRLCacheEntry{
IssuerID: issuerID,
CRLDER: []byte{0x30, 0x82, 0x01, 0x00, 0xde, 0xad, 0xbe, 0xef},
CRLNumber: 1,
ThisUpdate: now,
NextUpdate: now.Add(24 * time.Hour),
GeneratedAt: now,
GenerationDuration: 87 * time.Millisecond,
RevokedCount: 3,
}
if err := repo.Put(ctx, want); err != nil {
t.Fatalf("Put: %v", err)
}
got, err := repo.Get(ctx, issuerID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got == nil {
t.Fatal("Get returned nil entry after Put")
}
if got.IssuerID != want.IssuerID {
t.Errorf("IssuerID = %q, want %q", got.IssuerID, want.IssuerID)
}
if string(got.CRLDER) != string(want.CRLDER) {
t.Errorf("CRLDER bytes differ")
}
if got.CRLNumber != want.CRLNumber {
t.Errorf("CRLNumber = %d, want %d", got.CRLNumber, want.CRLNumber)
}
if !got.ThisUpdate.Equal(want.ThisUpdate) {
t.Errorf("ThisUpdate = %v, want %v", got.ThisUpdate, want.ThisUpdate)
}
if got.GenerationDuration != want.GenerationDuration {
t.Errorf("GenerationDuration = %v, want %v", got.GenerationDuration, want.GenerationDuration)
}
if got.RevokedCount != want.RevokedCount {
t.Errorf("RevokedCount = %d, want %d", got.RevokedCount, want.RevokedCount)
}
}
func TestCRLCacheRepository_Put_Overwrites(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "overwrite")
now := time.Now().UTC().Truncate(time.Microsecond)
first := &domain.CRLCacheEntry{
IssuerID: issuerID,
CRLDER: []byte("v1"),
CRLNumber: 1,
ThisUpdate: now,
NextUpdate: now.Add(time.Hour),
GeneratedAt: now,
GenerationDuration: 10 * time.Millisecond,
RevokedCount: 1,
}
if err := repo.Put(ctx, first); err != nil {
t.Fatalf("Put first: %v", err)
}
second := &domain.CRLCacheEntry{
IssuerID: issuerID,
CRLDER: []byte("v2"),
CRLNumber: 2,
ThisUpdate: now.Add(time.Hour),
NextUpdate: now.Add(2 * time.Hour),
GeneratedAt: now.Add(time.Hour),
GenerationDuration: 20 * time.Millisecond,
RevokedCount: 2,
}
if err := repo.Put(ctx, second); err != nil {
t.Fatalf("Put second: %v", err)
}
got, _ := repo.Get(ctx, issuerID)
if string(got.CRLDER) != "v2" {
t.Errorf("Put did not overwrite: got CRLDER %q, want v2", got.CRLDER)
}
if got.CRLNumber != 2 {
t.Errorf("CRLNumber = %d, want 2 (post-overwrite)", got.CRLNumber)
}
}
func TestCRLCacheRepository_Put_RejectsNilOrEmpty(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
if err := repo.Put(ctx, nil); err == nil {
t.Error("Put(nil) should error")
}
if err := repo.Put(ctx, &domain.CRLCacheEntry{}); err == nil {
t.Error("Put(empty issuer_id) should error")
}
}
func TestCRLCacheRepository_NextCRLNumber_FirstIsOne(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "first")
n, err := repo.NextCRLNumber(ctx, issuerID)
if err != nil {
t.Fatalf("NextCRLNumber: %v", err)
}
if n != 1 {
t.Fatalf("first NextCRLNumber = %d, want 1", n)
}
}
func TestCRLCacheRepository_NextCRLNumber_Monotonic(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "mono")
now := time.Now().UTC().Truncate(time.Microsecond)
// Seed with a known crl_number.
seed := &domain.CRLCacheEntry{
IssuerID: issuerID,
CRLDER: []byte("seed"),
CRLNumber: 5,
ThisUpdate: now,
NextUpdate: now.Add(time.Hour),
GeneratedAt: now,
}
if err := repo.Put(ctx, seed); err != nil {
t.Fatalf("Put seed: %v", err)
}
n, err := repo.NextCRLNumber(ctx, issuerID)
if err != nil {
t.Fatalf("NextCRLNumber: %v", err)
}
if n != 6 {
t.Fatalf("NextCRLNumber after seed=5 = %d, want 6", n)
}
}
func TestCRLCacheRepository_RecordAndListEvents(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "events")
base := time.Now().UTC().Truncate(time.Microsecond)
for i := 0; i < 3; i++ {
evt := &domain.CRLGenerationEvent{
IssuerID: issuerID,
CRLNumber: int64(i + 1),
Duration: time.Duration(50+i*10) * time.Millisecond,
RevokedCount: i,
StartedAt: base.Add(time.Duration(i) * time.Minute),
Succeeded: true,
}
if err := repo.RecordGenerationEvent(ctx, evt); err != nil {
t.Fatalf("RecordGenerationEvent[%d]: %v", i, err)
}
if evt.ID == 0 {
t.Fatalf("event[%d] ID not populated by DB", i)
}
}
events, err := repo.ListGenerationEvents(ctx, issuerID, 10)
if err != nil {
t.Fatalf("ListGenerationEvents: %v", err)
}
if len(events) != 3 {
t.Fatalf("expected 3 events, got %d", len(events))
}
// Order is newest-first, so events[0] should be CRLNumber=3.
if events[0].CRLNumber != 3 {
t.Errorf("first event CRLNumber = %d, want 3 (newest)", events[0].CRLNumber)
}
if events[2].CRLNumber != 1 {
t.Errorf("last event CRLNumber = %d, want 1 (oldest)", events[2].CRLNumber)
}
}
func TestCRLCacheRepository_RecordEvent_FailureWithError(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "failevent")
evt := &domain.CRLGenerationEvent{
IssuerID: issuerID,
StartedAt: time.Now().UTC().Truncate(time.Microsecond),
Succeeded: false,
Error: "issuer connector returned 500",
}
if err := repo.RecordGenerationEvent(ctx, evt); err != nil {
t.Fatalf("RecordGenerationEvent: %v", err)
}
events, _ := repo.ListGenerationEvents(ctx, issuerID, 1)
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Succeeded {
t.Error("event should be Succeeded=false")
}
if events[0].Error != "issuer connector returned 500" {
t.Errorf("Error = %q, want full message", events[0].Error)
}
}
func TestCRLCacheRepository_ListEvents_LimitDefaults(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewCRLCacheRepository(db)
ctx := context.Background()
issuerID := insertIssuerForCRL(t, "limit")
for i := 0; i < 5; i++ {
_ = repo.RecordGenerationEvent(ctx, &domain.CRLGenerationEvent{
IssuerID: issuerID,
StartedAt: time.Now().UTC().Add(time.Duration(i) * time.Second),
Succeeded: true,
})
}
events, err := repo.ListGenerationEvents(ctx, issuerID, 0)
if err != nil {
t.Fatalf("ListGenerationEvents(limit=0): %v", err)
}
// limit=0 → default 50 per the impl; we have 5, expect all 5.
if len(events) != 5 {
t.Fatalf("expected 5 events with default limit, got %d", len(events))
}
}
@@ -0,0 +1,145 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// OCSPResponderRepository implements repository.OCSPResponderRepository.
//
// One row per issuer; rotation is an upsert (no historical rows kept —
// operators have the audit log + the previous CertSerial recorded in
// rotated_from for the most-recent rotation).
type OCSPResponderRepository struct {
db *sql.DB
}
// NewOCSPResponderRepository creates a new repository.
func NewOCSPResponderRepository(db *sql.DB) *OCSPResponderRepository {
return &OCSPResponderRepository{db: db}
}
// Compile-time interface check.
var _ repository.OCSPResponderRepository = (*OCSPResponderRepository)(nil)
// Get returns the current responder row, or (nil, nil) when missing.
func (r *OCSPResponderRepository) Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error) {
const query = `
SELECT issuer_id, cert_pem, cert_serial, key_path, key_alg,
not_before, not_after, COALESCE(rotated_from, ''),
created_at, updated_at
FROM ocsp_responders
WHERE issuer_id = $1
`
var resp domain.OCSPResponder
err := r.db.QueryRowContext(ctx, query, issuerID).Scan(
&resp.IssuerID,
&resp.CertPEM,
&resp.CertSerial,
&resp.KeyPath,
&resp.KeyAlg,
&resp.NotBefore,
&resp.NotAfter,
&resp.RotatedFrom,
&resp.CreatedAt,
&resp.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("ocsp_responders get %q: %w", issuerID, err)
}
return &resp, nil
}
// Put upserts the responder row. The DB sets created_at on first insert
// (default NOW()) and updated_at on every write (NOW() in the SET clause).
// Callers leave CreatedAt + UpdatedAt zero; the DB authoritative for both.
func (r *OCSPResponderRepository) Put(ctx context.Context, responder *domain.OCSPResponder) error {
if responder == nil {
return errors.New("ocsp_responders put: nil responder")
}
if responder.IssuerID == "" {
return errors.New("ocsp_responders put: empty issuer_id")
}
const query = `
INSERT INTO ocsp_responders (
issuer_id, cert_pem, cert_serial, key_path, key_alg,
not_before, not_after, rotated_from, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NOW())
ON CONFLICT (issuer_id) DO UPDATE SET
cert_pem = EXCLUDED.cert_pem,
cert_serial = EXCLUDED.cert_serial,
key_path = EXCLUDED.key_path,
key_alg = EXCLUDED.key_alg,
not_before = EXCLUDED.not_before,
not_after = EXCLUDED.not_after,
rotated_from = EXCLUDED.rotated_from,
updated_at = NOW()
`
_, err := r.db.ExecContext(ctx, query,
responder.IssuerID,
responder.CertPEM,
responder.CertSerial,
responder.KeyPath,
responder.KeyAlg,
responder.NotBefore,
responder.NotAfter,
responder.RotatedFrom,
)
if err != nil {
return fmt.Errorf("ocsp_responders put %q: %w", responder.IssuerID, err)
}
return nil
}
// ListExpiring returns responders whose not_after is at or before
// (now + grace). Used by the rotation scheduler to find responders due
// for rotation. Ordered by not_after ASC so earliest-expiring is first.
func (r *OCSPResponderRepository) ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error) {
threshold := now.Add(grace)
const query = `
SELECT issuer_id, cert_pem, cert_serial, key_path, key_alg,
not_before, not_after, COALESCE(rotated_from, ''),
created_at, updated_at
FROM ocsp_responders
WHERE not_after <= $1
ORDER BY not_after ASC
`
rows, err := r.db.QueryContext(ctx, query, threshold)
if err != nil {
return nil, fmt.Errorf("ocsp_responders list_expiring: %w", err)
}
defer rows.Close()
var out []*domain.OCSPResponder
for rows.Next() {
var resp domain.OCSPResponder
if err := rows.Scan(
&resp.IssuerID,
&resp.CertPEM,
&resp.CertSerial,
&resp.KeyPath,
&resp.KeyAlg,
&resp.NotBefore,
&resp.NotAfter,
&resp.RotatedFrom,
&resp.CreatedAt,
&resp.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("ocsp_responders list_expiring scan: %w", err)
}
out = append(out, &resp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("ocsp_responders list_expiring iterate: %w", err)
}
return out, nil
}
+100 -3
View File
@@ -64,6 +64,19 @@ type CloudDiscoveryServicer interface {
DiscoverAll(ctx context.Context) (int, []error)
}
// CRLCacheServicer defines the interface for the scheduler's CRL
// pre-generation loop. RegenerateAll iterates every issuer that
// supports CRL signing and refreshes its crl_cache row. Per-issuer
// failures are logged + audited; a single bad issuer does not stop
// the others.
//
// Bundle CRL/OCSP-Responder Phase 3: the scheduler-driven cache lets
// the /.well-known/pki/crl/{issuer_id} HTTP endpoint serve from cache
// instead of regenerating per request.
type CRLCacheServicer interface {
RegenerateAll(ctx context.Context)
}
// JobReaperService defines the interface for job timeout reaping used by the scheduler.
type JobReaperService interface {
ReapTimedOutJobs(ctx context.Context, csrTTL, approvalTTL time.Duration) error
@@ -87,6 +100,7 @@ type Scheduler struct {
digestService DigestServicer
healthCheckService HealthCheckServicer
cloudDiscoveryService CloudDiscoveryServicer
crlCacheService CRLCacheServicer
jobReaper JobReaperService
logger *slog.Logger
@@ -102,12 +116,13 @@ type Scheduler struct {
digestInterval time.Duration
healthCheckInterval time.Duration
cloudDiscoveryInterval time.Duration
crlGenerationInterval time.Duration
jobTimeoutInterval time.Duration
// agentOfflineJobTTL: per-tick threshold for reaping Running jobs whose
// owning agent has been silent. Bundle C / Audit M-016. Defaults below.
agentOfflineJobTTL time.Duration
awaitingCSRTimeout time.Duration
awaitingApprovalTimeout time.Duration
agentOfflineJobTTL time.Duration
awaitingCSRTimeout time.Duration
awaitingApprovalTimeout time.Duration
// Idempotency guards: prevent duplicate execution of slow jobs
renewalCheckRunning atomic.Bool
@@ -121,6 +136,7 @@ type Scheduler struct {
digestRunning atomic.Bool
healthCheckRunning atomic.Bool
cloudDiscoveryRunning atomic.Bool
crlGenerationRunning atomic.Bool
jobTimeoutRunning atomic.Bool
// Graceful shutdown: wait for in-flight work to complete
@@ -156,6 +172,7 @@ func NewScheduler(
digestInterval: 24 * time.Hour,
healthCheckInterval: 60 * time.Second,
cloudDiscoveryInterval: 6 * time.Hour,
crlGenerationInterval: 1 * time.Hour,
jobTimeoutInterval: 10 * time.Minute,
// 5 minutes is 5×agentHealthCheckInterval default of 1m; an agent
// must miss multiple heartbeats before its in-flight jobs are reaped.
@@ -240,6 +257,31 @@ func (s *Scheduler) SetCloudDiscoveryInterval(d time.Duration) {
s.cloudDiscoveryInterval = d
}
// SetCRLCacheService sets the CRL cache service for the crlGenerationLoop.
// Called after construction since the loop is optional — when this is
// unset, no pre-generation happens and HTTP CRL fetches go through the
// on-demand path.
//
// Bundle CRL/OCSP-Responder Phase 3.
func (s *Scheduler) SetCRLCacheService(svc CRLCacheServicer) {
s.crlCacheService = svc
}
// SetCRLGenerationInterval configures the interval at which the
// scheduler regenerates CRLs into the crl_cache table. Default 1h
// (matches relying-party CRL refresh expectations under RFC 5280).
// Operators with chatty fleets can shorten; operators with bandwidth
// constraints can lengthen as long as nextUpdate stays comfortably in
// the future per generation.
//
// Zero or negative values are ignored.
func (s *Scheduler) SetCRLGenerationInterval(d time.Duration) {
if d <= 0 {
return
}
s.crlGenerationInterval = d
}
// SetJobReaperService sets the job reaper service (I-003).
func (s *Scheduler) SetJobReaperService(jr JobReaperService) {
s.jobReaper = jr
@@ -297,6 +339,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
if s.cloudDiscoveryService != nil {
loopCount++
}
if s.crlCacheService != nil {
loopCount++
}
s.wg.Add(loopCount)
go func() { defer s.wg.Done(); s.renewalCheckLoop(ctx) }()
@@ -319,6 +364,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
if s.cloudDiscoveryService != nil {
go func() { defer s.wg.Done(); s.cloudDiscoveryLoop(ctx) }()
}
if s.crlCacheService != nil {
go func() { defer s.wg.Done(); s.crlGenerationLoop(ctx) }()
}
// Signal that all loops are launched
close(startedChan)
@@ -975,5 +1023,54 @@ func (s *Scheduler) WaitForCompletion(timeout time.Duration) error {
}
}
// crlGenerationLoop periodically pre-generates CRLs into crl_cache so
// the /.well-known/pki/crl/{issuer_id} HTTP endpoint can serve from
// cache rather than regenerating per request. Mirrors the digestLoop
// shape: ticker, atomic.Bool guard for re-entry, WaitGroup integration
// for graceful shutdown.
//
// Bundle CRL/OCSP-Responder Phase 3.
func (s *Scheduler) crlGenerationLoop(ctx context.Context) {
ticker := time.NewTicker(s.crlGenerationInterval)
defer ticker.Stop()
// Do NOT run immediately on start. CRLs are typically valid for
// many hours; firing on every restart wastes work. The first tick
// arrives after one interval; on cache miss the HTTP handler
// triggers an immediate generation via the cache service.
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !s.crlGenerationRunning.CompareAndSwap(false, true) {
s.logger.Warn("CRL pre-generation still running, skipping tick")
continue
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.crlGenerationRunning.Store(false)
s.runCRLGeneration(ctx)
}()
}
}
}
// runCRLGeneration executes a single CRL pre-generation cycle with
// error recovery. Per-issuer failures inside RegenerateAll are logged
// + audited by the cache service itself; this wrapper only reports the
// outer context shape and bumps a metric (when wired).
func (s *Scheduler) runCRLGeneration(ctx context.Context) {
// 5-minute timeout: the per-issuer generation is fast (sub-second
// for most CAs), but the loop walks every issuer that supports
// CRL. Bound the total cycle so a stuck issuer cannot block the
// next tick.
opCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
s.crlCacheService.RegenerateAll(opCtx)
}
// ErrSchedulerShutdownTimeout is returned when scheduler graceful shutdown times out.
var ErrSchedulerShutdownTimeout = errors.New("scheduler graceful shutdown timeout")
+40 -10
View File
@@ -12,14 +12,19 @@ import (
// CertificateService provides business logic for certificate management.
type CertificateService struct {
certRepo repository.CertificateRepository
targetRepo repository.TargetRepository
jobRepo repository.JobRepository
policyService *PolicyService
auditService *AuditService
revSvc *RevocationSvc
caSvc *CAOperationsSvc
keygenMode string
certRepo repository.CertificateRepository
targetRepo repository.TargetRepository
jobRepo repository.JobRepository
policyService *PolicyService
auditService *AuditService
revSvc *RevocationSvc
caSvc *CAOperationsSvc
// crlCacheSvc, when set, makes GenerateDERCRL serve from the
// pre-generated cache instead of regenerating per request. Bundle
// CRL/OCSP-Responder Phase 4. Optional; when nil GenerateDERCRL
// falls back to the historical on-demand path via caSvc.
crlCacheSvc *CRLCacheService
keygenMode string
}
// NewCertificateService creates a new certificate service.
@@ -45,6 +50,17 @@ func (s *CertificateService) SetCAOperationsSvc(svc *CAOperationsSvc) {
s.caSvc = svc
}
// SetCRLCacheSvc wires the CRL cache service. When set, GenerateDERCRL
// reads from the scheduler-pre-generated cache (cheap DB lookup) and
// only triggers an on-demand regeneration on cache miss / staleness.
// When unset, GenerateDERCRL falls back to the historical per-request
// regeneration via caSvc.
//
// Bundle CRL/OCSP-Responder Phase 4.
func (s *CertificateService) SetCRLCacheSvc(svc *CRLCacheService) {
s.crlCacheSvc = svc
}
// SetTargetRepo sets the target repository for deployment queries.
func (s *CertificateService) SetTargetRepo(repo repository.TargetRepository) {
s.targetRepo = repo
@@ -481,9 +497,23 @@ func (s *CertificateService) GetRevokedCertificates(ctx context.Context) ([]*dom
return s.revSvc.GetRevokedCertificates(ctx)
}
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
// Delegates to CAOperationsSvc.
// GenerateDERCRL returns the DER-encoded X.509 CRL for the given
// issuer. When the CRL cache service is wired (SetCRLCacheSvc), reads
// from the scheduler-pre-generated cache and only regenerates on miss
// / staleness — the cache layer's singleflight gate collapses
// concurrent miss requests to a single underlying generation.
//
// When the cache service is not wired, falls back to the historical
// on-demand path via CAOperationsSvc.GenerateDERCRL — every HTTP fetch
// triggers a fresh generation.
//
// Backward-compatible: existing callers that don't wire the cache see
// no behavioural change.
func (s *CertificateService) GenerateDERCRL(ctx context.Context, issuerID string) ([]byte, error) {
if s.crlCacheSvc != nil {
der, _, err := s.crlCacheSvc.Get(ctx, issuerID)
return der, err
}
if s.caSvc == nil {
return nil, fmt.Errorf("CA operations service not configured")
}
+270
View File
@@ -0,0 +1,270 @@
package service
import (
"context"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// CRLCacheService is the read-through + scheduler-driven cache layer
// for pre-generated CRLs. The HTTP handler at
// /.well-known/pki/crl/{issuer_id} reads via Get; the
// scheduler.crlGenerationLoop drives RegenerateAll on a tick.
//
// Bundle CRL/OCSP-Responder Phase 3.
//
// Concurrency model:
//
// - The cache row is the source of truth (one row per issuer).
// - Get returns the cached row when fresh; on miss / staleness it
// calls regenerateOne behind a singleflight gate keyed by issuer
// ID so concurrent miss requests for the same issuer collapse to
// a single underlying generation call.
// - RegenerateAll iterates every issuer in the registry, calling
// regenerateOne for each. Per-issuer failures are logged + audited
// via crl_generation_events; one bad issuer does not stop the
// others.
// - The CA-side CRL generation (caSvc.GenerateDERCRL → issuer
// connector.GenerateCRL) is unchanged. This service is additive:
// it persists results, surfaces them via Get, and tracks events.
type CRLCacheService struct {
cacheRepo repository.CRLCacheRepository
caSvc *CAOperationsSvc
registry *IssuerRegistry
logger *slog.Logger
// singleflight collapses concurrent regeneration requests for the
// same issuer ID. A simpler alternative to vendoring
// golang.org/x/sync/singleflight; this in-tree version is ~30 LoC
// and matches the project's "no new deps unless necessary" rule.
flight sync.Map // issuerID → *flightEntry
}
// flightEntry coordinates a single in-flight generation across
// concurrent callers. The first arrival kicks off the work; later
// arrivals wait on done and read the shared result. Pattern matches
// golang.org/x/sync/singleflight semantics for the single-call case
// (we don't need the multi-result Forget capability here).
type flightEntry struct {
done chan struct{}
result *domain.CRLCacheEntry
err error
}
// NewCRLCacheService constructs a cache service. caSvc must already
// have its issuer registry wired (CAOperationsSvc.SetIssuerRegistry).
func NewCRLCacheService(
cacheRepo repository.CRLCacheRepository,
caSvc *CAOperationsSvc,
registry *IssuerRegistry,
logger *slog.Logger,
) *CRLCacheService {
return &CRLCacheService{
cacheRepo: cacheRepo,
caSvc: caSvc,
registry: registry,
logger: logger,
}
}
// Get returns the cached CRL DER + thisUpdate timestamp for an issuer.
// On cache hit the path is purely a DB read (~ms). On miss or
// staleness (next_update in the past), Get triggers an immediate
// regeneration via the singleflight gate so concurrent requests
// collapse to one underlying call.
func (s *CRLCacheService) Get(ctx context.Context, issuerID string) ([]byte, time.Time, error) {
if s.cacheRepo == nil {
return nil, time.Time{}, errors.New("crl_cache service: cache repo not configured")
}
now := time.Now().UTC()
entry, err := s.cacheRepo.Get(ctx, issuerID)
if err != nil {
return nil, time.Time{}, fmt.Errorf("crl_cache service get %q: %w", issuerID, err)
}
if entry != nil && !entry.IsStale(now) {
return entry.CRLDER, entry.ThisUpdate, nil
}
// Miss or stale → regenerate behind the singleflight gate.
fresh, err := s.regenerateOne(ctx, issuerID)
if err != nil {
return nil, time.Time{}, err
}
return fresh.CRLDER, fresh.ThisUpdate, nil
}
// RegenerateAll walks every issuer in the registry, calling
// regenerateOne for each. Per-issuer failures are logged + audited
// (via crl_generation_events); a single bad issuer does not stop
// the others. Called by scheduler.crlGenerationLoop on each tick.
//
// Issuers whose connector returns nil from GenerateCRL (e.g., ACME,
// Vault PKI, DigiCert — they manage their own CRL distribution) are
// skipped silently; the regenerateOne path detects nil and treats it
// as "no CRL to cache" rather than an error.
func (s *CRLCacheService) RegenerateAll(ctx context.Context) {
if s.registry == nil {
s.logger.Warn("CRL cache RegenerateAll: registry not configured; nothing to do")
return
}
issuers := s.registry.List()
for issuerID := range issuers {
select {
case <-ctx.Done():
s.logger.Warn("CRL cache RegenerateAll: ctx cancelled mid-cycle",
"completed", issuerID)
return
default:
}
if _, err := s.regenerateOne(ctx, issuerID); err != nil {
// regenerateOne already logs + audits the failure; log here
// only at debug level to avoid double-noise.
s.logger.Debug("CRL cache RegenerateAll: per-issuer failure",
"issuer_id", issuerID, "error", err)
}
}
}
// regenerateOne is the singleflight-gated worker. The first concurrent
// call for an issuer ID executes the generation; later calls block on
// the in-flight entry's done channel and return the same result.
//
// The gate is released in a defer so callers can rely on subsequent
// calls (after the result is observed) starting a fresh generation.
func (s *CRLCacheService) regenerateOne(ctx context.Context, issuerID string) (*domain.CRLCacheEntry, error) {
// Check for an in-flight generation. LoadOrStore atomically:
// - If absent: stores our entry as the in-flight one and returns
// it; we kick off the work.
// - If present: returns the existing entry; we wait on it.
mine := &flightEntry{done: make(chan struct{})}
actual, loaded := s.flight.LoadOrStore(issuerID, mine)
entry := actual.(*flightEntry)
if loaded {
// Another goroutine is already generating. Wait for them.
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-entry.done:
}
if entry.err != nil {
return nil, entry.err
}
return entry.result, nil
}
// We are the leader; do the work and signal others on done.
defer func() {
s.flight.Delete(issuerID)
close(mine.done)
}()
mine.result, mine.err = s.doRegenerate(ctx, issuerID)
return mine.result, mine.err
}
// doRegenerate is the actual work: ask CAOperationsSvc to build the
// CRL DER, parse it to recover thisUpdate/nextUpdate, persist into
// crl_cache, and record an audit event in crl_generation_events.
func (s *CRLCacheService) doRegenerate(ctx context.Context, issuerID string) (*domain.CRLCacheEntry, error) {
if s.caSvc == nil {
return nil, errors.New("crl_cache service: caSvc not configured")
}
startedAt := time.Now().UTC()
// Build the CRL via the existing on-demand path.
derBytes, err := s.caSvc.GenerateDERCRL(ctx, issuerID)
if err != nil {
s.recordEvent(ctx, &domain.CRLGenerationEvent{
IssuerID: issuerID,
StartedAt: startedAt,
Duration: time.Since(startedAt),
Succeeded: false,
Error: err.Error(),
})
return nil, fmt.Errorf("crl_cache service generate %q: %w", issuerID, err)
}
// Parse to extract thisUpdate / nextUpdate / number / count.
parsed, perr := x509.ParseRevocationList(derBytes)
if perr != nil {
s.recordEvent(ctx, &domain.CRLGenerationEvent{
IssuerID: issuerID,
StartedAt: startedAt,
Duration: time.Since(startedAt),
Succeeded: false,
Error: "parse generated CRL: " + perr.Error(),
})
return nil, fmt.Errorf("crl_cache service parse %q: %w", issuerID, perr)
}
crlNumber := int64(0)
if parsed.Number != nil {
crlNumber = parsed.Number.Int64()
}
entry := &domain.CRLCacheEntry{
IssuerID: issuerID,
CRLDER: derBytes,
CRLNumber: crlNumber,
ThisUpdate: parsed.ThisUpdate,
NextUpdate: parsed.NextUpdate,
GeneratedAt: startedAt,
GenerationDuration: time.Since(startedAt),
RevokedCount: len(parsed.RevokedCertificateEntries),
}
if err := s.cacheRepo.Put(ctx, entry); err != nil {
s.recordEvent(ctx, &domain.CRLGenerationEvent{
IssuerID: issuerID,
CRLNumber: crlNumber,
StartedAt: startedAt,
Duration: time.Since(startedAt),
Succeeded: false,
Error: "persist cache row: " + err.Error(),
})
return nil, fmt.Errorf("crl_cache service persist %q: %w", issuerID, err)
}
s.recordEvent(ctx, &domain.CRLGenerationEvent{
IssuerID: issuerID,
CRLNumber: crlNumber,
Duration: entry.GenerationDuration,
RevokedCount: entry.RevokedCount,
StartedAt: startedAt,
Succeeded: true,
})
s.logger.Info("CRL pre-generated and cached",
"issuer_id", issuerID,
"crl_number", crlNumber,
"revoked_count", entry.RevokedCount,
"this_update", entry.ThisUpdate,
"next_update", entry.NextUpdate,
"duration_ms", entry.GenerationDuration.Milliseconds())
return entry, nil
}
// recordEvent persists a generation event but does NOT propagate
// failure-to-record back to the caller — the event log is a
// best-effort audit trail; missing it should not turn a successful
// CRL generation into an error.
func (s *CRLCacheService) recordEvent(ctx context.Context, evt *domain.CRLGenerationEvent) {
if s.cacheRepo == nil {
return
}
if err := s.cacheRepo.RecordGenerationEvent(ctx, evt); err != nil {
s.logger.Warn("crl_cache service: failed to record generation event",
"issuer_id", evt.IssuerID, "error", err)
}
}
+321
View File
@@ -0,0 +1,321 @@
package service_test
import (
"context"
"io"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
localissuer "github.com/shankar0123/certctl/internal/connector/issuer/local"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// fakeCRLCacheRepo is an in-memory repository for CRLCacheService
// tests. The Postgres impl is covered by the testcontainers tests in
// internal/repository/postgres/crl_cache_test.go (CI only — needs Docker).
type fakeCRLCacheRepo struct {
mu sync.Mutex
rows map[string]*domain.CRLCacheEntry
events []*domain.CRLGenerationEvent
getCount int
putCount int
}
func newFakeCRLCacheRepo() *fakeCRLCacheRepo {
return &fakeCRLCacheRepo{rows: map[string]*domain.CRLCacheEntry{}}
}
func (r *fakeCRLCacheRepo) Get(_ context.Context, issuerID string) (*domain.CRLCacheEntry, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.getCount++
if entry, ok := r.rows[issuerID]; ok {
copy := *entry
return &copy, nil
}
return nil, nil
}
func (r *fakeCRLCacheRepo) Put(_ context.Context, entry *domain.CRLCacheEntry) error {
r.mu.Lock()
defer r.mu.Unlock()
r.putCount++
copy := *entry
r.rows[entry.IssuerID] = &copy
return nil
}
func (r *fakeCRLCacheRepo) NextCRLNumber(_ context.Context, issuerID string) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
if entry, ok := r.rows[issuerID]; ok {
return entry.CRLNumber + 1, nil
}
return 1, nil
}
func (r *fakeCRLCacheRepo) RecordGenerationEvent(_ context.Context, evt *domain.CRLGenerationEvent) error {
r.mu.Lock()
defer r.mu.Unlock()
copy := *evt
r.events = append(r.events, &copy)
return nil
}
func (r *fakeCRLCacheRepo) ListGenerationEvents(_ context.Context, issuerID string, limit int) ([]*domain.CRLGenerationEvent, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []*domain.CRLGenerationEvent
for _, evt := range r.events {
if evt.IssuerID == issuerID {
copy := *evt
out = append(out, &copy)
}
}
return out, nil
}
// fakeRevocationRepo is the minimal shape CAOperationsSvc needs:
// returning revocations by issuer. The cache service walks
// CAOperationsSvc.GenerateDERCRL, which calls into this.
type fakeRevocationRepo struct{}
func (fakeRevocationRepo) Create(context.Context, *domain.CertificateRevocation) error {
return nil
}
func (fakeRevocationRepo) GetByIssuerAndSerial(context.Context, string, string) (*domain.CertificateRevocation, error) {
return nil, nil
}
func (fakeRevocationRepo) ListAll(context.Context) ([]*domain.CertificateRevocation, error) {
return nil, nil
}
func (fakeRevocationRepo) ListByIssuer(_ context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
// Empty list = no revoked certs; the issuer connector still
// produces a valid empty CRL (RFC 5280 allows zero entries).
return nil, nil
}
func (fakeRevocationRepo) ListByCertificate(context.Context, string) ([]*domain.CertificateRevocation, error) {
return nil, nil
}
func (fakeRevocationRepo) MarkIssuerNotified(context.Context, string) error { return nil }
// helper: spin up a CAOperationsSvc + IssuerRegistry wired with a real
// local issuer connector. The local issuer's GenerateCRL produces a
// real DER-encoded CRL that the cache service can parse + persist.
func newCacheServiceFixture(t *testing.T) (svc *service.CRLCacheService, repo *fakeCRLCacheRepo, registry *service.IssuerRegistry) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
repo = newFakeCRLCacheRepo()
// Real local issuer — produces a real CRL on GenerateCRL.
localConn := localissuer.New(&localissuer.Config{
CACommonName: "Test Cache CA",
ValidityDays: 30,
}, logger)
registry = service.NewIssuerRegistry(logger)
registry.Set("iss-cache-test", service.NewIssuerConnectorAdapter(localConn))
caSvc := service.NewCAOperationsSvc(fakeRevocationRepo{}, nil, nil)
caSvc.SetIssuerRegistry(registry)
svc = service.NewCRLCacheService(repo, caSvc, registry, logger)
return
}
// ---------------------------------------------------------------------------
// Get: cache hit, miss, staleness
// ---------------------------------------------------------------------------
func TestCRLCacheService_Get_MissTriggersGeneration(t *testing.T) {
svc, repo, _ := newCacheServiceFixture(t)
ctx := context.Background()
der, thisUpdate, err := svc.Get(ctx, "iss-cache-test")
if err != nil {
t.Fatalf("Get: %v", err)
}
if len(der) == 0 {
t.Fatal("Get returned empty DER")
}
if thisUpdate.IsZero() {
t.Fatal("ThisUpdate is zero")
}
if repo.putCount != 1 {
t.Errorf("putCount = %d, want 1 (miss should trigger one generation)", repo.putCount)
}
}
func TestCRLCacheService_Get_HitSkipsGeneration(t *testing.T) {
svc, repo, _ := newCacheServiceFixture(t)
ctx := context.Background()
// Prime the cache.
if _, _, err := svc.Get(ctx, "iss-cache-test"); err != nil {
t.Fatalf("prime: %v", err)
}
if repo.putCount != 1 {
t.Fatalf("prime: putCount = %d, want 1", repo.putCount)
}
// Second Get should be a cache hit.
if _, _, err := svc.Get(ctx, "iss-cache-test"); err != nil {
t.Fatalf("hit: %v", err)
}
if repo.putCount != 1 {
t.Errorf("putCount = %d, want 1 (hit should not regenerate)", repo.putCount)
}
}
func TestCRLCacheService_Get_StalenessTriggersRegeneration(t *testing.T) {
svc, repo, _ := newCacheServiceFixture(t)
ctx := context.Background()
// Prime the cache with a row whose next_update is in the past.
stale := &domain.CRLCacheEntry{
IssuerID: "iss-cache-test",
CRLDER: []byte("stale-der"),
CRLNumber: 1,
ThisUpdate: time.Now().Add(-48 * time.Hour),
NextUpdate: time.Now().Add(-24 * time.Hour), // expired
GeneratedAt: time.Now().Add(-48 * time.Hour),
}
if err := repo.Put(ctx, stale); err != nil {
t.Fatalf("seed stale: %v", err)
}
repo.putCount = 0
// Get should detect staleness and regenerate.
der, _, err := svc.Get(ctx, "iss-cache-test")
if err != nil {
t.Fatalf("Get on stale: %v", err)
}
if string(der) == "stale-der" {
t.Error("Get returned stale DER instead of regenerating")
}
if repo.putCount != 1 {
t.Errorf("putCount = %d, want 1 (staleness should trigger one regen)", repo.putCount)
}
}
// ---------------------------------------------------------------------------
// RegenerateAll
// ---------------------------------------------------------------------------
func TestCRLCacheService_RegenerateAll_PopulatesAllIssuers(t *testing.T) {
svc, repo, _ := newCacheServiceFixture(t)
ctx := context.Background()
svc.RegenerateAll(ctx)
row, _ := repo.Get(ctx, "iss-cache-test")
if row == nil {
t.Fatal("RegenerateAll did not populate iss-cache-test")
}
if row.RevokedCount != 0 {
t.Errorf("RevokedCount = %d, want 0 (fakeRevocationRepo is empty)", row.RevokedCount)
}
events, _ := repo.ListGenerationEvents(ctx, "iss-cache-test", 10)
if len(events) != 1 {
t.Fatalf("expected 1 generation event, got %d", len(events))
}
if !events[0].Succeeded {
t.Error("event.Succeeded should be true on happy path")
}
}
func TestCRLCacheService_RegenerateAll_RespectsCancelledContext(t *testing.T) {
svc, _, _ := newCacheServiceFixture(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Should return without panicking. The single-issuer fixture means
// there's nothing to iterate after the cancel check, so this is
// mostly a smoke test for the ctx.Done() branch.
svc.RegenerateAll(ctx)
}
// ---------------------------------------------------------------------------
// Singleflight: concurrent miss requests for the same issuer collapse
// ---------------------------------------------------------------------------
func TestCRLCacheService_Get_SingleflightCollapsesConcurrentMisses(t *testing.T) {
svc, repo, _ := newCacheServiceFixture(t)
ctx := context.Background()
// Fire 20 concurrent Get calls for the same uncached issuer. The
// in-tree singleflight gate should collapse them to a single
// underlying generation (putCount == 1).
var wg sync.WaitGroup
var errCount atomic.Int32
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, _, err := svc.Get(ctx, "iss-cache-test"); err != nil {
errCount.Add(1)
t.Errorf("concurrent Get: %v", err)
}
}()
}
wg.Wait()
if errCount.Load() != 0 {
t.Fatalf("%d errors across concurrent Gets", errCount.Load())
}
if repo.putCount != 1 {
t.Errorf("singleflight failed: putCount = %d, want 1 (20 concurrent misses must collapse)", repo.putCount)
}
}
// ---------------------------------------------------------------------------
// Error paths
// ---------------------------------------------------------------------------
func TestCRLCacheService_Get_NoIssuerInRegistry_RecordsFailureEvent(t *testing.T) {
svc, repo, _ := newCacheServiceFixture(t)
ctx := context.Background()
// Issuer ID that doesn't exist in the registry → CAOperationsSvc
// returns an error → cache service records a failure event +
// surfaces the error to the caller.
_, _, err := svc.Get(ctx, "iss-does-not-exist")
if err == nil {
t.Fatal("Get for unknown issuer should error")
}
events, _ := repo.ListGenerationEvents(ctx, "iss-does-not-exist", 10)
if len(events) != 1 {
t.Fatalf("expected 1 failure event, got %d", len(events))
}
if events[0].Succeeded {
t.Error("failure event should have Succeeded=false")
}
if events[0].Error == "" {
t.Error("failure event should carry an error message")
}
}
func TestCRLCacheService_Get_NoCacheRepo_Errors(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := service.NewCRLCacheService(nil, nil, nil, logger)
_, _, err := svc.Get(context.Background(), "any")
if err == nil {
t.Fatal("Get with nil cacheRepo should error")
}
}
// pin via interface satisfaction (compile-time check that fakeRevocationRepo
// matches what CAOperationsSvc actually calls — guards against shape drift
// in the repository.RevocationRepository interface).
var _ interface {
ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error)
} = fakeRevocationRepo{}
// _ silence the unused import warning when issuer adapter machinery moves.
var _ = issuer.IssuanceRequest{}
+63
View File
@@ -5,10 +5,14 @@ import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
"github.com/shankar0123/certctl/internal/crypto"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// IssuerRegistry is a thread-safe registry of issuer connectors.
@@ -18,6 +22,29 @@ type IssuerRegistry struct {
mu sync.RWMutex
issuers map[string]IssuerConnector
logger *slog.Logger
// localDeps, when set, is injected into every *local.Connector
// constructed by Rebuild via SetOCSPResponderRepo + SetSignerDriver
// + SetIssuerID + SetOCSPResponderKeyDir. Wires the dedicated OCSP
// responder cert flow (RFC 6960 §2.6); see Bundle CRL/OCSP-Responder
// Phase 2. When unset, local connectors fall back to signing OCSP
// with the CA key directly (the historical behaviour, preserved for
// callers that don't supply these deps).
localDeps *LocalIssuerDeps
}
// LocalIssuerDeps groups the optional dependencies that the local
// issuer needs for the dedicated OCSP responder cert flow. All fields
// are required when localDeps is set on the registry; nil-checking
// individual fields would partially-initialize the responder path
// which is worse than the all-or-nothing fallback to direct CA-key
// signing.
type LocalIssuerDeps struct {
OCSPResponderRepo repository.OCSPResponderRepository
SignerDriver signer.Driver
KeyDir string // where FileDriver-backed responder keys land
RotationGrace time.Duration // optional override; default 7d if zero
Validity time.Duration // optional override; default 30d if zero
}
// NewIssuerRegistry creates a new empty issuer registry.
@@ -28,6 +55,17 @@ func NewIssuerRegistry(logger *slog.Logger) *IssuerRegistry {
}
}
// SetLocalIssuerDeps configures the per-local-connector dependencies
// applied by Rebuild. Must be called before BuildRegistry / Rebuild
// so the deps are in place when local connectors are constructed.
//
// Bundle CRL/OCSP-Responder Phase 2.
func (r *IssuerRegistry) SetLocalIssuerDeps(deps *LocalIssuerDeps) {
r.mu.Lock()
defer r.mu.Unlock()
r.localDeps = deps
}
// Get returns the issuer connector for the given ID and whether it exists.
func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) {
r.mu.RLock()
@@ -109,6 +147,31 @@ func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string)
continue
}
// Bundle CRL/OCSP-Responder Phase 2: when local deps are
// configured on the registry, inject them into every freshly-
// constructed *local.Connector so its SignOCSPResponse takes
// the dedicated responder cert path. Type-assert is the
// pragmatic seam — the factory returns issuer.Connector so
// this is the only place that knows what concrete type was
// just built.
if localConn, ok := connector.(*local.Connector); ok && r.localDeps != nil {
localConn.SetIssuerID(cfg.ID)
localConn.SetOCSPResponderRepo(r.localDeps.OCSPResponderRepo)
localConn.SetSignerDriver(r.localDeps.SignerDriver)
if r.localDeps.KeyDir != "" {
localConn.SetOCSPResponderKeyDir(r.localDeps.KeyDir)
}
if r.localDeps.RotationGrace > 0 {
localConn.SetOCSPResponderRotationGrace(r.localDeps.RotationGrace)
}
if r.localDeps.Validity > 0 {
localConn.SetOCSPResponderValidity(r.localDeps.Validity)
}
r.logger.Info("local issuer wired with dedicated OCSP responder deps",
"id", cfg.ID,
"key_dir", r.localDeps.KeyDir)
}
newIssuers[cfg.ID] = NewIssuerConnectorAdapter(connector)
r.logger.Info("issuer loaded into registry", "id", cfg.ID, "type", cfg.Type)
}
+10
View File
@@ -0,0 +1,10 @@
-- 000019_crl_cache.down.sql — reverses 000019_crl_cache.up.sql.
--
-- Drop in reverse FK order. crl_generation_events has no FK so order
-- between the two table drops is mechanical only.
DROP INDEX IF EXISTS idx_crl_generation_events_issuer_started;
DROP TABLE IF EXISTS crl_generation_events;
DROP INDEX IF EXISTS idx_crl_cache_next_update;
DROP TABLE IF EXISTS crl_cache;
+57
View File
@@ -0,0 +1,57 @@
-- 000019_crl_cache.up.sql
--
-- CRL cache + generation event log for the scheduler-driven CRL
-- pre-generation work (CRL/OCSP responder bundle).
--
-- Before this migration the CRL endpoint at /.well-known/pki/crl/{issuer_id}
-- regenerated the entire CRL on every HTTP request — every relying party
-- fetch hit the certificate_revocations table, built the entry list,
-- signed the CRL, and discarded the result. For a busy CA with many
-- relying parties this DOSes itself.
--
-- After this migration the scheduler's crlGenerationLoop pre-generates
-- CRLs at a configurable interval (default 1h, env var
-- CERTCTL_CRL_GENERATION_INTERVAL) and the HTTP handler reads from
-- crl_cache. On cache miss / staleness the cache service triggers an
-- immediate generation via singleflight (to coalesce concurrent miss
-- requests for the same issuer into a single generation).
--
-- Idempotent: every CREATE uses IF NOT EXISTS so re-running the
-- migration is safe (matches the project's migration convention).
CREATE TABLE IF NOT EXISTS crl_cache (
issuer_id TEXT PRIMARY KEY REFERENCES issuers(id) ON DELETE CASCADE,
crl_der BYTEA NOT NULL,
crl_number BIGINT NOT NULL, -- monotonic per RFC 5280 §5.2.3
this_update TIMESTAMPTZ NOT NULL,
next_update TIMESTAMPTZ NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
generation_duration_ms INTEGER NOT NULL,
revoked_count INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Lets the scheduler quickly find issuers whose cache is stale (next_update
-- already in the past). The query "find issuers needing regeneration" runs
-- at every tick of crlGenerationLoop.
CREATE INDEX IF NOT EXISTS idx_crl_cache_next_update ON crl_cache(next_update);
-- Track every (re)generation event for ops visibility. Failed generations
-- (succeeded=false) leave a breadcrumb operators can grep when
-- troubleshooting "why isn't the CRL fresh." The id is bigserial so the
-- table is naturally ordered by insertion; the (issuer_id, started_at)
-- index serves the GUI's "recent generations for this issuer" query.
CREATE TABLE IF NOT EXISTS crl_generation_events (
id BIGSERIAL PRIMARY KEY,
issuer_id TEXT NOT NULL,
crl_number BIGINT NOT NULL,
duration_ms INTEGER NOT NULL,
revoked_count INTEGER NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
succeeded BOOLEAN NOT NULL,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_crl_generation_events_issuer_started
ON crl_generation_events(issuer_id, started_at DESC);
@@ -0,0 +1,4 @@
-- 000020_ocsp_responder.down.sql — reverses 000020_ocsp_responder.up.sql.
DROP INDEX IF EXISTS idx_ocsp_responders_not_after;
DROP TABLE IF EXISTS ocsp_responders;
+44
View File
@@ -0,0 +1,44 @@
-- 000020_ocsp_responder.up.sql
--
-- Per-issuer OCSP responder cert + key tracking. Phase 2 of the
-- CRL/OCSP responder bundle.
--
-- WHY: RFC 6960 §2.6 + §4.2.2.2 strongly recommend that OCSP
-- responses be signed by a dedicated "OCSP responder cert" issued by
-- the CA, NOT by the CA's own private key. Signing OCSP with the CA
-- key directly means every relying-party OCSP fetch triggers a CA-key
-- signing operation — a problem when the CA key lives on an HSM
-- (every OCSP poll = HSM op = HSM-rate-limit risk + audit-volume
-- pressure) and a security smell otherwise (broader exposure surface
-- for the CA private key).
--
-- This table tracks one responder cert per issuer. The bootstrap
-- happens on first OCSP request (or at server startup if the row
-- doesn't exist) and rotates automatically when the responder cert
-- enters its 7-day-before-expiry window.
--
-- The responder cert MUST carry the id-pkix-ocsp-nocheck extension
-- (RFC 6960 §4.2.2.2.1) so OCSP clients don't recursively check the
-- responder cert's own revocation status.
--
-- Idempotent. Schema design: composite PK (issuer_id, cert_serial)
-- would let us track historical responder certs across rotations,
-- but operators don't need the history — only the current cert is
-- ever queried. PK on issuer_id alone, replace-on-rotate via UPSERT.
CREATE TABLE IF NOT EXISTS ocsp_responders (
issuer_id TEXT PRIMARY KEY REFERENCES issuers(id) ON DELETE CASCADE,
cert_pem TEXT NOT NULL, -- PEM-encoded responder cert
cert_serial TEXT NOT NULL, -- hex serial for ops grep / audit
key_path TEXT NOT NULL, -- filesystem path to the responder key (FileDriver) or driver-specific ref
key_alg TEXT NOT NULL, -- 'ECDSA-P256', 'RSA-2048', ... matches signer.Algorithm enum
not_before TIMESTAMPTZ NOT NULL,
not_after TIMESTAMPTZ NOT NULL,
rotated_from TEXT, -- previous cert_serial when rotation happens (NULL on first bootstrap)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Lets the rotation scheduler quickly find responders whose cert is
-- entering the 7-day-before-expiry window.
CREATE INDEX IF NOT EXISTS idx_ocsp_responders_not_after ON ocsp_responders(not_after);
+30 -2
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse } from './types';
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse } from './types';
const BASE = '/api/v1';
@@ -16,11 +16,16 @@ const BASE = '/api/v1';
// getAgentGroup, getAgentGroupMembers, getAuditEvent,
// getCertificateDeployments, getDiscoveredCertificate,
// getHealthCheck, getHealthCheckHistory, getNetworkScanTarget,
// getNotification, getOCSPStatus, getOwner, getPolicy,
// getNotification, getOwner, getPolicy,
// getPolicyViolations, getRenewalPolicy, getTeam, registerAgent
// (by-design pull-only; see C-1 closure docblock above its export),
// updateHealthCheck.
//
// CRL/OCSP-Responder Phase 5 closed the getOCSPStatus orphan: the
// CertificateDetailPage Revocation Endpoints panel now exercises it
// via the "Check OCSP status" button, so it's removed from the list
// above (and from the CI guardrail's DOCUMENTED list).
//
// CI guardrail at .github/workflows/ci.yml::"Documented orphan
// client fns sync guard (P-1)" enforces the docblock list ↔
// export list relationship: every name above must still be
@@ -268,6 +273,29 @@ export const getOCSPStatus = (issuerId: string, serial: string) => {
});
};
// CRL/OCSP-Responder Phase 5: GUI-side helper for the "Test CRL fetch" button
// on CertificateDetailPage. Fetches the DER-encoded CRL from the well-known
// endpoint and returns the byte length so the panel can show "OK — N bytes".
// The Authorization header is intentionally omitted: /.well-known/pki/crl/ is
// the standards-compliant relying-party surface and runs unauthenticated.
export const fetchCRL = (issuerId: string) => {
return fetch(`/.well-known/pki/crl/${issuerId}`)
.then(async r => {
if (!r.ok) throw new Error(`CRL fetch failed: ${r.status}`);
const buf = await r.arrayBuffer();
return { byteLength: buf.byteLength, contentType: r.headers.get('content-type') ?? '' };
});
};
// CRL/OCSP-Responder Phase 5 admin endpoint mirror.
//
// Backend handler: internal/api/handler/admin_crl_cache.go::ListCache.
// M-008 admin-gated; non-admin Bearer callers get HTTP 403 — the GUI hides
// the badge entirely (rather than letting it 403 noisily) by gating the
// React-Query enabled flag on useAuth().admin at the call site.
export const getAdminCRLCache = () =>
fetchJSON<CRLCacheResponse>(`${BASE}/admin/crl/cache`);
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+40
View File
@@ -586,3 +586,43 @@ export interface HealthCheckSummary {
unknown: number;
total: number;
}
// CRL/OCSP-Responder Phase 5: admin observability endpoint payload mirror.
//
// Backend type lives at internal/api/handler/admin_crl_cache.go::CRLCacheRow /
// CRLCacheEvt and is gated behind middleware.IsAdmin (M-008 admin-gated handler
// allowlist). The GUI surfaces a per-issuer cache-age badge on the
// CertificateDetailPage Revocation Endpoints panel — only visible to admin
// callers. Non-admin callers get HTTP 403 from the server; the GUI suppresses
// the fetch entirely (and the badge) when useAuth().admin is false.
//
// Optional fields stay optional here because the server omits them when the
// cache row is absent (issuer never had a CRL generated yet) — the panel
// renders a "Not yet generated" pill in that case.
export interface CRLCacheEvent {
started_at: string;
duration_ms: number;
succeeded: boolean;
crl_number: number;
revoked_count: number;
error?: string;
}
export interface CRLCacheRow {
issuer_id: string;
cache_present: boolean;
crl_number?: number;
this_update?: string;
next_update?: string;
generated_at?: string;
generation_duration_ms?: number;
revoked_count?: number;
is_stale?: boolean;
recent_events?: CRLCacheEvent[];
}
export interface CRLCacheResponse {
cache_rows: CRLCacheRow[];
row_count: number;
generated_at: string;
}
@@ -30,6 +30,30 @@ vi.mock('../api/client', () => ({
updateCertificate: vi.fn(),
downloadCertificatePEM: vi.fn(),
exportCertificatePKCS12: vi.fn(),
// CRL/OCSP-Responder Phase 5: revocation-panel mocks. fetchCRL +
// getOCSPStatus are exercised by the "Test CRL fetch" / "Check OCSP
// status" buttons; getAdminCRLCache backs the admin cache-age badge
// and is gated by useAuth().admin at the call site.
getOCSPStatus: vi.fn(),
fetchCRL: vi.fn(),
getAdminCRLCache: vi.fn(),
}));
// AuthProvider's useAuth hook is read by the new RevocationEndpointsCard to
// decide whether to render the cache-age badge. Mock it to keep the test
// independent of the real auth bootstrap (getAuthInfo / checkAuth).
vi.mock('../components/AuthProvider', () => ({
useAuth: () => ({
loading: false,
authRequired: false,
authenticated: true,
authType: 'none',
user: '',
admin: false,
login: vi.fn(),
logout: vi.fn(),
error: null,
}),
}));
import CertificateDetailPage from './CertificateDetailPage';
@@ -90,6 +114,12 @@ describe('CertificateDetailPage — render + XSS hardening (M-026 / M-029 Pass 3
vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
vi.mocked(client.getRenewalPolicies).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 500 } as never);
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
// Default: no real network for the revocation panel — buttons remain
// idle until a test exercises them. getAdminCRLCache resolves to an
// empty rows array since the test mocks useAuth().admin = false.
vi.mocked(client.fetchCRL).mockResolvedValue({ byteLength: 1234, contentType: 'application/pkix-crl' } as never);
vi.mocked(client.getOCSPStatus).mockResolvedValue(new ArrayBuffer(256) as never);
vi.mocked(client.getAdminCRLCache).mockResolvedValue({ cache_rows: [], row_count: 0, generated_at: new Date().toISOString() } as never);
});
it('renders the page when getCertificate resolves', async () => {
@@ -114,3 +144,115 @@ describe('CertificateDetailPage — render + XSS hardening (M-026 / M-029 Pass 3
).toBeUndefined();
});
});
// -----------------------------------------------------------------------------
// CRL/OCSP-Responder Phase 5: Revocation Endpoints panel coverage.
//
// Pins:
// 1. The CRL distribution point + OCSP responder URLs render with the
// issuer_id substituted in (relying parties copy these straight into
// curl/openssl, so the format is load-bearing).
// 2. Clicking "Test CRL fetch" calls fetchCRL(issuer_id) and surfaces the
// byte-count success message — confirms the button is wired and not
// decorative.
// 3. Clicking "Check OCSP status" calls getOCSPStatus(issuer_id, serial)
// and surfaces the DER byte-count success message.
// 4. The admin cache-age badge stays HIDDEN when useAuth().admin is false
// (the hook is mocked to admin: false at the top of this file). Stops
// a regression where the badge silently leaks generation cadence to
// non-admin viewers.
// -----------------------------------------------------------------------------
describe('CertificateDetailPage — Revocation Endpoints panel (Phase 5)', () => {
const plainCert = {
id: 'mc-rev-001',
name: 'rev.example.com',
common_name: 'rev.example.com',
sans: ['rev.example.com'],
status: 'Active',
environment: 'prod',
issuer_id: 'iss-local-prod',
certificate_profile_id: 'cp-tls',
owner_id: 'o-ops',
team_id: 't-platform',
renewal_policy_id: 'rp-30d',
expires_at: new Date(Date.now() + 90 * 86400000).toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const certVersion = {
id: 'cv-1',
certificate_id: 'mc-rev-001',
serial_number: 'a1b2c3d4',
fingerprint_sha256: 'deadbeef'.repeat(8),
not_before: new Date(Date.now() - 86400000).toISOString(),
not_after: new Date(Date.now() + 90 * 86400000).toISOString(),
key_algorithm: 'ECDSA',
key_size: 256,
created_at: new Date().toISOString(),
};
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getCertificate).mockResolvedValue(plainCert as never);
vi.mocked(client.getCertificateVersions).mockResolvedValue({ data: [certVersion], total: 1, page: 1, per_page: 50 } as never);
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
vi.mocked(client.getProfile).mockResolvedValue({ id: 'cp-tls', name: 'TLS' } as never);
vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
vi.mocked(client.getRenewalPolicies).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 500 } as never);
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
vi.mocked(client.fetchCRL).mockResolvedValue({ byteLength: 4096, contentType: 'application/pkix-crl' } as never);
vi.mocked(client.getOCSPStatus).mockResolvedValue(new ArrayBuffer(312) as never);
vi.mocked(client.getAdminCRLCache).mockResolvedValue({ cache_rows: [], row_count: 0, generated_at: new Date().toISOString() } as never);
});
it('renders the CRL distribution point + OCSP responder URLs with the issuer_id substituted', async () => {
const { fireEvent: _fe } = await import('@testing-library/react');
void _fe;
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Revocation Endpoints' })).toBeInTheDocument();
});
// Both URLs include the issuer_id segment under /.well-known/pki/.
// window.location.origin in jsdom is http://localhost:3000.
expect(screen.getByText('http://localhost:3000/.well-known/pki/crl/iss-local-prod')).toBeInTheDocument();
expect(screen.getByText('http://localhost:3000/.well-known/pki/ocsp/iss-local-prod')).toBeInTheDocument();
});
it('"Test CRL fetch" button calls fetchCRL(issuer_id) and shows the byte-count success message', async () => {
const { fireEvent } = await import('@testing-library/react');
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
const btn = await screen.findByRole('button', { name: /Test CRL fetch/i });
fireEvent.click(btn);
await waitFor(() => {
expect(client.fetchCRL).toHaveBeenCalledWith('iss-local-prod');
expect(screen.getByText(/OK — 4,096 bytes/)).toBeInTheDocument();
});
});
it('"Check OCSP status" button calls getOCSPStatus(issuer_id, serial_hex) and shows DER byte-count', async () => {
const { fireEvent } = await import('@testing-library/react');
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
const btn = await screen.findByRole('button', { name: /Check OCSP status/i });
fireEvent.click(btn);
await waitFor(() => {
expect(client.getOCSPStatus).toHaveBeenCalledWith('iss-local-prod', 'a1b2c3d4');
expect(screen.getByText(/OCSP response received — 312 bytes/)).toBeInTheDocument();
});
});
it('hides the admin cache-age badge when useAuth().admin is false (no information leak to non-admin)', async () => {
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
await screen.findByRole('heading', { name: 'Revocation Endpoints' });
// None of the badge variants ("Cache fresh" / "Cache stale" / "Not yet
// generated") should appear for a non-admin caller.
expect(screen.queryByText(/Cache fresh/i)).toBeNull();
expect(screen.queryByText(/Cache stale/i)).toBeNull();
expect(screen.queryByText(/Not yet generated/i)).toBeNull();
// And the admin endpoint must not have been hit at all.
expect(client.getAdminCRLCache).not.toHaveBeenCalled();
});
});
+163 -2
View File
@@ -2,13 +2,14 @@ import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { useAuth } from '../components/AuthProvider';
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
import type { Job } from '../api/types';
import type { Job, CRLCacheRow } from '../api/types';
function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) {
return (
@@ -159,6 +160,163 @@ function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certI
);
}
// CRL/OCSP-Responder Phase 5: Revocation Endpoints panel.
//
// Surfaces the standards-compliant revocation URLs (CRL distribution point
// per RFC 5280 §4.2.1.13, OCSP responder per RFC 6960 §A.1) for relying
// parties that don't already know certctl's well-known scheme. Both endpoints
// live under /.well-known/pki/ and run unauthenticated — relying-party clients
// should never need a Bearer key to check revocation status.
//
// The "Test CRL fetch" / "Check OCSP status" buttons exercise the same
// network path the CRL/OCSP responders advertise via the AIA + CDP
// extensions on issued leaves, so an operator confirming "did Phase 4
// actually wire end-to-end?" can do it without curl. Failures bubble up
// as inline error text rather than throwing a global error boundary.
//
// The cache-age badge is admin-only (gated client-side AND server-side; the
// server returns 403 for non-admin even if the GUI bug-clicks the fetch).
// Stale rows render in amber per the IsStale flag (next_update < now). Rows
// missing entirely (issuer never had a CRL pre-generated) render the neutral
// "Not yet generated" pill.
function RevocationEndpointsCard({ issuerId, serialNumber }: { issuerId: string; serialNumber?: string }) {
const { admin } = useAuth();
const [crlState, setCrlState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'err'; msg?: string }>({ status: 'idle' });
const [ocspState, setOcspState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'err'; msg?: string }>({ status: 'idle' });
// Build the absolute URLs from window.location so operators can copy-paste
// them straight into curl / openssl. Using window.location keeps the URLs
// honest under reverse-proxy deployments where the perceived host differs
// from what the dev sees in their browser bar — the location object is the
// ground truth for "what URL does the relying party hit?".
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const crlURL = `${origin}/.well-known/pki/crl/${issuerId}`;
// OCSP per RFC 6960 §A.1.1 supports both POST (preferred for CSR-style
// requests) and the GET form with base64-url(DER) in the path. The GUI's
// "Check OCSP status" button uses the simpler /{issuer}/{serial_hex}
// helper certctl exposes alongside the standards endpoint — that's what
// getOCSPStatus() in client.ts hits.
const ocspURL = `${origin}/.well-known/pki/ocsp/${issuerId}`;
// Admin-only: pull the cache row for this issuer so we can show
// "generated 2m ago / next update 58m" with a stale-warning chip.
const { data: cacheData } = useQuery({
queryKey: ['admin-crl-cache'],
queryFn: () => getAdminCRLCache(),
enabled: admin,
// Refresh a touch faster than the default scheduler interval (1h) so
// the badge feels live during ops investigation. Falls back gracefully
// if the user navigates away before the next tick.
refetchInterval: 60_000,
retry: false,
});
const issuerRow: CRLCacheRow | undefined = cacheData?.cache_rows?.find(r => r.issuer_id === issuerId);
const handleTestCRL = async () => {
setCrlState({ status: 'loading' });
try {
const r = await fetchCRL(issuerId);
setCrlState({ status: 'ok', msg: `OK — ${r.byteLength.toLocaleString()} bytes (${r.contentType || 'no content-type'})` });
} catch (e) {
setCrlState({ status: 'err', msg: e instanceof Error ? e.message : 'Fetch failed' });
}
};
const handleCheckOCSP = async () => {
if (!serialNumber) {
setOcspState({ status: 'err', msg: 'Serial number unavailable — cert has not been issued yet.' });
return;
}
setOcspState({ status: 'loading' });
try {
const buf = await getOCSPStatus(issuerId, serialNumber);
setOcspState({ status: 'ok', msg: `OCSP response received — ${buf.byteLength.toLocaleString()} bytes (DER)` });
} catch (e) {
setOcspState({ status: 'err', msg: e instanceof Error ? e.message : 'OCSP request failed' });
}
};
return (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-ink-muted">Revocation Endpoints</h3>
{admin && (
issuerRow ? (
issuerRow.cache_present ? (
<span
className={`text-xs px-2 py-0.5 rounded font-medium ${
issuerRow.is_stale ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'
}`}
title={`CRL #${issuerRow.crl_number ?? '—'} — generated ${
issuerRow.generated_at ? formatDateTime(issuerRow.generated_at) : '—'
}, next update ${issuerRow.next_update ? formatDateTime(issuerRow.next_update) : '—'}`}
>
{issuerRow.is_stale ? 'Cache stale' : 'Cache fresh'}
{issuerRow.generated_at ? ` · ${timeAgo(issuerRow.generated_at)}` : ''}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded font-medium bg-surface-muted text-ink-faint">
Not yet generated
</span>
)
) : null
)}
</div>
<div className="space-y-3">
<div>
<div className="text-xs text-ink-muted mb-1">CRL Distribution Point (RFC 5280 §4.2.1.13)</div>
<div className="flex items-center gap-2">
<code className="font-mono text-xs bg-surface-muted px-2 py-1 rounded text-ink flex-1 break-all">{crlURL}</code>
<button
onClick={handleTestCRL}
disabled={crlState.status === 'loading'}
className="text-xs px-3 py-1 rounded border border-surface-border text-brand-400 hover:text-brand-500 hover:border-brand-300 disabled:opacity-50 transition-colors"
>
{crlState.status === 'loading' ? 'Fetching…' : 'Test CRL fetch'}
</button>
</div>
{crlState.status === 'ok' && (
<div className="text-xs text-emerald-600 mt-1">{crlState.msg}</div>
)}
{crlState.status === 'err' && (
<div className="text-xs text-red-600 mt-1">{crlState.msg}</div>
)}
</div>
<div>
<div className="text-xs text-ink-muted mb-1">OCSP Responder (RFC 6960 §A.1)</div>
<div className="flex items-center gap-2">
<code className="font-mono text-xs bg-surface-muted px-2 py-1 rounded text-ink flex-1 break-all">{ocspURL}</code>
<button
onClick={handleCheckOCSP}
disabled={ocspState.status === 'loading' || !serialNumber}
title={!serialNumber ? 'Serial number unavailable — cert not yet issued' : ''}
className="text-xs px-3 py-1 rounded border border-surface-border text-brand-400 hover:text-brand-500 hover:border-brand-300 disabled:opacity-50 transition-colors"
>
{ocspState.status === 'loading' ? 'Checking…' : 'Check OCSP status'}
</button>
</div>
{ocspState.status === 'ok' && (
<div className="text-xs text-emerald-600 mt-1">{ocspState.msg}</div>
)}
{ocspState.status === 'err' && (
<div className="text-xs text-red-600 mt-1">{ocspState.msg}</div>
)}
{!serialNumber && ocspState.status === 'idle' && (
<div className="text-xs text-ink-faint mt-1">Serial number unavailable issue the cert first.</div>
)}
</div>
</div>
<p className="text-xs text-ink-faint mt-4">
Both endpoints run unauthenticated under <code className="font-mono">/.well-known/pki/</code> per RFC 8615 so relying parties can validate revocation without API keys. The CRL is pre-generated by the scheduler (configurable via <code className="font-mono">CERTCTL_CRL_GENERATION_INTERVAL</code>); OCSP is signed by the per-issuer responder cert (RFC 6960 §2.6).
</p>
</div>
);
}
function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { certId: string; currentPolicyId: string; currentProfileId: string }) {
const [editing, setEditing] = useState(false);
const [policyId, setPolicyId] = useState(currentPolicyId);
@@ -613,6 +771,9 @@ export default function CertificateDetailPage() {
currentProfileId={cert.certificate_profile_id || ''}
/>
{/* Revocation Endpoints (CRL + OCSP) — Phase 5 */}
<RevocationEndpointsCard issuerId={cert.issuer_id} serialNumber={serialNumber} />
{/* Tags */}
{cert.tags && Object.keys(cert.tags).length > 0 && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">