Commit Graph

9 Commits

Author SHA1 Message Date
shankar0123 dcd82d062f docs: convert all 9 ASCII diagrams to mermaid
Audit of docs/ found 32 diagrams: 23 already in mermaid, 9 in ASCII
art (box-drawing chars / +-pipe boxes). Converting all 9 to mermaid
so GitHub renders them as actual diagrams in the docs preview.

Files affected (9 diagram blocks across 6 files):

  docs/architecture.md   block 1 line 706  EST request flow
  docs/architecture.md   block 2 line 798  SCEP request flow
  docs/architecture.md   block 3 line 893  Per-profile TrustAnchor +
                                           Intune challenge dispatch
  docs/architecture.md   block 4 line 935  signer.Driver interface +
                                           4 implementations
  docs/ci-pipeline.md    block 1 line 20   On-push pipeline tree
  docs/est.md            block 1 line 254  WiFi 802.1X / EAP-TLS flow
  docs/legacy-est-scep.md block 1 line 40  TLS-version-bridging proxy
  docs/qa-test-guide.md  block 1 line 41   qa_test.go to demo stack
  docs/scep-intune.md    block 1 line 39   Intune cloud chain

Conversion notes:

  - Linear flows → flowchart TD/LR. Per-step annotations that the
    ASCII had as floating text between arrows are now edge labels —
    cleaner and easier to read.
  - architecture.md block 4 (signer drivers) → flowchart LR with a
    subgraph for the Driver interface. Cleaner than a class diagram
    for the "code uses one of these implementations" semantics.
  - ci-pipeline.md tree → flowchart TD. Adds a dotted '-.depends
    on.->' arrow making the go-build-and-test → deploy-vendor-e2e
    dependency visually obvious (was a parenthetical in the ASCII).
  - est.md WiFi/RADIUS → flowchart LR with EAP, Radius, trusts,
    and EST as four distinct labeled arrows. The 'trusts' annotation
    was floating off to the side in the ASCII; now it's the arrow
    label between Radius and certctl CA.
  - All semantic detail preserved: every node label, arrow direction,
    inline annotation, and multi-line cell content carries through.

Verified: post-conversion audit shows 32 mermaid blocks, 0 ASCII.
Diff is symmetric — 108 inserts, 123 deletes — because mermaid is
slightly more compact than the box-drawing characters it replaces.

GitHub renders mermaid blocks natively in markdown previews since
2022, so all 9 diagrams now render as real flowcharts in the docs
view rather than as monospaced character art.
2026-05-01 05:09:00 +00:00
shankar0123 0be889ff1d refactor(scep-gui): rebrand SCEP admin surface to per-profile tabbed interface (Profiles + Intune + Recent Activity)
Phase 9 follow-up to the SCEP RFC 8894 + Intune master bundle. The
Phase 9.4 GUI shipped 'SCEP Intune Monitoring' at /scep/intune, which
made the per-profile observability surface look Intune-only — operators
running EJBCA + Jamf would never click that nav link expecting per-
profile RA cert + mTLS observability. The page is per-profile keyed
under the hood; this commit rebrands + restructures so the surface
matches what operators actually need.

Spec: cowork/scep-gui-restructure-prompt.md.

User-visible change:

  - Nav link renamed: 'SCEP Intune' → 'SCEP Admin'.
  - Route: /scep is the new canonical path; /scep/intune kept as a
    backward-compat alias that lands directly on the Intune tab.
  - Page header: 'SCEP Administration'.
  - Three tabs:
      * Profiles (default) — per-profile lean cards with RA cert
        expiry countdown, mTLS sibling-route status badge, Intune
        enabled/disabled badge, challenge-password-set indicator.
        'View Intune details →' link on Intune-enabled cards
        deep-links into the Intune tab.
      * Intune Monitoring — the existing Phase 9.4 deep-dive
        (per-status counters, trust anchor expiry, recent failures
        table, reload-trust button + confirmation modal).
      * Recent Activity — full SCEP audit log filter merging all
        four action codes (scep_pkcsreq + scep_renewalreq +
        scep_pkcsreq_intune + scep_renewalreq_intune); chip filters
        for All / Initial / Renewal / Intune / Static.

Backend:

  * internal/service/scep.go — new SCEPProfileStatsSnapshot type +
    IntuneSection sub-block + ProfileStats(now) accessor. Adds
    raCertSubject/raCertNotBefore/raCertNotAfter + mtlsEnabled +
    mtlsTrustBundlePath fields with SetRACert + SetMTLSConfig setters.
    Existing IntuneStatsSnapshot + IntuneStats(now) preserved
    UNCHANGED for /admin/scep/intune/stats backward compat (the
    JSON shape stays byte-stable for external consumers — the
    aliasing approach the prompt initially suggested doesn't work
    because the new shape nests Intune while the old one is flat).
    ChallengePasswordSet is derived from challengePassword != ''
    (the secret value itself is never surfaced).

  * internal/api/handler/admin_scep_intune.go — new Profiles handler
    method on AdminSCEPIntuneHandler with the same M-008 admin gate.
    AdminSCEPIntuneServiceImpl extended (in place; same
    map[string]*service.SCEPService) to satisfy the new
    AdminSCEPProfileService interface. Single handler file gets the
    third method so the M-008 pin entry count stays steady (no new
    file, no new triplet of admin-gate test files — just three new
    Profiles tests inside the existing test file).

  * internal/api/router/router.go — one new route
    'GET /api/v1/admin/scep/profiles' registered to
    reg.AdminSCEPIntune.Profiles. HandlerRegistry unchanged.

  * api/openapi.yaml — new operation 'listSCEPProfiles' documenting
    the request body / response shape / error mapping. Existing
    Intune entries unchanged.

  * cmd/server/main.go — per-profile loop now calls
    scepService.SetMTLSConfig(profile.MTLSEnabled,
    profile.MTLSClientCATrustBundlePath) right after SetPathID, and
    scepService.SetRACert(raCert) right after loadSCEPRAPair returns
    the leaf cert. Both setters are nil-safe.

  * internal/api/handler/m008_admin_gate_test.go — extended the
    existing admin_scep_intune.go entry's justification to mention
    the third endpoint. No new map entry needed (file already
    listed).

Backend tests (8 new):

  * TestAdminSCEPProfiles_NonAdmin_Returns403
  * TestAdminSCEPProfiles_AdminExplicitFalse_Returns403
  * TestAdminSCEPProfiles_AdminPermitted_ForwardsActor — also pins
    that Intune-enabled profiles emit an 'intune' sub-block while
    Intune-disabled profiles OMIT it.
  * TestAdminSCEPProfiles_RejectsNonGetMethod
  * TestAdminSCEPProfiles_PropagatesServiceError
  * TestAdminSCEPProfilesServiceImpl_NilMapReturnsEmpty
  * (existing 16 Phase 9 admin tests still pass — backward-compat
    preserved)

Frontend:

  * web/src/api/types.ts — new SCEPProfileStatsSnapshot +
    IntuneSection + SCEPProfilesResponse types. Existing
    IntuneStatsSnapshot et al unchanged.
  * web/src/api/client.ts — new getAdminSCEPProfiles helper.
  * web/src/pages/SCEPAdminPage.tsx — full rewrite as the tabbed
    surface. Reuses the existing ConfirmReloadModal and Intune
    deep-dive card components verbatim; adds ProfileSummaryCard
    (lean card for the Profiles tab) and ActivityTab. URL state
    sync via useSearchParams so deep links survive reloads + browser
    back/forward. The legacy /scep/intune route alias defaults the
    activeTab to 'intune' on mount.
  * web/src/main.tsx — new <Route path='scep' /> + preserved
    <Route path='scep/intune' /> alias. Both render SCEPAdminPage.
  * web/src/components/Layout.tsx — nav link rebranded:
    label 'SCEP Intune' → 'SCEP Admin', to '/scep/intune' → '/scep'.

Frontend tests (20 — full rebuild):

  * Admin gate (non-admin sees gated banner + zero admin API calls)
  * Profiles tab default + Intune tab tabswitch + ?tab=intune deep
    link + legacy /scep/intune alias all land on Intune
  * Profiles tab status badges (Intune + mTLS + challenge-set)
    reflect each profile's flags
  * RA cert expiry tone bands (good ≥30d / warn 7-30d / bad <7d /
    EXPIRED) verified across three fixture profiles
  * 'View Intune details →' only renders for Intune-enabled
    profiles AND switches tabs on click
  * Empty-state banner when no profiles configured
  * Intune tab counters render with the existing Phase 9 deep-dive
    shape; reload modal Open/Confirm/Cancel/Error paths all pinned
  * Recent Activity tab merges all four SCEP audit actions across
    four parallel useQuery calls; filter chips
    (all/initial/renewal/intune/static) narrow correctly
  * Error path surfaces ErrorState on the active tab

Docs:

  * docs/scep-intune.md — Operational monitoring section heading
    expanded to '(SCEP Administration → Intune Monitoring tab)'.
    Page-surface description rewritten for the tabbed shape;
    admin-endpoints list extended with the new /admin/scep/profiles
    entry.
  * docs/architecture.md — Microsoft Intune Connector trust anchor
    subsection updated to reference the Intune Monitoring tab inside
    the SCEP Administration page + lists all three admin endpoints.
  * docs/legacy-est-scep.md — forward-ref expanded with a parallel
    sentence for the per-profile observability surface (independent
    of Intune).
  * README.md — Enrollment Protocols bullet for Intune updated to
    'admin GUI SCEP Administration page at /scep' with the three
    tabs called out.

Verification:
  * gofmt clean on touched files
  * go vet ./... clean
  * staticcheck on intune+service+handler+router+cmd-server clean
  * go test -short across intune+service+handler+router+cmd-server:
    all green (existing Phase 9 tests + new Profiles tests)
  * Frontend tsc --noEmit clean
  * Vitest: 20/20 SCEPAdminPage tests + 3/3 sibling AuditPage tests
    pass
  * G-3 docs-drift CI guard reproduced locally: clean (no new env
    vars; existing CERTCTL_SCEP_ allowlist prefix covers everything)
  * M-009 hard-zero useMutation guard reproduced locally: clean
    (the existing reload mutation already used useTrackedMutation
    from the Phase 9 follow-up commit 28e277a)
  * openapi-parity test green (new GET /api/v1/admin/scep/profiles
    operation documented)
  * M-008 admin-gate scanner green (existing admin_scep_intune.go
    entry covers all three handler methods; the test scanner
    enforces the triplet by file, not by endpoint, and the new
    Profiles triplet was added to the existing test file)

Backward compat preserved:
  * /api/v1/admin/scep/intune/stats unchanged — same JSON shape,
    same error codes, same M-008 gate
  * /api/v1/admin/scep/intune/reload-trust unchanged
  * /scep/intune route still works (alias to /scep with activeTab=intune)
  * IntuneStatsSnapshot Go type unchanged
  * IntuneStats(now) accessor unchanged

Refs: cowork/scep-gui-restructure-prompt.md
      cowork/scep-rfc8894-intune-master-prompt.md::Phase 9
      Phase 11.5 (SCEP probe in scanner — opt-in) and Phase 12
      (release prep + tag) of the master bundle resume after this.
2026-04-29 17:46:42 +00:00
shankar0123 5d080c86fd docs(scep-intune): deployment guide + troubleshooting + Microsoft support statement
Phase 11 of the SCEP RFC 8894 + Intune master bundle.

Phase 11.1 — docs/scep-intune.md (new, ~340 lines):

  * TL;DR — drop-in NDES replacement framing; what an operator gets
    over NDES (per-profile endpoints, audit-log forensics, SIGHUP
    reload, GUI monitoring, per-device rate limit).
  * Architecture diagram — Intune cloud → Connector → certctl SCEP
    → issuer connector. Explicit 'certctl replaces NDES, NOT the
    Connector' framing; nine-gate dispatcher walk (shape pre-check,
    JWS sig, version dispatch, time bounds, audience pin, CSR binding,
    replay, per-device rate limit, optional compliance).
  * Migration playbook (NDES + EJBCA / NDES + ADCS) — 9-step run-book:
    install alongside, configure per-profile endpoint, extract trust
    anchor, configure CONNECTOR_CERT_PATH + AUDIENCE, configure
    issuer connector, migrate one profile, verify enrollment, roll
    out fleet, decommission NDES.
  * Intune SCEP profile field mapping table — every Intune admin
    center field mapped to certctl's behavior (cert type, subject
    name format, SAN, validity, key storage provider, key usage,
    EKU, hash algorithm, SCEP server URL).
  * Trust anchor extraction recipe — step-by-step certlm.msc export
    of the 'CN=Microsoft Intune Certificate Connector' cert, PEM
    rename, env-var configuration, HA Connector concatenation, SIGHUP
    rotation flow.
  * Troubleshooting matrix — 10 failure modes mapped to root causes
    and operator actions: signature_invalid (trust anchor stale),
    claim_mismatch (Intune profile SAN config), expired (clock skew /
    Connector cert past NotAfter), not_yet_valid (reverse skew),
    wrong_audience (URL mismatch), replay (retry-window collision),
    rate_limited (limiter doing its job), unknown_version (Microsoft
    shipped new format), malformed (proxy mangling body),
    compliance_failed (V3-Pro hook returned non-compliant).
  * Operational monitoring — admin GUI surface description, expiry
    badge tone bands (≥30d green / 7-30d amber / <7d red / EXPIRED),
    per-status counter polling cadence, audit log filter, recommended
    Prometheus alert thresholds.
  * Limitations — explicit V3-Pro deferrals: native Microsoft Graph
    integration, Conditional Access compliance gating, per-tenant
    trust anchors (MSP scoping), OCSP stapling at SCEP-response time,
    auto-discovery of Connector signing cert.
  * Microsoft support statement — three Microsoft Learn URLs (verified
    live with HTTP 200): Connector overview, SCEP profile setup,
    Connector install validation. Microsoft documents the Connector
    as RFC-8894-compliant and supports its use against any RFC 8894
    SCEP server.

Phase 11.2 — Cross-references:

  * docs/legacy-est-scep.md — the previous forward-ref pointed at
    'the Phase 11 doc this bundle ships'; updated to a richer pointer
    that lists what scep-intune.md covers (architecture, migration,
    profile mapping, extraction, troubleshooting, monitoring,
    limitations, Microsoft support).
  * README.md — new bullet under Enrollment Protocols table:
    'Microsoft Intune SCEP fleet (drop-in NDES replacement)' with
    the per-profile dispatcher feature list + link to scep-intune.md.
    Procurement teams scanning the README see the Intune story
    alongside ChromeOS / Jamf in the same table row.
  * docs/architecture.md — new 'Microsoft Intune Connector trust
    anchor (per-profile, opt-in)' subsection in the Security Model
    section. ASCII diagram showing the dispatcher walk; calls out
    the SIGHUP reload + admin-gated GUI surface; forward-link to
    scep-intune.md.

Verification:
  * All linked anchors inside scep-intune.md resolve to existing
    headings: #limitations, #microsoft-support-statement,
    #operational-monitoring, #trust-anchor-extraction.
  * All linked doc paths resolve: legacy-est-scep.md, architecture.md,
    features.md, tls.md.
  * All three Microsoft Learn URLs return HTTP 200 (verified via curl).
  * G-3 docs-drift CI guard reproduced locally and clean — the
    migration playbook uses the <NAME> placeholder convention
    consistently (matching features.md style) so the docs scanner
    doesn't extract literal env-var names that aren't in config.go.
  * Backend tests across intune+handler+service+router still green.

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 11
      cowork/scep-rfc8894-intune/progress.md
2026-04-29 17:03:56 +00:00
shankar0123 7612da783a feat(scep-intune): per-profile dispatcher + SIGHUP reload + per-device rate limit + compliance hook seam
Phase 8 of the SCEP RFC 8894 + Intune master bundle. Wires the
internal/scep/intune validator from Phase 7 into the SCEPService
dispatch path, with a SIGHUP-reloadable trust anchor holder, a
per-(Subject, Issuer) sliding-window rate limiter, and a nil-default
ComplianceCheck seam for V3-Pro.

Operator-visible surface (per-profile, all default to off):

  CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true
  CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH=/etc/certctl/intune.pem
  CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE=https://certctl.example.com/scep/corp
  CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CHALLENGE_VALIDITY=60m
  CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H=3

Per-profile dispatch (Phase 8.8): an operator running corp-laptops
through Intune AND IoT devices through static challenge configures
INTUNE_ENABLED=true on the corp profile only — the IoT profile's
PKCSReq path skips the dispatcher entirely. Mirrors the per-profile
shape established by Phase 1.5.

Wire-in surfaces:

  * config.go (Phase 8.1): SCEPProfileConfig.Intune sub-config of
    type SCEPIntuneProfileConfig (Enabled/ConnectorCertPath/Audience/
    ChallengeValidity/PerDeviceRateLimit24h). Loaded from the indexed
    CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_* env-var family. Per-profile
    Validate gate refuses INTUNE_ENABLED=true with empty ConnectorCertPath
    OR negative PerDeviceRateLimit24h.

  * cmd/server/main.go (Phase 8.2 + wire-in): preflightSCEPIntuneTrustAnchor
    helper mirrors preflightSCEPRACertKey/preflightSCEPMTLSTrustBundle
    shape — fail-loud at boot when the trust anchor file is missing /
    unreadable / empty / contains an expired cert. The per-profile loop
    builds the holder + replay cache + rate limiter, calls
    SetIntuneIntegration on the SCEPService, and starts the SIGHUP
    watcher. A deferred sweep stops every watcher at shutdown.

  * internal/scep/intune/trust_anchor_holder.go (Phase 8.5):
    TrustAnchorHolder mirrors cmd/server/tls.go::certHolder. RWMutex-
    guarded pool + Reload that swaps a fresh slice on success +
    WatchSIGHUP goroutine that responds to the same SIGHUP the existing
    TLS-cert watcher uses. A bad reload (parse error, expired cert)
    keeps the OLD pool in place so a half-rotation doesn't take Intune
    enrollment down — same fail-safe pattern. Operators rotate via the
    on-disk file then 'kill -HUP <certctl-pid>'.

  * internal/scep/intune/rate_limit.go (Phase 8.6): hand-rolled
    sliding-window-log limiter keyed by (Subject, Issuer). 100k-entry
    map cap (matches replay cache); at-cap drops the bucket whose
    newest timestamp is the oldest. Default 3 enrollments per 24h
    covers legitimate first-cert + recovery + post-wipe re-enrollment
    but blocks bulk enumeration from a compromised Connector signing
    key. maxN <= 0 disables the limiter for tests + the rare operator
    who wants no per-device cap. Empty subject short-circuits to allow
    (defense-in-depth: caller's claim validation rejects empty-subject
    upstream; no shared bucket on '').

    Why hand-rolled instead of golang.org/x/time/rate: the rate
    package is in go.sum as an indirect transitive but not a direct
    dep. ~30 LoC of stdlib avoids creating a new direct dep.

  * internal/service/scep.go (Phase 8.3 + 8.4 + 8.7):
    - SCEPService gains intuneEnabled / intuneTrust / intuneAudience /
      intuneValidity / intuneReplayCache / intuneRateLimiter /
      complianceCheck fields.
    - SetIntuneIntegration() constructor-time injection wires the
      per-profile state. Profiles with INTUNE_ENABLED=false never
      call this method, so they pay zero overhead.
    - SetComplianceCheck() installs the V3-Pro plug-in (see Phase 8.7).
    - looksIntuneShaped(): JWT-shape pre-check (length > 200 + exactly
      two dots). Allowed to false-positive (validator catches malformed
      → ErrChallengeMalformed); MUST NOT false-negative on real Intune
      challenges.
    - dispatchIntuneChallenge(): the load-bearing core. Runs
      ValidateChallenge → CSR-binding via DeviceMatchesCSR → replay
      cache CheckAndInsert → per-device Allow → optional ComplianceCheck.
      Each failure leg increments a typed metric label and emits an
      audit-friendly Warn log line.
    - PKCSReq + PKCSReqWithEnvelope + RenewalReqWithEnvelope all call
      dispatchIntuneChallenge first; on outcome.decided=true they
      either short-circuit (with a typed-error → SCEPFailInfo mapping)
      or call processEnrollment with action='scep_pkcsreq_intune'
      (so audit greps can count Intune-vs-static enrollments).
    - mapIntuneErrorToFailInfo(): typed-error → SCEPFailInfo per
      RFC 8894 §3.2.1.4.5 (signature/replay/expired → BadMessageCheck;
      claim-mismatch → BadRequest; default → BadRequest).
    - intuneFailReason(): typed-error → metric label
      ('signature_invalid' / 'expired' / 'rate_limited' / etc.). Default
      'malformed' so a previously-unseen error category still surfaces
      in the metric for follow-up.
    - ComplianceCheck (Phase 8.7): nil-default no-op gate. V3-Pro plugs
      in via SetComplianceCheck to call Microsoft Graph's compliance
      API. Returns (compliant, reason, err). nil-err + compliant=false
      → CertRep FAILURE + 'compliance' reason in audit. err != nil →
      fail-safe deny (V3-Pro module is responsible for any 'permit on
      API failure' policy).

  * internal/service/scep.go also gains parseCSRForIntune() — small
    private wrapper around encoding/pem + x509 used by the dispatcher
    for the claim ↔ CSR binding check (separated from the broader
    processEnrollment because we want to bind BEFORE consuming the
    replay-cache slot).

Tests (gates: ≥85% coverage on intune package, ≥70% on service):

  * scep_intune_test.go (in internal/service): 14 dispatcher tests
    covering happy-path Intune enrollment + static-challenge fallback
    + tampered-challenge reject + claim-mismatch reject + replay
    detected + rate-limited + compliance-hook nil-default + compliance-
    hook denies non-compliant + compliance-hook error fails closed +
    IntuneEnabled accessor + 'no IntuneEnabled = static path
    unchanged' regression pin + intuneFailReason mapping for every
    typed error + looksIntuneShaped boundary cases.

  * trust_anchor_holder_test.go (in internal/scep/intune): NewLoadsBundle,
    NewRequiresLogger, NewSurfacesLoadError, ReloadHappyPath,
    ReloadKeepsOldOnFailure, ReloadKeepsOldOnExpired (the fail-safe
    semantics that make the SIGHUP path operator-friendly),
    WatchSIGHUPReloadsPool (real SIGHUP to self with poll-for-swap
    pattern mirroring cmd/server/tls_test.go), WatchSIGHUPStopIsClean
    (does NOT fire SIGHUP after stop — same caveat as the TLS test:
    the Go runtime would otherwise terminate the test runner on the
    next SIGHUP since signal.Stop has removed the handler).

  * rate_limit_test.go (in internal/scep/intune): AllowsUpToCap,
    DistinctKeysIndependent, WindowExpiry, DisabledBypass (maxN=0),
    NegativeCapDisabled, EmptySubjectShortCircuits (defense-in-depth
    against an empty-subject DoS chokepoint), DefaultCapsHonored,
    MapCapEvictsOldest (at-cap eviction branch), ConcurrentRaceFree
    (50 goroutines × 200 inserts), pruneOlderThan + the no-op case.

Verification:

  * gofmt -l on all touched files: clean
  * go vet ./... : clean
  * staticcheck on intune/service/config/cmd-server: clean
  * go test -count=1 -cover ./internal/scep/intune/...: 94.8%
    (target ≥85%)
  * go test -short across intune+service+config+handler+cmd-server:
    all green
  * G-3 docs-drift CI guard reproduced locally: docs-only filtered=
    empty, config-only=empty. The new env vars match the existing
    CERTCTL_SCEP_ allowlist prefix.

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 8
      cowork/scep-rfc8894-intune/progress.md
      Constitutional rule: 'Always take the complete path, not the
      easy path' (cowork/CLAUDE.md::Operating Rules) — operator can
      flip CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true and observe
      the dispatcher pick up Intune-shaped challenges end-to-end with
      no further code changes. Foundation + plumbing ship together.
2026-04-29 15:34:19 +00:00
shankar0123 a12a437664 feat(scep): mTLS sibling route /scep-mtls/<pathID> (opt-in)
SCEP RFC 8894 + Intune master bundle — Phase 6.5 of 14 (opt-in,
enterprise-procurement-checkbox).

Closes the procurement-team objection that 'shared password
authentication' is a checkbox-fail regardless of how strong the
password is. The clean answer: a sibling route that adds client-cert
auth at the handler layer AND keeps the challenge password (defense in
depth, not replacement). Devices present a bootstrap cert from a
trusted CA (e.g. a manufacturing-time cert), then SCEP-enroll for
their long-lived cert. Same model Apple's MDM and Cisco's BRSKI use.

internal/config/config.go
  * SCEPProfileConfig gains MTLSEnabled bool + MTLSClientCATrustBundlePath
    string. Indexed env-var loader reads
    CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED +
    CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH.
  * Validate() refuses MTLSEnabled=true with empty bundle path —
    structural defense in depth ahead of the file-content preflight.

cmd/server/main.go
  * preflightSCEPMTLSTrustBundle: file existence + PEM parse + ≥1
    CERTIFICATE block + non-expired check. Returns the parsed
    *x509.CertPool ready to inject into the per-profile SCEPHandler.
    Failures os.Exit(1) with the offending PathID in the structured log.
  * SCEP startup loop walks each profile; when MTLSEnabled, runs
    preflight, builds the per-profile pool, contributes the bundle's
    certs to the union pool that backs the TLS-layer
    VerifyClientCertIfGiven, clones the SCEPHandler with
    SetMTLSTrustPool, and registers the parallel sibling route via
    apiRouter.RegisterSCEPMTLSHandlers.
  * Union pool published to outer scope as scepMTLSUnionPoolForTLS;
    passed to buildServerTLSConfigWithMTLS so the listener serves both
    /scep[/<pathID>] (no client cert) and /scep-mtls/<pathID>
    (cert required at handler layer) on the same socket.
  * Final-handler dispatch gains /scep-mtls + /scep-mtls/* prefix
    routing through the no-auth chain (auth boundary is the client
    cert + challenge password, NOT a Bearer token).

cmd/server/tls.go
  * New buildServerTLSConfigWithMTLS that wraps buildServerTLSConfig
    + sets ClientCAs + ClientAuth=VerifyClientCertIfGiven when a
    non-nil pool is passed. nil pool = identical TLS shape to the
    pre-Phase-6.5 builder (no behavior change for deploys without
    mTLS profiles).
  * Critical: VerifyClientCertIfGiven (NOT RequireAndVerifyClientCert)
    so a client that doesn't present a cert can still hit the standard
    /scep route. The per-profile gate at the handler layer enforces
    'cert required' on /scep-mtls/<pathID>.

internal/api/handler/scep.go
  * SCEPHandler gains mtlsTrustPool *x509.CertPool field +
    SetMTLSTrustPool method. Per-profile pool injected by
    cmd/server/main.go after preflight.
  * HandleSCEPMTLS wrapper: gates on r.TLS.PeerCertificates non-empty
    + per-profile cert.Verify against THIS profile's pool. Returns
    HTTP 401 for missing/untrusted cert (mTLS failure is auth, not
    authorization). Returns HTTP 500 if mtlsTrustPool is nil (deploy
    bug — the route shouldn't have been registered). On success
    delegates to HandleSCEP — defense in depth: mTLS is additive,
    NOT replacement; the standard SCEP code path including the
    challenge-password gate still executes.
  * Per-profile re-verification via cert.Verify(...) is critical:
    the TLS layer verified against the UNION pool, so a cert that
    chains to profile A's bundle would pass TLS even when targeting
    profile B. The handler-layer gate prevents cross-profile
    bleed-through.

internal/api/router/router.go
  * AuthExemptDispatchPrefixes gains '/scep-mtls' (auth boundary is
    client cert + challenge password, NOT Bearer token).
  * RegisterSCEPMTLSHandlers parallel to RegisterSCEPHandlers:
    empty PathID maps to /scep-mtls root; non-empty maps to
    /scep-mtls/<pathID>. Each handler in the map MUST have had
    SetMTLSTrustPool called.

internal/api/router/openapi_parity_test.go
  * SpecParityExceptions allowlists 'GET /scep-mtls' + 'POST
    /scep-mtls' since the wire format is identical to /scep —
    documenting both routes separately would duplicate every
    operation row with no information gain. Documented alternative
    in docs/legacy-est-scep.md.

internal/api/handler/scep_mtls_test.go (new, ~210 LoC)
  * 6 tests + 2 helpers covering the auth contract:
    1. RejectsMissingClientCert — request with r.TLS=nil → 401
    2. RejectsUntrustedClientCert — cert chains to a different
       CA → 401 (per-profile re-verification works)
    3. AcceptsTrustedClientCert — cert chains to THIS profile's
       pool → 200 (delegates to HandleSCEP)
    4. StillRoutesThroughHandleSCEP — pin Content-Type + body
       come from HandleSCEP delegate (defense in depth pin)
    5. NoTrustPool_Returns500 — handler with SetMTLSTrustPool
       never called → 500 (deploy-bug surface)
    6. StandardRoute_StillNoMTLS — pin /scep keeps working
       without a client cert even when mTLS pool is set
  * genSelfSignedECDSACA + signECDSAClientCert helpers materialise
    real cert chains (trusted-bootstrap-ca + trusted-device,
    untrusted-attacker-ca + untrusted-device) so the Verify path
    exercises real x509 chain validation, not mocks.

docs/features.md
  * SCEP env-vars table extended with the two new MTLS env vars
    (CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED,
    CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH).
    Closes the G-3 'env var defined in Go but never documented' gate.

docs/legacy-est-scep.md
  * New 'mTLS sibling route (Phase 6.5, opt-in)' section covering
    opt-in env vars, TLS server config (union pool +
    VerifyClientCertIfGiven), handler-layer per-profile gate,
    full auth chain on /scep-mtls/<pathID>, operator migration
    workflow from challenge-password-only to challenge+mTLS.

cowork/CLAUDE.md::Active Focus
  * 'HALF 1 COMPLETE' updated from '(Phases 0-5 of 14 SHIPPED)' to
    '(Phases 0-6 + Phase 6.5 of 14 SHIPPED)'.

Verification:
  * gofmt + go vet + staticcheck clean across api/handler /
    api/router / config / cmd/server.
  * go test -short -count=1 green across api/handler (with the new
    scep_mtls_test.go) / api/router / service / config / pkcs7 /
    cmd/server / connector/issuer/local.
  * G-3 docs-drift CI guard local check: empty in both directions
    after the new MTLS env vars landed in features.md.
  * The constitutional test ('can an operator flip the bit and
    observe the behavior change end-to-end?') is YES: setting
    CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true plus the trust
    bundle path produces a working /scep-mtls/<pathID> endpoint
    that accepts trusted client certs + rejects untrusted ones,
    with no further code changes required.

Phase 6.5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-6 + 6.5) is now FEATURE-COMPLETE for the
ChromeOS / general-MDM use case. Half 2 (Phases 7-12) adds the
Microsoft Intune dynamic-challenge layer.
2026-04-29 13:58:18 +00:00
shankar0123 b857bdc560 docs(scep): close G-3 docs-only drift in legacy-est-scep.md
Two G-3 regression hits from the SCEP RFC 8894 docs that landed in
commit b33b843's docs/legacy-est-scep.md addition:

1. CERTCTL_SCEP_PROFILE_CORP_* (5 vars) — the multi-profile dispatch
   recipe used literal CORP placeholders in the example block, which
   the G-3 scanner treats as phantom env vars (the loader expands
   <NAME> at runtime; CORP is never a literal env-var key in Go
   source). Replaced the literal example with a prose description
   that uses the <NAME> token explicitly + cross-references
   docs/features.md where the per-profile suffix table lives. The
   G-3 scanner sees only CERTCTL_SCEP_PROFILES + the prefix
   CERTCTL_SCEP_ (already on the ALLOWED list per commit 5c7c125),
   matching the convention used elsewhere in the SCEP env-var docs.

2. CERTCTL_TLS_CERT_PATH — incorrect env var name in the RA-cert
   rotation paragraph. The actual config field is
   CERTCTL_SERVER_TLS_CERT_PATH (per internal/config/config.go:1130).
   Fixed the reference. The CERTCTL_TLS_ prefix is already allowlisted
   (covers e.g. CERTCTL_TLS_INSECURE_SKIP_VERIFY), but the literal
   suffix _CERT_PATH was a typo that bypassed the prefix match.

Verification: local G-3 set difference (Go-defined ∖ docs-mentioned)
empty in BOTH directions after the fix.

Restores green CI on the env-var docs drift guard for the SCEP
plumbing PR.
2026-04-29 13:41:08 +00:00
shankar0123 b33b843908 feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.

Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.

== Phase 4: RenewalReq + GetCertInitial wiring ============================

internal/service/scep.go
  * RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
    existing valid cert. Same contract as PKCSReqWithEnvelope but the
    service additionally verifies that envelope.SignerCert chains to
    the issuer's CA (verifyRenewalSignerCertChain). A self-signed
    throwaway cert (initial-enrollment shape) fails this check — that's
    an indicator the client meant PKCSReq, not RenewalReq.
  * GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
    Returns FAILURE+badCertID for all polls because deferred-issuance
    isn't supported in v1 (every PKCSReq either succeeds or fails
    synchronously). Wiring stays in place for a future enhancement.
  * Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
    grep the audit log to distinguish initial enrollments from renewals.

internal/api/handler/scep.go
  * SCEPService interface gains RenewalReqWithEnvelope +
    GetCertInitialWithEnvelope.
  * pkiOperation RFC 8894 path now switches on envelope.MessageType:
    PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
    GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
    badRequest per RFC 8894 §3.3.2.2.

== Phase 5.1: GetCACaps capability advertisement =========================

internal/service/scep.go
  * Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
    to add 'SHA-512' (modern digest alternative now implemented in the
    Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
    Phase 4). ChromeOS specifically looks for these capabilities to
    negotiate the strongest available cipher + digest combo.
  * scep_test.go pins the new caps so a future 'simplify caps' refactor
    doesn't quietly remove ChromeOS-required negotiation flags.

== Phase 5.2: ChromeOS-shape integration tests ===========================

internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
  * 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
    in-test (acting as the ChromeOS client), POSTs through the handler,
    parses the CertRep response back via the same internal/pkcs7/
    builders the handler uses.
  * TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
    SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
    EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
    POSTed; verifies CertRep parses + RA signature verifies.
  * TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
    routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
  * TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
    returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
  * TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
    signature falls through to MVP path (which also rejects since the
    encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
  * TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
    AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
  * TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
    path keeps working when no RA pair is configured. Backward compat
    is non-negotiable.

== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============

internal/domain/profile.go
  * Added MustStaple bool to CertificateProfile. Default false; operators
    opt in once they've confirmed the TLS reverse proxy / load balancer
    staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
    require explicit config).

internal/connector/issuer/interface.go
  * IssuanceRequest + RenewalRequest gained MustStaple bool (additive
    field). Connectors that don't support extension injection (Vault,
    EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
    issuer-only feature in V2 since upstream connectors enforce their
    own extension policy.

internal/connector/issuer/local/local.go
  * Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
    pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
    SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
    RFC 7633 §6).
  * generateCertificate signature gained mustStaple bool; when true,
    appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
    mustStapleExtensionValue} to template.ExtraExtensions before
    x509.CreateCertificate.

internal/connector/issuer/local/must_staple_test.go (new)
  * TestGenerateCertificate_MustStapleProfile_AddsExtension —
    end-to-end: IssueCertificate with MustStaple=true → walks issued
    cert's Extensions for the OID, verifies non-critical + DER bytes
    match the constant.
  * TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
    'omit by default' contract (adding it by default would break
    customer deployments where the TLS path doesn't staple).
  * TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
    DER bytes against RFC 7633 §6 verbatim; round-trips through
    asn1.Unmarshal as []int{5}.

Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.

== Phase 5.4: docs/legacy-est-scep.md ====================================

Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).

== Verification ==========================================================

  * gofmt + go vet clean for the files I touched.
  * staticcheck ./internal/api/handler/... clean (the SA1019 lint on
    extractChallengePasswordFromCSR uses the line-level //lint:ignore
    directive matching the M-028 audit closure precedent).
  * go test -short -count=1 green across api/handler / api/router /
    service / pkcs7 / connector/issuer/local / domain / cmd/server.
  * G-3 docs-drift CI guard local check: empty diff in both directions.

Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.

Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 13:16:09 +00:00
shankar0123 389f6b8233 Bundle F follow-up: M-023 doc env-var cleanup (G-3 guard fix)
CI on the bundle-F merge (run #24972730564) failed the G-3 env-var
docs guardrail because docs/legacy-est-scep.md mentioned
  CERTCTL_EST_PROXY_TRUSTED_SOURCES
  CERTCTL_EST_TRUST_PROXY_CLIENT_CERT_HEADER
which are documented as future-feature env vars but don't exist in
config.go. The G-3 guard treats any env-var name in docs that's not
either defined in source OR on the documented integration-surface
allowlist as drift.

The runbook's 'certctl-side configuration' section was over-promising
features that haven't shipped yet. Rewritten to be honest:

  - Current implementation is header-agnostic (X-SSL-Client-Cert is
    ignored). EST/SCEP authentication still works correctly because
    both protocols carry their own auth (CSR signature for EST,
    challengePassword for SCEP) inside the request body.
  - The reverse proxy is purely a TLS-version bridge.
  - Future-feature description retained in prose form (without
    literal env-var names) so an operator who needs proxy-supplied
    client identity knows to open an issue.

The nginx config block's comment was also rewritten to reflect the
header-agnostic default. The proxy still SETS the headers (cheap,
no-op when ignored); a future commit can flip certctl to read them
behind a fail-closed CIDR allowlist + opt-in toggle.

Verification:
  grep -rnE 'CERTCTL_EST_PROXY|CERTCTL_EST_TRUST' README.md docs/ deploy/helm/
    — empty (G-3 guard now passes for these names)
2026-04-27 01:55:04 +00:00
shankar0123 8aff1c16f8 Bundle F: Compliance tail + CI gate hardening — 2 findings closed; audit closure complete
Closes M-023 + M-024 from comprehensive-audit-2026-04-25. Final
audit-bundle commit. Score 51/55 closed (93%); High 9/9 (100%);
Medium 26/27 (96%); Low 19/19 (100%); Deferred 4/7.

M-023 (PCI-DSS Req 4 §2.2.5) — Legacy EST/SCEP reverse-proxy runbook
  docs/legacy-est-scep.md (NEW): operator runbook for embedded
  EST/SCEP clients that only speak TLS 1.2 against a TLS-1.3-pinned
  certctl listener. Sections:
    - 3-condition gate for when this runbook applies
    - Architecture diagram (legacy client -> proxy TLS 1.2 -> certctl TLS 1.3)
    - Full nginx config with ssl_protocols TLSv1.2 TLSv1.3 + ECDHE
      AEAD-only ciphers + mTLS optional verification + proxy_ssl_protocols
      TLSv1.3 on the backend hop
    - HAProxy alternative config with ssl-min-ver TLSv1.2 frontend +
      ssl-min-ver TLSv1.3 backend
    - certctl-side env vars: CERTCTL_EST_PROXY_TRUSTED_SOURCES (CIDR
      allowlist of trusted proxies) + CERTCTL_EST_TRUST_PROXY_CLIENT_CERT_HEADER
      (toggle header-as-identity). Dual-knob design forces operators
      to think about header spoofing.
    - PCI-DSS Req 4 v4.0 §2.2.5 attestation language
    - Forward-look on TLS 1.2 deprecation watch
  certctl listener stays pinned at TLS 1.3 minimum (cmd/server/tls.go:131);
  the proxy-to-certctl hop is also TLS 1.3.

M-024 (NIST SSDF PW.7.2) — govulncheck hard gate
  .github/workflows/ci.yml: 'Run govulncheck' step renamed to
  'Run govulncheck (M-024 hard gate)' with updated comment block
  documenting why no carve-out is needed.
  Bundle E's transitive bumps (x/net 0.42->0.47, x/crypto 0.41->0.45)
  cleared the 5 L-021 deferred-call advisories that the original
  Bundle F prompt designed an exception list for. Plain
  'govulncheck ./...' is now the right gate; default exit-code
  semantics fail on any future called-vuln advisory. Deferred-call
  advisories that legitimately can't be remediated should land in
  a NIST SSDF deviation log in docs/security.md, not be silenced.

Audit endgame:
  51/55 closed (93%). Remaining open items don't require further
  bundle work:
    - M-029 frontend per-page migration backlog — closes per-PR
    - L-004 rotation infra — explicit scope-pivot defer
    - D-003 mutation testing — sandbox-blocked
    - D-004 DAST suite — wired CI-only via security-deep-scan.yml
    - D-005 testssl.sh — wired CI-only
    - D-007 frontend semgrep — wired CI-only

Audit deliverables:
  audit-report.md: score 49/55 -> 51/55 closed; M-023 + M-024
    boxes flipped [x] with closure notes.
  findings.yaml: 2 status flips
  CHANGELOG.md: Bundle F section + 'Audit endgame' summary
2026-04-27 01:43:56 +00:00