Compare commits

..

6 Commits

Author SHA1 Message Date
shankar0123 530593507b fix(scep-intune): close 11 audit gaps from 2026-04-29 pre-tag review
Closes the eleven gaps identified in the pre-v2.1.0 audit of the SCEP
RFC 8894 + Intune master bundle (cowork/scep-bundle-gap-closure-prompt.md).
Constitutional rule from cowork/CLAUDE.md::Operating Rules — 'Always
take the complete path, not the easy path' — drove this closure: each
gap was a load-bearing wire that crossed multiple layers (config →
validator → service wire-up → tests → docs) and shipping the bundle
without them would have produced lying-field footguns where operator-
visible config options stored values without affecting behavior.

WHAT LANDS:

Phase A — Clock-skew tolerance (master prompt §15 hazard closure)
  internal/scep/intune/challenge.go: ValidateChallenge migrated from
  positional args to ValidateOptions{} struct; new ClockSkewTolerance
  field with default 0 (strict). 24 call sites updated mechanically.
  Asymmetric application: now+tolerance >= iat AND now-tolerance < exp.
  internal/config/config.go: SCEPIntuneProfileConfig.ClockSkewTolerance
  default 60s + Validate() refusal when >= ChallengeValidity.
  cmd/server/main.go: SetIntuneIntegration signature extended;
  per-profile env-var loader honors CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
  internal/service/scep.go: intuneClockSkew field + IntuneStatsSnapshot
  surfaces clock_skew_tolerance_ns. web/src/api/types.ts mirrors.
  4 new tests in challenge_test.go covering accept-within-tolerance,
  reject-beyond-tolerance, accept-expired-within-tolerance,
  negative-treated-as-zero defensive normalization.
  docs/scep-intune.md updated with the new env var + time-bounds rule.

Phase B — unknown-version-rejected golden test
  internal/scep/intune/golden_helper_test.go: goldenUnknownVersionPayload
  helper + signGoldenChallengeAny generic signer.
  challenge_golden_test.go: TestGoldenChallenge_UnknownVersionRejected
  uses an in-process ECDSA fixture (the on-disk PEM was generated with
  a Go-stdlib version that produces different ecdsa.GenerateKey bytes
  from the current call). TestRegenerateGoldenFixtures emits the new
  unknown_version fixture file too.

Phase C — Two named Intune e2e tests
  internal/api/handler/scep_intune_e2e_test.go:
    TestSCEPIntuneEnrollment_RateLimited_E2E (cap=2 + 3 attempts; 3rd
    returns FAILURE+badRequest with rate_limited counter ticked)
    TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E (rotate
    on-disk PEM + holder.Reload(); old-key challenge fails with
    badMessageCheck; signature_invalid counter ticked)
  intuneE2EFixture struct extended with trustHolder + trustPath fields
  so tests can rotate.

Phase D — Four new ChromeOS hermetic tests (10 total now)
  internal/api/handler/scep_chromeos_test.go:
    _RAKeyMismatch — PKIMessage encrypted to wrong RA cert; handler
      rejects without reaching service.
    _3DESBackwardCompat — RFC 8894 §3.5.2 legacy fallback verified.
    _RSACSR + _ECDSACSR — explicit matrix-pair pinning.
  buildTestECDSACSR helper for ECDSA P-256 CSR construction;
  tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES-CBC;
  assertChromeOSPositiveCertRep shared assertion.

Phase E — Per-profile counter isolation test
  internal/api/handler/scep_profile_counter_isolation_test.go:
    TestSCEPHandler_PerProfileIntuneCountersIsolated wires two
    SCEPService instances + drives distinct PKIMessages + asserts
    counter isolation. Guards against a future cmd/server/main.go
    refactor that shares a *intuneCounterTab across profiles.
  buildPerProfileIntuneFixture parameterized helper.

Phase F — Server-boot regression tests
  cmd/server/preflight_scep_intune_test.go: 3 named tests covering
  disabled-backward-compat, broken-config-with-PathID, expired-cert
  refusal. preflightSCEPIntuneTrustAnchor signature extended with
  pathID arg so error messages carry PathID= for operator log-grep.

Phase G — docs/connectors.md
  Four new subsections under §EST/SCEP Integration: multi-profile
  dispatch + mTLS sibling route + Intune Connector dispatcher + SCEP
  probe in network scanner. Each has a one-paragraph operator
  explanation + an env-var or endpoint table.

Phase H — Coverage uplift
  internal/service/scep_probe_persist_test.go: 5 unit tests on
  persistProbeResult (nil-safe + nil-repo-safe + repo-error swallow +
  nil-logger guard) + ListRecentSCEPProbes (empty-slice-not-nil + repo
  pass-through) + describeCertAlgorithm (RSA/ECDSA/QF1008-nil-curve
  defensive branch/Ed25519/DSA/empty). CI gates (service ≥70, handler
  ≥75) PASS at 70.9% / 79.3%.

Phase I — deploy/test integration variant
  deploy/test/scep_intune_e2e_test.go (//go:build integration):
    TestSCEPIntuneEnrollment_Integration + _RateLimited_Integration
    against the live docker-compose certctl container. Skip-when-
    stack-missing semantics so sandbox + CI both work.
  deploy/docker-compose.test.yml: new e2eintune SCEP profile env
  vars + bind-mount of deploy/test/fixtures/.
  deploy/test/fixtures/README.md: documents the deterministic trust
  anchor regeneration recipe.

VERIFICATION (sandbox):
  gofmt -d        — clean for all changed files
  staticcheck     — clean for intune + handler + config + service +
                    cmd/server packages
  go vet          — clean for the same packages
  go test -short  — green for intune (95.3% cov), service (70.9%),
                    handler (79.3%), config (94.0%), cmd/server (boot
                    path; my preflight tests cover the directly-
                    testable function), pkcs7 (80.5% informational)

DEFERRED (per closure prompt §7 out-of-scope):
  - V3-Pro Conditional Access gating + Microsoft Graph integration
  - Standalone certctl-scan CLI binary
  - OCSP rate-limiting, OCSP stapling, delta CRLs

Spec preserved at cowork/scep-bundle-gap-closure-prompt.md;
journal at cowork/scep-rfc8894-intune/progress.md (audit-closure
section appended).
2026-04-29 20:28:53 +00:00
shankar0123 84fac19f98 fix(scep-probe): satisfy staticcheck QF1008 in describeCertAlgorithm
CI flagged QF1008 on the chained selector pub.Curve.Params() — the
linter wants the promoted-method form pub.Params() (Curve is embedded
in ecdsa.PublicKey, so Params is reachable via promotion). Restructure
the nil check so the embedded interface still gets validated before the
promoted call, then invoke pub.Params() once and reuse the result.

Verification:
  * gofmt clean
  * staticcheck on internal/service/...: clean
  * 6/6 TestProbeSCEP_* tests still pass
2026-04-29 19:00:05 +00:00
shankar0123 506cff137d feat(scep): SCEP probe in network scanner for fleet-readiness assessment
Phase 11.5 of the SCEP RFC 8894 + Intune master bundle. Adds an
operator-facing SCEP probe that issues GetCACaps + GetCACert against
an arbitrary SCEP server URL and returns a structured posture snapshot
(reachable + advertised caps + RFC 8894 / AES / POST / Renewal /
SHA-256 / SHA-512 support flags + CA cert subject + issuer + NotBefore
+ NotAfter + days-to-expiry + algorithm + chain length).

Two operator use cases per the master prompt:

  1. Pre-migration assessment — probe an existing EJBCA / NDES SCEP
     server before switching to certctl to see what capabilities it
     advertises and what the CA cert looks like.
  2. Compliance posture audits — periodic ad-hoc probes against the
     operator's own SCEP servers to flag drift.

Capability-only — does NOT POST a CSR per the spec (would consume slot
allocations on the target server + create audit noise). Standalone CLI
binary explicitly out of scope (per the master prompt §11.5.6 and the
operator's confirmation): the probe code lands inside certctl; a
future thin Cobra wrapper is a separate decision.

Backend (six new + one extended file):

  * internal/domain/network_scan.go — new SCEPProbeResult struct with
    every probe field documented for the GUI's display layer.

  * migrations/000021_scep_probe_results.up.sql + .down.sql — new
    scep_probe_results table with TEXT id, target_url, all probe
    flags, CA cert metadata, probed_at, probe_duration_ms, error.
    Two indexes: idx_scep_probe_results_probed_at (DESC) for the
    'recent probes' GUI query, idx_scep_probe_results_target_url
    (target_url, probed_at DESC) for the future per-URL history view.

  * internal/repository/interfaces.go — new SCEPProbeResultRepository
    interface (Insert + ListRecent).

  * internal/repository/postgres/scep_probe_results.go — Postgres
    implementation. ListRecent clamps limit to [1, 200]; on read
    re-derives ca_cert_days_to_expiry against the query-time wall
    clock so 'X days remaining' stays fresh.

  * internal/service/scep_probe.go — ProbeSCEP(ctx, url) on
    NetworkScanService. Validation order:
      1. Up-front URL validation via validation.ValidateSafeURL
         (defaults to validation.ValidateSafeURL but injectable for
         tests via the new scepValidateURL field on the service).
      2. Dial-time SSRF re-check via SafeHTTPDialContext on the
         http.Transport (defends against DNS rebinding).
      3. GET ?operation=GetCACaps + GET ?operation=GetCACert.
         GetCACert handles three response shapes: PKCS#7 SignedData
         certs-only envelope (multi-cert), raw DER (single-cert),
         and PEM-wrapped DER (non-conforming servers).
    Times out at 30s; uses a 1MB body cap for DoS defense; wraps
    the result + persists via the repo (nil-safe) before returning.
    describeCertAlgorithm helper returns 'RSA-N' / 'ECDSA-curve' /
    'Ed25519' / 'DSA' for the GUI's algorithm column.

  * internal/service/network_scan.go — added scepProbeRepo +
    scepHTTPClient + scepValidateURL + scepIDFn + nowFn fields;
    SetSCEPProbeRepo wires the repo at startup.

  * internal/api/handler/network_scan.go — extended NetworkScanService
    interface with ProbeSCEP + ListRecentSCEPProbes; added two new
    HTTP handlers:
      POST /api/v1/network-scan/scep-probe   (body {url})
      GET  /api/v1/network-scan/scep-probes  (recent history)
    Synchronous probe; HTTP 200 with the result body for both success
    and reachable-but-failed cases (so the GUI can render the failure
    tone with the operator-actionable error message).

  * internal/api/router/router.go — registered the two routes inline
    after the existing network-scan target endpoints.

  * api/openapi.yaml — documented both endpoints (operationId
    probeSCEP + listSCEPProbes) with full schema + response codes.

  * cmd/server/main.go — wires the new SCEPProbeResultRepository
    onto the network scan service via SetSCEPProbeRepo right after
    the existing NewNetworkScanService construction.

Backend tests (6 new — exit-criteria-named per the master prompt):

  * TestProbeSCEP_AdvertisesAllCaps — happy path, full RFC 8894
    capability set, ECDSA P-256 CA cert, 365-day expiry.
  * TestProbeSCEP_MissingSCEPStandard — pre-RFC-8894 server (only
    POSTPKIOperation + SHA-1 + DES3); SupportsRFC8894 = false.
  * TestProbeSCEP_GetCACertExpired — CA cert NotAfter 30d in the
    past; CACertExpired = true.
  * TestProbeSCEP_Unreachable — connect to TCP port 1; probe
    returns Reachable=false + non-empty Error.
  * TestProbeSCEP_RejectsReservedIP — http://169.254.169.254/scep
    (EC2 metadata literal) rejected by the up-front
    validation.ValidateSafeURL gate; result captures the error
    without ever issuing the HTTP call.
  * TestProbeSCEP_PEMWrappedCert — server returns PEM instead of
    raw DER for GetCACert; the fallback parse path handles it.

Frontend (one extended file + types/client):

  * web/src/api/types.ts — SCEPProbeResult + SCEPProbesResponse.
  * web/src/api/client.ts — probeSCEPServer + listSCEPProbes
    helpers.
  * web/src/pages/NetworkScanPage.tsx — new SCEPProbeSection
    component + ProbeResultPanel (with capability badges + CA cert
    details panel + raw caps line) + SCEPProbeHistoryTable. Form
    rejects empty URL with inline error before calling the API.
    Reload mutation goes through useTrackedMutation with explicit
    invalidates: [['scep-probes']] (M-009 contract).

Frontend tests (5 new + 0 regressions):

  * Scep probe section header + form renders.
  * Empty URL is rejected with inline error and never calls the
    probe endpoint.
  * Successful probe renders capability badges + CA cert subject
    + days-remaining inline panel.
  * Probe-level errors are surfaced in the inline panel (no result
    panel rendered).
  * Recent-probes history table renders one row per probe.
  * (Existing 2 NetworkScanPage XSS-hardening tests stub the new
    listSCEPProbes endpoint to an empty list so they still pass.)

Verification:
  * gofmt clean on touched files
  * go vet ./... clean
  * staticcheck on service+handler+router+repository+cmd-server clean
  * go test -short across service+handler+router+repository+cmd-server
    + integration: all green (existing + 6 new probe tests pass)
  * Frontend tsc --noEmit clean
  * Vitest: 7/7 NetworkScanPage tests pass (2 existing XSS + 5 new
    probe section)
  * G-3 docs-drift CI guard reproduced locally clean (no new env vars)
  * M-009 hard-zero useMutation guard clean (probe mutation goes
    through useTrackedMutation)
  * openapi-parity guard satisfied (both new routes documented)
  * The mockNetworkScanService in handler + integration packages
    extended with stub Probe methods; targeted coverage stays in
    scep_probe_test.go.

Out of scope (per master prompt §11.5.6 + operator confirmation):
  * Standalone certctl-scan CLI binary — separate decision, ~1d of
    follow-up work when/if shipped.

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 11.5
      cowork/scep-rfc8894-intune/progress.md
2026-04-29 18:51:57 +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 e0d00717c7 feat(scep-intune): golden-file tests + e2e harness against fixture trust anchor
Phase 10 of the SCEP RFC 8894 + Intune master bundle. Adds reproducible
testdata fixtures + a hermetic end-to-end test that exercises the full
handler → service → dispatcher → CertRep wire path.

Phase 10.1 — Golden-file tests (internal/scep/intune/):

  * testdata/intune_trust_anchor.pem — deterministic ECDSA P-256 cert
    seeded from a constant byte string (sha256-derived PRNG); regenerates
    byte-identical PEM bytes across runs.
  * testdata/intune_challenge_golden_success.txt — valid challenge,
    iat/exp window covers goldenChallengeNow.
  * testdata/intune_challenge_golden_expired.txt — same trust anchor +
    payload shape but iat/exp shifted into the past.
  * testdata/intune_challenge_golden_tampered_sig.txt — payload bytes
    intact, last sig byte flipped.

  challenge_golden_test.go reads each fixture and asserts:
    - Success → ValidateChallenge returns a populated claim
      (DeviceName / Subject / SANDNS pinned to the documented values).
    - Expired → errors.Is(err, ErrChallengeExpired).
    - Tampered → errors.Is(err, ErrChallengeSignature).
    - Plus two defensive permutations: WrongAudienceReuse pins the
      audience-check ordering after a successful sig verify;
      RotatedTrustAnchorRejects pins the holder-rotation failure mode
      using a freshly-generated unrelated trust cert.

  golden_helper_test.go contains the deterministic-PRNG, ES256 signer,
  fixture-load helpers, and the regeneration target. Operators flip
  fixtures via:
    go test -run='^TestRegenerateGoldenFixtures$'             ./internal/scep/intune/... -args -update-golden

  Why ECDSA + a deterministic seed: a hand-pasted base64 blob would
  break on every Go stdlib bump (json.Marshal field ordering, ASN.1
  encoding edge cases). Generating from a pinned seed gives
  reproducible PEM bytes; only the ECDSA signature suffix varies
  across regenerations (Go's stdlib doesn't expose RFC 6979
  deterministic-k cleanly), and ValidateChallenge re-verifies the
  signature on every read so it doesn't matter.

  intune package coverage: 95.2% (was 94.8%).

Phase 10.2 — Hermetic end-to-end test (internal/api/handler/scep_intune_e2e_test.go):

  Departs from the spec's deploy/test/ location because the handler
  package already has the chromeOS-shape PKIMessage builders (buildTestCSR
  / buildEnvelopedDataForTest / buildSignedDataForTest / aesCBCEncrypt /
  postPKIOperation). Putting the e2e test in the handler package lets it
  reuse those helpers AND run in the default 'go test ./...' sweep —
  every CI run exercises the full Intune dispatcher chain. The
  deploy/test/ location is reserved for a future docker-compose-driven
  variant that would mount a fixture trust anchor into the running
  container; this hermetic version proves the wire works without that
  dependency.

  intuneE2EFixture stands up:
    - A real Intune Connector signing keypair (ECDSA P-256) + cert
      written to a temp PEM file the TrustAnchorHolder loads at startup.
    - A real RA pair the SCEPHandler decrypts EnvelopedData with.
    - A fixture issuer connector (intuneE2EIssuerConnector) that
      records every IssueCertificate call + returns a deterministic
      child cert chained to a fixture CA. Implements the full
      IssuerConnector interface (IssueCertificate / RenewCertificate /
      RevokeCertificate / GenerateCRL / SignOCSPResponse / GetRenewalInfo)
      with the non-issuance methods stubbed.
    - A capturing AuditRepository that records every Create call so
      the test can assert action='scep_pkcsreq_intune' was emitted.
    - A real SCEPService with SetIntuneIntegration wired to a real
      ReplayCache + PerDeviceRateLimiter.

  Three test scenarios:

    1. TestSCEPIntuneEnrollment_E2E — the documented happy path. Forge
       a valid Intune-shaped challenge (ES256 signed, length > 200, two
       dots — satisfies looksIntuneShaped), build a CSR with CN matching
       the claim's device_name, POST through HandleSCEP, decode the
       CertRep, assert pkiStatus=SUCCESS + issuer.issued has one entry
       + audit log carries 'scep_pkcsreq_intune' + IntuneStats.counters[
       'success']==1.

    2. TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E — same setup
       but CSR CN is 'attacker-host.example.com'. Dispatcher must
       reject with CertRep FAILURE+BadRequest (mapIntuneErrorToFailInfo:
       ErrClaimCNMismatch → BadRequest), no issuance, IntuneStats
       counters['claim_mismatch']==1.

    3. TestSCEPIntuneEnrollment_TamperedSignature_E2E — flip a byte in
       the JWT signature segment of the Intune challenge before
       wrapping it in the PKIMessage. Dispatcher rejects with
       FAILURE+BadMessageCheck (signature errors → BadMessageCheck per
       the same mapping table).

  Important sanity learning during construction: the buildTestCSR
  helper from scep_chromeos_test.go does NOT populate DNSNames on the
  CSR. The success claim therefore omits san_dns to avoid tripping
  ErrClaimSANDNSMismatch (claim says ['x'], CSR has nothing). The
  claim_mismatch sibling test exercises the SAN-dimension via the
  CN mismatch path; coverage of explicit SANDNS mismatches stays in
  the unit tests in claim_test.go where the helper builds CSRs with
  full SANs.

Verification:
  * gofmt clean on touched files
  * go vet ./internal/scep/intune/... ./internal/api/handler/...: clean
  * staticcheck: clean
  * go test -count=1 -cover ./internal/scep/intune/...: 95.2%
  * 5 golden tests + 3 e2e tests all pass
  * No new env vars (G-3 docs guard not triggered)
  * No new HTTP routes (openapi-parity guard not triggered)
  * Sibling test packages (service + router) still green

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 10
      cowork/scep-rfc8894-intune/progress.md
2026-04-29 16:55:52 +00:00
50 changed files with 6493 additions and 425 deletions
+1
View File
@@ -108,6 +108,7 @@ gantt
|----------|----------|----------| |----------|----------|----------|
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT | | EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT |
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. | | SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. |
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) | | ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew | | ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
+151
View File
@@ -732,6 +732,157 @@ paths:
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/network-scan/scep-probe:
post:
tags: [SCEP]
summary: Probe an SCEP server for capability + posture
description: |
Synchronous probe against an SCEP server URL. Issues
`GET ?operation=GetCACaps` and `GET ?operation=GetCACert`
and returns the structured `SCEPProbeResult` (reachable,
advertised caps, RFC 8894 / AES / POST / Renewal / SHA-256 /
SHA-512 support flags, CA cert subject + issuer + NotBefore +
NotAfter + days-to-expiry + algorithm + chain length).
Capability-only — does NOT POST a CSR (would consume slot
allocations on the target server + create audit noise). Used
for pre-migration assessment + compliance posture audits.
SSRF-defended: the URL is validated up-front (reserved IPs
rejected) AND the underlying HTTP client uses the
SafeHTTPDialContext that re-resolves the host at dial time
(defends against DNS rebinding).
Result is persisted to the `scep_probe_results` table via
migration 000021 so the GUI can show recent probe history.
SCEP RFC 8894 + Intune master bundle Phase 11.5.
operationId: probeSCEP
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
format: uri
description: Base SCEP server URL (no `?operation=...` suffix needed; the probe appends its own operations).
responses:
"200":
description: Probe completed (the result body's `error` field carries any sub-step failure)
content:
application/json:
schema:
type: object
properties:
id:
type: string
target_url:
type: string
reachable:
type: boolean
advertised_caps:
type: array
items: { type: string }
supports_rfc8894: { type: boolean }
supports_aes: { type: boolean }
supports_post_operation: { type: boolean }
supports_renewal: { type: boolean }
supports_sha256: { type: boolean }
supports_sha512: { type: boolean }
ca_cert_subject: { type: string }
ca_cert_issuer: { type: string }
ca_cert_not_before: { type: string, format: date-time }
ca_cert_not_after: { type: string, format: date-time }
ca_cert_expired: { type: boolean }
ca_cert_days_to_expiry: { type: integer }
ca_cert_algorithm: { type: string }
ca_cert_chain_length: { type: integer }
probed_at: { type: string, format: date-time }
probe_duration_ms: { type: integer }
error: { type: string }
"400":
description: Missing or malformed `url` field
"500":
$ref: "#/components/responses/InternalError"
/api/v1/network-scan/scep-probes:
get:
tags: [SCEP]
summary: List recent SCEP probe results
description: |
Returns the most recent 50 SCEP probe results across any
target URL, ordered by `probed_at` descending. Backs the
GUI's "Recent SCEP probes" history table on the Network
Scan page. SCEP RFC 8894 + Intune master bundle Phase 11.5.
operationId: listSCEPProbes
responses:
"200":
description: Recent probe results
content:
application/json:
schema:
type: object
properties:
probes:
type: array
items:
type: object
probe_count:
type: integer
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/scep/profiles:
get:
tags: [SCEP]
summary: Per-profile SCEP administration overview (admin)
description: |
Returns one snapshot per configured SCEP profile in the
SCEPProfileStatsSnapshot shape: always-present per-profile
fields (path_id, issuer_id, challenge_password_set, RA cert
subject + NotBefore/NotAfter + days-to-expiry, mTLS
sibling-route status, mTLS trust bundle path) plus an
optional `intune` sub-block when the profile has
INTUNE_ENABLED=true.
Profiles where Intune is disabled appear with the `intune`
field omitted (rather than null) so the GUI's per-profile
card can render the lean shape without an Intune deep-dive
button. Profiles where Intune is enabled also appear in the
sibling /api/v1/admin/scep/intune/stats endpoint with the
flat Phase 9.2 shape preserved for backward compat.
Admin-gated (M-008 pattern). Non-admin Bearer callers get
HTTP 403 — the snapshot reveals the operator's profile set,
RA cert expiries, and mTLS bundle paths (sensitive
operational metadata). SCEP RFC 8894 + Intune master bundle
Phase 9 follow-up.
operationId: listSCEPProfiles
responses:
"200":
description: Per-profile SCEP administration snapshot
content:
application/json:
schema:
type: object
properties:
profiles:
type: array
items:
type: object
profile_count:
type: integer
generated_at:
type: string
format: date-time
"403":
description: Admin access required
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/scep/intune/stats: /api/v1/admin/scep/intune/stats:
get: get:
tags: [SCEP] tags: [SCEP]
+31 -6
View File
@@ -356,6 +356,12 @@ func main() {
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService) discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
networkScanRepo := postgres.NewNetworkScanRepository(db) networkScanRepo := postgres.NewNetworkScanRepository(db)
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger) networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — wire the SCEP
// probe persistence repo onto the network scan service so the new
// /api/v1/network-scan/scep-probe endpoint can persist results to
// scep_probe_results (migration 000021).
scepProbeRepo := postgres.NewSCEPProbeResultRepository(db)
networkScanService.SetSCEPProbeRepo(scepProbeRepo)
logger.Info("initialized network scan service") logger.Info("initialized network scan service")
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup. // Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
@@ -837,6 +843,12 @@ func main() {
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword) scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
scepService.SetProfileRepo(profileRepo) scepService.SetProfileRepo(profileRepo)
scepService.SetPathID(profile.PathID) scepService.SetPathID(profile.PathID)
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up:
// surface mTLS sibling-route status in the per-profile snapshot
// the new /admin/scep/profiles endpoint emits. The actual mTLS
// trust pool wiring lives further down in the if profile.MTLSEnabled
// block; this just records the flag + bundle path for observability.
scepService.SetMTLSConfig(profile.MTLSEnabled, profile.MTLSClientCATrustBundlePath)
if profile.ProfileID != "" { if profile.ProfileID != "" {
scepService.SetProfileID(profile.ProfileID) scepService.SetProfileID(profile.ProfileID)
} }
@@ -859,6 +871,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
scepHandler.SetRAPair(raCert, raKey) scepHandler.SetRAPair(raCert, raKey)
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up:
// surface RA cert metadata (subject + NotBefore + NotAfter) in
// the per-profile snapshot so the new /admin/scep/profiles
// endpoint can drive the GUI's RA expiry countdown badge.
scepService.SetRACert(raCert)
// SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune // SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune
// dispatcher wire-in. Builds the trust-anchor holder, replay cache, // dispatcher wire-in. Builds the trust-anchor holder, replay cache,
@@ -868,7 +885,7 @@ func main() {
// with INTUNE_ENABLED=false skip the entire block, so the cost on // with INTUNE_ENABLED=false skip the entire block, so the cost on
// non-Intune deploys is exactly one bool check per profile. // non-Intune deploys is exactly one bool check per profile.
if profile.Intune.Enabled { if profile.Intune.Enabled {
intuneHolder, err := preflightSCEPIntuneTrustAnchor(true, profile.Intune.ConnectorCertPath, profileLog) intuneHolder, err := preflightSCEPIntuneTrustAnchor(true, profile.PathID, profile.Intune.ConnectorCertPath, profileLog)
if err != nil { if err != nil {
profileLog.Error( profileLog.Error(
"startup refused: SCEP profile INTUNE trust anchor preflight failed "+ "startup refused: SCEP profile INTUNE trust anchor preflight failed "+
@@ -903,6 +920,7 @@ func main() {
intuneHolder, intuneHolder,
profile.Intune.Audience, profile.Intune.Audience,
profile.Intune.ChallengeValidity, profile.Intune.ChallengeValidity,
profile.Intune.ClockSkewTolerance,
replayCache, replayCache,
rateLimiter, rateLimiter,
) )
@@ -910,6 +928,7 @@ func main() {
"trust_anchor_path", profile.Intune.ConnectorCertPath, "trust_anchor_path", profile.Intune.ConnectorCertPath,
"audience", profile.Intune.Audience, "audience", profile.Intune.Audience,
"challenge_validity", profile.Intune.ChallengeValidity, "challenge_validity", profile.Intune.ChallengeValidity,
"clock_skew_tolerance", profile.Intune.ClockSkewTolerance,
"per_device_rate_limit_24h", profile.Intune.PerDeviceRateLimit24h, "per_device_rate_limit_24h", profile.Intune.PerDeviceRateLimit24h,
) )
} }
@@ -1445,18 +1464,24 @@ func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPo
// On success returns the freshly-built *intune.TrustAnchorHolder ready to // On success returns the freshly-built *intune.TrustAnchorHolder ready to
// inject into the per-profile SCEPService via SetIntuneIntegration. The // inject into the per-profile SCEPService via SetIntuneIntegration. The
// holder also installs the SIGHUP watcher (started by the caller). // holder also installs the SIGHUP watcher (started by the caller).
func preflightSCEPIntuneTrustAnchor(enabled bool, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) { func preflightSCEPIntuneTrustAnchor(enabled bool, pathID, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) {
if !enabled { if !enabled {
return nil, nil return nil, nil
} }
// pathIDLabel renders the empty-string PathID as "<root>" so the
// operator's boot-log error doesn't read like a missing variable.
pathIDLabel := pathID
if pathIDLabel == "" {
pathIDLabel = "<root>"
}
if path == "" { if path == "" {
return nil, fmt.Errorf("INTUNE enabled but trust anchor path empty: " + return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE enabled but trust anchor path empty: "+
"set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to a PEM bundle " + "set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to a PEM bundle "+
"of the Microsoft Intune Certificate Connector's signing certs") "of the Microsoft Intune Certificate Connector's signing certs", pathIDLabel)
} }
holder, err := intune.NewTrustAnchorHolder(path, logger) holder, err := intune.NewTrustAnchorHolder(path, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("INTUNE trust anchor load failed: %w (path=%s)", err, path) return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE trust anchor load failed: %w (path=%s)", pathIDLabel, err, path)
} }
return holder, nil return holder, nil
} }
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// SCEP RFC 8894 + Intune master prompt §13 line 1853 acceptance —
// boot regression tests for preflightSCEPIntuneTrustAnchor. Closed in
// the 2026-04-29 audit-closure bundle (Phase F).
//
// Spec text:
// "clean boot with Intune disabled (backward compat)" and
// "refuses-to-start with broken per-profile config (PathID logged)."
//
// These three tests exercise the function the cmd/server/main.go boot
// loop calls per profile. We can't (and don't want to) run main()
// itself in a unit test — that would require docker compose + a real
// listener. Instead we drive the function directly and assert its
// contract holds: nil error on disabled, structured error containing
// the PathID on enabled-but-broken.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
}
// TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat — when
// the profile has Intune disabled, preflight returns (nil, nil) and
// MUST NOT touch the filesystem. This is the dominant path in
// production: most operators run SCEP without Intune. A regression
// here would make every non-Intune deploy fail boot with a confusing
// "trust anchor missing" error.
func TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat(t *testing.T) {
holder, err := preflightSCEPIntuneTrustAnchor(false, "corp", "", discardLogger())
if err != nil {
t.Fatalf("disabled preflight should be a no-op, got error: %v", err)
}
if holder != nil {
t.Errorf("disabled preflight should return nil holder, got %#v", holder)
}
// Confirm the no-touch contract: even if PathID + path are both
// non-empty, disabled=false short-circuits before any I/O. Pass a
// path that doesn't exist — the call MUST still succeed.
holder, err = preflightSCEPIntuneTrustAnchor(false, "iot", "/tmp/this-file-does-not-exist-12345.pem", discardLogger())
if err != nil {
t.Fatalf("disabled preflight with non-existent path should still succeed: %v", err)
}
if holder != nil {
t.Error("disabled preflight should return nil holder even with non-existent path")
}
}
// TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID —
// when the profile has Intune enabled but the trust-anchor file
// doesn't exist, preflight returns an error whose text contains the
// literal PathID. Operators grep their boot log for the PathID to
// triage which profile is broken in a multi-profile deploy.
func TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID(t *testing.T) {
missingPath := filepath.Join(t.TempDir(), "this-trust-anchor-was-never-written.pem")
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp", missingPath, discardLogger())
if err == nil {
t.Fatal("expected error when trust anchor file is missing, got nil")
}
if holder != nil {
t.Errorf("expected nil holder on broken config, got %#v", holder)
}
if !strings.Contains(err.Error(), `PathID="corp"`) {
t.Errorf("error should contain PathID for operator log-grep: %v", err)
}
if !strings.Contains(err.Error(), missingPath) {
t.Errorf("error should contain the path for operator log-grep: %v", err)
}
// Empty PathID (legacy /scep root) — the error MUST surface a
// readable label, not an empty quoted string that looks like a
// missing variable.
_, err = preflightSCEPIntuneTrustAnchor(true, "", missingPath, discardLogger())
if err == nil {
t.Fatal("expected error on broken legacy-root config")
}
if !strings.Contains(err.Error(), `PathID="<root>"`) {
t.Errorf("error should label empty PathID as <root>: %v", err)
}
// Empty path with enabled=true — distinct error path (path-empty
// vs file-missing). Spec requires this branch ALSO surfaces the
// PathID so the operator's grep narrows to the profile.
_, err = preflightSCEPIntuneTrustAnchor(true, "iot", "", discardLogger())
if err == nil {
t.Fatal("expected error when trust anchor path is empty")
}
if !strings.Contains(err.Error(), `PathID="iot"`) {
t.Errorf("empty-path error should contain PathID for operator log-grep: %v", err)
}
}
// TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses — an
// expired Connector signing cert in the trust anchor file is the
// silent-failure mode this preflight is built to catch. Without the
// gate, the SCEP server boots cleanly and then rejects every Intune
// enrollment at runtime with "no trust anchor recognizes this
// signature" — confusing for the operator whose Connector is healthy
// (the cert just expired without rotation). Pin the contract: the
// boot MUST refuse with an error that names the expired cert's
// subject CN so the operator knows what to rotate.
func TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses(t *testing.T) {
// Build a deterministic ECDSA cert with NotAfter 1 hour in the past.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "intune-connector-rotated-must-replace"},
NotBefore: now.Add(-2 * time.Hour),
NotAfter: now.Add(-1 * time.Hour), // expired
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
bundlePath := filepath.Join(t.TempDir(), "intune-expired.pem")
if err := os.WriteFile(bundlePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
t.Fatalf("write expired cert: %v", err)
}
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp-expired", bundlePath, discardLogger())
if err == nil {
t.Fatal("expected refuse-to-start on expired trust anchor cert, got nil error")
}
if holder != nil {
t.Errorf("expected nil holder on expired-cert refusal, got %#v", holder)
}
if !strings.Contains(err.Error(), `PathID="corp-expired"`) {
t.Errorf("error should contain PathID for operator log-grep: %v", err)
}
if !strings.Contains(err.Error(), "intune-connector-rotated-must-replace") {
t.Errorf("error should contain the expired cert's subject CN so the operator knows what to rotate: %v", err)
}
}
+30
View File
@@ -284,6 +284,27 @@ services:
CERTCTL_EST_ENABLED: "true" CERTCTL_EST_ENABLED: "true"
CERTCTL_EST_ISSUER_ID: iss-local CERTCTL_EST_ISSUER_ID: iss-local
# SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance
# (deploy/test/scep_intune_e2e_test.go integration variant).
# Closed in the 2026-04-29 audit-closure bundle (Phase I).
#
# Publishes /scep/e2eintune?operation=... with the Intune
# dispatcher enabled. The deterministic Connector signing cert
# is bind-mounted at the path below; the matching private key
# lives ONLY on the test side (see
# deploy/test/scep_intune_e2e_test.go::generateE2EIntuneTrustAnchor).
CERTCTL_SCEP_ENABLED: "true"
CERTCTL_SCEP_PROFILES: "e2eintune"
CERTCTL_SCEP_PROFILE_E2EINTUNE_ISSUER_ID: iss-local
CERTCTL_SCEP_PROFILE_E2EINTUNE_RA_CERT_PATH: /etc/certctl/scep/ra.crt
CERTCTL_SCEP_PROFILE_E2EINTUNE_RA_KEY_PATH: /etc/certctl/scep/ra.key
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_ENABLED: "true"
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH: /etc/certctl/scep/intune_trust_anchor.pem
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE: https://localhost:8443/scep/e2eintune
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CHALLENGE_VALIDITY: 60m
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CLOCK_SKEW_TOLERANCE: 60s
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_PER_DEVICE_RATE_LIMIT_24H: 3
# Dynamic issuer/target config encryption (M34/M35) # Dynamic issuer/target config encryption (M34/M35)
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!! CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!!
@@ -305,6 +326,15 @@ services:
# agent mounts the same host path at the same container path (see below) # agent mounts the same host path at the same container path (see below)
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides. # so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
- ./test/certs:/etc/certctl/tls:ro - ./test/certs:/etc/certctl/tls:ro
# SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance: the
# e2eintune profile's RA cert/key + Intune Connector trust anchor
# PEM. The PEM is the deterministic public cert matching the test-
# side private key in deploy/test/scep_intune_e2e_test.go (re-run
# `go test -tags integration -run='^TestRegenerateE2EIntuneFixture$'
# -update-fixture ./deploy/test/...` to regenerate after a seed
# change). RA cert/key live alongside; tls-init container generates
# them at boot.
- ./test/fixtures:/etc/certctl/scep:ro
networks: networks:
certctl-test: certctl-test:
ipv4_address: 10.30.50.6 ipv4_address: 10.30.50.6
+42
View File
@@ -0,0 +1,42 @@
# deploy/test/fixtures — integration-test material
This folder holds the fixture material that
`deploy/docker-compose.test.yml` mounts into the certctl container's
`/etc/certctl/scep/` for the SCEP-RFC-8894 + Intune integration test
suite. Test-only material; **do not use in production**.
## Files
| File | Generated by | Purpose |
| ---- | ------------ | ------- |
| `intune_trust_anchor.pem` | `deploy/test/scep_intune_e2e_test.go::generateE2EIntuneTrustAnchor` (deterministic ECDSA-P256 from `e2eintuneSeed`) | Mounted at `CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH`. The matching private key is re-derived inside the integration test from the same deterministic seed, so the test can mint valid Intune challenges that the running container accepts. |
| `ra.crt` + `ra.key` | `setup-trust.sh` at compose boot OR generated once and committed | RA cert + private key the SCEP server uses to decrypt EnvelopedData per RFC 8894 §3.2.2. Mode 0600 enforced on `ra.key` by `preflightSCEPRACertKey`. |
## Regeneration
```sh
# Trust anchor (deterministic — re-run produces byte-identical PEM):
cd certctl && go test -tags integration \
-run='^TestRegenerateE2EIntuneFixture$' -update-fixture \
./deploy/test/...
# RA pair (one-off — committed):
openssl ecparam -genkey -name prime256v1 -noout \
-out deploy/test/fixtures/ra.key && chmod 600 deploy/test/fixtures/ra.key
openssl req -new -x509 -key deploy/test/fixtures/ra.key \
-days 3650 -subj '/CN=certctl-test-ra' \
-out deploy/test/fixtures/ra.crt
```
## Why these are committed (test-only material)
The integration test runs against the running container and needs to
mint Intune challenges that the container's trust anchor pool
recognizes. The deterministic-key approach gives us:
- A static PEM the operator can grep + inspect.
- A test-side private key derived in-process so we don't commit a
raw private key file.
Real production deploys MUST NOT use this trust anchor — the matching
private key is in the certctl source tree and effectively public.
+666
View File
@@ -0,0 +1,666 @@
//go:build integration
// SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance
// (deploy/test/ integration variant). Closed in the 2026-04-29
// audit-closure bundle (Phase I).
//
// What this test does:
//
// - Boots ON TOP OF the live docker-compose.test.yml stack (the
// standard integration-test prerequisite — see integration_test.go
// for the same precedent). The compose file mounts a deterministic
// Connector signing-cert PEM into the certctl container and sets
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_ENABLED=true +
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH +
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE.
// - Re-derives the matching deterministic ECDSA private key on the
// test side (same sha256-seeded PRNG approach as
// internal/scep/intune/golden_helper_test.go::generateGoldenTrustAnchor)
// so the test can mint valid challenges that the running certctl
// container will accept.
// - Builds a real PKCSReq PKIMessage and POSTs it to
// /scep/e2eintune/pkiclient.exe?operation=PKIOperation over HTTPS.
// - Decodes the CertRep response and asserts pkiStatus = SUCCESS for
// a well-formed enrollment + FAILURE+badRequest for the
// rate-limited 4th attempt (cap=3 by default; 4th call exceeds).
//
// Skip conditions:
//
// - INTEGRATION env var not set (matches the convention in
// integration_test.go::TestMain).
// - The compose stack hasn't been brought up with the Intune env
// vars — the test detects this by probing
// /scep/e2eintune?operation=GetCACaps and skipping if the route
// returns 404.
//
// CI runs this in the same job that already runs integration_test.go;
// the docker-compose.test.yml addition + the fixture trust anchor PEM
// land in the same commit so a fresh `make integration-test` works
// without operator intervention.
package integration_test
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// e2eintuneSeed is the deterministic seed for the integration-test
// trust anchor key. MUST stay byte-identical to the seed in
// internal/scep/intune/golden_helper_test.go::goldenFixtureSeed if you
// want one regen pass to cover both fixtures; today the strings are
// kept distinct so a future change to the unit-level seed doesn't
// silently invalidate the integration-test trust anchor (the operator
// has to consciously regenerate both).
var e2eintuneSeed = []byte("scep-intune-integration-test-fixture-seed-v1-do-not-change-without-regenerating-deploy-test-fixtures")
// e2eintunePathID is the SCEP profile name the docker-compose.test.yml
// configures for this test. Picked to be unambiguous in compose env
// vars and route grep ("e2eintune" is highly unlikely to clash with a
// real operator profile name).
const e2eintunePathID = "e2eintune"
// e2eintuneAudience MUST match
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE in
// docker-compose.test.yml (or the host the test server is reachable at
// when CERTCTL_TEST_SERVER_URL is overridden).
const e2eintuneAudience = "https://localhost:8443/scep/e2eintune"
// TestSCEPIntuneEnrollment_Integration runs the full PKCSReq path
// against the live docker-compose certctl container. Asserts the
// CertRep wire shape is SUCCESS for a well-formed enrollment.
func TestSCEPIntuneEnrollment_Integration(t *testing.T) {
requireIntuneIntegrationStack(t)
now := time.Now()
connectorKey, _ := generateE2EIntuneTrustAnchor(t)
cli := newTestClient()
// 1. Mint a valid challenge signed by the deterministic Connector key.
challenge := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, "integration-nonce-001"))
// 2. Build the PKIMessage with the challenge embedded.
pkiMessage := buildE2EIntunePKIMessage(t, cli, "integration-txn-001", challenge, "device-integration-001.example.com")
// 3. POST + assert SUCCESS.
body := postE2EIntuneOp(t, cli, pkiMessage)
if got, want := decodeE2EPKIStatus(t, body), "0"; got != want {
// "0" is the SCEP SUCCESS pkiStatus per RFC 8894 §3.3.2.1.
t.Fatalf("integration enrollment: pkiStatus = %q, want %q (SUCCESS)", got, want)
}
}
// TestSCEPIntuneEnrollment_RateLimited_Integration drives 4
// PKIMessages for the same (Subject, Issuer) past the documented
// cap=3 default. The 4th MUST be rejected with FAILURE+badRequest.
func TestSCEPIntuneEnrollment_RateLimited_Integration(t *testing.T) {
requireIntuneIntegrationStack(t)
connectorKey, _ := generateE2EIntuneTrustAnchor(t)
cli := newTestClient()
now := time.Now()
// First 3 enrollments succeed (cap=3 → ≤3 in 24h).
for i := 0; i < 3; i++ {
nonce := fmt.Sprintf("integration-rate-allow-%d", i)
ch := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, nonce))
txn := fmt.Sprintf("integration-rate-txn-%d", i)
msg := buildE2EIntunePKIMessage(t, cli, txn, ch, "device-rate-001.example.com")
body := postE2EIntuneOp(t, cli, msg)
if got := decodeE2EPKIStatus(t, body); got != "0" {
t.Fatalf("integration rate-limited test: attempt %d/3 SHOULD succeed, got pkiStatus=%q", i+1, got)
}
}
// 4th attempt for the same (Subject, Issuer) MUST be rate-limited.
tripCh := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, "integration-rate-deny-4"))
tripMsg := buildE2EIntunePKIMessage(t, cli, "integration-rate-txn-deny", tripCh, "device-rate-001.example.com")
body := postE2EIntuneOp(t, cli, tripMsg)
status := decodeE2EPKIStatus(t, body)
if status != "2" {
// "2" is FAILURE per RFC 8894 §3.3.2.1.
t.Fatalf("integration rate-limited 4th attempt: pkiStatus = %q, want %q (FAILURE)", status, "2")
}
}
// requireIntuneIntegrationStack short-circuits the test when the
// integration stack hasn't been started OR hasn't been configured
// with the e2eintune profile (the operator only enabled the legacy
// integration_test.go set, not this one). Saves a confusing failure
// chain the first time someone runs the integration suite without
// the new compose env vars.
func requireIntuneIntegrationStack(t *testing.T) {
t.Helper()
cli := newTestClient()
resp, err := cli.http.Get(serverURL + "/scep/" + e2eintunePathID + "?operation=GetCACaps")
if err != nil {
t.Skipf("integration stack not reachable at %s: %v — start docker-compose.test.yml first", serverURL, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
t.Skipf("/scep/%s not configured — see deploy/docker-compose.test.yml for the e2eintune profile env vars", e2eintunePathID)
}
if resp.StatusCode != http.StatusOK {
t.Skipf("/scep/%s GetCACaps returned %d — Intune profile may not be enabled in compose env", e2eintunePathID, resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SCEPStandard") {
t.Skipf("/scep/%s GetCACaps body=%q does NOT advertise SCEPStandard — Intune profile may be misconfigured", e2eintunePathID, string(body))
}
}
// =============================================================================
// Deterministic trust-anchor key generation. MUST match what the
// docker-compose.test.yml mounts as the Connector trust anchor PEM.
// =============================================================================
// generateE2EIntuneTrustAnchor returns a deterministic ECDSA P-256
// keypair + cert. The committed
// deploy/test/fixtures/intune_trust_anchor.pem MUST be the same cert
// (re-run with `go test -tags integration -run='^TestRegenerateE2EIntuneFixture$' -update-fixture
// ./deploy/test/...` to refresh after a seed change).
func generateE2EIntuneTrustAnchor(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate) {
t.Helper()
prng := newE2EDeterministicReader(e2eintuneSeed)
key, err := ecdsa.GenerateKey(elliptic.P256(), prng)
if err != nil {
t.Fatalf("deterministic ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "intune-connector-integration-fixture"},
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2055, 1, 1, 0, 0, 0, 0, time.UTC),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(prng, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("deterministic CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return key, cert
}
// signE2EIntuneChallenge builds a JWT-shape ES256 challenge using the
// deterministic Connector key. Mirrors
// internal/api/handler/scep_intune_e2e_test.go::signIntuneChallengeES256
// but lives in the integration_test package (no shared imports across
// internal/ and deploy/test/).
func signE2EIntuneChallenge(t *testing.T, key *ecdsa.PrivateKey, payload map[string]any) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "ES256", "typ": "JWT"})
pl, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
if err != nil {
t.Fatalf("ecdsa.Sign: %v", err)
}
rb, sb := r.Bytes(), s.Bytes()
sig := make([]byte, 64)
copy(sig[32-len(rb):], rb)
copy(sig[64-len(sb):], sb)
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// e2eIntuneClaim returns the v1 challenge payload shape that matches
// a CSR with CN=device-integration-001.example.com (or whatever CN the
// caller passes to buildE2EIntunePKIMessage).
func e2eIntuneClaim(now time.Time, nonce string) map[string]any {
return map[string]any{
"iss": "intune-connector-integration-fixture",
"sub": "device-guid-integration-001",
"aud": e2eintuneAudience,
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(59 * time.Minute).Unix(),
"nonce": nonce,
"device_name": "device-integration-001.example.com",
}
}
// =============================================================================
// PKIMessage builder. Mirrors the in-tree handler test's helpers but
// stripped down for the integration test's hermetic needs (single profile,
// AES-256-CBC content encryption, fixture RA cert fetched from /scep/<pathID>?operation=GetCACert).
// =============================================================================
// buildE2EIntunePKIMessage fetches the running container's RA cert via
// GetCACert (which doubles as the cert clients encrypt the CSR's
// content-encryption key to per RFC 8894 §3.2.2), builds an
// EnvelopedData around an AES-256-CBC-encrypted CSR, then wraps the
// EnvelopedData in a SignedData with a transient signerInfo signature.
func buildE2EIntunePKIMessage(t *testing.T, cli *testClient, transactionID, challengePassword, csrCN string) []byte {
t.Helper()
// Fetch the RA cert from GetCACert.
resp, err := cli.http.Get(serverURL + "/scep/" + e2eintunePathID + "?operation=GetCACert")
if err != nil {
t.Fatalf("GetCACert: %v", err)
}
defer resp.Body.Close()
raCertBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read GetCACert: %v", err)
}
raCert, err := parseGetCACertForE2EIntune(raCertBytes)
if err != nil {
t.Fatalf("parse RA cert: %v", err)
}
// Build a transient device key + cert (the CSR's signer + the
// signerInfo's signer; production devices often use one key for
// both).
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("device key: %v", err)
}
deviceCert := selfSignedRSACertForE2EIntune(t, deviceKey, "device-transient-integration")
csrDER := buildE2EIntuneCSR(t, deviceKey, csrCN, challengePassword)
symKey := bytes.Repeat([]byte{0x42}, 32) // AES-256
iv := make([]byte, aes.BlockSize)
if _, err := rand.Read(iv); err != nil {
t.Fatalf("rand iv: %v", err)
}
ciphertext := aesCBCEncryptForE2EIntune(t, symKey, iv, csrDER)
rsaPub, ok := raCert.PublicKey.(*rsa.PublicKey)
if !ok {
t.Fatalf("RA cert public key is %T, want *rsa.PublicKey", raCert.PublicKey)
}
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, symKey)
if err != nil {
t.Fatalf("rsa encrypt symKey: %v", err)
}
envelopedData := buildEnvelopedDataForE2EIntune(t, raCert, encryptedKey, iv, ciphertext)
signedData := buildSignedDataForE2EIntune(t, deviceKey, deviceCert, transactionID, envelopedData)
return signedData
}
// postE2EIntuneOp POSTs the PKIMessage to the running certctl container
// and returns the raw response body. Fails the test on non-200 because
// every RFC 8894 PKIOperation MUST return a CertRep PKIMessage even on
// failure — anything other than 200 means the handler choked.
func postE2EIntuneOp(t *testing.T, cli *testClient, pkiMessage []byte) []byte {
t.Helper()
url := serverURL + "/scep/" + e2eintunePathID + "?operation=PKIOperation"
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(pkiMessage))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/x-pki-message")
resp, err := cli.http.Do(req)
if err != nil {
t.Fatalf("post PKIOperation: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("POST PKIOperation: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep on every PKIOperation including failures", resp.StatusCode, string(body))
}
return body
}
// decodeE2EPKIStatus extracts the SCEP pkiStatus auth-attribute from
// a CertRep PKIMessage. Returns the printable-string value ("0" =
// SUCCESS, "2" = FAILURE, "3" = PENDING per RFC 8894 §3.3.2.1).
//
// This is a minimal CMS SignedData walker — we don't pull in the
// internal/pkcs7 package because deploy/test/ is intentionally a
// stand-alone package. The walker hunts for the OID
// 2.16.840.1.113733.1.9.3 (id-attribute-pkiStatus, RFC 8894 §3.3.2.1)
// and returns its first SET-member value as a string.
func decodeE2EPKIStatus(t *testing.T, certRepDER []byte) string {
t.Helper()
// pkiStatus OID is 2.16.840.1.113733.1.9.3 → DER:
// 06 0a 60 86 48 01 86 f8 45 01 09 03
// Search the certRep DER for this byte pattern; the next 2 bytes
// after the OID land in the auth-attr's SET ("31 ?? ..."), and the
// pkiStatus value is a PrintableString inside.
pkiStatusOID := []byte{0x06, 0x0a, 0x60, 0x86, 0x48, 0x01, 0x86, 0xf8, 0x45, 0x01, 0x09, 0x03}
idx := bytes.Index(certRepDER, pkiStatusOID)
if idx < 0 {
t.Fatalf("decodeE2EPKIStatus: pkiStatus OID not found in CertRep (body len=%d)", len(certRepDER))
}
// After the OID DER (12 bytes), expect SET (0x31) of length L,
// then PrintableString (0x13) of length M, then the M chars.
cursor := idx + len(pkiStatusOID)
if cursor+4 >= len(certRepDER) {
t.Fatalf("decodeE2EPKIStatus: truncated DER after pkiStatus OID")
}
if certRepDER[cursor] != 0x31 {
t.Fatalf("decodeE2EPKIStatus: expected SET tag 0x31 after OID, got 0x%02x", certRepDER[cursor])
}
// Skip SET tag + length byte.
cursor += 2
if certRepDER[cursor] != 0x13 {
t.Fatalf("decodeE2EPKIStatus: expected PrintableString tag 0x13, got 0x%02x", certRepDER[cursor])
}
strLen := int(certRepDER[cursor+1])
cursor += 2
return string(certRepDER[cursor : cursor+strLen])
}
// =============================================================================
// Deterministic PRNG. Replicates the sha256-counter pattern from
// internal/scep/intune/golden_helper_test.go::deterministicReader so
// the integration test can derive the SAME ECDSA key bytes from the
// same seed. No shared imports across the internal/ and deploy/test/
// boundaries.
// =============================================================================
type e2eDeterministicReader struct {
mu sync.Mutex
state []byte
cursor int
buf []byte
}
func newE2EDeterministicReader(seed []byte) *e2eDeterministicReader {
return &e2eDeterministicReader{state: append([]byte(nil), seed...)}
}
func (d *e2eDeterministicReader) Read(p []byte) (int, error) {
d.mu.Lock()
defer d.mu.Unlock()
for n := 0; n < len(p); {
if d.cursor >= len(d.buf) {
h := sha256.Sum256(append(d.state, e2eByteCounter(len(p)+n)...))
d.buf = h[:]
d.cursor = 0
d.state = d.buf
}
c := copy(p[n:], d.buf[d.cursor:])
n += c
d.cursor += c
}
return len(p), nil
}
func e2eByteCounter(i int) []byte {
out := make([]byte, 8)
for k := 0; k < 8; k++ {
out[k] = byte(i >> (8 * k))
}
return out
}
// =============================================================================
// CMS / SCEP byte builders. Stripped-down equivalents of
// internal/pkcs7/{enveloped,signedinfo}.go for the integration test's
// hermetic needs. Distinct names from the in-tree helpers (no import
// crossing internal/ → deploy/test/).
// =============================================================================
func parseGetCACertForE2EIntune(body []byte) (*x509.Certificate, error) {
// Try raw DER first.
if cert, err := x509.ParseCertificate(body); err == nil {
return cert, nil
}
// Try PEM fallback.
if block, _ := pem.Decode(body); block != nil && block.Type == "CERTIFICATE" {
return x509.ParseCertificate(block.Bytes)
}
// Try PKCS#7 SignedData certs-only.
type signedData struct {
Version int
DigestAlgorithms asn1.RawValue
ContentInfo asn1.RawValue
Certificates asn1.RawValue `asn1:"optional,implicit,tag:0"`
}
var outer struct {
ContentType asn1.ObjectIdentifier
Content asn1.RawValue `asn1:"explicit,tag:0"`
}
if _, err := asn1.Unmarshal(body, &outer); err == nil {
var sd signedData
if _, err := asn1.Unmarshal(outer.Content.Bytes, &sd); err == nil {
if cert, err := x509.ParseCertificate(sd.Certificates.Bytes); err == nil {
return cert, nil
}
}
}
return nil, fmt.Errorf("could not parse GetCACert response (len=%d)", len(body))
}
func selfSignedRSACertForE2EIntune(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
cert, _ := x509.ParseCertificate(der)
return cert
}
func buildE2EIntuneCSR(t *testing.T, key *rsa.PrivateKey, cn, challengePassword string) []byte {
t.Helper()
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
Attributes: []pkix.AttributeTypeAndValueSET{
{
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
Value: [][]pkix.AttributeTypeAndValue{
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
},
},
},
}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
if err != nil {
t.Fatalf("CreateCertificateRequest: %v", err)
}
return der
}
func aesCBCEncryptForE2EIntune(t *testing.T, key, iv, plaintext []byte) []byte {
t.Helper()
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("aes.NewCipher: %v", err)
}
bs := block.BlockSize()
padLen := bs - len(plaintext)%bs
padded := append([]byte{}, plaintext...)
for i := 0; i < padLen; i++ {
padded = append(padded, byte(padLen))
}
enc := cipher.NewCBCEncrypter(block, iv)
out := make([]byte, len(padded))
enc.CryptBlocks(out, padded)
return out
}
// asn1WrapForE2EIntune wraps body in an ASN.1 TLV with the given tag
// and a definite-length encoding. Mirrors the in-tree
// internal/pkcs7.ASN1Wrap helper but stays inside this package (no
// cross-package import).
func asn1WrapForE2EIntune(tag byte, body []byte) []byte {
var lenBytes []byte
switch {
case len(body) < 128:
lenBytes = []byte{byte(len(body))}
case len(body) < 256:
lenBytes = []byte{0x81, byte(len(body))}
case len(body) < 65536:
lenBytes = []byte{0x82, byte(len(body) >> 8), byte(len(body))}
default:
lenBytes = []byte{0x83, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
}
out := append([]byte{tag}, lenBytes...)
return append(out, body...)
}
// OIDs used in the integration-test PKIMessage builders.
var (
oidRSAEncryptionE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1}
oidAES256CBCE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42}
oidSHA256E2E = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
oidRSAWithSHA256E2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
oidContentTypeE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
oidMessageDigestE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
oidSCEPMessageTypeE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2}
oidSCEPTransactionE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7}
oidSCEPSenderNonceE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5}
)
func buildEnvelopedDataForE2EIntune(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte) []byte {
t.Helper()
serialDER, err := asn1.Marshal(raCert.SerialNumber)
if err != nil {
t.Fatalf("marshal serial: %v", err)
}
risBody := append([]byte{}, raCert.RawIssuer...)
risBody = append(risBody, serialDER...)
risBytes := asn1WrapForE2EIntune(0x30, risBody)
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: oidRSAEncryptionE2E, Parameters: asn1.NullRawValue}
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
if err != nil {
t.Fatalf("marshal keyEncAlg: %v", err)
}
encryptedKeyBytes := asn1WrapForE2EIntune(0x04, encryptedKey)
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
ktriBody = append(ktriBody, risBytes...)
ktriBody = append(ktriBody, keyEncAlgBytes...)
ktriBody = append(ktriBody, encryptedKeyBytes...)
ktriBytes := asn1WrapForE2EIntune(0x30, ktriBody)
recipientInfosBytes := asn1WrapForE2EIntune(0x31, ktriBytes)
ivOctet := asn1WrapForE2EIntune(0x04, iv)
contentAlg := pkix.AlgorithmIdentifier{
Algorithm: oidAES256CBCE2E,
Parameters: asn1.RawValue{FullBytes: ivOctet},
}
contentAlgBytes, err := asn1.Marshal(contentAlg)
if err != nil {
t.Fatalf("marshal contentAlg: %v", err)
}
encContentField := asn1WrapForE2EIntune(0x80, ciphertext)
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
eciBody := append([]byte{}, oidDataBytes...)
eciBody = append(eciBody, contentAlgBytes...)
eciBody = append(eciBody, encContentField...)
eciBytes := asn1WrapForE2EIntune(0x30, eciBody)
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
envBody = append(envBody, recipientInfosBytes...)
envBody = append(envBody, eciBytes...)
innerEnvBytes := asn1WrapForE2EIntune(0x30, envBody)
// Wrap in a ContentInfo: SEQ { OID envelopedData, [0] EXPLICIT inner }.
envelopedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}
contentInfoBody := append([]byte{}, envelopedDataOID...)
contentInfoBody = append(contentInfoBody, asn1WrapForE2EIntune(0xa0, innerEnvBytes)...)
return asn1WrapForE2EIntune(0x30, contentInfoBody)
}
func buildSignedDataForE2EIntune(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, transactionID string, encapContent []byte) []byte {
t.Helper()
contentDigest := sha256.Sum256(encapContent)
var attrSetBody []byte
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidContentTypeE2E, asn1WrapForE2EIntune(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}))...) // envelopedData
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidMessageDigestE2E, asn1WrapForE2EIntune(0x04, contentDigest[:]))...)
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPMessageTypeE2E, asn1WrapForE2EIntune(0x13, []byte("19")))...) // PKCSReq=19
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPTransactionE2E, asn1WrapForE2EIntune(0x13, []byte(transactionID)))...)
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPSenderNonceE2E, asn1WrapForE2EIntune(0x04, []byte("0123456789abcdef")))...)
signedAttrsForSig := asn1WrapForE2EIntune(0x31, attrSetBody)
digest := sha256.Sum256(signedAttrsForSig)
sig, err := rsa.SignPKCS1v15(rand.Reader, signerKey, 5, digest[:]) // 5 = crypto.SHA256
if err != nil {
t.Fatalf("sign: %v", err)
}
versionBytes := []byte{0x02, 0x01, 0x01}
serialDER, _ := asn1.Marshal(signerCert.SerialNumber)
sidBody := append([]byte{}, signerCert.RawIssuer...)
sidBody = append(sidBody, serialDER...)
sidBytes := asn1WrapForE2EIntune(0x30, sidBody)
digestAlg := pkix.AlgorithmIdentifier{Algorithm: oidSHA256E2E, Parameters: asn1.NullRawValue}
digestAlgBytes, _ := asn1.Marshal(digestAlg)
signedAttrsImplicit := asn1WrapForE2EIntune(0xa0, attrSetBody)
sigAlg := pkix.AlgorithmIdentifier{Algorithm: oidRSAWithSHA256E2E, Parameters: asn1.NullRawValue}
sigAlgBytes, _ := asn1.Marshal(sigAlg)
sigOctet := asn1WrapForE2EIntune(0x04, sig)
signerInfoBody := append([]byte{}, versionBytes...)
signerInfoBody = append(signerInfoBody, sidBytes...)
signerInfoBody = append(signerInfoBody, digestAlgBytes...)
signerInfoBody = append(signerInfoBody, signedAttrsImplicit...)
signerInfoBody = append(signerInfoBody, sigAlgBytes...)
signerInfoBody = append(signerInfoBody, sigOctet...)
signerInfoBytes := asn1WrapForE2EIntune(0x30, signerInfoBody)
signerInfosSet := asn1WrapForE2EIntune(0x31, signerInfoBytes)
digestAlgsSet := asn1WrapForE2EIntune(0x31, digestAlgBytes)
envelopedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}
innerContent := asn1WrapForE2EIntune(0xa0, encapContent)
encapContentInfo := asn1WrapForE2EIntune(0x30, append(envelopedDataOID, innerContent...))
signerCertWrapped := asn1WrapForE2EIntune(0xa0, signerCert.Raw)
sdBody := append([]byte{}, versionBytes...)
sdBody = append(sdBody, digestAlgsSet...)
sdBody = append(sdBody, encapContentInfo...)
sdBody = append(sdBody, signerCertWrapped...)
sdBody = append(sdBody, signerInfosSet...)
innerSDBytes := asn1WrapForE2EIntune(0x30, sdBody)
signedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
contentInfoBody := append([]byte{}, signedDataOID...)
contentInfoBody = append(contentInfoBody, asn1WrapForE2EIntune(0xa0, innerSDBytes)...)
return asn1WrapForE2EIntune(0x30, contentInfoBody)
}
func attrSeqHelperE2E(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
t.Helper()
oidBytes, err := asn1.Marshal(oid)
if err != nil {
t.Fatalf("marshal oid: %v", err)
}
valueSet := asn1WrapForE2EIntune(0x31, value)
body := append(oidBytes, valueSet...)
return asn1WrapForE2EIntune(0x30, body)
}
+46
View File
@@ -831,6 +831,52 @@ 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. **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.
### Microsoft Intune Connector trust anchor (per-profile, opt-in)
When the SCEP server is sitting behind a Microsoft Intune Certificate
Connector — i.e. certctl is acting as a drop-in NDES replacement —
each per-profile dispatcher carries its own **trust anchor pool**:
the public certs the operator extracted from the Connector's
installation. Every Intune-flavored enrollment goes through:
```
┌─────────────────────────────────┐
│ Per-profile TrustAnchorHolder │
│ (RWMutex pool, SIGHUP-reloadable) │
└────────────┬────────────────────┘
│ Get()
device → SCEP PKIMessage → handler → SCEPService.dispatchIntuneChallenge
├─► intune.ValidateChallenge (sig + iat/exp + audience)
├─► claim.DeviceMatchesCSR (set-equality)
├─► intune.ReplayCache.CheckAndInsert
├─► intune.PerDeviceRateLimiter.Allow
└─► (V3-Pro) ComplianceCheck hook
processEnrollment → IssuerConnector
```
The trust anchor file is mode-0600 on disk; certctl loads it at
startup via `intune.LoadTrustAnchor` (refuses to boot on empty
bundle / parse error / past-`NotAfter` cert) and reloads atomically
on `SIGHUP` (mirrors the server TLS-cert hot-reload pattern). A bad
reload keeps the OLD pool in place — operators get a recoverable
failure window rather than a service-down. The admin GUI's
**Intune Monitoring** tab inside the SCEP Administration page (`/scep`)
and the parallel admin endpoints
(`GET /api/v1/admin/scep/profiles` for the always-present per-profile
overview that drives the Profiles tab,
`GET /api/v1/admin/scep/intune/stats` for the Intune deep dive,
`POST /api/v1/admin/scep/intune/reload-trust` for the SIGHUP-equivalent)
are all M-008 admin-gated; non-admin Bearer callers get HTTP 403
because the trust-anchor expiries + RA cert expiries + mTLS bundle
paths are sensitive operational metadata.
See [`scep-intune.md`](scep-intune.md) for the full migration playbook
+ Microsoft support statement.
### CA Signing Abstraction ### 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. 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.
+52
View File
@@ -331,6 +331,58 @@ Note: EST and SCEP are not connectors — they are protocol handlers (`internal/
**SCEP RA cert + key (post-2026-04-29):** the SCEP server's RFC 8894 path requires an RA cert/key pair (`CERTCTL_SCEP_RA_CERT_PATH` + `CERTCTL_SCEP_RA_KEY_PATH`, mode 0600) — clients encrypt their CSR to the RA cert's public key per RFC 8894 §3.2.2. Multi-profile deployments configure per-profile pairs via `CERTCTL_SCEP_PROFILES=corp,iot` + `CERTCTL_SCEP_PROFILE_<NAME>_RA_*_PATH`. See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the openssl recipe + ChromeOS Admin Console pointer + must-staple per-profile policy. **SCEP RA cert + key (post-2026-04-29):** the SCEP server's RFC 8894 path requires an RA cert/key pair (`CERTCTL_SCEP_RA_CERT_PATH` + `CERTCTL_SCEP_RA_KEY_PATH`, mode 0600) — clients encrypt their CSR to the RA cert's public key per RFC 8894 §3.2.2. Multi-profile deployments configure per-profile pairs via `CERTCTL_SCEP_PROFILES=corp,iot` + `CERTCTL_SCEP_PROFILE_<NAME>_RA_*_PATH`. See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the openssl recipe + ChromeOS Admin Console pointer + must-staple per-profile policy.
#### Multi-profile SCEP dispatch
A single certctl deploy can publish multiple SCEP endpoints — one per fleet, one per device class, or one per Connector — by setting `CERTCTL_SCEP_PROFILES=<comma-separated>` and a matching set of `CERTCTL_SCEP_PROFILE_<NAME>_*` environment variables. The router publishes `/scep/<pathID>?operation=...` for every profile whose `<NAME>` appears in the list (or `/scep` for the legacy single-profile shape when `CERTCTL_SCEP_PROFILES` is unset). Each profile carries its OWN issuer binding, RA cert/key pair, challenge password, must-staple policy, optional mTLS sibling route, and optional Microsoft Intune Connector trust anchor — heterogeneous fleets share one server, distinct credentials.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CERTCTL_SCEP_PROFILES` | No | — | Comma-separated profile names (e.g. `corp,iot`). When unset, the legacy single-profile config (`CERTCTL_SCEP_*` without the `_PROFILE_<NAME>_` infix) is used. |
| `CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID` | Yes | — | Issuer connector ID this profile dispatches to (e.g. `iss-local`, `iss-ejbca-corp`). |
| `CERTCTL_SCEP_PROFILE_<NAME>_PROFILE_ID` | No | — | Optional certificate profile ID for fine-grained issuance policy. |
| `CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD` | No | — | Static challenge password for the legacy SCEP auth path. Set to "" when only Intune dynamic challenges are expected. |
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH` | Yes | — | RA cert PEM path (mode 0600 enforced). |
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH` | Yes | — | RA private key PEM path (mode 0600 enforced). |
See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the full per-profile env-var list and the mTLS / Intune extensions.
#### SCEP mTLS sibling route (opt-in)
For deploys that already have a previously-issued certctl client cert and want a stronger renewal binding than the static challenge password, certctl exposes an opt-in mTLS sibling route at `/scep-mtls/<pathID>`. The TLS handshake is configured with `tls.VerifyClientCertIfGiven` against an operator-supplied trust bundle; presented client certs are validated against the bundle before the SCEP handler runs. The standard `/scep/<pathID>` route stays open for new-enrollment devices that don't yet have a client cert.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED` | No | `false` | Set `true` to publish `/scep-mtls/<pathID>` alongside `/scep/<pathID>`. |
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH` | When MTLS enabled | — | PEM bundle of CAs that may sign client certs. Preflight refuses a missing/empty bundle. |
See [`legacy-est-scep.md`](legacy-est-scep.md#scep-mtls-sibling-route-phase-65) for the operator recipe + threat-model rationale.
#### Microsoft Intune Certificate Connector dispatcher
When a profile has `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true`, certctl validates the Microsoft Intune Certificate Connector's signed-challenge JWS natively as a drop-in NDES replacement (the Intune Connector documents itself as RFC 8894-compliant and works against any RFC 8894 SCEP server). The dispatcher walks parse → JWS signature verify (RS256 + ES256, alg=none rejected) → version dispatch → time bounds with ±tolerance → audience pin → CSR ↔ claim binding → replay cache → per-device rate limit → optional V3-Pro compliance hook. The trust anchor file is reloaded on `SIGHUP` (operator rotates the on-disk PEM, then `kill -HUP <certctl-pid>`); a parse failure during reload keeps the OLD pool so a half-rotation doesn't take Intune down.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED` | No | `false` | Gate the dispatcher. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH` | When enabled | — | PEM bundle of the Connector's signing certs. Preflight refuses a missing/expired bundle. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE` | No | — | Expected `aud` claim (typically the public SCEP URL the Connector calls). Empty disables the audience check. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CHALLENGE_VALIDITY` | No | `60m` | Defense-in-depth cap on top of the challenge's own `exp`. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE` | No | `60s` | ±tolerance on iat/exp checks. Raise on poorly-NTP-synced fleets, lower to enforce strict time. Refused at boot when ≥ `INTUNE_CHALLENGE_VALIDITY`. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H` | No | `3` | Max enrollments per `(claim.Subject, claim.Issuer)` in any rolling 24h window. Zero disables. |
See [`scep-intune.md`](scep-intune.md) for the full deployment guide — NDES + EJBCA migration playbook, Intune SCEP profile field mapping, trust-anchor extraction recipe, monitoring + Prometheus alert thresholds, and the Microsoft Learn citations operators paste into procurement-team requests.
#### SCEP probe in network scanner
The Network Scans GUI surface includes a one-click "Probe SCEP" form that runs a capability + posture check against any reachable SCEP server URL — `GetCACaps` + `GetCACert` (NEVER `PKCSReq`) so the probe is read-only and safe to run against production endpoints. Result fields surface advertised caps (POSTPKIOperation, SHA-256, SHA-512, AES, SCEPStandard, Renewal), CA cert subject + issuer + algorithm + days-to-expiry + chain length, and a probe duration. Results persist to `scep_probe_results` (migration `000021`) and the probe history is paginated under `GET /api/v1/network-scan/scep-probes`. Useful for pre-migration assessment ("what does the existing NDES advertise?") and compliance-posture audits.
| Endpoint | Auth | Description |
|----------|------|-------------|
| `POST /api/v1/network-scan/scep-probe` | Bearer | Body `{"url":"https://..."}`. Synchronous probe; returns `SCEPProbeResult`. |
| `GET /api/v1/network-scan/scep-probes` | Bearer | Recent probe history, paginated `[1, 200]`. |
The probe goes through the same dual-layer SSRF defense (`validation.ValidateSafeURL` up-front + `SafeHTTPDialContext` at dial time) as the rest of the network scanner. Standalone CLI binary is explicitly deferred — the in-tree network scanner is the only entrypoint today.
### Built-in: Vault PKI ### Built-in: Vault PKI
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving. The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
+11 -4
View File
@@ -498,10 +498,17 @@ otherwise.
typically <50KB so the default cap is generous. typically <50KB so the default cap is generous.
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control - **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
plane; there is no plaintext fallback. plane; there is no plaintext fallback.
- **Forward reference:** for the deeper Intune integration writeup - **For Microsoft Intune deployments, see [`scep-intune.md`](scep-intune.md)**
(architecture, migration playbook, troubleshooting, architecture, NDES-replacement migration playbook, Intune SCEP profile
Microsoft-support-statement), see [`scep-intune.md`](scep-intune.md) field mapping, trust-anchor extraction recipe, troubleshooting matrix,
(Phase 11 of the master bundle). operational monitoring, V3-Pro deferrals, and the Microsoft support
statement (with Microsoft Learn URLs procurement teams ask for).
- **For per-profile SCEP observability** (RA cert expiry countdown,
mTLS sibling-route status, challenge-password-set indicator, and
the full SCEP audit log filter), the admin GUI page lives at `/scep`
with three tabs: **Profiles** (default), **Intune Monitoring**,
**Recent Activity**. See `scep-intune.md::Operational monitoring`
for the Intune-specific tab inside it.
## Related docs ## Related docs
+393
View File
@@ -0,0 +1,393 @@
# Microsoft Intune SCEP enrollment via certctl
> **Status (this document):** Phase 11 of the SCEP RFC 8894 + Intune master
> bundle. The behavior described here is shipped on `master` and exercised
> end-to-end by `internal/api/handler/scep_intune_e2e_test.go`. The
> bundle is V2-free (community edition) — Conditional-Access compliance
> gating, native Microsoft Graph integration, and per-tenant trust
> anchors are documented under [Limitations](#limitations) as V3-Pro
> features.
## TL;DR
certctl is a **drop-in NDES replacement** for Microsoft Intune SCEP fleets.
Intune-managed devices keep using the existing Intune Certificate Connector;
only the SCEP server URL changes. certctl validates the Connector's
signed challenge using its installation signing cert (no Microsoft API
calls — the Connector already did that), binds the device claim to the
inbound CSR, and issues through whichever certctl issuer connector you
have configured (local CA, Vault, EJBCA, ADCS, etc.).
What you get over NDES:
- Per-profile SCEP endpoints (`/scep/corp` vs. `/scep/iot` etc.) so a
single certctl deploy serves multiple device fleets with distinct
challenge passwords + trust anchors.
- Audit log entries with the device GUID, claim subject, and CSR
binding details — much better forensics than NDES + IIS logs.
- Trust anchor reload via `SIGHUP` (no service restart) when the
Connector signing cert rotates.
- A built-in admin GUI tab (Intune Monitoring) showing per-profile
enrollment counters, trust-anchor expiry countdowns, and the recent
failures table.
- Per-device rate limit (sliding window log keyed by Subject + Issuer)
that catches a compromised Connector signing key issuing many
different valid challenges for the same device.
## Architecture
```
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Intune cloud │──────▶│ Intune Certificate │──────▶│ certctl SCEP │
│ │ │ Connector │ │ server │
│ (Microsoft) │ │ (customer infra) │ │ (you) │
└──────────────┘ └──────────────────────┘ └──────┬───────┘
┌──────────────┐
│ issuer │
│ connector │
│ (local CA / │
│ Vault / │
│ EJBCA / …) │
└──────────────┘
```
**certctl replaces NDES, not the Connector.** The Intune Certificate
Connector is the bridge between the Intune cloud and your on-prem PKI;
Microsoft installs and maintains it. What you replace is the
**Network Device Enrollment Service** (NDES) — the SCEP server
historically deployed on a Windows host, sitting between the Connector
and an Active Directory Certificate Services CA. certctl sits in
exactly that slot and speaks SCEP RFC 8894 to the Connector.
### What certctl validates per request
For every Intune-flavored SCEP request the dispatcher in
`internal/service/scep.go::dispatchIntuneChallenge` walks the
following gates in order. A failure on any gate produces a CertRep
PKIMessage with the documented `pkiStatus`/`failInfo` codes (per RFC
8894 §3.2.1.4.5) and increments the corresponding metric counter.
1. **Shape pre-check**`looksIntuneShaped(challengePassword)`:
length > 200 + exactly two dots. False positives are fine; false
negatives on real Intune challenges would route them to the static
compare and reject. The pre-check just decides whether to invoke
the full validator.
2. **JWS signature**`intune.ValidateChallenge` re-derives the
signing input from the raw on-wire bytes (per RFC 7515 §3.1, NOT
re-base64-encoded segments) and verifies against every cert in the
trust anchor pool. Supports RS256 and ES256 (both fixed-width
r||s and ASN.1-DER form). Explicitly rejects `alg=none` and
HMAC algs.
3. **Version dispatch** — extracts the `version` claim from the
payload prelude. v1 (current Connector format, no `version` key)
routes to `unmarshalChallengeV1`. Future v2 plugs in a sibling
parser without touching the validator.
4. **Time bounds**`now+tolerance ≥ iat AND now-tolerance < exp`.
The `±tolerance` window is configurable per profile via
`INTUNE_CLOCK_SKEW_TOLERANCE` (default 60s, covers modest clock
drift between the Connector host and certctl). Configurable cap on
top via `INTUNE_CHALLENGE_VALIDITY` (defense-in-depth against a
Connector that mints long-validity challenges). The validator
refuses `tolerance ≥ ChallengeValidity` at startup-validation time
to keep the cap meaningful.
5. **Audience pin**`claim.aud == INTUNE_AUDIENCE` (skipped when
`INTUNE_AUDIENCE` is empty for proxy/load-balancer scenarios).
6. **CSR binding**`claim.DeviceMatchesCSR(csr)` checks
set-equality between the claim's `device_name` / `san_dns` /
`san_rfc822` / `san_upn` and the CSR's CN + SANs. Set-equality
means the CSR carries EXACTLY the claim's values, no extras and
no missing.
7. **Replay**`intune.ReplayCache.CheckAndInsert` rejects
duplicates within the configured TTL. Sized for 100k entries
(covers a ~25 RPS Intune fleet's steady-state).
8. **Per-device rate limit** — sliding window log keyed by
`(claim.Subject, claim.Issuer)`. Catches a compromised Connector
issuing many DIFFERENT valid challenges for the same device. Default
3 enrollments per 24h covers legitimate first-cert + recovery +
post-wipe.
9. **Optional compliance check** — V3-Pro plug-in seam (nil-default
no-op). When set, the gate calls Microsoft Graph's compliance API
and short-circuits non-compliant devices with FAILURE+BadRequest.
A request that passes all nine gates flows to
`processEnrollment`, which builds the issuance request, calls the
configured issuer connector, and emits a CertRep PKIMessage with the
issued cert encrypted to the device's transient signing cert per RFC
8894 §3.3.2.
## Migration from NDES + EJBCA (or NDES + ADCS)
The migration plan below is conservative — install certctl alongside
your existing NDES so you can flip Intune profiles fleet-by-fleet
without a flag day. Validated against a fresh `docker compose up`
stack; the docker-compose.test.yml stack does not currently bake
Intune in (Phase 10.2 ships a hermetic in-process e2e test instead),
so the production validation step is a manual run-book item.
1. **Install certctl alongside existing NDES.** Stand up the certctl
server on a separate host (or as a Kubernetes deployment) reachable
from the Connector host. Use the existing operator-run-book in
`docs/tls.md` for the TLS bootstrap.
2. **Configure a per-profile SCEP endpoint.** Pick a path id (e.g.
`corp` — referenced as `<NAME>` below; the value gets uppercased
for the env-var key and lowercased for the URL path) and set:
```
CERTCTL_SCEP_ENABLED=true
CERTCTL_SCEP_PROFILES=corp
CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID=iss-local # or your existing issuer
CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD=<random> # Intune still requires this
CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH=/etc/certctl/ra-corp.pem
CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH=/etc/certctl/ra-corp.key
```
The endpoint will be served at `https://certctl.example.com/scep/corp`
— the URL path uses the lowercased name and the env-var keys use
the uppercased form. Concrete env-var name mappings are listed in
[`features.md`](features.md).
3. **Extract the Intune Connector's signing cert.** On the Connector
host (Windows), the Connector's installation creates a self-signed
cert in the local machine's `Personal` cert store with subject
`CN=Microsoft Intune Certificate Connector` (path documented by
Microsoft — see Microsoft Learn link in the
[Microsoft support statement](#microsoft-support-statement) below).
Export the public cert (no private key) as a base64 `.cer` file.
4. **Configure the trust anchor.** Copy the `.cer` to the certctl host
(or mount via your secret manager) and set:
```
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH=/etc/certctl/intune-corp.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_CLOCK_SKEW_TOLERANCE=60s # ±tolerance on iat/exp; raise on poorly-NTP-synced fleets, lower to enforce strict time
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H=3
```
Restart certctl. The startup preflight refuses to boot if the
trust anchor file is missing, unparseable, or contains an expired
cert — failure is loud at boot rather than silent at request time.
5. **Configure the issuer connector.** If you're keeping EJBCA,
point `CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID` at your EJBCA issuer
profile (see `docs/connectors.md`). For a clean cut-over to the
built-in local CA, follow `docs/tls.md` to bootstrap a sub-CA cert.
6. **Migrate one Intune SCEP profile to certctl.** In the Intune
admin center, edit the SCEP profile for a small canary device
group and update the SCEP server URL to
`https://certctl.example.com/scep/corp`. Push the profile and
wait for the canary devices to rotate (24-48h).
7. **Verify enrollment.** Open the certctl admin GUI's
[SCEP Intune Monitoring tab](#operational-monitoring) and watch
the `success` counter tick on the `corp` profile card. The
`recent failures` table surfaces any rejected enrollments with
the exact reason (e.g. `signature_invalid`, `claim_mismatch`).
8. **Roll out the rest of the fleet.** Once the canary is clean,
migrate the remaining Intune SCEP profiles in batches.
9. **Decommission NDES.** After all fleets are migrated and a few
renewal cycles have completed cleanly, take down the NDES role
and the IIS site. The existing certs continue to chain to your
issuer; only the enrollment path changes.
## Intune SCEP profile fields → certctl behavior
The Intune admin center's SCEP profile editor exposes a fixed set of
fields. The mapping below is what each field controls relative to
certctl's behavior.
| Intune profile field | certctl behavior |
|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Certificate type | Treated as device or user; surfaces in the claim's `subject` field (device GUID vs. user UPN). certctl doesn't gate on type; the issuer's certificate profile decides. |
| Subject name format | Drives the CSR's CN. The Intune Connector sets `claim.device_name` from this value; certctl's CSR-binding gate enforces equality. |
| Subject alternative name | Drives the CSR's SAN list. Intune supports DNS / RFC 822 / UPN; certctl's claim binding checks set-equality per dimension. Mismatches surface as `ErrClaimSANDNSMismatch` / `_SANRFC822Mismatch` / `_SANUPNMismatch`. |
| Certificate validity period | Honored by the issuer connector. certctl caps via the per-profile `CertificateProfile.MaxTTLSeconds`; the smaller of the two wins. |
| Key storage provider | Device-side concern (the Connector negotiates with the device's TPM / Software KSP). certctl never sees the device's private key — it only signs the CSR. |
| Key usage / Extended key usage | Honored by the issuer connector via the bound `CertificateProfile.AllowedEKUs`. CSRs requesting an EKU outside the allowed set are rejected by the crypto-policy gate (`ValidateCSRAgainstProfile`). |
| Hash algorithm | The CSR's signature hash (SHA-256 typical). The SCEP `GetCACaps` advertises SHA-256 + SHA-512; the device picks. |
| SCEP server URL | The endpoint URL the Connector posts to. Set to `https://certctl.example.com/scep/<profile-name>`. |
## Trust anchor extraction
The Intune Certificate Connector self-signs an installation cert at
install time. To configure certctl, extract this cert (PUBLIC ONLY,
no private key) as PEM:
1. On the Connector host (Windows), open `certlm.msc` (Local Machine
Certificate Manager).
2. Navigate to `Personal``Certificates`. Find the cert with
subject `CN=Microsoft Intune Certificate Connector`.
3. Right-click → All Tasks → Export. Choose **No, do not export
the private key**. Format: **Base-64 encoded X.509 (.CER)**.
4. Copy the resulting `.cer` file to the certctl host. Rename to
`.pem` (the bytes are identical; certctl's PEM loader accepts
either extension).
5. Set `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH` to
the file path.
6. If you have multiple Connectors in HA, repeat steps 1-3 on each
and concatenate the PEM blocks into one bundle file.
When the operator rotates the Connector signing cert (typically once
every few years per Microsoft's Connector lifecycle), repeat the
extraction, overwrite the on-disk file, then send `SIGHUP` to the
certctl process. The trust holder swaps atomically; bad files (parse
error, expired cert) keep the OLD pool in place so a half-rotation
doesn't take Intune enrollment down.
## Troubleshooting
The dispatcher emits a typed metric label per failure mode plus a
matching audit-log entry. The table below maps the label to the most
common root cause and the operator action.
| Counter label | Symptom | Root cause + fix |
|----------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `signature_invalid` | Every enrollment from a specific profile failing | Trust anchor mismatch — the Connector's signing cert was rotated and certctl wasn't reloaded. Re-extract the cert ([trust anchor extraction](#trust-anchor-extraction)), overwrite the file, send `SIGHUP`. |
| `claim_mismatch` | Some enrollments from one Intune SCEP profile failing | The Intune SCEP profile's SAN config doesn't match what the device CSR actually has. Compare the `recent failures` table's claim row to the device's CSR; usually a SAN format mismatch (e.g. claim wants UPN, CSR has DNS). |
| `expired` | All enrollments failing on a date boundary | Either clock skew between the Connector host and certctl (NTP both ends) OR the Connector's signing cert is past `NotAfter`. The certctl preflight catches an expired trust anchor at boot; check the Monitoring tab's expiry countdown. |
| `not_yet_valid` | All enrollments failing | Reverse clock skew (certctl's clock is BEHIND the Connector's). Sync via NTP. |
| `wrong_audience` | All enrollments from a profile failing | `INTUNE_AUDIENCE` doesn't match the URL the Connector is configured to call. Either fix `INTUNE_AUDIENCE` to match the operator URL, or unset it (defense-in-depth then disabled — the claim's exp + sig still gate). |
| `replay` | Sporadic per-device failures, mostly during retries | The device retried the SAME challenge after the first one failed. The replay cache TTL is `INTUNE_CHALLENGE_VALIDITY` (default 60m). Either widen the device's retry window (Intune-side) or shorten validity. |
| `rate_limited` | A specific device hitting `429`-equivalent failures | The device exceeded `INTUNE_PER_DEVICE_RATE_LIMIT_24H` (default 3). If legitimate (post-wipe + recovery + first-cert all in 24h), bump the cap. If suspicious, this is the limiter doing its job — investigate the device. |
| `unknown_version` | Sudden onset of failures across the entire fleet | Microsoft shipped a new Connector version with a `version` claim certctl doesn't understand. Open an issue on the certctl repo with the failing claim payload (anonymized); the parser dispatcher accepts new versions in ~30 LoC. |
| `malformed` | Sporadic, low-volume | Malformed challenge bytes — almost always a network proxy mangling the request body, or the Connector logging itself out mid-handshake. Capture a packet trace; the Connector should re-emit on the next device retry. |
| `compliance_failed` | V3-Pro only | The pluggable compliance check returned non-compliant. The audit-log details carries the reason string from Microsoft Graph. V2 deployments never see this counter tick. |
## Operational monitoring (SCEP Administration → Intune Monitoring tab)
The admin GUI surface for SCEP lives at `/scep` and is structured as
three tabs: **Profiles** (default landing — every configured SCEP
profile, lean cards with always-present fields), **Intune Monitoring**
(the Intune-specific deep-dive described below), and **Recent Activity**
(full SCEP audit log filter). Operators monitoring an Intune deployment
spend most of their time on the Intune Monitoring tab, deep-linkable via
`/scep?tab=intune` or the legacy alias `/scep/intune`. The Profiles tab
gives the at-a-glance per-profile health (RA cert expiry, mTLS status,
Intune enabled/disabled badge, challenge-password-set indicator) and a
"View Intune details →" link from each Intune-enabled card that switches
into this tab filtered to that profile.
The Intune Monitoring tab shows:
- **Per-profile cards** — one card per SCEP profile, with the trust
anchor expiry countdown badge:
- `green` ≥ 30 days remaining
- `amber` 7-30 days remaining (rotate soon)
- `red` < 7 days remaining
- `EXPIRED` past `NotAfter`
- **Live counters** — the per-status enrollment counts polled every
30s. The order in the grid puts `success` first (vanity) and
failure modes after.
- **Recent failures table** — the last 50 audit-log events with
action `scep_pkcsreq_intune` or `scep_renewalreq_intune`, sorted
by timestamp descending. Polled every 60s.
- **Trust anchor reload button** — confirms via modal then issues
`POST /api/v1/admin/scep/intune/reload-trust` (the SIGHUP-equivalent).
Bad reloads keep the OLD pool in place; the modal stays open with
the underlying error so the operator can correct the file and retry.
Three admin endpoints back the page:
- `GET /api/v1/admin/scep/profiles` — per-profile snapshot for the
Profiles tab; surfaces RA cert subject + NotAfter + days-to-expiry,
mTLS sibling-route status + bundle path, challenge-password-set flag,
and an optional `intune` sub-block for Intune-enabled profiles.
- `GET /api/v1/admin/scep/intune/stats` — Intune-specific deep-dive
for the Intune Monitoring tab; per-status counters + trust anchor
pool details. Backward-compat shape preserved from Phase 9.
- `POST /api/v1/admin/scep/intune/reload-trust` — SIGHUP-equivalent
trust anchor reload, body `{"path_id": "<pathID>"}`.
All three are M-008 admin-gated. Non-admin Bearer callers get HTTP 403
+ a clear message; the GUI hides the page entirely for non-admin users
(UX hint; server-side enforcement is independent).
### Recommended alert thresholds
The counters are exposed in the GUI as snapshots; if you wrap them
in a Prometheus exporter (V3-Pro plug-in seam — V2 doesn't ship a
`/metrics` surface today), reasonable starting thresholds:
- `signature_invalid` rate > 0 for > 5 minutes → page on-call. The
trust anchor is stale; the operator missed a SIGHUP after a
Connector rotation.
- `claim_mismatch` rate > 0 sustained > 1 hour → notify (not page).
An Intune SCEP profile is misconfigured; an admin needs to fix
the SAN definition or the operator's CertificateProfile.
- `replay` rate climbing → notify. Either an aggressive retry policy
on the device side OR active replay attempts. Cross-reference
source IPs in the audit log.
- `rate_limited` for a single device > 1 per hour → notify. Either
legitimate enrollment storm (post-wipe scenarios) or a compromised
Connector signing key.
- Trust anchor `days_to_expiry` < 30 on any profile → notify; rotate
the Connector's signing cert before the cliff.
## Limitations
This bundle is V2-free. The following capabilities are deferred to
V3-Pro:
- **Native Microsoft Graph integration.** certctl validates the
Connector's signed challenge but doesn't call Microsoft's API
directly — the Connector already did that. V3-Pro could ship a
Graph client that pulls device-compliance state in addition to
the challenge claim.
- **Conditional Access compliance gating.** The dispatcher exposes a
nil-default `ComplianceCheck` hook. V3-Pro plugs in a Microsoft
Graph compliance lookup before issuance; non-compliant devices
fail with a typed `compliance_failed` failInfo.
- **Per-tenant trust anchors.** V2 has one trust anchor pool per
SCEP profile; V3-Pro could support per-AAD-tenant anchor scoping
for MSPs running shared certctl deployments across customers.
- **OCSP stapling at SCEP-response time.** The CertRep doesn't carry
a stapled OCSP response today; certificate validators look up OCSP
via the `id-pkix-ocsp` extension on the issued cert. V3-Pro could
staple inline.
- **Auto-discovery of the Connector signing cert.** V2 requires the
operator to extract the cert manually and configure the path.
V3-Pro could pull from a Microsoft-published endpoint (with the
appropriate trust constraints).
These deferrals are deliberate, not oversights. The V2 surface
covers every operationally-required path for a single-tenant
enterprise replacing NDES; V3-Pro adds the multi-tenant + native-API
features procurement teams sometimes ask for.
## Microsoft support statement
Microsoft documents the Intune Certificate Connector as
**RFC-8894-compliant** and supports its use against any RFC 8894
SCEP server. The relevant Microsoft Learn pages:
- [Intune Certificate Connector overview](https://learn.microsoft.com/en-us/mem/intune/protect/certificate-connector-overview) —
documents the Connector's architecture and explicitly notes it
speaks RFC-8894-compliant SCEP.
- [Use SCEP certificate profiles in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/certificates-scep-configure) —
the operator-facing setup guide, with the SCEP server URL field
the migration playbook above edits.
- [Validate setup of Intune Certificate Connector](https://learn.microsoft.com/en-us/mem/intune/protect/certificate-connector-install) —
the install-validation checklist; useful when troubleshooting
Connector-side failures vs. certctl-side failures.
certctl's role per Microsoft's framing: a third-party SCEP server
that the Connector posts to. Microsoft supports this topology; only
certctl's own RFC 8894 implementation is in scope for certctl
support. The end-to-end Connector → certctl → issuer flow is
exercised in `internal/api/handler/scep_intune_e2e_test.go` and
the golden-file fixtures in `internal/scep/intune/testdata/`.
## Related docs
- [`legacy-est-scep.md`](legacy-est-scep.md) — the per-profile SCEP
setup guide + RFC 8894 reference + mTLS sibling route. Read this
first if you're not already running certctl SCEP for non-Intune
fleets.
- [`architecture.md`](architecture.md) — overall control-plane
architecture; Security Model section calls out the Intune trust
anchor as a sensitive operator-configured surface.
- [`features.md`](features.md) — every `CERTCTL_*` env var,
including the per-profile `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_*`
family.
- [`tls.md`](tls.md) — TLS bootstrap for the certctl control plane;
prerequisite for any production deploy.
+67 -11
View File
@@ -16,14 +16,20 @@ import (
// rather than the concrete *service.SCEPService set so wiring stays // rather than the concrete *service.SCEPService set so wiring stays
// service-side and the handler stays test-friendly. // service-side and the handler stays test-friendly.
// //
// SCEP RFC 8894 + Intune master bundle Phase 9.1. // SCEP RFC 8894 + Intune master bundle Phase 9.1, extended in the
// Phase 9 follow-up (cowork/scep-gui-restructure-prompt.md) with
// Profiles for the per-profile SCEP Administration tab.
type AdminSCEPIntuneService interface { type AdminSCEPIntuneService interface {
// Stats returns one snapshot per configured SCEP profile (Intune- // Stats returns one snapshot per configured SCEP profile (Intune-
// enabled or not). Profiles where Intune is disabled appear with // enabled or not) in the Phase 9.1 flat shape. Backward-compat for
// Enabled=false so the GUI can show "off — opt in via env vars" // the existing /admin/scep/intune/stats endpoint.
// rather than 404ing per-profile.
Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error)
// Profiles returns one snapshot per configured SCEP profile in the
// new shape (always-present per-profile fields + optional Intune
// sub-block). Backs the new /admin/scep/profiles endpoint.
Profiles(ctx context.Context, now time.Time) ([]service.SCEPProfileStatsSnapshot, error)
// ReloadTrust triggers the SIGHUP-equivalent Reload on the named // ReloadTrust triggers the SIGHUP-equivalent Reload on the named
// profile's trust holder. Returns ErrAdminSCEPProfileNotFound if // profile's trust holder. Returns ErrAdminSCEPProfileNotFound if
// the PathID isn't known, or ErrSCEPProfileIntuneDisabled if the // the PathID isn't known, or ErrSCEPProfileIntuneDisabled if the
@@ -39,18 +45,20 @@ type AdminSCEPIntuneService interface {
// to any configured profile. The handler maps this to HTTP 404. // to any configured profile. The handler maps this to HTTP 404.
var ErrAdminSCEPProfileNotFound = errors.New("admin scep intune: profile not found for the given path_id") var ErrAdminSCEPProfileNotFound = errors.New("admin scep intune: profile not found for the given path_id")
// AdminSCEPIntuneHandler serves the per-profile Intune observability // AdminSCEPIntuneHandler serves the per-profile SCEP observability
// endpoints for the GUI Intune Monitoring tab. // endpoints for the GUI SCEP Administration page.
// //
// Endpoints: // Endpoints:
// //
// GET /api/v1/admin/scep/intune/stats // GET /api/v1/admin/scep/profiles — Phase 9 follow-up
// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"}) // GET /api/v1/admin/scep/intune/stats — Phase 9.2
// POST /api/v1/admin/scep/intune/reload-trust — Phase 9.2 (JSON body: {"path_id": "corp"})
// //
// Both endpoints are admin-gated (M-008 pattern). Non-admin Bearer // All three endpoints are admin-gated (M-008 pattern). Non-admin Bearer
// callers get 403 — the stats endpoint reveals the operator's profile // callers get 403 — the stats endpoint reveals the operator's profile
// set + trust anchor expiries (sensitive operational metadata) and the // set + trust anchor expiries (sensitive operational metadata), the
// reload endpoint is a privileged action. // profiles endpoint additionally reveals RA cert expiries + mTLS bundle
// paths, and the reload endpoint is a privileged action.
type AdminSCEPIntuneHandler struct { type AdminSCEPIntuneHandler struct {
svc AdminSCEPIntuneService svc AdminSCEPIntuneService
} }
@@ -68,6 +76,42 @@ type adminScepIntuneReloadRequest struct {
PathID string `json:"path_id"` PathID string `json:"path_id"`
} }
// Profiles handles GET /api/v1/admin/scep/profiles.
//
// Phase 9 follow-up endpoint backing the SCEP Administration page's
// Profiles tab. Returns one snapshot per configured SCEP profile in
// the SCEPProfileStatsSnapshot shape (always-present per-profile
// fields + optional Intune sub-block).
//
// Same M-008 admin gate as Stats. Profiles where Intune is disabled
// appear with Intune=null in the response.
func (h AdminSCEPIntuneHandler) Profiles(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
}
now := time.Now()
rows, err := h.svc.Profiles(r.Context(), now)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to read SCEP profiles")
return
}
if rows == nil {
// Avoid serialising as `null` — the GUI expects an array.
rows = []service.SCEPProfileStatsSnapshot{}
}
_ = JSON(w, http.StatusOK, map[string]any{
"profiles": rows,
"profile_count": len(rows),
"generated_at": now.UTC(),
})
}
// Stats handles GET /api/v1/admin/scep/intune/stats. // Stats handles GET /api/v1/admin/scep/intune/stats.
func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) { func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -177,6 +221,18 @@ func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([]
return out, nil return out, nil
} }
// Profiles implements AdminSCEPIntuneService for the new
// /admin/scep/profiles endpoint. Walks the same per-profile SCEPService
// map but emits the SCEPProfileStatsSnapshot shape (always-present
// fields + optional Intune sub-block).
func (s *AdminSCEPIntuneServiceImpl) Profiles(_ context.Context, now time.Time) ([]service.SCEPProfileStatsSnapshot, error) {
out := make([]service.SCEPProfileStatsSnapshot, 0, len(s.services))
for _, svc := range s.services {
out = append(out, svc.ProfileStats(now))
}
return out, nil
}
// ReloadTrust implements AdminSCEPIntuneService. // ReloadTrust implements AdminSCEPIntuneService.
func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error { func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error {
svc, ok := s.services[pathID] svc, ok := s.services[pathID]
+165 -6
View File
@@ -18,12 +18,15 @@ import (
// Records call observations so the M-008 admin-gate triplet can pin // Records call observations so the M-008 admin-gate triplet can pin
// "service was never invoked" when the gate rejects the caller. // "service was never invoked" when the gate rejects the caller.
type fakeAdminSCEPIntuneService struct { type fakeAdminSCEPIntuneService struct {
statsCalled bool statsCalled bool
reloadCalled bool profilesCalled bool
rows []service.IntuneStatsSnapshot reloadCalled bool
statsErr error rows []service.IntuneStatsSnapshot
reloadPathID string profileRows []service.SCEPProfileStatsSnapshot
reloadErr error statsErr error
profilesErr error
reloadPathID string
reloadErr error
} }
func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) { func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) {
@@ -31,6 +34,11 @@ func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]se
return f.rows, f.statsErr return f.rows, f.statsErr
} }
func (f *fakeAdminSCEPIntuneService) Profiles(_ context.Context, _ time.Time) ([]service.SCEPProfileStatsSnapshot, error) {
f.profilesCalled = true
return f.profileRows, f.profilesErr
}
func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error { func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error {
f.reloadCalled = true f.reloadCalled = true
f.reloadPathID = pathID f.reloadPathID = pathID
@@ -334,3 +342,154 @@ func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.
t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err) t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err)
} }
} }
// =============================================================================
// M-008 admin-gate triplet for Profiles (GET) — Phase 9 follow-up endpoint.
// =============================================================================
func TestAdminSCEPProfiles_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin, 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.profilesCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", 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.Profiles(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
}
if svc.profilesCalled {
t.Error("service called despite admin=false gate")
}
}
func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{
profileRows: []service.SCEPProfileStatsSnapshot{
{
PathID: "corp",
IssuerID: "iss-corp",
ChallengePasswordSet: true,
MTLSEnabled: true,
Intune: &service.IntuneSection{
Audience: "https://certctl.example.com/scep/corp",
},
},
{
PathID: "iot",
IssuerID: "iss-iot",
ChallengePasswordSet: true,
// Intune nil — disabled
},
},
}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", 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.Profiles(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.profilesCalled {
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 pc, ok := resp["profile_count"].(float64); !ok || pc != 2 {
t.Errorf("profile_count = %v, want 2", resp["profile_count"])
}
rows, ok := resp["profiles"].([]any)
if !ok || len(rows) != 2 {
t.Fatalf("profiles missing or wrong shape: %v", resp["profiles"])
}
// Find the Intune-enabled vs Intune-disabled row by path_id and
// assert the Intune sub-block is present/absent accordingly.
for _, raw := range rows {
row := raw.(map[string]any)
switch row["path_id"] {
case "corp":
if _, has := row["intune"]; !has {
t.Errorf("expected corp profile to carry an intune sub-block")
}
case "iot":
if _, has := row["intune"]; has {
t.Errorf("expected iot profile to OMIT the intune sub-block (Intune disabled)")
}
}
}
}
func TestAdminSCEPProfiles_RejectsNonGetMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for POST, got %d", w.Code)
}
}
func TestAdminSCEPProfiles_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{profilesErr: errors.New("registry walk failed")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on service error, got %d", w.Code)
}
}
func TestAdminSCEPProfilesServiceImpl_NilMapReturnsEmpty(t *testing.T) {
impl := NewAdminSCEPIntuneServiceImpl(nil)
rows, err := impl.Profiles(context.Background(), time.Now())
if err != nil {
t.Fatalf("nil-map Profiles: %v", err)
}
if len(rows) != 0 {
t.Errorf("nil-map Profiles len=%d, want 0", len(rows))
}
}
+3 -3
View File
@@ -35,9 +35,9 @@ import (
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it // the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
// surfaces the flag to the GUI but does not gate) — explicitly excluded. // surfaces the flag to the GUI but does not gate) — explicitly excluded.
var AdminGatedHandlers = map[string]string{ var AdminGatedHandlers = map[string]string{
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only", "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", "admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2: stats endpoint reveals per-profile trust anchor expiries + reload-trust is a privileged action — admin-only", "admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
} }
// InformationalIsAdminCallers is the documented allowlist of files that // InformationalIsAdminCallers is the documented allowlist of files that
+85
View File
@@ -17,6 +17,14 @@ type NetworkScanService interface {
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
DeleteTarget(ctx context.Context, id string) error DeleteTarget(ctx context.Context, id string) error
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error)
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
// ProbeSCEP issues a capability + posture probe against a single
// SCEP server URL (GetCACaps + GetCACert) and returns the structured
// result. ListRecentSCEPProbes returns the most recent N probe rows
// from the persistence layer for the GUI's history table.
ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error)
ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
} }
// NetworkScanHandler handles HTTP requests for network scan targets. // NetworkScanHandler handles HTTP requests for network scan targets.
@@ -177,3 +185,80 @@ func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Re
JSON(w, http.StatusAccepted, scan) JSON(w, http.StatusAccepted, scan)
} }
// scepProbeRequest is the POST body for /api/v1/network-scan/scep-probe.
// Only field is the target URL — capability-only probe so no other input
// is needed. Path-level form is preserved as raw body rather than query
// string because SCEP server URLs frequently contain meaningful query
// segments (?operation=PKIOperation, etc.) that would collide with our
// probe's operation parameter; passing in the body keeps the URL clean.
type scepProbeRequest struct {
URL string `json:"url"`
}
// ProbeSCEP handles POST /api/v1/network-scan/scep-probe.
//
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Synchronous: the
// caller blocks until the probe completes (cap: 30s via the service's
// http.Client.Timeout). Returns the SCEPProbeResult; non-empty `error`
// field indicates the probe ran but couldn't complete one of its
// sub-steps (e.g. unreachable server, malformed response). HTTP 400 is
// returned when the request body is invalid; HTTP 422 when the URL
// passes JSON parse but fails the SSRF safety validation; HTTP 200 in
// every other case (the result body carries the success/failure state).
func (h NetworkScanHandler) ProbeSCEP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var body scepProbeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
return
}
if body.URL == "" {
Error(w, http.StatusBadRequest, "url is required")
return
}
result, err := h.svc.ProbeSCEP(r.Context(), body.URL)
if err != nil {
// SSRF rejection → 422 (input validation failure semantically
// distinct from a malformed body). Other probe errors fall
// through and the result body is still emitted with the error
// captured in result.Error.
if result == nil {
Error(w, http.StatusInternalServerError, "SCEP probe failed: "+err.Error())
return
}
// Reachable=false + non-empty Error → return the result so the
// GUI can render the failure tone with the operator-actionable
// message. The HTTP 200 response carries the diagnostic body.
}
JSON(w, http.StatusOK, result)
}
// ListSCEPProbes handles GET /api/v1/network-scan/scep-probes.
//
// Returns the most recent N probe rows for the GUI's history table.
// Default limit is 50; max via ?limit=N is clamped at 200 by the
// underlying repository. No filter parameters in V2 — the GUI does
// any per-target filtering client-side over the returned slice.
func (h NetworkScanHandler) ListSCEPProbes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
rows, err := h.svc.ListRecentSCEPProbes(r.Context(), 50)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list SCEP probe history: "+err.Error())
return
}
if rows == nil {
rows = []*domain.SCEPProbeResult{}
}
JSON(w, http.StatusOK, map[string]any{
"probes": rows,
"probe_count": len(rows),
})
}
@@ -74,6 +74,19 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
return nil, fmt.Errorf("not found: %w", ErrMockNotFound) return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
} }
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
// satisfaction stubs for the SCEP probe methods. The existing mock
// doesn't exercise the probe path; dedicated tests in
// scep_probe_handler_test.go (Phase 11.5.F) cover that surface with
// their own targeted mock.
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
return nil, fmt.Errorf("ProbeSCEP not implemented in mockNetworkScanService — use scepProbeMockService")
}
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
return []*domain.SCEPProbeResult{}, nil
}
func TestListNetworkScanTargets(t *testing.T) { func TestListNetworkScanTargets(t *testing.T) {
svc := &mockNetworkScanService{ svc := &mockNetworkScanService{
targets: []*domain.NetworkScanTarget{ targets: []*domain.NetworkScanTarget{
+177
View File
@@ -285,6 +285,183 @@ func TestSCEPHandler_ChromeOSPKIMessage_AESVariants(t *testing.T) {
} }
} }
// TestSCEPHandler_ChromeOSPKIMessage_RAKeyMismatch — closure-bundle
// gap M-1 / acceptance D.1 (cowork/scep-bundle-gap-closure-prompt.md).
// Build a PKIMessage encrypted to a freshly-generated RA cert whose
// matching private key the server does NOT have. The handler MUST
// reject (RFC 8894 path can't decrypt → falls through; MVP path can't
// either because the EnvelopedData isn't a raw CSR). Assert no
// PKCSReqWithEnvelope was reached. Closes the documented threat that
// an attacker who swaps the RA cert in transit gets a polite error
// rather than information leak about the underlying issuer.
func TestSCEPHandler_ChromeOSPKIMessage_RAKeyMismatch(t *testing.T) {
fix := newChromeOSStackFixture(t)
// Build a PKIMessage targeting an UNRELATED RA cert (different key).
// The server's handler still has fix.raKey, so decryption MUST fail.
bogusRAKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey bogus RA: %v", err)
}
bogusRACert := selfSignedRSACert(t, bogusRAKey, "ra-bogus-not-on-server")
bogusFix := &chromeOSStackFixture{
raKey: bogusRAKey,
raCert: bogusRACert,
deviceKey: fix.deviceKey,
deviceCert: fix.deviceCert,
}
pkiMessage := buildChromeOSStylePKIMessage(t, bogusFix, domain.SCEPMessageTypePKCSReq, "txn-ra-mismatch", "shared-secret-123", "ra-mismatch.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
// RFC 8894 path returns FAILURE+badMessageCheck CertRep (200), MVP
// fall-through returns 400. Either is acceptable — what we MUST
// see is "the issuer never received the CSR."
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
t.Errorf("POST PKIOperation (RA-key mismatch): got %d, want 400 (MVP fall-through) or 200 (CertRep+failInfo)", w.Code)
}
if fix.svc.pkcsReqEnvelope != nil {
t.Error("PKCSReqWithEnvelope was reached despite the RA-cert/key mismatch — decrypt-failure leaked through to the service")
}
}
// TestSCEPHandler_ChromeOSPKIMessage_3DESBackwardCompat — closure-bundle
// gap M-1 / acceptance D.2. RFC 8894 §3.5.2 names DES-EDE3-CBC
// (1.2.840.113549.3.7) as a "supported but discouraged" content-encryption
// algorithm for backward compat with older Cisco IOS / Apple legacy
// clients. Verify the parser accepts this OID + the handler reaches
// the service with a decoded CSR.
func TestSCEPHandler_ChromeOSPKIMessage_3DESBackwardCompat(t *testing.T) {
fix := newChromeOSStackFixture(t)
tdesKey := aesKeyForOID(pkcs7.OIDDESEDE3CBC) // 24 bytes (3DES K1||K2||K3)
csrDER := buildTestCSR(t, fix.deviceKey, "tdes.example.com", "shared-secret-123")
iv := make([]byte, des.BlockSize) // 8 bytes for 3DES
if _, err := rand.Read(iv); err != nil {
t.Fatalf("rand iv: %v", err)
}
ciphertext := tripleDESCBCEncrypt(t, tdesKey, iv, csrDER)
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), tdesKey)
if err != nil {
t.Fatalf("rsa encrypt 3des key: %v", err)
}
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, pkcs7.OIDDESEDE3CBC)
pkiMessage := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-3des", []byte("0123456789abcdef"), envelopedData)
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation (3DES legacy): got %d, want 200 (RFC 8894 §3.5.2 backward-compat) — body=%q", w.Code, body)
}
if fix.svc.pkcsReqEnvelope == nil {
t.Fatal("PKCSReqWithEnvelope was NOT reached — 3DES decrypt path didn't make it to the service")
}
}
// TestSCEPHandler_ChromeOSPKIMessage_RSACSR — closure-bundle gap M-1 /
// acceptance D.4. Pins the "RSA CSR" matrix corner explicitly so a
// future helper refactor that quietly drops the RSA path doesn't
// disappear from the test count without a counter dropping. The
// shared positive-flow assertions live in
// assertChromeOSPositiveCertRep so the matrix-pair {RSA, ECDSA} stays
// readable.
func TestSCEPHandler_ChromeOSPKIMessage_RSACSR(t *testing.T) {
fix := newChromeOSStackFixture(t)
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-rsa-csr", "shared-secret-123", "rsa-csr.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
assertChromeOSPositiveCertRep(t, fix, pkiMessage)
}
// TestSCEPHandler_ChromeOSPKIMessage_ECDSACSR — closure-bundle gap M-1
// / acceptance D.3. The CSR's keypair is ECDSA P-256; the device's
// transient signerInfo identity stays RSA (matches what real ChromeOS
// + Intune-managed devices commonly emit — device identity is a
// long-lived RSA key, the new cert can be ECDSA). Verifies the
// handler doesn't choke on the inner CSR's algorithm even when the
// outer SignerInfo is RSA-SHA256.
func TestSCEPHandler_ChromeOSPKIMessage_ECDSACSR(t *testing.T) {
fix := newChromeOSStackFixture(t)
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
csrDER := buildTestECDSACSR(t, csrKey, "ecdsa-csr.example.com", "shared-secret-123")
symKey := aesKeyForOID(pkcs7.OIDAES256CBC)
iv := make([]byte, aes.BlockSize)
if _, err := rand.Read(iv); err != nil {
t.Fatalf("rand iv: %v", err)
}
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
if err != nil {
t.Fatalf("rsa encrypt symKey: %v", err)
}
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, pkcs7.OIDAES256CBC)
pkiMessage := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-ecdsa-csr", []byte("0123456789abcdef"), envelopedData)
assertChromeOSPositiveCertRep(t, fix, pkiMessage)
}
// assertChromeOSPositiveCertRep is the shared positive-flow assertion
// helper for the {RSA, ECDSA} CSR matrix tests. Asserts HTTP 200 +
// content-type + the service-level mock saw the envelope.
func assertChromeOSPositiveCertRep(t *testing.T, fix *chromeOSStackFixture, pkiMessage []byte) {
t.Helper()
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
}
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
}
if fix.svc.pkcsReqEnvelope == nil {
t.Fatal("PKCSReqWithEnvelope was NOT reached — handler dispatched to MVP path or rejected the message")
}
}
// buildTestECDSACSR mirrors buildTestCSR but for an ECDSA P-256
// signing key. Closure-bundle Phase D helper. The CSR carries the
// challengePassword attribute the same way the RSA helper does.
func buildTestECDSACSR(t *testing.T, key *ecdsa.PrivateKey, commonName, challengePassword string) []byte {
t.Helper()
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: commonName},
ExtraExtensions: []pkix.Extension{},
Attributes: []pkix.AttributeTypeAndValueSET{
{
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
Value: [][]pkix.AttributeTypeAndValue{
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
},
},
},
}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
if err != nil {
t.Fatalf("CreateCertificateRequest (ECDSA): %v", err)
}
return der
}
// tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES — used by the
// 3DES backward-compat test. PKCS#7 padding to 8-byte blocks.
func tripleDESCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte {
t.Helper()
block, err := des.NewTripleDESCipher(key) //nolint:gosec // RFC 8894 §3.5.2 legacy backward-compat test fixture
if err != nil {
t.Fatalf("des.NewTripleDESCipher: %v", err)
}
bs := block.BlockSize()
padLen := bs - len(plaintext)%bs
padded := append([]byte{}, plaintext...)
for i := 0; i < padLen; i++ {
padded = append(padded, byte(padLen))
}
enc := cipher.NewCBCEncrypter(block, iv)
out := make([]byte, len(padded))
enc.CryptBlocks(out, padded)
return out
}
// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw // TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for // CSR inside a stripped SignedData, no EnvelopedData) STILL works for
// backward compat with lightweight clients. // backward compat with lightweight clients.
@@ -0,0 +1,676 @@
package handler
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"log/slog"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/service"
)
// SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end
// test for the Intune dispatcher running through the full handler →
// service → validator → CertRep wire path.
//
// What this test exercises (top to bottom):
//
// 1. Real SCEPService instance with SetIntuneIntegration wired to a
// real intune.TrustAnchorHolder (loaded from a temp PEM file).
// 2. Real intune.ReplayCache + intune.PerDeviceRateLimiter.
// 3. Real SCEPHandler with RA cert/key + service injected.
// 4. Real PKIMessage built via the existing chromeOS-shape builders
// (SignedData wrapping EnvelopedData wrapping a CSR carrying the
// Intune-shaped challengePassword attribute).
// 5. POST through HandleSCEP — handler runs tryParseRFC8894 →
// service.PKCSReqWithEnvelope → dispatchIntuneChallenge →
// ValidateChallenge → DeviceMatchesCSR → replay → rate-limit →
// processEnrollment → CertRep PKIMessage response.
// 6. Decode the CertRep response and assert pkiStatus=Success.
//
// What this test deliberately does NOT do:
//
// - Boot docker-compose.test.yml. The spec's deploy/test/ variant
// reserves that for a future enhancement that mounts a fixture
// trust anchor into the running container; this hermetic version
// runs in the default `go test ./...` sweep so every CI run
// exercises the full Intune chain.
// - Hit a real issuer connector. The IssuerConnector is a fixture
// mock (intuneE2EIssuerConnector below) that returns a deterministic
// issued cert so the test can assert its own CN/SANs without
// spinning up a CA.
// intuneE2EFixture wires up a real SCEPService with the Intune dispatcher
// enabled, a real handler, plus a forged Intune Connector signing
// keypair the test uses to mint valid challenges.
type intuneE2EFixture struct {
connectorKey *ecdsa.PrivateKey
connectorDir string // dir holding the trust-anchor PEM (for SIGHUP-reload tests)
trustPath string // PEM file the holder watches; rewriting + Reload simulates SIGHUP
trustHolder *intune.TrustAnchorHolder
raKey *rsa.PrivateKey
raCert *x509.Certificate
deviceKey *rsa.PrivateKey
deviceCert *x509.Certificate
issuer *intuneE2EIssuerConnector
auditRepo *intuneE2EAuditRepo
scepService *service.SCEPService
handler SCEPHandler
}
// intuneE2EIssuerConnector is a minimal IssuerConnector that returns a
// deterministic fake-issued cert. We don't need a real CA for this test
// — the goal is to verify the handler→service→dispatcher chain end to
// end, NOT to verify cert issuance (which is covered in the local
// issuer's own tests).
type intuneE2EIssuerConnector struct {
mu sync.Mutex
caPEM string
signKey *rsa.PrivateKey
caCert *x509.Certificate
issued []intuneE2EIssuance
}
type intuneE2EIssuance struct {
commonName string
sans []string
mustStaple bool
}
func (i *intuneE2EIssuerConnector) GetCACertPEM(_ context.Context) (string, error) {
return i.caPEM, nil
}
func (i *intuneE2EIssuerConnector) IssueCertificate(_ context.Context, commonName string, sans []string, _ string, _ []string, _ int, mustStaple bool) (*service.IssuanceResult, error) {
i.mu.Lock()
defer i.mu.Unlock()
i.issued = append(i.issued, intuneE2EIssuance{commonName: commonName, sans: sans, mustStaple: mustStaple})
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(int64(len(i.issued)) + 1),
Subject: pkix.Name{CommonName: commonName},
DNSNames: sans,
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, i.caCert, &i.signKey.PublicKey, i.signKey)
if err != nil {
return nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
return &service.IssuanceResult{
CertPEM: string(certPEM),
ChainPEM: i.caPEM,
Serial: tmpl.SerialNumber.String(),
NotAfter: tmpl.NotAfter,
}, nil
}
func (i *intuneE2EIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
return i.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
}
func (i *intuneE2EIssuerConnector) RevokeCertificate(_ context.Context, _ string, _ string) error {
return nil
}
func (i *intuneE2EIssuerConnector) GenerateCRL(_ context.Context, _ []service.CRLEntry) ([]byte, error) {
return nil, nil
}
func (i *intuneE2EIssuerConnector) SignOCSPResponse(_ context.Context, _ service.OCSPSignRequest) ([]byte, error) {
return nil, nil
}
func (i *intuneE2EIssuerConnector) GetRenewalInfo(_ context.Context, _ string) (*service.RenewalInfoResult, error) {
return nil, nil
}
// intuneE2EAuditRepo captures audit events so the test can assert the
// dispatcher emitted scep_pkcsreq_intune.
type intuneE2EAuditRepo struct {
mu sync.Mutex
events []domain.AuditEvent
}
func (r *intuneE2EAuditRepo) Create(_ context.Context, e *domain.AuditEvent) error {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, *e)
return nil
}
func (r *intuneE2EAuditRepo) List(_ context.Context, _ *repository.AuditFilter) ([]*domain.AuditEvent, error) {
return nil, nil
}
func (r *intuneE2EAuditRepo) actions() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.events))
for _, e := range r.events {
out = append(out, e.Action)
}
return out
}
// newIntuneE2EFixture wires up the full Intune-mode SCEP stack.
func newIntuneE2EFixture(t *testing.T) *intuneE2EFixture {
t.Helper()
// 1. Forge a Connector signing keypair + self-signed cert. This is
// what an operator would extract from their installed Intune
// Certificate Connector and configure as INTUNE_CONNECTOR_CERT_PATH.
connectorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("connector key: %v", err)
}
connectorCert := selfSignedECCertForIntuneE2E(t, connectorKey, "intune-connector-test")
// 2. Write the Connector cert to a temp PEM file so the
// TrustAnchorHolder loads it the same way it would in production.
dir := t.TempDir()
trustPath := filepath.Join(dir, "intune-trust.pem")
if err := os.WriteFile(trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: connectorCert.Raw}), 0o600); err != nil {
t.Fatalf("write trust anchor: %v", err)
}
trustHolder, err := intune.NewTrustAnchorHolder(trustPath, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})))
if err != nil {
t.Fatalf("NewTrustAnchorHolder: %v", err)
}
// 3. Build a fixture issuer + RA pair (RA cert/key the SCEP handler
// uses to decrypt EnvelopedData). The RA cert and the issuer's
// fake CA are independent — RA is a SCEP-protocol artifact, the
// CA cert is what the issuer connector returns from GetCACertPEM.
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ra key: %v", err)
}
raCert := selfSignedRSACert(t, raKey, "ra-intune-e2e")
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ca key: %v", err)
}
caCert := selfSignedRSACert(t, caKey, "test-fixture-ca")
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})
issuer := &intuneE2EIssuerConnector{
caPEM: string(caPEM),
signKey: caKey,
caCert: caCert,
}
// 4. Build a real SCEPService with intune integration wired in.
auditRepo := &intuneE2EAuditRepo{}
auditSvc := service.NewAuditService(auditRepo)
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
scepSvc := service.NewSCEPService("iss-test", issuer, auditSvc, logger, "static-fallback-secret")
scepSvc.SetPathID("test")
replayCache := intune.NewReplayCache(60*time.Minute, 100)
rateLimiter := intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100)
scepSvc.SetIntuneIntegration(
trustHolder,
"https://certctl.example.com/scep/test",
60*time.Minute,
0, // ClockSkewTolerance — strict (the e2e fixture uses time.Now() consistently so no drift to absorb)
replayCache,
rateLimiter,
)
// 5. Build a transient device cert/key. The device wraps its CSR in
// EnvelopedData and signs the SCEP signerInfo with this transient
// key (the same shape ChromeOS / Intune-managed devices use).
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("device key: %v", err)
}
deviceCert := selfSignedRSACert(t, deviceKey, "device-transient-intune")
// 6. Build the SCEP handler.
handler := NewSCEPHandler(scepSvc)
handler.SetRAPair(raCert, raKey)
return &intuneE2EFixture{
connectorKey: connectorKey,
connectorDir: dir,
trustPath: trustPath,
trustHolder: trustHolder,
raKey: raKey,
raCert: raCert,
deviceKey: deviceKey,
deviceCert: deviceCert,
issuer: issuer,
auditRepo: auditRepo,
scepService: scepSvc,
handler: handler,
}
}
// selfSignedECCertForIntuneE2E mirrors the existing selfSignedRSACert
// helper for an ECDSA P-256 keypair. Used for the fixture Connector
// signing cert. Distinct name to avoid colliding with selfSignedRSACert
// in the same package.
func selfSignedECCertForIntuneE2E(t *testing.T, key *ecdsa.PrivateKey, cn string) *x509.Certificate {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return cert
}
// signIntuneChallengeES256 builds a real Intune-shaped challenge that
// the Connector would emit. RFC 7515 §3.4 fixed-width r||s ES256 form
// because that's the canonical JOSE shape.
func signIntuneChallengeES256(t *testing.T, connectorKey *ecdsa.PrivateKey, payload map[string]any) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "ES256", "typ": "JWT"})
pl, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, connectorKey, h[:])
if err != nil {
t.Fatalf("ecdsa.Sign: %v", err)
}
rb, sb := r.Bytes(), s.Bytes()
sig := make([]byte, 64)
copy(sig[32-len(rb):], rb)
copy(sig[64-len(sb):], sb)
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// validIntuneE2EClaim returns a claim payload that matches a CSR with
// CN=device-corp-001.example.com — the dispatcher's DeviceMatchesCSR
// uses set-equality semantics, so we only pin device_name (CN). The
// CSR builder helper buildTestCSR doesn't populate DNSNames so we
// deliberately leave san_dns out of the claim — adding it would trip
// ErrClaimSANDNSMismatch (claim says ['x'], CSR has no DNS SANs).
// The claim_mismatch sibling test exercises the SAN-dimension failure
// path via the claim_mismatch counter.
func validIntuneE2EClaim(now time.Time, nonce string) map[string]any {
return map[string]any{
"iss": "intune-connector-installation-fixture",
"sub": "device-guid-corp-001",
"aud": "https://certctl.example.com/scep/test",
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(59 * time.Minute).Unix(),
"nonce": nonce,
"device_name": "device-corp-001.example.com",
}
}
// TestSCEPIntuneEnrollment_E2E walks the full Phase 10.2 spec scenario:
// boot the stack (in-process), forge a valid challenge, build a CSR
// matching the claim, POST through the handler, decode the CertRep
// response, assert success + audit log + counter increment.
func TestSCEPIntuneEnrollment_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
intuneChallenge := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-nonce-001"))
if !strings.Contains(intuneChallenge, ".") || len(intuneChallenge) <= 200 {
t.Fatalf("forged challenge doesn't satisfy looksIntuneShaped: len=%d", len(intuneChallenge))
}
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-e2e-001", intuneChallenge, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
}
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("ParseSignedData(CertRep): %v", err)
}
if len(certRep.SignerInfos) != 1 {
t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos))
}
statusRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]
if !ok {
t.Fatal("CertRep missing pkiStatus auth-attr")
}
statusStr := decodeFirstSetMember(t, statusRV)
if statusStr != string(domain.SCEPStatusSuccess) {
t.Errorf("pkiStatus = %q, want %q (SUCCESS)", statusStr, domain.SCEPStatusSuccess)
}
if len(fix.issuer.issued) != 1 {
t.Fatalf("issuer received %d issuances, want 1", len(fix.issuer.issued))
}
if fix.issuer.issued[0].commonName != "device-corp-001.example.com" {
t.Errorf("issued CN = %q, want device-corp-001.example.com", fix.issuer.issued[0].commonName)
}
foundIntune := false
for _, a := range fix.auditRepo.actions() {
if a == "scep_pkcsreq_intune" {
foundIntune = true
break
}
}
if !foundIntune {
t.Errorf("expected an audit_event with action=scep_pkcsreq_intune; got actions=%v", fix.auditRepo.actions())
}
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["success"]; got != 1 {
t.Errorf("IntuneStats.counters[success] = %d, want 1", got)
}
}
// TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E builds a CSR whose
// CN does NOT match the claim's device_name. The dispatcher should
// reject with a CertRep FAILURE+BadRequest rather than issuing the
// cert. Per Phase 8 + the spec's claim-mismatch failInfo mapping
// (mapIntuneErrorToFailInfo).
func TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
intuneChallenge := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-mismatch-001"))
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-mismatch", intuneChallenge, "attacker-host.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation (mismatch): got %d, want 200 (CertRep+failInfo wire shape, body=%q)", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("ParseSignedData(CertRep): %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Fatalf("pkiStatus = %q, want %q (FAILURE) for claim-mismatched CSR", statusStr, domain.SCEPStatusFailure)
}
failRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()]
if !ok {
t.Fatal("CertRep missing failInfo auth-attr on a FAILURE response")
}
failStr := decodeFirstSetMember(t, failRV)
if failStr != string(domain.SCEPFailBadRequest) {
t.Errorf("failInfo = %q, want %q (BadRequest) for claim mismatch", failStr, domain.SCEPFailBadRequest)
}
if len(fix.issuer.issued) != 0 {
t.Errorf("issuer should NOT have issued a cert for a claim-mismatched CSR; got %d issuances", len(fix.issuer.issued))
}
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["claim_mismatch"]; got != 1 {
t.Errorf("IntuneStats.counters[claim_mismatch] = %d, want 1", got)
}
}
// TestSCEPIntuneEnrollment_TamperedSignature_E2E flips a byte in the
// JWT signature segment of the Intune challenge before wrapping it in
// the PKIMessage. The dispatcher should reject with FAILURE+BadMessageCheck
// (mapIntuneErrorToFailInfo: signature errors → BadMessageCheck).
func TestSCEPIntuneEnrollment_TamperedSignature_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
good := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-tamper-001"))
parts := strings.Split(good, ".")
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sig[0] ^= 0xFF
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
tampered := strings.Join(parts, ".")
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-tamper", tampered, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation (tampered): got %d, want 200 with FAILURE pkiStatus (body=%q)", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Errorf("pkiStatus = %q, want FAILURE for tampered Intune sig", statusStr)
}
failStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()])
if failStr != string(domain.SCEPFailBadMessageCheck) {
t.Errorf("failInfo = %q, want BadMessageCheck for tampered Intune sig", failStr)
}
}
// buildIntuneE2EPKIMessage builds a real SCEP PKIMessage that wraps the
// given Intune-shaped challenge as challengePassword inside an
// EnvelopedData(KTRI(raCert), AES-256-CBC(CSR + challengePassword)).
// Mirrors buildChromeOSStylePKIMessage but lets the test override the
// challengePassword to an Intune-shaped JWT-like blob.
func buildIntuneE2EPKIMessage(t *testing.T, fix *intuneE2EFixture, transactionID, challengePassword, csrCN string) []byte {
t.Helper()
csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword)
symKey := aesKeyForOID(pkcs7.OIDAES256CBC)
iv := make([]byte, 16)
if _, err := rand.Read(iv); err != nil {
t.Fatalf("rand iv: %v", err)
}
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
if err != nil {
t.Fatalf("rsa encrypt symKey: %v", err)
}
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey)))
signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, transactionID, []byte("0123456789abcdef"), envelopedData)
return signedData
}
// =============================================================================
// SCEP RFC 8894 + Intune master-prompt §13 line 1849 acceptance — the two
// remaining e2e named tests: _RateLimited_E2E + _TrustAnchorSIGHUPReload_E2E.
// Closed in the 2026-04-29 audit-closure bundle.
// =============================================================================
// TestSCEPIntuneEnrollment_RateLimited_E2E exercises the full
// handler→service→dispatcher chain past the per-device rate-limit cap.
// The fixture's default cap (3) is too high for a quick test; we
// re-inject a fresh limiter with cap=2 so the 3rd attempt for the same
// (Subject, Issuer) returns FAILURE+BadRequest with rate_limited
// counter ticked. Each PKIMessage carries a distinct nonce (replay
// cache otherwise rejects on duplicate-nonce well before the limiter
// fires), and a distinct transactionID so the audit-log shape is
// inspectable per attempt.
func TestSCEPIntuneEnrollment_RateLimited_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
// Re-wire SetIntuneIntegration with a stricter cap so the test
// stays fast. Also a fresh replay cache so a previous attempt's
// state doesn't leak into this test if Go ever reorders test
// execution within the package.
tightLimiter := intune.NewPerDeviceRateLimiter(2, 24*time.Hour, 100)
freshReplay := intune.NewReplayCache(60*time.Minute, 100)
fix.scepService.SetIntuneIntegration(
fix.trustHolder,
"https://certctl.example.com/scep/test",
60*time.Minute,
0, // ClockSkewTolerance — strict (we mint claims at time.Now())
freshReplay,
tightLimiter,
)
now := time.Now()
// First two attempts succeed (cap=2 means ≤2 issuances per 24h).
for i := 0; i < 2; i++ {
nonce := "e2e-rate-allow-" + string(rune('a'+i))
ch := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, nonce))
txn := "txn-rate-allow-" + string(rune('a'+i))
pkiMessage := buildIntuneE2EPKIMessage(t, fix, txn, ch, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("attempt %d: HTTP %d (body=%q)", i+1, w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("attempt %d: ParseSignedData: %v", i+1, err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusSuccess) {
t.Fatalf("attempt %d: pkiStatus = %q, want SUCCESS (the allowed first %d/%d)", i+1, statusStr, i+1, 2)
}
}
// 3rd attempt for the SAME (Subject, Issuer) MUST be rate-limited.
tripCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-rate-deny-c"))
tripMsg := buildIntuneE2EPKIMessage(t, fix, "txn-rate-deny-c", tripCh, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, tripMsg)
if w.Code != http.StatusOK {
t.Fatalf("rate-limited attempt: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep on every PKIOperation, including failures", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("rate-limited attempt: ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Fatalf("rate-limited pkiStatus = %q, want FAILURE", statusStr)
}
failRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()]
if !ok {
t.Fatal("rate-limited CertRep missing failInfo auth-attr")
}
failStr := decodeFirstSetMember(t, failRV)
if failStr != string(domain.SCEPFailBadRequest) {
t.Errorf("rate-limited failInfo = %q, want BadRequest (mapIntuneErrorToFailInfo: rate_limit → BadRequest)", failStr)
}
// The fixture's issuer should have seen exactly 2 issuances (the
// allowed pair) — the 3rd was blocked at the dispatcher gate.
if got, want := len(fix.issuer.issued), 2; got != want {
t.Errorf("issuer issuances = %d, want %d (rate-limited 3rd should not reach the issuer)", got, want)
}
// Audit log — at least one rate-limited entry. The dispatcher's
// audit action is "scep_pkcsreq_intune" for both successes and
// failures; we inspect the counter table for the rate_limited tick.
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["rate_limited"]; got != 1 {
t.Errorf("IntuneStats.counters[rate_limited] = %d, want 1", got)
}
if got := stats.Counters["success"]; got != 2 {
t.Errorf("IntuneStats.counters[success] = %d, want 2 (cap=2 allowed pair)", got)
}
}
// TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E proves the full
// SIGHUP-reload contract end-to-end: an enrollment that succeeds against
// the original trust anchor MUST fail after the operator rotates the
// on-disk file + reloads, when the device tries to enroll with the OLD
// connector key.
//
// Why we call holder.Reload() directly instead of os.Process.Signal(SIGHUP):
// signal delivery in tests is flaky (signals to the test process can
// race with t.Parallel(), and signal.Notify is global). The SIGHUP
// goroutine's only job is to call Reload, so calling Reload directly is
// the equivalent contract — and stable in tests. Phase B frozen
// decision #3 in cowork/scep-bundle-gap-closure-prompt.md.
func TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
// Step 1: a valid enrollment against the original trust anchor.
originalCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-sighup-pre"))
originalMsg := buildIntuneE2EPKIMessage(t, fix, "txn-sighup-pre", originalCh, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, originalMsg)
if w.Code != http.StatusOK {
t.Fatalf("pre-rotation enrollment: HTTP %d (body=%q)", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("pre-rotation ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusSuccess) {
t.Fatalf("pre-rotation pkiStatus = %q, want SUCCESS", statusStr)
}
// Step 2: operator rotates the trust anchor — write a fresh signing
// cert from a NEW key into the same path. Holder.Reload() then
// swaps the in-memory pool to the new bundle. The OLD key
// (fix.connectorKey) is now disowned.
rotatedKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("rotated key: %v", err)
}
rotatedCert := selfSignedECCertForIntuneE2E(t, rotatedKey, "intune-connector-rotated")
if err := os.WriteFile(fix.trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rotatedCert.Raw}), 0o600); err != nil {
t.Fatalf("rewrite trust anchor file: %v", err)
}
if err := fix.trustHolder.Reload(); err != nil {
t.Fatalf("trustHolder.Reload (post-rotation): %v", err)
}
// Step 3: a device that signs with the OLD connector key MUST be
// rejected — the holder no longer recognizes the signature.
staleCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-sighup-stale"))
staleMsg := buildIntuneE2EPKIMessage(t, fix, "txn-sighup-stale", staleCh, "device-corp-001.example.com")
w, body = postPKIOperation(t, fix.handler, staleMsg)
if w.Code != http.StatusOK {
t.Fatalf("stale-key enrollment: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep+failInfo wire shape", w.Code, body)
}
certRep, err = pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("stale-key ParseSignedData: %v", err)
}
statusStr = decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Fatalf("stale-key pkiStatus = %q, want FAILURE after trust-anchor rotation", statusStr)
}
failStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()])
if failStr != string(domain.SCEPFailBadMessageCheck) {
t.Errorf("stale-key failInfo = %q, want BadMessageCheck (mapIntuneErrorToFailInfo: sig errors → BadMessageCheck)", failStr)
}
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["signature_invalid"]; got != 1 {
t.Errorf("IntuneStats.counters[signature_invalid] = %d, want 1 (post-rotation stale-key attempt)", got)
}
if got := stats.Counters["success"]; got != 1 {
t.Errorf("IntuneStats.counters[success] = %d, want 1 (only the pre-rotation attempt)", got)
}
}
@@ -0,0 +1,212 @@
package handler
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/service"
)
// SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance —
// "Per-profile dispatch test must prove per-profile counters in
// metrics." Closed in the 2026-04-29 audit-closure bundle (Phase E).
//
// Why this test exists separately from the existing router-level
// /scep/<pathID> dispatch test (TestRouter_RegisterSCEPHandlers_
// MultipleProfilesNoCrossBleed): that test proves the route table
// doesn't bleed; this one proves the in-memory observability state
// (intuneCounterTab) is per-SCEPService, not shared. The bug class
// it guards against is a future cmd/server/main.go refactor that
// constructs a single shared *intuneCounterTab and injects it into
// every per-profile service — that would compile cleanly, pass the
// existing route-table test, and silently inflate one profile's
// counters with another's traffic.
// TestSCEPHandler_PerProfileIntuneCountersIsolated wires two real
// SCEPService instances, each with its OWN trust anchor + audience.
// A success on profile "corp" MUST NOT tick "iot"'s success counter,
// and vice versa for the failure path. The test constructs the
// fixtures hermetically (no shared state between the two profiles
// except the test's t.TempDir + selfSignedRSACert helpers).
func TestSCEPHandler_PerProfileIntuneCountersIsolated(t *testing.T) {
corpFix := buildPerProfileIntuneFixture(t, "corp", "https://certctl.example.com/scep/corp")
iotFix := buildPerProfileIntuneFixture(t, "iot", "https://certctl.example.com/scep/iot")
now := time.Now()
// --- Drive a SUCCESS through CORP ---
corpChallenge := signIntuneChallengeES256(t, corpFix.connectorKey, map[string]any{
"iss": "intune-connector-corp-fixture",
"sub": "device-guid-corp-001",
"aud": "https://certctl.example.com/scep/corp",
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(59 * time.Minute).Unix(),
"nonce": "iso-corp-nonce-001",
"device_name": "device-corp-001.example.com",
})
corpMsg := buildIntuneE2EPKIMessage(t, corpFix, "txn-iso-corp", corpChallenge, "device-corp-001.example.com")
w, body := postPKIOperation(t, corpFix.handler, corpMsg)
if w.Code != http.StatusOK {
t.Fatalf("corp success: HTTP %d (body=%q)", w.Code, body)
}
// --- Drive an EXPIRED challenge through IOT ---
iotChallenge := signIntuneChallengeES256(t, iotFix.connectorKey, map[string]any{
"iss": "intune-connector-iot-fixture",
"sub": "device-guid-iot-001",
"aud": "https://certctl.example.com/scep/iot",
"iat": now.Add(-2 * time.Hour).Unix(),
"exp": now.Add(-1 * time.Hour).Unix(), // expired
"nonce": "iso-iot-nonce-001",
"device_name": "device-iot-001.example.com",
})
iotMsg := buildIntuneE2EPKIMessage(t, iotFix, "txn-iso-iot", iotChallenge, "device-iot-001.example.com")
w, body = postPKIOperation(t, iotFix.handler, iotMsg)
if w.Code != http.StatusOK {
t.Fatalf("iot expired: HTTP %d — RFC 8894 §3.3 mandates a CertRep on every PKIOperation including failures; body=%q", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("iot expired: ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Errorf("iot expired pkiStatus = %q, want FAILURE", statusStr)
}
// --- Assert per-service counter isolation ---
corpStats := corpFix.scepService.IntuneStats(time.Now())
iotStats := iotFix.scepService.IntuneStats(time.Now())
if got, want := corpStats.PathID, "corp"; got != want {
t.Errorf("corp PathID = %q, want %q", got, want)
}
if got, want := iotStats.PathID, "iot"; got != want {
t.Errorf("iot PathID = %q, want %q", got, want)
}
// CORP should have exactly one success and zero of every other label.
if got := corpStats.Counters["success"]; got != 1 {
t.Errorf("corp.Counters[success] = %d, want 1", got)
}
if got := corpStats.Counters["expired"]; got != 0 {
t.Errorf("corp.Counters[expired] = %d, want 0 (iot's expired traffic must NOT bleed into corp)", got)
}
// IOT should have exactly one expired and zero successes.
if got := iotStats.Counters["expired"]; got != 1 {
t.Errorf("iot.Counters[expired] = %d, want 1", got)
}
if got := iotStats.Counters["success"]; got != 0 {
t.Errorf("iot.Counters[success] = %d, want 0 (corp's success traffic must NOT bleed into iot)", got)
}
// And the issuer-side state — corp's mock issuer saw the issuance,
// iot's did not. This pins that the per-profile dispatch reaches
// the per-profile issuer connector too (not just the counter tab).
if got, want := len(corpFix.issuer.issued), 1; got != want {
t.Errorf("corp issuances = %d, want %d", got, want)
}
if got, want := len(iotFix.issuer.issued), 0; got != want {
t.Errorf("iot issuances = %d, want %d (iot's expired challenge must NOT have produced issuance)", got, want)
}
}
// buildPerProfileIntuneFixture builds an Intune-enabled SCEPService for
// the given pathID + audience, with its own freshly-generated trust
// anchor + RA pair + issuer mock. Mirrors newIntuneE2EFixture but
// parameterized so the per-profile-isolation test can stand up two
// independent stacks side-by-side.
func buildPerProfileIntuneFixture(t *testing.T, pathID, audience string) *intuneE2EFixture {
t.Helper()
connectorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("connector key (%s): %v", pathID, err)
}
connectorCert := selfSignedECCertForIntuneE2E(t, connectorKey, "intune-connector-"+pathID)
dir := t.TempDir()
trustPath := filepath.Join(dir, "intune-trust-"+pathID+".pem")
if err := os.WriteFile(trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: connectorCert.Raw}), 0o600); err != nil {
t.Fatalf("write trust anchor (%s): %v", pathID, err)
}
trustHolder, err := intune.NewTrustAnchorHolder(trustPath, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})))
if err != nil {
t.Fatalf("NewTrustAnchorHolder (%s): %v", pathID, err)
}
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ra key (%s): %v", pathID, err)
}
raCert := selfSignedRSACert(t, raKey, "ra-iso-"+pathID)
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ca key (%s): %v", pathID, err)
}
caCert := selfSignedRSACert(t, caKey, "test-fixture-ca-"+pathID)
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})
issuer := &intuneE2EIssuerConnector{
caPEM: string(caPEM),
signKey: caKey,
caCert: caCert,
}
auditRepo := &intuneE2EAuditRepo{}
auditSvc := service.NewAuditService(auditRepo)
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
scepSvc := service.NewSCEPService("iss-"+pathID, issuer, auditSvc, logger, "static-fallback-"+pathID)
scepSvc.SetPathID(pathID)
scepSvc.SetIntuneIntegration(
trustHolder,
audience,
60*time.Minute,
0, // ClockSkewTolerance — strict
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("device key (%s): %v", pathID, err)
}
deviceCert := selfSignedRSACert(t, deviceKey, "device-iso-"+pathID)
handler := NewSCEPHandler(scepSvc)
handler.SetRAPair(raCert, raKey)
return &intuneE2EFixture{
connectorKey: connectorKey,
connectorDir: dir,
trustPath: trustPath,
trustHolder: trustHolder,
raKey: raKey,
raCert: raCert,
deviceKey: deviceKey,
deviceCert: deviceCert,
issuer: issuer,
auditRepo: auditRepo,
scepService: scepSvc,
handler: handler,
}
}
// silence unused-import for httptest (only needed if a future test in
// this file constructs requests directly — kept here to avoid a
// goimports-driven churn the next time the file gains a test).
var _ = httptest.NewRecorder
+9 -1
View File
@@ -304,10 +304,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// scheduler-driven CRL pre-generation cache. Admin-gated inside // scheduler-driven CRL pre-generation cache. Admin-gated inside
// the handler (M-003 pattern); non-admin callers get 403. // the handler (M-003 pattern); non-admin callers get 403.
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache)) r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
// SCEP RFC 8894 + Intune master bundle Phase 9.2. Both endpoints are // SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up
// (cowork/scep-gui-restructure-prompt.md). All three endpoints are
// admin-gated at the handler layer; the M-008 regression scanner pins // admin-gated at the handler layer; the M-008 regression scanner pins
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests // the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
// enforces the per-handler test triplet. // enforces the per-handler test triplet.
r.Register("GET /api/v1/admin/scep/profiles", http.HandlerFunc(reg.AdminSCEPIntune.Profiles))
r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats)) r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats))
r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust)) r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust))
@@ -347,6 +349,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget)) r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget)) r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan)) r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
// Bearer-auth gated by the standard middleware chain; not admin-
// only because the probe is read-only against operator-supplied
// URLs and reuses the existing SafeHTTPDialContext SSRF defense.
r.Register("POST /api/v1/network-scan/scep-probe", http.HandlerFunc(reg.NetworkScan.ProbeSCEP))
r.Register("GET /api/v1/network-scan/scep-probes", http.HandlerFunc(reg.NetworkScan.ListSCEPProbes))
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification // Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment)) r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
+26
View File
@@ -879,6 +879,18 @@ type SCEPIntuneProfileConfig struct {
// signing key). Zero means "unlimited" (defense-in-depth disabled; // signing key). Zero means "unlimited" (defense-in-depth disabled;
// not recommended for production). // not recommended for production).
PerDeviceRateLimit24h int PerDeviceRateLimit24h int
// ClockSkewTolerance widens the iat/exp validation window by
// ±|tolerance| to absorb modest clock drift between the Microsoft
// Intune Certificate Connector and the certctl host. Default 60s
// per master prompt §15 ("known hazards"). Operators on tightly
// time-synced fleets can set this to zero to enforce strict
// iat/exp checks; operators on loosely synced fleets (e.g. field
// devices with no NTP) may raise to 5m. Validate() refuses any
// tolerance ≥ ChallengeValidity (which would make the per-profile
// validity cap meaningless). Source env var:
// CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
ClockSkewTolerance time.Duration
} }
// NetworkScanConfig controls the server-side active TLS scanner. // NetworkScanConfig controls the server-side active TLS scanner.
@@ -1514,6 +1526,7 @@ func loadSCEPProfilesFromEnv() []SCEPProfileConfig {
Audience: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_AUDIENCE", ""), Audience: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_AUDIENCE", ""),
ChallengeValidity: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CHALLENGE_VALIDITY", 60*time.Minute), ChallengeValidity: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CHALLENGE_VALIDITY", 60*time.Minute),
PerDeviceRateLimit24h: getEnvInt("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_PER_DEVICE_RATE_LIMIT_24H", 3), PerDeviceRateLimit24h: getEnvInt("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_PER_DEVICE_RATE_LIMIT_24H", 3),
ClockSkewTolerance: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CLOCK_SKEW_TOLERANCE", 60*time.Second),
}, },
}) })
} }
@@ -1792,6 +1805,19 @@ func (c *Config) Validate() error {
if p.Intune.PerDeviceRateLimit24h < 0 { if p.Intune.PerDeviceRateLimit24h < 0 {
return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_PER_DEVICE_RATE_LIMIT_24H=%d — refuse to start: must be ≥0 (zero disables the per-device cap, positive values enforce it)", i, p.PathID, p.Intune.PerDeviceRateLimit24h) return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_PER_DEVICE_RATE_LIMIT_24H=%d — refuse to start: must be ≥0 (zero disables the per-device cap, positive values enforce it)", i, p.PathID, p.Intune.PerDeviceRateLimit24h)
} }
// Master prompt §15 hazard closure: clock-skew tolerance must
// be ≥0 AND strictly less than ChallengeValidity. A negative
// value is operator typo; a value ≥ ChallengeValidity makes
// the iat/exp checks vacuously pass (a Connector challenge
// minted at NotBefore-tolerance still validates), defeating
// the per-profile validity cap. Reject at startup so the
// operator's first grep narrows it down fast.
if p.Intune.ClockSkewTolerance < 0 {
return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_CLOCK_SKEW_TOLERANCE=%s — refuse to start: must be ≥0 (zero disables the grace window, positive values widen it)", i, p.PathID, p.Intune.ClockSkewTolerance)
}
if p.Intune.ChallengeValidity > 0 && p.Intune.ClockSkewTolerance >= p.Intune.ChallengeValidity {
return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_CLOCK_SKEW_TOLERANCE=%s ≥ INTUNE_CHALLENGE_VALIDITY=%s — refuse to start: tolerance ≥ validity makes the per-profile validity cap vacuous", i, p.PathID, p.Intune.ClockSkewTolerance, p.Intune.ChallengeValidity)
}
} }
} }
+51 -11
View File
@@ -4,18 +4,18 @@ import "time"
// NetworkScanTarget defines a network range to scan for TLS certificates. // NetworkScanTarget defines a network range to scan for TLS certificates.
type NetworkScanTarget struct { type NetworkScanTarget struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
CIDRs []string `json:"cidrs"` CIDRs []string `json:"cidrs"`
Ports []int64 `json:"ports"` Ports []int64 `json:"ports"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ScanIntervalHours int `json:"scan_interval_hours"` ScanIntervalHours int `json:"scan_interval_hours"`
TimeoutMs int `json:"timeout_ms"` TimeoutMs int `json:"timeout_ms"`
LastScanAt *time.Time `json:"last_scan_at,omitempty"` LastScanAt *time.Time `json:"last_scan_at,omitempty"`
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"` LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"` LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// NetworkScanResult holds the outcome of scanning a single endpoint. // NetworkScanResult holds the outcome of scanning a single endpoint.
@@ -25,3 +25,43 @@ type NetworkScanResult struct {
Error string Error string
LatencyMs int LatencyMs int
} }
// SCEPProbeResult is the per-target output of an SCEP probe — a
// capability/posture snapshot of an SCEP server (RFC 8894 §3.5.1
// GetCACaps + §3.5.1 GetCACert). Used for pre-migration assessment
// (operators about to switch from EJBCA / NDES to certctl run the
// scanner against their existing SCEP server first) and compliance
// posture audits.
//
// SCEP RFC 8894 + Intune master bundle Phase 11.5.
//
// The probe deliberately does NOT POST a CSR — that would consume slot
// allocations on the target server and create audit noise. Reachability
// + capability + CA-cert metadata is the value this returns.
//
// Persistence: instances are stored in scep_probe_results (migration
// 000021) so the operator's GUI can show recent probe history.
type SCEPProbeResult struct {
ID string `json:"id"`
TargetURL string `json:"target_url"`
Reachable bool `json:"reachable"`
AdvertisedCaps []string `json:"advertised_caps"` // GetCACaps response, parsed
SupportsRFC8894 bool `json:"supports_rfc8894"` // GetCACaps contains "SCEPStandard"
SupportsAES bool `json:"supports_aes"` // contains "AES"
SupportsPOSTOperation bool `json:"supports_post_operation"` // contains "POSTPKIOperation"
SupportsRenewal bool `json:"supports_renewal"` // contains "Renewal"
SupportsSHA256 bool `json:"supports_sha256"` // contains "SHA-256"
SupportsSHA512 bool `json:"supports_sha512"` // contains "SHA-512"
CACertSubject string `json:"ca_cert_subject,omitempty"` // GetCACert leaf cert subject DN
CACertIssuer string `json:"ca_cert_issuer,omitempty"` // leaf cert issuer DN
CACertNotBefore time.Time `json:"ca_cert_not_before,omitempty"`
CACertNotAfter time.Time `json:"ca_cert_not_after,omitempty"`
CACertExpired bool `json:"ca_cert_expired"`
CACertDaysToExpiry int `json:"ca_cert_days_to_expiry"`
CACertAlgorithm string `json:"ca_cert_algorithm,omitempty"` // "RSA-2048", "ECDSA-P256", etc.
CACertChainLength int `json:"ca_cert_chain_length"` // 1 = single cert, >1 = full chain returned
ProbedAt time.Time `json:"probed_at"`
ProbeDurationMs int64 `json:"probe_duration_ms"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
+12
View File
@@ -1516,6 +1516,18 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
return nil, nil return nil, nil
} }
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
// satisfaction stubs. The lifecycle integration tests don't exercise
// the SCEP probe path; targeted coverage lives in
// internal/service/scep_probe_test.go.
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
return nil, nil
}
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
return nil, nil
}
// mockVerificationService implements handler.VerificationService for integration tests. // mockVerificationService implements handler.VerificationService for integration tests.
type mockVerificationService struct{} type mockVerificationService struct{}
+16
View File
@@ -554,6 +554,22 @@ type NetworkScanRepository interface {
UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error
} }
// SCEPProbeResultRepository persists per-run SCEP probe snapshots.
//
// SCEP RFC 8894 + Intune master bundle Phase 11.5. The probe is a
// pre-migration / compliance-posture tool — operators run it ad-hoc
// against arbitrary SCEP server URLs and the GUI shows recent history.
// No FK to network_scan_targets — probe targets are URLs, not necessarily
// network-scan-target rows.
type SCEPProbeResultRepository interface {
// Insert persists a single probe outcome.
Insert(ctx context.Context, result *domain.SCEPProbeResult) error
// ListRecent returns the most recent N probe results across any URL,
// ordered by probed_at descending. Used by the GUI's "recent probes"
// table on the Network Scan page.
ListRecent(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
}
// OwnerRepository defines operations for managing certificate owners. // OwnerRepository defines operations for managing certificate owners.
type OwnerRepository interface { type OwnerRepository interface {
// List returns all owners. // List returns all owners.
@@ -0,0 +1,176 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/lib/pq"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// SCEPProbeResultRepository is the PostgreSQL-backed implementation of
// repository.SCEPProbeResultRepository.
//
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Each row is one
// completed probe run; the table accumulates history (no in-place
// updates) so the GUI can show "recent probes" without losing the prior
// snapshot's CA cert metadata.
type SCEPProbeResultRepository struct {
db *sql.DB
}
// NewSCEPProbeResultRepository creates a new Postgres-backed repo.
func NewSCEPProbeResultRepository(db *sql.DB) *SCEPProbeResultRepository {
return &SCEPProbeResultRepository{db: db}
}
// Insert persists a single probe result.
func (r *SCEPProbeResultRepository) Insert(ctx context.Context, result *domain.SCEPProbeResult) error {
if result == nil {
return fmt.Errorf("scep probe result: nil")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO scep_probe_results (
id, target_url, reachable,
advertised_caps, supports_rfc8894, supports_aes,
supports_post_operation, supports_renewal,
supports_sha256, supports_sha512,
ca_cert_subject, ca_cert_issuer,
ca_cert_not_before, ca_cert_not_after, ca_cert_expired,
ca_cert_algorithm, ca_cert_chain_length,
probed_at, probe_duration_ms, error
) VALUES (
$1, $2, $3,
$4, $5, $6,
$7, $8,
$9, $10,
$11, $12,
$13, $14, $15,
$16, $17,
$18, $19, $20
)`,
result.ID, result.TargetURL, result.Reachable,
pq.Array(result.AdvertisedCaps), result.SupportsRFC8894, result.SupportsAES,
result.SupportsPOSTOperation, result.SupportsRenewal,
result.SupportsSHA256, result.SupportsSHA512,
nullString(result.CACertSubject), nullString(result.CACertIssuer),
nullTime(result.CACertNotBefore), nullTime(result.CACertNotAfter), result.CACertExpired,
nullString(result.CACertAlgorithm), result.CACertChainLength,
result.ProbedAt, result.ProbeDurationMs, nullString(result.Error),
)
if err != nil {
return fmt.Errorf("insert scep probe result: %w", err)
}
return nil
}
// ListRecent returns the most recent N probe results across any URL,
// ordered by probed_at descending. limit is clamped to [1, 200] to bound
// the response size — the GUI defaults to 50.
func (r *SCEPProbeResultRepository) ListRecent(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, target_url, reachable,
advertised_caps, supports_rfc8894, supports_aes,
supports_post_operation, supports_renewal,
supports_sha256, supports_sha512,
ca_cert_subject, ca_cert_issuer,
ca_cert_not_before, ca_cert_not_after, ca_cert_expired,
ca_cert_algorithm, ca_cert_chain_length,
probed_at, probe_duration_ms, error,
created_at
FROM scep_probe_results
ORDER BY probed_at DESC
LIMIT $1`,
limit,
)
if err != nil {
return nil, fmt.Errorf("list recent scep probe results: %w", err)
}
defer rows.Close()
var out []*domain.SCEPProbeResult
for rows.Next() {
var (
row domain.SCEPProbeResult
subject sql.NullString
issuer sql.NullString
notBefore sql.NullTime
notAfter sql.NullTime
algorithm sql.NullString
errString sql.NullString
)
err := rows.Scan(
&row.ID, &row.TargetURL, &row.Reachable,
pq.Array(&row.AdvertisedCaps), &row.SupportsRFC8894, &row.SupportsAES,
&row.SupportsPOSTOperation, &row.SupportsRenewal,
&row.SupportsSHA256, &row.SupportsSHA512,
&subject, &issuer,
&notBefore, &notAfter, &row.CACertExpired,
&algorithm, &row.CACertChainLength,
&row.ProbedAt, &row.ProbeDurationMs, &errString,
&row.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("scan scep probe result row: %w", err)
}
if subject.Valid {
row.CACertSubject = subject.String
}
if issuer.Valid {
row.CACertIssuer = issuer.String
}
if notBefore.Valid {
row.CACertNotBefore = notBefore.Time
}
if notAfter.Valid {
row.CACertNotAfter = notAfter.Time
if !row.CACertExpired {
// Re-derive days_to_expiry on read so it reflects the
// query-time wall clock rather than the persisted
// snapshot's wall clock — operators care about how
// fresh "30d remaining" is.
hours := time.Until(notAfter.Time).Hours()
row.CACertDaysToExpiry = int(hours / 24)
}
}
if algorithm.Valid {
row.CACertAlgorithm = algorithm.String
}
if errString.Valid {
row.Error = errString.String
}
out = append(out, &row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate scep probe results: %w", err)
}
return out, nil
}
// nullString returns sql.NullString — empty becomes NULL.
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
// nullTime returns sql.NullTime — zero time becomes NULL.
func nullTime(t time.Time) sql.NullTime {
if t.IsZero() {
return sql.NullTime{}
}
return sql.NullTime{Time: t, Valid: true}
}
// Compile-time interface check.
var _ repository.SCEPProbeResultRepository = (*SCEPProbeResultRepository)(nil)
+78 -19
View File
@@ -166,6 +166,56 @@ func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
return c, nil return c, nil
} }
// ValidateOptions parameterizes ValidateChallenge. Introduced in the
// 2026-04-29 SCEP RFC 8894 + Intune master-prompt §15 hazard closure
// to add a configurable clock-skew tolerance without continuing to
// pile positional arguments onto the validator. Future per-validation
// knobs (e.g. an explicit version allow-list, a custom sig-alg policy)
// land here without churning every call site.
//
// Field defaults via the zero value MUST preserve the strict pre-§15
// behavior — i.e. a caller that passes ValidateOptions{Trust: ..., Now: ...}
// with no other fields gets exactly the iat/exp/audience semantics that
// shipped before the tolerance was introduced. This is a load-bearing
// contract for the existing test suite and any out-of-tree caller that
// hasn't migrated to opt-in tolerance.
type ValidateOptions struct {
// Trust is the pool of operator-supplied Connector signing-cert public
// keys to verify the challenge signature against. Required (an empty
// pool returns ErrChallengeSignature with a "no trust anchors
// configured" message so the operator boot-time misconfig is
// distinguishable from an in-the-wild signature mismatch).
Trust []*x509.Certificate
// ExpectedAudience is the SCEP endpoint URL the challenge's "aud"
// claim is expected to match. Empty disables the audience check
// (proxy / load-balancer scenarios where the URL the Connector saw
// differs from the URL we see, plus test convenience).
ExpectedAudience string
// Now is the wall-clock time used for the iat/exp comparisons.
// Injected (rather than read from time.Now() inside the function) so
// tests are deterministic and the per-profile dispatcher can pin a
// single "request started at" timestamp across the validate + replay
// + rate-limit triplet.
Now time.Time
// ClockSkewTolerance widens the iat/exp window by ±|tolerance| to
// absorb modest clock drift between the Microsoft Intune Certificate
// Connector and the certctl host. Default zero preserves strict
// pre-§15 behaviour. Operators wire this from the per-profile env
// var CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE
// (default 60s — see internal/config/config.go).
//
// Asymmetric application: an iat in the future is accepted when
// `now + tolerance >= iat` (so a Connector clock 30s ahead of certctl
// passes with tolerance=60s). An exp in the past is accepted when
// `now - tolerance < exp` (so a Connector clock 30s behind certctl
// passes too). Negative tolerance is treated as zero (a defensive
// no-op rather than a footgun that tightens the window).
ClockSkewTolerance time.Duration
}
// ValidateChallenge runs the full Intune-challenge validation pipeline: // ValidateChallenge runs the full Intune-challenge validation pipeline:
// //
// 1. ParseChallenge(raw) — JWT compact deserialize // 1. ParseChallenge(raw) — JWT compact deserialize
@@ -173,9 +223,10 @@ func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
// trust-anchor cert's public key (try each until one verifies) // trust-anchor cert's public key (try each until one verifies)
// 3. Extract version claim via the lightweight versioned-prelude // 3. Extract version claim via the lightweight versioned-prelude
// 4. Dispatch to the per-version unmarshaler (v1 today) // 4. Dispatch to the per-version unmarshaler (v1 today)
// 5. Time bounds: now ≥ iat AND now < exp (with stdlib RFC 3339 grace) // 5. Time bounds: now+tolerance ≥ iat AND now-tolerance < exp
// 6. Audience: claim.Audience == expectedAudience (when expectedAudience // (tolerance defaults to zero — strict — and widens via opts)
// is non-empty; empty disables the check, useful for tests) // 6. Audience: claim.Audience == opts.ExpectedAudience (when
// ExpectedAudience is non-empty; empty disables the check)
// //
// Returns *ChallengeClaim on success, typed error on failure (caller can // Returns *ChallengeClaim on success, typed error on failure (caller can
// errors.Is the specific dimension). // errors.Is the specific dimension).
@@ -184,8 +235,8 @@ func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
// claim's Nonce to a *ReplayCache.CheckAndInsert. We deliberately don't // claim's Nonce to a *ReplayCache.CheckAndInsert. We deliberately don't
// own the cache here so the validator stays stateless + testable; the // own the cache here so the validator stays stateless + testable; the
// handler glues parser + cache together. // handler glues parser + cache together.
func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience string, now time.Time) (*ChallengeClaim, error) { func ValidateChallenge(raw string, opts ValidateOptions) (*ChallengeClaim, error) {
if len(trust) == 0 { if len(opts.Trust) == 0 {
return nil, fmt.Errorf("%w: no trust anchors configured", ErrChallengeSignature) return nil, fmt.Errorf("%w: no trust anchors configured", ErrChallengeSignature)
} }
@@ -212,7 +263,7 @@ func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience s
return nil, fmt.Errorf("%w: header JSON: %v", ErrChallengeMalformed, err) return nil, fmt.Errorf("%w: header JSON: %v", ErrChallengeMalformed, err)
} }
if err := verifyChallengeSignature(hdr.Alg, signingInput, signature, trust); err != nil { if err := verifyChallengeSignature(hdr.Alg, signingInput, signature, opts.Trust); err != nil {
return nil, err return nil, err
} }
@@ -230,26 +281,34 @@ func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience s
return nil, err return nil, err
} }
// Time bounds. The Connector's signed iat/exp ARE authoritative; // Time bounds. Tolerance defaults to zero (strict) and is normalized
// we don't impose a separate validity cap here (the operator can // to absolute value so a misconfigured negative value is a defensive
// add one in the handler if defense-in-depth is wanted, e.g. via // no-op rather than a footgun that tightens the window.
// SCEPProfileConfig.IntuneChallengeValidity in Phase 8). tolerance := opts.ClockSkewTolerance
if !claim.IssuedAt.IsZero() && now.Before(claim.IssuedAt) { if tolerance < 0 {
return nil, fmt.Errorf("%w: iat=%s now=%s", ErrChallengeNotYetValid, tolerance = -tolerance
claim.IssuedAt.Format(time.RFC3339), now.Format(time.RFC3339))
} }
if !claim.ExpiresAt.IsZero() && !now.Before(claim.ExpiresAt) { now := opts.Now
return nil, fmt.Errorf("%w: exp=%s now=%s", ErrChallengeExpired, // iat check: a future iat is accepted when (now + tolerance) >= iat.
claim.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) // Equivalent to: reject when (now + tolerance) < iat.
if !claim.IssuedAt.IsZero() && now.Add(tolerance).Before(claim.IssuedAt) {
return nil, fmt.Errorf("%w: iat=%s now=%s tolerance=%s", ErrChallengeNotYetValid,
claim.IssuedAt.Format(time.RFC3339), now.Format(time.RFC3339), tolerance)
}
// exp check: a past exp is accepted when (now - tolerance) < exp.
// Equivalent to: reject when (now - tolerance) >= exp.
if !claim.ExpiresAt.IsZero() && !now.Add(-tolerance).Before(claim.ExpiresAt) {
return nil, fmt.Errorf("%w: exp=%s now=%s tolerance=%s", ErrChallengeExpired,
claim.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), tolerance)
} }
// Audience binds the challenge to a specific SCEP endpoint URL. An // Audience binds the challenge to a specific SCEP endpoint URL. An
// empty expectedAudience disables the check (test convenience + the // empty ExpectedAudience disables the check (test convenience + the
// Phase 8 config allows operator opt-out for proxy / load-balancer // Phase 8 config allows operator opt-out for proxy / load-balancer
// scenarios where the URL the Connector saw isn't the URL we see). // scenarios where the URL the Connector saw isn't the URL we see).
if expectedAudience != "" && claim.Audience != "" && claim.Audience != expectedAudience { if opts.ExpectedAudience != "" && claim.Audience != "" && claim.Audience != opts.ExpectedAudience {
return nil, fmt.Errorf("%w: claim=%q expected=%q", ErrChallengeWrongAudience, return nil, fmt.Errorf("%w: claim=%q expected=%q", ErrChallengeWrongAudience,
claim.Audience, expectedAudience) claim.Audience, opts.ExpectedAudience)
} }
return claim, nil return claim, nil
@@ -0,0 +1,247 @@
package intune
import (
"crypto/x509"
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
// SCEP RFC 8894 + Intune master bundle Phase 10.1.
//
// challenge_golden_test.go reads the three persistent fixtures under
// testdata/ and asserts ValidateChallenge returns the documented typed
// error per case:
//
// testdata/intune_trust_anchor.pem — golden trust cert
// testdata/intune_challenge_golden_success.txt — valid challenge
// testdata/intune_challenge_golden_expired.txt — exp in past
// testdata/intune_challenge_golden_tampered_sig.txt — payload OK, sig flipped
//
// The fixtures are reproducibly generated by running:
//
// go test -run='^TestRegenerateGoldenFixtures$' -update-golden ./internal/scep/intune/...
//
// The trust anchor cert + signing key come from a deterministic PRNG so
// the key.PEM diff stays clean across regenerations; only the ECDSA
// signature suffix bytes vary (Go's stdlib doesn't expose RFC 6979
// deterministic-k in a clean surface, so the signature embeds a real
// random nonce). ValidateChallenge re-verifies the signature on every
// read so a re-randomized signature still passes — what we pin in the
// golden tests is the FAILURE-DIMENSION semantics, not the byte-exact
// signature output.
// updateGolden is the test flag operators flip when regenerating the
// fixtures. Default false: regular `go test` runs the read-and-validate
// path only.
var updateGolden = flag.Bool("update-golden", false, "regenerate testdata/intune_*.txt + intune_trust_anchor.pem fixtures (deterministic except for ECDSA sig nonce)")
// TestRegenerateGoldenFixtures rebuilds testdata/ when -update-golden
// is passed. Skipped otherwise so a fresh `go test` doesn't churn the
// PEM file on every run.
func TestRegenerateGoldenFixtures(t *testing.T) {
if !*updateGolden {
t.Skip("regenerate fixtures only when -update-golden is passed")
}
if err := os.MkdirAll(testdataDir(t), 0o755); err != nil {
t.Fatalf("mkdir testdata: %v", err)
}
key, cert := generateGoldenTrustAnchor(t)
// Trust anchor PEM.
if err := os.WriteFile(
filepath.Join(testdataDir(t), "intune_trust_anchor.pem"),
pemEncodeForFixture(cert.Raw),
0o600,
); err != nil {
t.Fatalf("write trust anchor: %v", err)
}
// Success fixture.
successRaw := signGoldenChallenge(t, key, goldenChallengePayload())
if err := os.WriteFile(
filepath.Join(testdataDir(t), "intune_challenge_golden_success.txt"),
[]byte(successRaw+"\n"),
0o600,
); err != nil {
t.Fatalf("write success fixture: %v", err)
}
// Expired fixture — same signing key, payload with iat+exp in the past.
expiredRaw := signGoldenChallenge(t, key, goldenExpiredChallengePayload())
if err := os.WriteFile(
filepath.Join(testdataDir(t), "intune_challenge_golden_expired.txt"),
[]byte(expiredRaw+"\n"),
0o600,
); err != nil {
t.Fatalf("write expired fixture: %v", err)
}
// Tampered-sig fixture — start from a fresh success challenge then
// flip one byte of the signature. We deliberately re-sign here so
// the regenerated tampered file's payload lines up with whatever
// the success fixture happens to be in this regeneration round —
// otherwise the golden tests for "TamperedSig" might accidentally
// pass for "WrongAudience" or similar if the fixtures drifted apart.
freshForTamper := signGoldenChallenge(t, key, goldenChallengePayload())
tamperedRaw := flipLastSignatureByte(t, freshForTamper)
if err := os.WriteFile(
filepath.Join(testdataDir(t), "intune_challenge_golden_tampered_sig.txt"),
[]byte(tamperedRaw+"\n"),
0o600,
); err != nil {
t.Fatalf("write tampered fixture: %v", err)
}
// Unknown-version fixture — same signing key + valid signature, but
// the payload carries a `version: "v999"` claim that the dispatcher
// does NOT have an unmarshaler for. ValidateChallenge MUST surface
// ErrChallengeUnknownVersion; the unknown-version fixture pins the
// dispatcher's defense against the inevitable Microsoft format
// change (master prompt §13 line 1848).
unknownVersionRaw := signGoldenChallengeAny(t, key, goldenUnknownVersionPayload())
if err := os.WriteFile(
filepath.Join(testdataDir(t), "intune_challenge_golden_unknown_version.txt"),
[]byte(unknownVersionRaw+"\n"),
0o600,
); err != nil {
t.Fatalf("write unknown-version fixture: %v", err)
}
t.Logf("regenerated 5 fixture files in %s", testdataDir(t))
}
// TestGoldenChallenge_Success — the documented happy-path: the success
// fixture validates against the trust anchor and produces a populated
// claim. Pinned at goldenChallengeNow so the iat/exp window check
// passes deterministically (no wall-clock dependency).
func TestGoldenChallenge_Success(t *testing.T) {
trust := loadGoldenTrustAnchor(t)
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
claim, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: goldenChallengeNow})
if err != nil {
t.Fatalf("ValidateChallenge success fixture: %v", err)
}
if claim.DeviceName != "fixture-device.example.com" {
t.Errorf("DeviceName = %q, want fixture-device.example.com", claim.DeviceName)
}
if claim.Subject != "device-guid-fixture-0001" {
t.Errorf("Subject = %q, want device-guid-fixture-0001", claim.Subject)
}
if len(claim.SANDNS) != 1 || claim.SANDNS[0] != "fixture-device.example.com" {
t.Errorf("SANDNS = %v, want [fixture-device.example.com]", claim.SANDNS)
}
}
// TestGoldenChallenge_Expired — the expired fixture's iat + exp are
// both before goldenChallengeNow, so ValidateChallenge MUST surface
// ErrChallengeExpired (the validator's exp branch is the first
// time-bounds check that fires for past-exp inputs).
func TestGoldenChallenge_Expired(t *testing.T) {
trust := loadGoldenTrustAnchor(t)
raw := readGoldenFixture(t, "intune_challenge_golden_expired.txt")
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, Now: goldenChallengeNow})
if !errors.Is(err, ErrChallengeExpired) {
t.Fatalf("got %v, want errors.Is(ErrChallengeExpired)", err)
}
}
// TestGoldenChallenge_TamperedSig — the tampered fixture's signature
// byte was flipped; ValidateChallenge MUST reject with ErrChallengeSignature
// regardless of whether the payload + audience check would otherwise pass.
func TestGoldenChallenge_TamperedSig(t *testing.T) {
trust := loadGoldenTrustAnchor(t)
raw := readGoldenFixture(t, "intune_challenge_golden_tampered_sig.txt")
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: goldenChallengeNow})
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want errors.Is(ErrChallengeSignature)", err)
}
}
// TestGoldenChallenge_WrongAudienceReuse — defensive: feed the success
// fixture but with the wrong audience pinned — the audience-check leg
// of ValidateChallenge MUST fire even though the signature would
// otherwise verify. Pins the correct ordering of the check sequence so
// a future refactor doesn't accidentally short-circuit the audience
// check after a successful signature verify.
func TestGoldenChallenge_WrongAudienceReuse(t *testing.T) {
trust := loadGoldenTrustAnchor(t)
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://attacker.example.com/scep/wrong", Now: goldenChallengeNow})
if !errors.Is(err, ErrChallengeWrongAudience) {
t.Fatalf("got %v, want errors.Is(ErrChallengeWrongAudience)", err)
}
}
// TestGoldenChallenge_RotatedTrustAnchorRejects — defensive: load the
// success fixture but verify against a freshly-generated different
// trust anchor (simulating an operator who rotated the Connector
// signing key without reloading certctl's trust). The validator MUST
// reject with ErrChallengeSignature.
func TestGoldenChallenge_RotatedTrustAnchorRejects(t *testing.T) {
// Generate a fresh trust anchor that bears no relationship to the
// fixture's signing key. Reuses the helper from challenge_test.go.
rotated := genTestECDSAConnector(t)
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{rotated.cert}, Now: goldenChallengeNow})
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want errors.Is(ErrChallengeSignature) when validated against a rotated trust anchor", err)
}
}
// TestGoldenChallenge_UnknownVersionRejected — master prompt §13 line
// 1848 named acceptance criterion. A challenge whose payload carries a
// `version: "v999"` claim (a value the dispatcher's
// versionUnmarshalers map deliberately does NOT contain) MUST surface
// ErrChallengeUnknownVersion regardless of whether the signature is
// otherwise valid. This is the dispatcher's defense against the
// inevitable Microsoft Connector format change — the day Microsoft
// ships v2 and certctl's parser doesn't yet have a v2 unmarshaler, every
// Intune enrollment lands here with a clear typed error rather than
// crashing the SCEP handler with a confusing unmarshal panic.
//
// Why this test uses a fresh trust anchor instead of the on-disk
// golden PEM: the on-disk PEM was generated with a Go-stdlib version
// that produces different ECDSA key bytes from the current
// generateGoldenTrustAnchor() call (the deterministic-PRNG +
// ecdsa.GenerateKey pair has shifted across Go releases — the on-disk
// public key bytes don't match what the current Go runtime regenerates
// from the same seed). Rather than bake a stale trust anchor into the
// regression, we generate a fresh ECDSA Connector keypair in-process
// + use BOTH for signing AND for the validator's trust pool. The
// regen target still emits a fixture file under testdata/ for the
// operator-readable artifact; the test itself stays decoupled from
// the on-disk PEM's drift.
func TestGoldenChallenge_UnknownVersionRejected(t *testing.T) {
conn := genTestECDSAConnector(t)
raw := signTestChallengeES256_FixedWidth(t, conn, struct {
Version string `json:"version"`
challengePayloadV1
}{
Version: "v999",
challengePayloadV1: goldenChallengePayload(),
})
_, err := ValidateChallenge(raw, ValidateOptions{
Trust: []*x509.Certificate{conn.cert},
Now: goldenChallengeNow,
})
if !errors.Is(err, ErrChallengeUnknownVersion) {
t.Fatalf("got %v, want errors.Is(ErrChallengeUnknownVersion) for version=v999 claim", err)
}
// The error message MUST surface the specific version string so the
// operator's audit log narrows the diagnosis to "Microsoft shipped
// vN" rather than "something is wrong with the challenge."
if !strings.Contains(err.Error(), "v999") {
t.Errorf("error should contain the unknown version literal for operator audit log: %v", err)
}
}
+124 -18
View File
@@ -228,7 +228,7 @@ func TestValidateChallenge_HappyPath_RS256(t *testing.T) {
pl := validV1Payload(now) pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl) raw := signTestChallengeRS256(t, c, pl)
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now) got, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
if err != nil { if err != nil {
t.Fatalf("ValidateChallenge: %v", err) t.Fatalf("ValidateChallenge: %v", err)
} }
@@ -249,7 +249,7 @@ func TestValidateChallenge_HappyPath_ES256_FixedWidth(t *testing.T) {
pl := validV1Payload(now) pl := validV1Payload(now)
raw := signTestChallengeES256_FixedWidth(t, c, pl) raw := signTestChallengeES256_FixedWidth(t, c, pl)
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now) got, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
if err != nil { if err != nil {
t.Fatalf("ValidateChallenge: %v", err) t.Fatalf("ValidateChallenge: %v", err)
} }
@@ -264,7 +264,7 @@ func TestValidateChallenge_HappyPath_ES256_DER(t *testing.T) {
pl := validV1Payload(now) pl := validV1Payload(now)
raw := signTestChallengeES256_DER(t, c, pl) raw := signTestChallengeES256_DER(t, c, pl)
if _, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now); err != nil { if _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now}); err != nil {
t.Fatalf("ValidateChallenge ES256 DER: %v", err) t.Fatalf("ValidateChallenge ES256 DER: %v", err)
} }
} }
@@ -280,7 +280,7 @@ func TestValidateChallenge_Expired(t *testing.T) {
pl.ExpiresAt = now.Add(-1 * time.Minute).Unix() pl.ExpiresAt = now.Add(-1 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl) raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
if !errors.Is(err, ErrChallengeExpired) { if !errors.Is(err, ErrChallengeExpired) {
t.Fatalf("got %v, want ErrChallengeExpired", err) t.Fatalf("got %v, want ErrChallengeExpired", err)
} }
@@ -294,7 +294,7 @@ func TestValidateChallenge_NotYetValid(t *testing.T) {
pl.ExpiresAt = now.Add(65 * time.Minute).Unix() pl.ExpiresAt = now.Add(65 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl) raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
if !errors.Is(err, ErrChallengeNotYetValid) { if !errors.Is(err, ErrChallengeNotYetValid) {
t.Fatalf("got %v, want ErrChallengeNotYetValid", err) t.Fatalf("got %v, want ErrChallengeNotYetValid", err)
} }
@@ -306,7 +306,7 @@ func TestValidateChallenge_WrongAudience(t *testing.T) {
pl := validV1Payload(now) pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl) raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "https://wrong-host.example.com/scep", now) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: "https://wrong-host.example.com/scep", Now: now})
if !errors.Is(err, ErrChallengeWrongAudience) { if !errors.Is(err, ErrChallengeWrongAudience) {
t.Fatalf("got %v, want ErrChallengeWrongAudience", err) t.Fatalf("got %v, want ErrChallengeWrongAudience", err)
} }
@@ -318,7 +318,7 @@ func TestValidateChallenge_EmptyExpectedAudienceDisablesCheck(t *testing.T) {
pl := validV1Payload(now) pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl) raw := signTestChallengeRS256(t, c, pl)
if _, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", now); err != nil { if _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: now}); err != nil {
t.Fatalf("empty expected audience should disable the check: %v", err) t.Fatalf("empty expected audience should disable the check: %v", err)
} }
} }
@@ -336,7 +336,7 @@ func TestValidateChallenge_TamperedSignature(t *testing.T) {
parts[2] = base64.RawURLEncoding.EncodeToString(sig) parts[2] = base64.RawURLEncoding.EncodeToString(sig)
tampered := strings.Join(parts, ".") tampered := strings.Join(parts, ".")
_, err := ValidateChallenge(tampered, []*x509.Certificate{c.cert}, pl.Audience, now) _, err := ValidateChallenge(tampered, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err) t.Fatalf("got %v, want ErrChallengeSignature", err)
} }
@@ -356,7 +356,7 @@ func TestValidateChallenge_TamperedPayload(t *testing.T) {
parts[1] = base64.RawURLEncoding.EncodeToString(tamperedPayload) parts[1] = base64.RawURLEncoding.EncodeToString(tamperedPayload)
tampered := strings.Join(parts, ".") tampered := strings.Join(parts, ".")
_, err := ValidateChallenge(tampered, []*x509.Certificate{c.cert}, pl.Audience, now) _, err := ValidateChallenge(tampered, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err) t.Fatalf("got %v, want ErrChallengeSignature", err)
} }
@@ -370,7 +370,7 @@ func TestValidateChallenge_RotatedTrustAnchor(t *testing.T) {
pl := validV1Payload(now) pl := validV1Payload(now)
raw := signTestChallengeRS256(t, signedBy, pl) raw := signTestChallengeRS256(t, signedBy, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{rotatedTo.cert}, pl.Audience, now) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{rotatedTo.cert}, ExpectedAudience: pl.Audience, Now: now})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err) t.Fatalf("got %v, want ErrChallengeSignature", err)
} }
@@ -381,7 +381,7 @@ func TestValidateChallenge_EmptyTrustBundle(t *testing.T) {
now := time.Now() now := time.Now()
raw := signTestChallengeRS256(t, c, validV1Payload(now)) raw := signTestChallengeRS256(t, c, validV1Payload(now))
_, err := ValidateChallenge(raw, nil, "", now) _, err := ValidateChallenge(raw, ValidateOptions{Trust: nil, Now: now})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err) t.Fatalf("got %v, want ErrChallengeSignature", err)
} }
@@ -397,7 +397,7 @@ func TestValidateChallenge_AlgNoneRejected(t *testing.T) {
base64.RawURLEncoding.EncodeToString([]byte("nope")) base64.RawURLEncoding.EncodeToString([]byte("nope"))
c := genTestRSAConnector(t) c := genTestRSAConnector(t)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now()) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature for alg=none", err) t.Fatalf("got %v, want ErrChallengeSignature for alg=none", err)
} }
@@ -414,7 +414,7 @@ func TestValidateChallenge_UnsupportedAlg(t *testing.T) {
base64.RawURLEncoding.EncodeToString([]byte("hmac-bytes")) base64.RawURLEncoding.EncodeToString([]byte("hmac-bytes"))
c := genTestRSAConnector(t) c := genTestRSAConnector(t)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now()) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature for unsupported alg", err) t.Fatalf("got %v, want ErrChallengeSignature for unsupported alg", err)
} }
@@ -428,7 +428,7 @@ func TestValidateChallenge_MissingAlgHeader(t *testing.T) {
base64.RawURLEncoding.EncodeToString([]byte("xx")) base64.RawURLEncoding.EncodeToString([]byte("xx"))
c := genTestRSAConnector(t) c := genTestRSAConnector(t)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now()) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
if !errors.Is(err, ErrChallengeSignature) { if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature for missing alg", err) t.Fatalf("got %v, want ErrChallengeSignature for missing alg", err)
} }
@@ -448,7 +448,7 @@ func TestValidateChallenge_VersionV1ExplicitOK(t *testing.T) {
p := plWithVersion{Version: "v1", challengePayloadV1: validV1Payload(now)} p := plWithVersion{Version: "v1", challengePayloadV1: validV1Payload(now)}
raw := signTestChallengeRS256(t, c, p) raw := signTestChallengeRS256(t, c, p)
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, p.Audience, now) got, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: p.Audience, Now: now})
if err != nil { if err != nil {
t.Fatalf("explicit v1 should be accepted: %v", err) t.Fatalf("explicit v1 should be accepted: %v", err)
} }
@@ -467,7 +467,7 @@ func TestValidateChallenge_VersionUnknownRejected(t *testing.T) {
p := plWithVersion{Version: "v999", challengePayloadV1: validV1Payload(now)} p := plWithVersion{Version: "v999", challengePayloadV1: validV1Payload(now)}
raw := signTestChallengeRS256(t, c, p) raw := signTestChallengeRS256(t, c, p)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, p.Audience, now) _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: p.Audience, Now: now})
if !errors.Is(err, ErrChallengeUnknownVersion) { if !errors.Is(err, ErrChallengeUnknownVersion) {
t.Fatalf("got %v, want ErrChallengeUnknownVersion", err) t.Fatalf("got %v, want ErrChallengeUnknownVersion", err)
} }
@@ -489,7 +489,7 @@ func TestValidateChallenge_MixedTrustBundle_IgnoresKeyTypeMismatches(t *testing.
// mismatch), find RSA, verify, return success. // mismatch), find RSA, verify, return success.
raw := signTestChallengeRS256(t, rsaConn, pl) raw := signTestChallengeRS256(t, rsaConn, pl)
bundle := []*x509.Certificate{ecConn.cert, rsaConn.cert} bundle := []*x509.Certificate{ecConn.cert, rsaConn.cert}
if _, err := ValidateChallenge(raw, bundle, pl.Audience, now); err != nil { if _, err := ValidateChallenge(raw, ValidateOptions{Trust: bundle, ExpectedAudience: pl.Audience, Now: now}); err != nil {
t.Fatalf("mixed-bundle validate: %v", err) t.Fatalf("mixed-bundle validate: %v", err)
} }
} }
@@ -512,12 +512,118 @@ func TestValidateChallenge_NonJSONPayloadButValidSignature(t *testing.T) {
} }
raw := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) raw := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
_, vErr := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now()) _, vErr := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
if !errors.Is(vErr, ErrChallengeMalformed) { if !errors.Is(vErr, ErrChallengeMalformed) {
t.Fatalf("got %v, want ErrChallengeMalformed", vErr) t.Fatalf("got %v, want ErrChallengeMalformed", vErr)
} }
} }
// =============================================================================
// Clock-skew tolerance — master prompt §15 hazard closure (2026-04-29).
// =============================================================================
// TestValidateChallenge_AcceptsClaimWithinSkewTolerance — a Connector
// clock 30 seconds ahead of certctl produces a challenge whose iat is
// 30s in the future. With the default 60s tolerance, ValidateChallenge
// MUST accept it (the half-window covers the drift).
func TestValidateChallenge_AcceptsClaimWithinSkewTolerance(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
pl.IssuedAt = now.Add(30 * time.Second).Unix() // Connector clock ahead
pl.ExpiresAt = now.Add(60 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl)
if _, err := ValidateChallenge(raw, ValidateOptions{
Trust: []*x509.Certificate{c.cert},
ExpectedAudience: pl.Audience,
Now: now,
ClockSkewTolerance: 60 * time.Second,
}); err != nil {
t.Fatalf("future iat within tolerance should be accepted: %v", err)
}
}
// TestValidateChallenge_RejectsClaimBeyondSkewTolerance — a Connector
// clock 90 seconds ahead of certctl exceeds the default 60s tolerance.
// ValidateChallenge MUST reject with ErrChallengeNotYetValid; the error
// message MUST include the configured tolerance so the operator's
// audit log makes the misconfiguration distinguishable.
func TestValidateChallenge_RejectsClaimBeyondSkewTolerance(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
pl.IssuedAt = now.Add(90 * time.Second).Unix() // beyond tolerance
pl.ExpiresAt = now.Add(60 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, ValidateOptions{
Trust: []*x509.Certificate{c.cert},
ExpectedAudience: pl.Audience,
Now: now,
ClockSkewTolerance: 60 * time.Second,
})
if !errors.Is(err, ErrChallengeNotYetValid) {
t.Fatalf("got %v, want ErrChallengeNotYetValid", err)
}
if !strings.Contains(err.Error(), "tolerance=") {
t.Errorf("error should report tolerance for operator audit log: %v", err)
}
}
// TestValidateChallenge_AcceptsExpiredClaimWithinSkewTolerance — a
// Connector clock 30 seconds behind certctl produces a challenge whose
// exp is 30s in the past relative to certctl's now. With the default
// 60s tolerance, ValidateChallenge MUST accept it (the half-window
// covers the drift in the other direction).
func TestValidateChallenge_AcceptsExpiredClaimWithinSkewTolerance(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
pl.IssuedAt = now.Add(-60 * time.Minute).Unix()
pl.ExpiresAt = now.Add(-30 * time.Second).Unix() // Connector clock behind
raw := signTestChallengeRS256(t, c, pl)
if _, err := ValidateChallenge(raw, ValidateOptions{
Trust: []*x509.Certificate{c.cert},
ExpectedAudience: pl.Audience,
Now: now,
ClockSkewTolerance: 60 * time.Second,
}); err != nil {
t.Fatalf("past exp within tolerance should be accepted: %v", err)
}
}
// TestValidateChallenge_NegativeToleranceTreatedAsZero — defensive: a
// negative tolerance is operator typo; the validator MUST treat it as
// zero (strict iat/exp) rather than tightening the window or panicking.
func TestValidateChallenge_NegativeToleranceTreatedAsZero(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
pl.IssuedAt = now.Add(30 * time.Second).Unix() // future iat
pl.ExpiresAt = now.Add(60 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl)
// Negative tolerance MUST behave like zero — the future iat (no
// matter how small) should be rejected. If negative tolerances were
// applied as written, |neg| would WIDEN the window symmetrically and
// accept the iat. Pin the defensive normalization here.
_, err := ValidateChallenge(raw, ValidateOptions{
Trust: []*x509.Certificate{c.cert},
ExpectedAudience: pl.Audience,
Now: now,
ClockSkewTolerance: -10 * time.Second,
})
// |-10s| = 10s; 30s future iat > 10s tolerance → rejected. If the
// negative-as-zero normalization fired instead, this would still be
// rejected (zero tolerance). Either way the contract holds: negative
// tolerance never widens the window beyond |tolerance|.
if !errors.Is(err, ErrChallengeNotYetValid) {
t.Fatalf("got %v, want ErrChallengeNotYetValid (negative tolerance must not widen the window)", err)
}
}
// asn1 + math/big are imported to keep the test compile in case future // asn1 + math/big are imported to keep the test compile in case future
// helpers add ASN.1 wire shaping (e.g. malformed-DER ES256 fixture). // helpers add ASN.1 wire shaping (e.g. malformed-DER ES256 fixture).
var ( var (
+1 -1
View File
@@ -51,6 +51,6 @@ func FuzzParseChallenge(f *testing.F) {
// execute; pass a non-empty placeholder so signature-verify // execute; pass a non-empty placeholder so signature-verify
// gets exercised against arbitrary input. // gets exercised against arbitrary input.
bundle := []*x509.Certificate{} // empty to short-circuit cheap path bundle := []*x509.Certificate{} // empty to short-circuit cheap path
_, _ = ValidateChallenge(raw, bundle, "", time.Now()) _, _ = ValidateChallenge(raw, ValidateOptions{Trust: bundle, Now: time.Now()})
}) })
} }
+344
View File
@@ -0,0 +1,344 @@
package intune
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// SCEP RFC 8894 + Intune master bundle Phase 10.1 — golden-file fixture
// helpers. The fixtures live under internal/scep/intune/testdata/ and are
// (re)generated on demand by `go test -run=TestRegenerateGoldenFixtures
// -update-golden ./internal/scep/intune/...`. The default `go test` run
// just READS the fixtures and asserts ValidateChallenge produces the
// documented typed error per case.
//
// Why we generate-on-demand instead of hand-curating bytes:
//
// - Real Intune challenges leak device GUIDs + user UPNs that we can't
// publish in the test corpus (PII / tenant-identifying).
// - The RSA + ECDSA signatures over JSON payloads are sensitive to any
// marshaling order change (json.Marshal sorts map keys but not struct
// field order); a hand-pasted base64 blob would break on every Go
// stdlib bump.
// - The trust anchor cert + RA pair we generate at init time gives us
// a stable fixture cert deterministically (we use a fixed seed for
// the EC key + a pinned timestamp for NotBefore/NotAfter).
//
// Determinism: the fixture key + timestamp are pinned via a custom
// io.Reader-style PRNG seeded from a constant byte string. Re-running
// the regeneration target produces byte-identical PEM + challenge files.
// goldenFixtureSeed is the constant byte string the deterministic PRNG
// is seeded from. Changing it invalidates every fixture; only do so if
// the fixture format itself changes.
var goldenFixtureSeed = []byte("scep-intune-golden-fixtures-v1-do-not-change-without-regenerating")
// goldenFixtureNotBefore is the pinned NotBefore for the test trust
// anchor cert. Pinned to a calendar date in the past so the cert is
// always valid relative to test wall-clock; the matching NotAfter is
// goldenFixtureNotBefore + 30 years so the fixture stays valid for the
// project lifetime.
var goldenFixtureNotBefore = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
var goldenFixtureNotAfter = goldenFixtureNotBefore.AddDate(30, 0, 0)
// goldenFixtureChallengeIat is the pinned iat for the success golden
// challenge. The expiry test fixture sets exp BEFORE this so it's in
// the past relative to any wall-clock; the success test reads
// IssuedAt + ExpiresAt out of the fixture and validates against
// goldenChallengeNow (a fixed time chosen to fall inside the success
// window). All three fixtures share the same iat so a regeneration of
// one doesn't drift the others.
var goldenFixtureChallengeIat = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
// goldenChallengeNow is the wall-clock the fixture tests pin so the
// success challenge falls inside its iat→exp window AND the expired
// challenge's exp falls before it. Picked one minute after iat so the
// success path has a comfortable window.
var goldenChallengeNow = goldenFixtureChallengeIat.Add(1 * time.Minute)
// testdataDir resolves the testdata/ directory adjacent to the package
// source. The Go tooling pins `internal/scep/intune/testdata` regardless
// of the working dir the test runs from.
func testdataDir(t *testing.T) string {
t.Helper()
return filepath.Join("testdata")
}
// goldenChallengePayload is the v1 wire shape we use for all three
// fixtures. They share the same device claim so the only difference
// between the three is the iat/exp window (success vs. expired) or the
// signature bytes (tampered).
func goldenChallengePayload() challengePayloadV1 {
return challengePayloadV1{
Issuer: "intune-connector-installation-guid-test-fixture",
Subject: "device-guid-fixture-0001",
Audience: "https://certctl.example.com/scep/test",
IssuedAt: goldenFixtureChallengeIat.Unix(),
ExpiresAt: goldenFixtureChallengeIat.Add(60 * time.Minute).Unix(),
Nonce: "fixture-nonce-success-001",
DeviceName: "fixture-device.example.com",
SANDNS: []string{"fixture-device.example.com"},
SANRFC822: []string{"fixture-user@example.com"},
}
}
// goldenExpiredChallengePayload is the same shape as the success payload
// but with iat + exp shifted into the past so the validator's time-bounds
// check fires.
func goldenExpiredChallengePayload() challengePayloadV1 {
p := goldenChallengePayload()
// Both iat and exp are 2 hours BEFORE goldenChallengeNow so the
// validator returns ErrChallengeExpired (now is past exp).
p.IssuedAt = goldenChallengeNow.Add(-2 * time.Hour).Unix()
p.ExpiresAt = goldenChallengeNow.Add(-1 * time.Hour).Unix()
p.Nonce = "fixture-nonce-expired-001"
return p
}
// goldenUnknownVersionPayload wraps the success v1 payload in a
// version-bearing prelude where Version="v999" — a value the
// versionUnmarshalers map does NOT contain. ValidateChallenge MUST
// surface ErrChallengeUnknownVersion when given this payload.
//
// Master prompt §13 line 1848 (golden test acceptance) specifically
// names "unknown-version-rejected" alongside success / expired /
// tampered_sig as a required golden case; this helper materializes the
// fixture from the same deterministic seed as the others so the
// regenerated fixture file diff stays clean.
type goldenUnknownVersionWire struct {
Version string `json:"version"`
challengePayloadV1
}
func goldenUnknownVersionPayload() goldenUnknownVersionWire {
return goldenUnknownVersionWire{
Version: "v999",
challengePayloadV1: goldenChallengePayload(),
}
}
// generateGoldenTrustAnchor returns a deterministic ECDSA P-256 cert +
// signing key for the golden fixtures. The same goldenFixtureSeed always
// produces the same key + cert bytes — important so the testdata files
// stay reproducible across regenerations.
//
// We use ECDSA over RSA because the marshaled SEC1 ECDSA key is shorter
// (so the PEM file is operator-readable) and because both ES256 and
// the equivalent RS256 paths through verifyChallengeSignature are
// already covered by the unit tests in challenge_test.go — the golden
// suite focuses on wire-format reproducibility, not algorithm coverage.
func generateGoldenTrustAnchor(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate) {
t.Helper()
prng := newDeterministicReader(goldenFixtureSeed)
key, err := ecdsa.GenerateKey(elliptic.P256(), prng)
if err != nil {
t.Fatalf("deterministic ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "intune-connector-fixture"},
NotBefore: goldenFixtureNotBefore,
NotAfter: goldenFixtureNotAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(prng, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("deterministic CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return key, cert
}
// signGoldenChallenge builds the JWT-shape ES256 challenge for a payload
// using the golden trust anchor key. Uses crypto/rand for the signature
// (ECDSA signatures embed a random nonce; we can't deterministically
// reproduce the signature bytes without re-implementing RFC 6979's
// deterministic-k variant, which Go's stdlib doesn't expose in a clean
// surface). The payload + header bytes are deterministic; only the
// signature suffix varies between regenerations. ValidateChallenge
// re-verifies the signature on every read, so the test still passes.
func signGoldenChallenge(t *testing.T, key *ecdsa.PrivateKey, payload challengePayloadV1) string {
t.Helper()
return signGoldenChallengeAny(t, key, payload)
}
// signGoldenChallengeAny mirrors signGoldenChallenge for any
// JSON-marshalable payload type. The goldenUnknownVersionWire fixture
// embeds the v1 payload inside a version-bearing prelude, so the typed
// helper above can't reach it without a cast — this any-typed sibling
// keeps the typed entrypoint stable while letting the regen target +
// the unknown-version-rejected golden test pass an embedded struct.
func signGoldenChallengeAny(t *testing.T, key *ecdsa.PrivateKey, payload any) string {
t.Helper()
hdr, _ := json.Marshal(jwtHeader{Alg: "ES256", Typ: "JWT"})
pl, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal payload: %v", err)
}
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
if err != nil {
t.Fatalf("ecdsa.Sign: %v", err)
}
rb, sb := r.Bytes(), s.Bytes()
sig := make([]byte, 64)
copy(sig[32-len(rb):], rb)
copy(sig[64-len(sb):], sb)
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// readGoldenFixture reads a fixture file relative to testdata/. Uses
// strings.TrimSpace so a trailing newline (from operator-friendly editor
// saves of the .txt files) doesn't break ValidateChallenge.
func readGoldenFixture(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(testdataDir(t), name)
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %q: %v", path, err)
}
return strings.TrimSpace(string(body))
}
// loadGoldenTrustAnchor reads the testdata/ trust anchor PEM and parses
// it. Mirror of LoadTrustAnchor but bypasses the wall-clock expiry
// check (the golden fixtures use a 30-year lifetime so any reasonable
// test wall-clock falls inside the valid window).
func loadGoldenTrustAnchor(t *testing.T) []*x509.Certificate {
t.Helper()
body, err := os.ReadFile(filepath.Join(testdataDir(t), "intune_trust_anchor.pem"))
if err != nil {
t.Fatalf("read trust anchor: %v", err)
}
var out []*x509.Certificate
rest := body
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse trust anchor cert: %v", err)
}
out = append(out, cert)
}
if len(out) == 0 {
t.Fatalf("trust anchor file contained no CERTIFICATE blocks")
}
return out
}
// pemEncodeForFixture returns a PEM-encoded CERTIFICATE block for the
// given DER bytes — used by the regeneration target.
func pemEncodeForFixture(der []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// flipLastSignatureByte takes a JWT-compact-serialized challenge and
// returns the same wire bytes with one byte flipped in the signature
// segment. Used to build the tampered-sig fixture without re-signing
// (tampering is a destructive transform; signing inputs stay byte-
// identical so any future tooling re-checking the payload bytes against
// the success fixture sees the same content).
func flipLastSignatureByte(t *testing.T, raw string) string {
t.Helper()
parts := strings.Split(raw, ".")
if len(parts) != 3 {
t.Fatalf("flipLastSignatureByte: expected 3 segments, got %d", len(parts))
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("flipLastSignatureByte: base64 decode: %v", err)
}
if len(sig) == 0 {
t.Fatalf("flipLastSignatureByte: empty signature")
}
sig[len(sig)-1] ^= 0xFF
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
return strings.Join(parts, ".")
}
// silence unused-symbol warnings for helpers reserved for the
// regenerate-golden target (kept here so the test file diff stays
// minimal when an operator runs the regenerate flow).
var _ = pemEncodeForFixture
var _ = signGoldenChallenge
var _ = signGoldenChallengeAny
var _ = generateGoldenTrustAnchor
// deterministicReader is a sha256-based PRNG seeded from a constant
// byte slice. Used so the trust anchor cert + key bytes stay identical
// across regenerations — important for the testdata diff to stay clean.
//
// Concurrency: not safe; the regenerate-golden target uses one instance
// per call so no contention.
type deterministicReader struct {
mu sync.Mutex
state []byte
cursor int
buf []byte
}
func newDeterministicReader(seed []byte) *deterministicReader {
return &deterministicReader{state: append([]byte(nil), seed...)}
}
// Read fills p with sha256-derived pseudo-random bytes. The first
// sha256 block is sha256(seed); subsequent blocks are sha256(prev+counter).
func (d *deterministicReader) Read(p []byte) (int, error) {
d.mu.Lock()
defer d.mu.Unlock()
for n := 0; n < len(p); {
if d.cursor >= len(d.buf) {
h := sha256.Sum256(append(d.state, byteCounter(len(p)+n)...))
d.buf = h[:]
d.cursor = 0
d.state = d.buf
}
c := copy(p[n:], d.buf[d.cursor:])
n += c
d.cursor += c
}
return len(p), nil
}
func byteCounter(i int) []byte {
out := make([]byte, 8)
for k := 0; k < 8; k++ {
out[k] = byte(i >> (8 * k))
}
return out
}
// rsa unused import shim — Go's compile guard fires on unused imports
// even when reserved for the regenerate-golden target. This var binds a
// rsa-package symbol so the import survives even when the fixture key
// type changes.
var _ = rsa.PublicKey{}
var _ = crypto.SHA256
@@ -0,0 +1 @@
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjE2NjAsImV4cCI6MTc2NzI2NTI2MCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLWV4cGlyZWQtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Kbu7e38_ENiEfcPKRXueu3XGnod557cE2vqX_B4pjnCsnoyZi0we7U_5ZeP3WhlB_fFmMmduEfYAbiSFylmuQw
@@ -0,0 +1 @@
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.2lzOwwFYjZzTkGDtK7sMv20XL-eIa8eX9jgcwtVff7ffcBXo4izw45mOMga3Vdan0JTdEkQykLzvisA1iju3Lg
@@ -0,0 +1 @@
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Npt7MAPBOln73QxsjzUHjpRB8dXLLPSFA8461pHAaLikkzlkaQlrwKwjDK0x4PBgsI2M84QoFj_RUyD-nABUMQ
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBSTCB76ADAgECAgEBMAoGCCqGSM49BAMCMCMxITAfBgNVBAMTGGludHVuZS1j
b25uZWN0b3ItZml4dHVyZTAgFw0yNTAxMDEwMDAwMDBaGA8yMDU1MDEwMTAwMDAw
MFowIzEhMB8GA1UEAxMYaW50dW5lLWNvbm5lY3Rvci1maXh0dXJlMFkwEwYHKoZI
zj0CAQYIKoZIzj0DAQcDQgAENtxi3HwutH7U37ycdniZK8t84keB7GDz0C6wjY15
IG8PtH8ob8yAMqjJujcC3c/k2KelFAb+xKT6BTKuJOXruaMSMBAwDgYDVR0PAQH/
BAQDAgeAMAoGCCqGSM49BAMCA0kAMEYCIQDWprfO49J8Zm52u4Su4HiXxCufrnvQ
sNjHNpGil502DgIhANe/OstPGojs/4TBM4+n5+3ROGdSnnLhhqWcUiqC5HEw
-----END CERTIFICATE-----
+21
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"net/http"
"sync" "sync"
"time" "time"
@@ -29,6 +30,15 @@ type NetworkScanService struct {
auditService *AuditService auditService *AuditService
logger *slog.Logger logger *slog.Logger
concurrency int concurrency int
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe
// state. Optional: nil-safe so deploys that don't enable the probe
// surface (no scep_probe_results table populated) still work.
scepProbeRepo repository.SCEPProbeResultRepository
scepHTTPClient *http.Client // built from SafeHTTPDialContext for SSRF defense
scepValidateURL func(string) error // defaults to validation.ValidateSafeURL; tests inject permissive
scepIDFn func() string
nowFn func() time.Time
} }
// NewNetworkScanService creates a new network scan service. // NewNetworkScanService creates a new network scan service.
@@ -44,9 +54,20 @@ func NewNetworkScanService(
auditService: auditService, auditService: auditService,
logger: logger, logger: logger,
concurrency: 50, concurrency: 50,
nowFn: time.Now,
} }
} }
// SetSCEPProbeRepo wires the SCEP probe persistence repository onto the
// service. Called from cmd/server/main.go at startup. Nil-safe — calling
// ProbeSCEP without a repo just skips the persist step (the probe still
// runs and returns its result synchronously).
//
// SCEP RFC 8894 + Intune master bundle Phase 11.5.
func (s *NetworkScanService) SetSCEPProbeRepo(repo repository.SCEPProbeResultRepository) {
s.scepProbeRepo = repo
}
// ListTargets returns all network scan targets. // ListTargets returns all network scan targets.
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) { func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
return s.networkScanRepo.List(ctx) return s.networkScanRepo.List(ctx)
+172 -12
View File
@@ -48,11 +48,24 @@ type SCEPService struct {
intuneTrust *intune.TrustAnchorHolder // SIGHUP-reloadable trust pool intuneTrust *intune.TrustAnchorHolder // SIGHUP-reloadable trust pool
intuneAudience string // expected "aud" claim; empty disables the check intuneAudience string // expected "aud" claim; empty disables the check
intuneValidity time.Duration // optional override on top of the challenge's exp intuneValidity time.Duration // optional override on top of the challenge's exp
intuneClockSkew time.Duration // ±tolerance applied to iat/exp; default 60s wired from config
intuneReplayCache *intune.ReplayCache // nonce-keyed; catches duplicate submission intuneReplayCache *intune.ReplayCache // nonce-keyed; catches duplicate submission
intuneRateLimiter *intune.PerDeviceRateLimiter intuneRateLimiter *intune.PerDeviceRateLimiter
complianceCheck ComplianceCheck // V3-Pro plug-in seam; nil-default no-op complianceCheck ComplianceCheck // V3-Pro plug-in seam; nil-default no-op
intuneCounters *intuneCounterTab // per-status atomic counters for the admin endpoint intuneCounters *intuneCounterTab // per-status atomic counters for the admin endpoint
pathID string // SCEP profile path ID; surfaced by admin endpoints pathID string // SCEP profile path ID; surfaced by admin endpoints
// Per-profile metadata surfaced by the new /admin/scep/profiles
// endpoint. SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// (cowork/scep-gui-restructure-prompt.md). All fields are nil/zero
// when the operator runs without Intune AND without mTLS — we still
// surface the always-present challenge-password-set + RA cert
// expiry on the Profiles tab for those.
raCertSubject string
raCertNotBefore time.Time
raCertNotAfter time.Time
mtlsEnabled bool
mtlsTrustBundlePath string
} }
// intuneCounterTab is the in-memory equivalent of the // intuneCounterTab is the in-memory equivalent of the
@@ -149,17 +162,18 @@ type IntuneTrustAnchorInfo struct {
// GET endpoint hands back. SCEPService.IntuneStats() builds one of // GET endpoint hands back. SCEPService.IntuneStats() builds one of
// these on demand under no contention with the dispatcher hot path. // these on demand under no contention with the dispatcher hot path.
type IntuneStatsSnapshot struct { type IntuneStatsSnapshot struct {
PathID string `json:"path_id"` PathID string `json:"path_id"`
IssuerID string `json:"issuer_id"` IssuerID string `json:"issuer_id"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TrustAnchorPath string `json:"trust_anchor_path,omitempty"` TrustAnchorPath string `json:"trust_anchor_path,omitempty"`
TrustAnchors []IntuneTrustAnchorInfo `json:"trust_anchors,omitempty"` TrustAnchors []IntuneTrustAnchorInfo `json:"trust_anchors,omitempty"`
Audience string `json:"audience,omitempty"` Audience string `json:"audience,omitempty"`
ChallengeValidity time.Duration `json:"challenge_validity_ns,omitempty"` ChallengeValidity time.Duration `json:"challenge_validity_ns,omitempty"`
RateLimitDisabled bool `json:"rate_limit_disabled"` ClockSkewTolerance time.Duration `json:"clock_skew_tolerance_ns,omitempty"`
ReplayCacheSize int `json:"replay_cache_size"` RateLimitDisabled bool `json:"rate_limit_disabled"`
Counters map[string]uint64 `json:"counters"` ReplayCacheSize int `json:"replay_cache_size"`
GeneratedAt time.Time `json:"generated_at"` Counters map[string]uint64 `json:"counters"`
GeneratedAt time.Time `json:"generated_at"`
} }
// SetPathID records the SCEP profile path ID this service instance // SetPathID records the SCEP profile path ID this service instance
@@ -195,6 +209,7 @@ func (s *SCEPService) IntuneStats(now time.Time) IntuneStatsSnapshot {
} }
out.Audience = s.intuneAudience out.Audience = s.intuneAudience
out.ChallengeValidity = s.intuneValidity out.ChallengeValidity = s.intuneValidity
out.ClockSkewTolerance = s.intuneClockSkew
if s.intuneRateLimiter != nil { if s.intuneRateLimiter != nil {
out.RateLimitDisabled = s.intuneRateLimiter.Disabled() out.RateLimitDisabled = s.intuneRateLimiter.Disabled()
} }
@@ -235,6 +250,144 @@ func (s *SCEPService) ReloadIntuneTrust() error {
return s.intuneTrust.Reload() return s.intuneTrust.Reload()
} }
// SetRACert records the RA cert metadata the admin Profiles endpoint
// surfaces (subject + NotBefore + NotAfter for the expiry countdown).
// Called from cmd/server/main.go right after loadSCEPRAPair returns the
// leaf cert. Nil-safe — passing nil leaves the fields zero-valued so
// the snapshot's RACertSubject is empty (the GUI then renders
// "RA cert not loaded").
//
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up.
func (s *SCEPService) SetRACert(cert *x509.Certificate) {
if cert == nil {
return
}
s.raCertSubject = cert.Subject.CommonName
s.raCertNotBefore = cert.NotBefore
s.raCertNotAfter = cert.NotAfter
}
// SetMTLSConfig records this profile's mTLS sibling-route status for
// the admin Profiles endpoint. The trust bundle PATH is surfaced (not
// the bundle contents) so operators can correlate against their own
// secret manager / file system audit. Called from cmd/server/main.go
// in the per-profile loop, parallel to SetIntuneIntegration.
//
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up.
func (s *SCEPService) SetMTLSConfig(enabled bool, bundlePath string) {
s.mtlsEnabled = enabled
s.mtlsTrustBundlePath = bundlePath
}
// SCEPProfileStatsSnapshot is the per-profile observability shape the
// new /admin/scep/profiles endpoint emits. Surfaces every always-
// present per-profile field PLUS an optional Intune sub-block.
// Profiles that don't have Intune enabled get Intune=nil (the GUI
// renders the lean per-profile card without the Intune deep-dive
// button).
//
// Distinct from IntuneStatsSnapshot (which the existing
// /admin/scep/intune/stats endpoint emits) so the existing endpoint's
// JSON shape stays byte-stable for external consumers — backward
// compatibility for the Phase 9 admin contract.
//
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// (cowork/scep-gui-restructure-prompt.md).
type SCEPProfileStatsSnapshot struct {
// Always-present per-profile fields.
PathID string `json:"path_id"`
IssuerID string `json:"issuer_id"`
ChallengePasswordSet bool `json:"challenge_password_set"`
RACertSubject string `json:"ra_cert_subject,omitempty"`
RACertNotBefore time.Time `json:"ra_cert_not_before,omitempty"`
RACertNotAfter time.Time `json:"ra_cert_not_after,omitempty"`
RACertDaysToExpiry int `json:"ra_cert_days_to_expiry"`
RACertExpired bool `json:"ra_cert_expired"`
MTLSEnabled bool `json:"mtls_enabled"`
MTLSTrustBundlePath string `json:"mtls_trust_bundle_path,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
// Optional Intune sub-block; nil when this profile has Intune
// disabled. Mirrors the IntuneStatsSnapshot fields minus the
// always-present per-profile ones (which now live on the parent).
Intune *IntuneSection `json:"intune,omitempty"`
}
// IntuneSection is the Intune-specific data a per-profile snapshot
// carries when INTUNE_ENABLED=true. Same fields as IntuneStatsSnapshot
// minus the always-present per-profile ones (PathID, IssuerID,
// GeneratedAt) which live on SCEPProfileStatsSnapshot.
type IntuneSection struct {
TrustAnchorPath string `json:"trust_anchor_path,omitempty"`
TrustAnchors []IntuneTrustAnchorInfo `json:"trust_anchors,omitempty"`
Audience string `json:"audience,omitempty"`
ChallengeValidity time.Duration `json:"challenge_validity_ns,omitempty"`
ClockSkewTolerance time.Duration `json:"clock_skew_tolerance_ns,omitempty"`
RateLimitDisabled bool `json:"rate_limit_disabled"`
ReplayCacheSize int `json:"replay_cache_size"`
Counters map[string]uint64 `json:"counters"`
}
// ProfileStats returns the per-profile observability snapshot in the
// new shape (always-present fields + optional Intune sub-block).
// Safe for concurrent callers; reads only; uses the same atomic
// counter snapshots as IntuneStats.
//
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up.
func (s *SCEPService) ProfileStats(now time.Time) SCEPProfileStatsSnapshot {
out := SCEPProfileStatsSnapshot{
PathID: s.pathID,
IssuerID: s.issuerID,
ChallengePasswordSet: s.challengePassword != "",
RACertSubject: s.raCertSubject,
RACertNotBefore: s.raCertNotBefore,
RACertNotAfter: s.raCertNotAfter,
MTLSEnabled: s.mtlsEnabled,
MTLSTrustBundlePath: s.mtlsTrustBundlePath,
GeneratedAt: now.UTC(),
}
if !s.raCertNotAfter.IsZero() {
out.RACertExpired = now.After(s.raCertNotAfter)
if !out.RACertExpired {
out.RACertDaysToExpiry = int(s.raCertNotAfter.Sub(now).Hours() / 24)
}
}
if !s.intuneEnabled {
return out
}
intuneSection := IntuneSection{
Audience: s.intuneAudience,
ChallengeValidity: s.intuneValidity,
ClockSkewTolerance: s.intuneClockSkew,
Counters: s.intuneCounters.snapshot(),
}
if s.intuneRateLimiter != nil {
intuneSection.RateLimitDisabled = s.intuneRateLimiter.Disabled()
}
if s.intuneReplayCache != nil {
intuneSection.ReplayCacheSize = s.intuneReplayCache.Len()
}
if s.intuneTrust != nil {
intuneSection.TrustAnchorPath = s.intuneTrust.Path()
certs := s.intuneTrust.Get()
intuneSection.TrustAnchors = make([]IntuneTrustAnchorInfo, 0, len(certs))
for _, c := range certs {
info := IntuneTrustAnchorInfo{
Subject: c.Subject.CommonName,
NotBefore: c.NotBefore,
NotAfter: c.NotAfter,
Expired: now.After(c.NotAfter),
}
if !info.Expired {
info.DaysToExpiry = int(c.NotAfter.Sub(now).Hours() / 24)
}
intuneSection.TrustAnchors = append(intuneSection.TrustAnchors, info)
}
}
out.Intune = &intuneSection
return out
}
// ErrSCEPProfileIntuneDisabled is returned by ReloadIntuneTrust when // ErrSCEPProfileIntuneDisabled is returned by ReloadIntuneTrust when
// invoked on a profile that has Intune turned off. Lets the admin // invoked on a profile that has Intune turned off. Lets the admin
// handler distinguish "operator targeted the wrong profile" (HTTP 409) // handler distinguish "operator targeted the wrong profile" (HTTP 409)
@@ -298,6 +451,7 @@ func (s *SCEPService) SetIntuneIntegration(
trust *intune.TrustAnchorHolder, trust *intune.TrustAnchorHolder,
audience string, audience string,
validity time.Duration, validity time.Duration,
clockSkew time.Duration,
replayCache *intune.ReplayCache, replayCache *intune.ReplayCache,
rateLimiter *intune.PerDeviceRateLimiter, rateLimiter *intune.PerDeviceRateLimiter,
) { ) {
@@ -305,6 +459,7 @@ func (s *SCEPService) SetIntuneIntegration(
s.intuneTrust = trust s.intuneTrust = trust
s.intuneAudience = audience s.intuneAudience = audience
s.intuneValidity = validity s.intuneValidity = validity
s.intuneClockSkew = clockSkew
s.intuneReplayCache = replayCache s.intuneReplayCache = replayCache
s.intuneRateLimiter = rateLimiter s.intuneRateLimiter = rateLimiter
if s.intuneCounters == nil { if s.intuneCounters == nil {
@@ -425,7 +580,12 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string
now := time.Now() now := time.Now()
trust := s.intuneTrust.Get() trust := s.intuneTrust.Get()
claim, err := intune.ValidateChallenge(challengePassword, trust, s.intuneAudience, now) claim, err := intune.ValidateChallenge(challengePassword, intune.ValidateOptions{
Trust: trust,
ExpectedAudience: s.intuneAudience,
Now: now,
ClockSkewTolerance: s.intuneClockSkew,
})
if err != nil { if err != nil {
s.logger.Warn("SCEP enrollment rejected: Intune challenge validation failed", s.logger.Warn("SCEP enrollment rejected: Intune challenge validation failed",
"transaction_id", transactionID, "reason", intuneFailReason(err), "error", err) "transaction_id", transactionID, "reason", intuneFailReason(err), "error", err)
+10
View File
@@ -171,6 +171,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_Success(t *testing.T) {
holder, holder,
"https://certctl.example.com/scep/corp", "https://certctl.example.com/scep/corp",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -207,6 +208,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_StaticChallengeStillWorks(t *testi
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"https://certctl.example.com/scep/corp", "https://certctl.example.com/scep/corp",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -224,6 +226,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_TamperedChallengeRejected(t *testi
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -252,6 +255,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ClaimMismatchRejected(t *testing.T
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -277,6 +281,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ReplayDetected(t *testing.T) {
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(0, 24*time.Hour, 100), // disable rate limit so we don't trip THAT first intune.NewPerDeviceRateLimiter(0, 24*time.Hour, 100), // disable rate limit so we don't trip THAT first
) )
@@ -300,6 +305,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_RateLimited(t *testing.T) {
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
// Replay cache must not block us — use disjoint nonces per call. // Replay cache must not block us — use disjoint nonces per call.
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(2, 24*time.Hour, 100), // limit = 2 intune.NewPerDeviceRateLimiter(2, 24*time.Hour, 100), // limit = 2
@@ -337,6 +343,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookNilDefault(t *testin
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -354,6 +361,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookDeniesNonCompliant(t
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -382,6 +390,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookErrorFailsClosed(t *
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
60*time.Minute, 60*time.Minute,
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
intune.NewReplayCache(60*time.Minute, 100), intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100), intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
) )
@@ -411,6 +420,7 @@ func TestSCEPService_IntuneEnabled_AccessorReflectsState(t *testing.T) {
holderFromCerts(t, []*x509.Certificate{conn.cert}), holderFromCerts(t, []*x509.Certificate{conn.cert}),
"", "",
0, 0,
0, // ClockSkewTolerance — strict (no grace)
nil, nil,
nil, nil,
) )
+344
View File
@@ -0,0 +1,344 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/validation"
)
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
//
// Probes an SCEP server URL for capability + posture metadata
// (RFC 8894 §3.5.1 GetCACaps + GetCACert). Used for pre-migration
// assessment + compliance posture audits. Deliberately does NOT POST a
// CSR — capability-only.
//
// SSRF defense: the HTTP client uses validation.SafeHTTPDialContext so
// dial-time DNS resolution is checked against the reserved-IP filter
// (defends against DNS rebinding); the URL is also validated up-front
// via validation.ValidateSafeURL for an early diagnostic.
//
// The probe accumulates persistent history in scep_probe_results
// (migration 000021) when SetSCEPProbeRepo wired a repo at startup;
// otherwise the probe runs and returns its result without persisting.
// scepProbeTimeout caps a single probe at 30s. The probe issues at
// most 2-3 GETs against the target, each with default Go HTTP-client
// behavior (single connection, no retries) — 30s is generous for
// reachable servers and bounds the wait for unreachable / hung ones.
const scepProbeTimeout = 30 * time.Second
// scepProbeUserAgent identifies certctl in the target server's logs so
// operators running the probe see a clear source attribution.
const scepProbeUserAgent = "certctl-network-scan/scep-probe"
// ProbeSCEP probes the given URL as an SCEP server and returns a
// structured posture snapshot. The result is also persisted via
// SetSCEPProbeRepo (when configured) so the GUI can render recent
// probe history.
//
// Validation order:
//
// 1. validation.ValidateSafeURL — catches obvious SSRF targets
// (loopback / link-local / cloud-metadata literals) before any
// network call. Cheap early diagnostic.
// 2. The HTTP transport's DialContext (SafeHTTPDialContext) re-
// resolves the target host at dial time and re-checks reserved
// IPs. Defends against DNS-rebinding (the URL passes step 1 but
// resolves to a reserved IP at dial time).
// 3. The probe issues GET ?operation=GetCACaps and GET ?operation=GetCACert.
// GetCACert can return either a single DER cert OR a PKCS#7
// SignedData certs-only envelope (RFC 8894 §3.5.1). The probe
// handles both.
func (s *NetworkScanService) ProbeSCEP(ctx context.Context, rawURL string) (*domain.SCEPProbeResult, error) {
id := s.scepProbeID()
now := s.nowFnOrDefault()
started := now()
result := &domain.SCEPProbeResult{
ID: id,
TargetURL: rawURL,
ProbedAt: started,
}
// Step 1: cheap up-front URL validation (SSRF early diagnostic).
// Defaults to validation.ValidateSafeURL; tests inject a permissive
// validator via service-level field so they can hit httptest
// loopback servers (which the production validator correctly
// rejects). Mirrors the webhook notifier's `newForTest` pattern.
validateURL := s.scepValidateURL
if validateURL == nil {
validateURL = validation.ValidateSafeURL
}
if err := validateURL(rawURL); err != nil {
result.Reachable = false
result.Error = "url validation: " + err.Error()
result.ProbeDurationMs = time.Since(started).Milliseconds()
s.persistProbeResult(ctx, result)
return result, fmt.Errorf("scep probe: validate url: %w", err)
}
// Normalize the base URL — strip any trailing query string so we
// can append ?operation=... unambiguously.
parsed, err := url.Parse(rawURL)
if err != nil {
result.Reachable = false
result.Error = "url parse: " + err.Error()
result.ProbeDurationMs = time.Since(started).Milliseconds()
s.persistProbeResult(ctx, result)
return result, fmt.Errorf("scep probe: parse url: %w", err)
}
parsed.RawQuery = ""
baseURL := parsed.String()
client := s.scepProbeClient()
// Step 2: GetCACaps — newline-separated capability list.
caps, capsErr := s.scepGetCACaps(ctx, client, baseURL)
if capsErr != nil {
result.Reachable = false
result.Error = "GetCACaps: " + capsErr.Error()
result.ProbeDurationMs = time.Since(started).Milliseconds()
s.persistProbeResult(ctx, result)
return result, capsErr
}
result.Reachable = true
result.AdvertisedCaps = caps
for _, c := range caps {
switch strings.TrimSpace(c) {
case "SCEPStandard":
result.SupportsRFC8894 = true
case "AES":
result.SupportsAES = true
case "POSTPKIOperation":
result.SupportsPOSTOperation = true
case "Renewal":
result.SupportsRenewal = true
case "SHA-256":
result.SupportsSHA256 = true
case "SHA-512":
result.SupportsSHA512 = true
}
}
// Step 3: GetCACert — DER cert OR PKCS#7 SignedData certs-only envelope.
certs, certErr := s.scepGetCACert(ctx, client, baseURL)
if certErr != nil {
// Non-fatal: server reached + caps parsed, but CA cert fetch
// failed. Operator gets caps + the error explaining the CA
// cert state.
result.Error = "GetCACert: " + certErr.Error()
} else if len(certs) > 0 {
result.CACertChainLength = len(certs)
leaf := certs[0]
result.CACertSubject = leaf.Subject.String()
result.CACertIssuer = leaf.Issuer.String()
result.CACertNotBefore = leaf.NotBefore
result.CACertNotAfter = leaf.NotAfter
nowVal := now()
result.CACertExpired = nowVal.After(leaf.NotAfter)
if !result.CACertExpired {
result.CACertDaysToExpiry = int(leaf.NotAfter.Sub(nowVal).Hours() / 24)
}
result.CACertAlgorithm = describeCertAlgorithm(leaf)
}
result.ProbeDurationMs = time.Since(started).Milliseconds()
s.persistProbeResult(ctx, result)
return result, nil
}
// scepGetCACaps fetches GET ?operation=GetCACaps and parses the
// newline-separated capability list. Lines are trimmed of CRLF; empty
// lines are skipped. Per RFC 8894 §3.5.2 the response Content-Type is
// text/plain with one capability per line.
func (s *NetworkScanService) scepGetCACaps(ctx context.Context, client *http.Client, baseURL string) ([]string, error) {
url := baseURL + "?operation=GetCACaps"
body, err := s.scepHTTPGet(ctx, client, url)
if err != nil {
return nil, err
}
var out []string
for _, line := range strings.Split(string(body), "\n") {
t := strings.TrimSpace(line)
if t == "" {
continue
}
out = append(out, t)
}
return out, nil
}
// scepGetCACert fetches GET ?operation=GetCACert and parses the
// returned cert(s). RFC 8894 §3.5.1: the response is either:
//
// - A single DER-encoded X.509 cert (Content-Type
// application/x-x509-ca-cert) when the CA has a single cert.
// - A PKCS#7 SignedData certs-only envelope (Content-Type
// application/x-x509-ca-ra-cert) when the CA returns multiple
// certs (CA + RA, or CA chain).
//
// We attempt the PKCS#7 parse first, fall back to single-cert DER
// parse if that fails. Returns the cert chain in order (CA leaf first).
func (s *NetworkScanService) scepGetCACert(ctx context.Context, client *http.Client, baseURL string) ([]*x509.Certificate, error) {
url := baseURL + "?operation=GetCACert"
body, err := s.scepHTTPGet(ctx, client, url)
if err != nil {
return nil, err
}
// Try PKCS#7 SignedData first — the multi-cert form. ParseSignedData
// already decodes each embedded cert into *x509.Certificate, so we
// just take the slice as-is.
if signed, p7Err := pkcs7.ParseSignedData(body); p7Err == nil && len(signed.Certificates) > 0 {
return signed.Certificates, nil
}
// Fall back to single DER cert (or a PEM-wrapped cert from a
// non-conforming server — try both).
if c, err := x509.ParseCertificate(body); err == nil {
return []*x509.Certificate{c}, nil
}
if block, _ := pem.Decode(body); block != nil {
if c, err := x509.ParseCertificate(block.Bytes); err == nil {
return []*x509.Certificate{c}, nil
}
}
return nil, errors.New("could not parse GetCACert response as DER, PEM, or PKCS#7 SignedData")
}
// scepHTTPGet issues a single GET with the probe's user agent + the
// SSRF-defended HTTP client. Reads the body up to 1MB to defend against
// a huge-response DoS from a misbehaving target.
func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", scepProbeUserAgent)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB cap
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return body, nil
}
// scepProbeClient returns the lazily-built SSRF-defended HTTP client.
// Built once per service lifetime; the transport reuses connections.
func (s *NetworkScanService) scepProbeClient() *http.Client {
if s.scepHTTPClient != nil {
return s.scepHTTPClient
}
transport := &http.Transport{
DialContext: validation.SafeHTTPDialContext(scepProbeTimeout),
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
}
s.scepHTTPClient = &http.Client{
Timeout: scepProbeTimeout,
Transport: transport,
}
return s.scepHTTPClient
}
// scepProbeID returns a fresh ID for a probe row. Defaults to
// "spr-<uuid>"; tests can inject a deterministic generator via
// (NetworkScanService).scepIDFn.
func (s *NetworkScanService) scepProbeID() string {
if s.scepIDFn != nil {
return s.scepIDFn()
}
return "spr-" + uuid.New().String()
}
// nowFnOrDefault returns the configured clock (for test injection) or
// time.Now if unset. Used so the probe's two NotAfter comparisons
// (CACertExpired + ProbedAt) share a single observation point.
func (s *NetworkScanService) nowFnOrDefault() func() time.Time {
if s.nowFn != nil {
return s.nowFn
}
return time.Now
}
// persistProbeResult writes the probe outcome to scep_probe_results
// when a repo was wired. Failure to persist is logged but doesn't
// fail the caller — the probe's primary contract is "run + return"
// not "run + persist". Operators get the result regardless.
func (s *NetworkScanService) persistProbeResult(ctx context.Context, result *domain.SCEPProbeResult) {
if s.scepProbeRepo == nil {
return
}
if err := s.scepProbeRepo.Insert(ctx, result); err != nil && s.logger != nil {
s.logger.Warn("scep probe result persist failed (probe still returned to caller)",
"target_url", result.TargetURL,
"id", result.ID,
"error", err)
}
}
// ListRecentSCEPProbes returns the most recent N probe rows. Thin
// wrapper around the repository so the handler depends on the service
// surface, not the repo directly. Returns empty slice (not nil) when
// no repo is wired so JSON marshaling stays clean.
func (s *NetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
if s.scepProbeRepo == nil {
return []*domain.SCEPProbeResult{}, nil
}
return s.scepProbeRepo.ListRecent(ctx, limit)
}
// describeCertAlgorithm returns a short, operator-friendly description
// of the cert's public key algorithm + size. Examples:
// - "RSA-2048" / "RSA-3072" / "RSA-4096"
// - "ECDSA-P256" / "ECDSA-P384" / "ECDSA-P521"
// - "Ed25519"
// - "" for unrecognized algorithms.
func describeCertAlgorithm(c *x509.Certificate) string {
switch pub := c.PublicKey.(type) {
case *rsa.PublicKey:
return fmt.Sprintf("RSA-%d", pub.N.BitLen())
case *ecdsa.PublicKey:
// Curve is embedded in ecdsa.PublicKey; check the interface
// itself for nil before calling Params() via promotion (QF1008
// — staticcheck wants the promoted-method form, not the
// chained selector). Still need the nil check because
// calling Params() on a nil embedded interface would panic.
if pub.Curve != nil {
if params := pub.Params(); params != nil {
return "ECDSA-" + params.Name
}
}
return "ECDSA"
}
switch c.PublicKeyAlgorithm {
case x509.Ed25519:
return "Ed25519"
case x509.DSA:
return "DSA"
}
return ""
}
+218
View File
@@ -0,0 +1,218 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"math/big"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// SCEP RFC 8894 + Intune master prompt §13 line 1859 acceptance —
// coverage uplift on the SCEP probe persistence + clamp paths. Closed
// in the 2026-04-29 audit-closure bundle (Phase H).
//
// Targets the lowest-coverage hot spots in
// internal/service/scep_probe.go (per the audit) without bloating the
// suite:
//
// 1. persistProbeResult is nil-safe + nil-repo-safe.
// 2. persistProbeResult swallows repo errors (probe stays a "best-
// effort persist") + still surfaces them through the logger.
// 3. ListRecentSCEPProbes returns an empty slice (NOT nil) when no
// repo is wired so JSON marshaling stays clean.
// 4. describeCertAlgorithm covers RSA/ECDSA/Ed25519/unknown branches
// including the QF1008 nil-curve defensive branch added in
// commit 9fcea95.
// stubSCEPProbeRepo is a controllable repository.SCEPProbeResultRepository
// used by the persist + list tests. Returns the configured insertErr +
// listResults from each Insert/ListRecent call; bumps insertCalls so the
// test can assert which probes reached the persist path.
type stubSCEPProbeRepo struct {
insertCalls int
insertErr error
listResults []*domain.SCEPProbeResult
listLimit int
listErr error
}
func (r *stubSCEPProbeRepo) Insert(_ context.Context, _ *domain.SCEPProbeResult) error {
r.insertCalls++
return r.insertErr
}
func (r *stubSCEPProbeRepo) ListRecent(_ context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
r.listLimit = limit
return r.listResults, r.listErr
}
// TestPersistProbeResult_NoRepoIsNoOp verifies persistProbeResult is
// safe to call before SetSCEPProbeRepo wires a repo (the production
// startup order is: build service → wire repo). Without this, a probe
// that runs during the boot window would nil-deref.
func TestPersistProbeResult_NoRepoIsNoOp(t *testing.T) {
s := newScepProbeServiceForTest(t)
// Should not panic even though scepProbeRepo is nil.
s.persistProbeResult(context.Background(), &domain.SCEPProbeResult{
ID: "probe-no-repo",
TargetURL: "https://example.com/scep",
})
}
// TestPersistProbeResult_RepoErrorDoesNotFailCaller pins the
// "best-effort persist" contract documented on persistProbeResult: a
// repo write failure MUST NOT bubble back to the probe caller (the
// probe's primary contract is "run + return," not "run + persist").
// The repo's insertCalls counter MUST still be bumped so an operator
// can prove the persist code path was reached even when it failed.
func TestPersistProbeResult_RepoErrorDoesNotFailCaller(t *testing.T) {
repo := &stubSCEPProbeRepo{insertErr: errors.New("simulated db down")}
s := newScepProbeServiceForTest(t)
s.SetSCEPProbeRepo(repo)
s.persistProbeResult(context.Background(), &domain.SCEPProbeResult{
ID: "probe-err",
TargetURL: "https://example.com/scep",
})
if repo.insertCalls != 1 {
t.Errorf("Insert calls = %d, want 1", repo.insertCalls)
}
// A logger-less service MUST also survive a repo error — the warn-
// log branch guards on `s.logger != nil`. Walk the same code path
// with a logger-nil service to exercise that defensive guard.
sNoLog := &NetworkScanService{nowFn: time.Now}
sNoLog.SetSCEPProbeRepo(repo)
sNoLog.persistProbeResult(context.Background(), &domain.SCEPProbeResult{
ID: "probe-err-nologger",
TargetURL: "https://example.com/scep",
})
if repo.insertCalls != 2 {
t.Errorf("Insert calls (after nologger run) = %d, want 2", repo.insertCalls)
}
}
// TestListRecentSCEPProbes_NilRepoReturnsEmptySlice pins the
// "JSON-clean empty" contract documented on ListRecentSCEPProbes —
// the absence of a repo MUST surface as an empty slice (not nil) so
// the GUI's JSON consumer doesn't render `null` instead of `[]`.
// Critical for the React Network Scan page that .map()s over the
// result and would crash on null.
func TestListRecentSCEPProbes_NilRepoReturnsEmptySlice(t *testing.T) {
s := newScepProbeServiceForTest(t)
got, err := s.ListRecentSCEPProbes(context.Background(), 50)
if err != nil {
t.Fatalf("ListRecentSCEPProbes (nil repo): %v", err)
}
if got == nil {
t.Fatal("ListRecentSCEPProbes (nil repo) returned nil, want empty slice for JSON cleanliness")
}
if len(got) != 0 {
t.Errorf("ListRecentSCEPProbes (nil repo) = %d items, want 0", len(got))
}
}
// TestListRecentSCEPProbes_DelegatesToRepo verifies the wired-repo
// path: the limit value flows through to the repository unmodified
// (the [1, 200] clamp lives at the handler layer, not the service —
// this test pins the service is a thin pass-through).
func TestListRecentSCEPProbes_DelegatesToRepo(t *testing.T) {
repo := &stubSCEPProbeRepo{
listResults: []*domain.SCEPProbeResult{
{ID: "probe-1", TargetURL: "https://a.example.com/scep"},
{ID: "probe-2", TargetURL: "https://b.example.com/scep"},
},
}
s := newScepProbeServiceForTest(t)
s.SetSCEPProbeRepo(repo)
got, err := s.ListRecentSCEPProbes(context.Background(), 17)
if err != nil {
t.Fatalf("ListRecentSCEPProbes: %v", err)
}
if repo.listLimit != 17 {
t.Errorf("repo.ListRecent received limit=%d, want 17", repo.listLimit)
}
if len(got) != 2 {
t.Errorf("ListRecentSCEPProbes returned %d items, want 2", len(got))
}
}
// TestDescribeCertAlgorithm covers every documented branch of the
// describe helper — including the QF1008 nil-curve defensive guard
// added in commit 9fcea95. Walking each branch keeps the staticcheck
// fix exercised in CI so a future "simplify" never reverts the nil
// check + crashes on a malformed cert.
func TestDescribeCertAlgorithm(t *testing.T) {
rsaCert, _ := fixtureRSACertForDescribeTest(t)
if got, want := describeCertAlgorithm(rsaCert), "RSA-2048"; got != want {
t.Errorf("RSA describe = %q, want %q", got, want)
}
ecCert, _ := fixtureCACert(t, "ec-describe", time.Now().Add(-1*time.Hour), time.Now().Add(24*time.Hour))
if got, want := describeCertAlgorithm(ecCert), "ECDSA-P-256"; got != want {
t.Errorf("ECDSA describe = %q, want %q", got, want)
}
// Defensive branch: an ECDSA public key with a nil Curve. The
// QF1008 fix keeps the explicit nil check so this case returns
// "ECDSA" without panicking.
bogusEC := &x509.Certificate{
PublicKey: &ecdsa.PublicKey{Curve: nil},
PublicKeyAlgorithm: x509.ECDSA,
}
if got, want := describeCertAlgorithm(bogusEC), "ECDSA"; got != want {
t.Errorf("nil-curve ECDSA describe = %q, want %q (QF1008 defensive branch)", got, want)
}
// Algorithm-only fall-through (no key type match) → Ed25519/DSA.
ed := &x509.Certificate{PublicKeyAlgorithm: x509.Ed25519}
if got, want := describeCertAlgorithm(ed), "Ed25519"; got != want {
t.Errorf("Ed25519 describe = %q, want %q", got, want)
}
dsa := &x509.Certificate{PublicKeyAlgorithm: x509.DSA}
if got, want := describeCertAlgorithm(dsa), "DSA"; got != want {
t.Errorf("DSA describe = %q, want %q", got, want)
}
// Unrecognized → empty string (the GUI then renders "—").
unknown := &x509.Certificate{}
if got := describeCertAlgorithm(unknown); got != "" {
t.Errorf("unknown describe = %q, want empty", got)
}
}
// fixtureRSACertForDescribeTest is a tiny helper exclusive to the
// describe-algo coverage test. The package's other RSA cert helpers
// live behind type-specialized fixtures; we want a generic 2048-bit
// RSA cert + nothing else.
func fixtureRSACertForDescribeTest(t *testing.T) (*x509.Certificate, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "rsa-describe"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return parsed, key
}
+312
View File
@@ -0,0 +1,312 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// SCEP RFC 8894 + Intune master bundle Phase 11.5.4 — five named backend
// tests for the SCEP probe per the master prompt's exit criteria:
//
// TestProbeSCEP_AdvertisesAllCaps
// TestProbeSCEP_MissingSCEPStandard
// TestProbeSCEP_GetCACertExpired
// TestProbeSCEP_Unreachable
// TestProbeSCEP_RejectsReservedIP
//
// Plus PrintsCACertAlgorithm + IDOverride for coverage of the algorithm
// helper + deterministic ID injection. Run-once tests; no fuzz.
// silentScepLogger drops all probe logs so test output stays clean.
func silentScepLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
}
// newScepProbeServiceForTest wires a NetworkScanService in a way that
// only exposes what the SCEP probe path needs — the TLS-scan side stays
// unconfigured (nil deps) which is fine because none of the probe tests
// touch ScanAllTargets / TriggerScan.
func newScepProbeServiceForTest(t *testing.T) *NetworkScanService {
t.Helper()
svc := NewNetworkScanService(nil, nil, nil, silentScepLogger())
return svc
}
// fixtureCACert returns a fresh self-signed cert + DER bytes the test
// httptest server can return for GetCACert. notAfter lets tests pin the
// cert into the past so the expired-cert assertions fire.
func fixtureCACert(t *testing.T, cn string, notBefore, notAfter time.Time) (*x509.Certificate, []byte) {
t.Helper()
key, 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: cn},
Issuer: pkix.Name{CommonName: cn + "-issuer"},
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
parsed, _ := x509.ParseCertificate(der)
return parsed, der
}
// fakeSCEPHandler returns an http.Handler that mimics an RFC 8894 SCEP
// server. Caller sets caps + an optional CA cert. GetCACert returns DER
// bytes (single cert form); GetCACaps returns the newline-separated
// list. Counts hits per operation for assertions.
type fakeSCEPHandler struct {
caps string
caCertDER []byte
getCAHits atomic.Int32
getCertHits atomic.Int32
emitFakeError bool
}
func (h *fakeSCEPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
op := r.URL.Query().Get("operation")
switch op {
case "GetCACaps":
h.getCAHits.Add(1)
if h.emitFakeError {
http.Error(w, "fake server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(h.caps))
case "GetCACert":
h.getCertHits.Add(1)
if len(h.caCertDER) == 0 {
http.Error(w, "no ca cert", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
_, _ = w.Write(h.caCertDER)
default:
http.NotFound(w, r)
}
}
// installPermissiveClientForTest swaps the production SSRF-defended
// HTTP client + URL validator for permissive test versions. The
// production stack rejects loopback / link-local / cloud-metadata IPs
// for SSRF defense; the httptest servers tests spin up bind to
// 127.0.0.1 by default, so tests need to bypass both layers. Mirrors
// the webhook notifier's `newForTest` pattern.
func installPermissiveClientForTest(svc *NetworkScanService) {
svc.scepHTTPClient = &http.Client{
Timeout: 5 * time.Second,
}
svc.scepValidateURL = func(string) error { return nil }
}
// TestProbeSCEP_AdvertisesAllCaps exercises the happy path where the
// fake server advertises the full RFC 8894 + AES + POST + Renewal +
// SHA-256 + SHA-512 set. Probe must parse all the flags + extract CA
// cert metadata + return reachable=true with no error.
func TestProbeSCEP_AdvertisesAllCaps(t *testing.T) {
cert, der := fixtureCACert(t, "fixture-ca", time.Now().Add(-1*time.Hour), time.Now().Add(365*24*time.Hour))
fake := &fakeSCEPHandler{
caps: "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n",
caCertDER: der,
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if !res.Reachable {
t.Fatalf("Reachable = false, want true")
}
if !res.SupportsRFC8894 || !res.SupportsAES || !res.SupportsPOSTOperation || !res.SupportsRenewal {
t.Errorf("expected all caps, got %+v", res)
}
if !res.SupportsSHA256 || !res.SupportsSHA512 {
t.Errorf("SHA cap flags missing")
}
if res.CACertSubject == "" || res.CACertSubject != cert.Subject.String() {
t.Errorf("CACertSubject = %q, want %q", res.CACertSubject, cert.Subject.String())
}
if res.CACertExpired {
t.Errorf("CACertExpired = true, want false (cert is valid for 365 days)")
}
if res.CACertChainLength != 1 {
t.Errorf("CACertChainLength = %d, want 1", res.CACertChainLength)
}
if !strings.HasPrefix(res.CACertAlgorithm, "ECDSA") {
t.Errorf("CACertAlgorithm = %q, want ECDSA-*", res.CACertAlgorithm)
}
if res.Error != "" {
t.Errorf("Error = %q, want empty", res.Error)
}
}
// TestProbeSCEP_MissingSCEPStandard probes a server that omits the
// "SCEPStandard" capability — modelling a pre-RFC-8894 server. Probe
// must succeed but flag SupportsRFC8894=false.
func TestProbeSCEP_MissingSCEPStandard(t *testing.T) {
_, der := fixtureCACert(t, "old-ca", time.Now().Add(-1*time.Hour), time.Now().Add(180*24*time.Hour))
fake := &fakeSCEPHandler{
caps: "POSTPKIOperation\nSHA-1\nDES3\n", // legacy server
caCertDER: der,
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if res.SupportsRFC8894 {
t.Errorf("SupportsRFC8894 = true, want false (legacy server)")
}
if !res.SupportsPOSTOperation {
t.Errorf("SupportsPOSTOperation = false (server advertises POSTPKIOperation)")
}
if res.SupportsAES {
t.Errorf("SupportsAES = true (server doesn't advertise AES)")
}
}
// TestProbeSCEP_GetCACertExpired probes a server whose CA cert NotAfter
// is in the past. Probe must mark CACertExpired=true.
func TestProbeSCEP_GetCACertExpired(t *testing.T) {
_, der := fixtureCACert(t, "expired-ca",
time.Now().Add(-2*365*24*time.Hour),
time.Now().Add(-30*24*time.Hour),
)
fake := &fakeSCEPHandler{
caps: "SCEPStandard\n",
caCertDER: der,
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if !res.CACertExpired {
t.Errorf("CACertExpired = false, want true (cert expired 30d ago)")
}
}
// TestProbeSCEP_Unreachable points the probe at a URL that doesn't
// respond. Probe must return reachable=false + a non-empty Error.
func TestProbeSCEP_Unreachable(t *testing.T) {
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
// Use a port nothing's listening on. A short connect timeout via
// the install client means we don't wait long.
svc.scepHTTPClient = &http.Client{Timeout: 500 * time.Millisecond}
res, err := svc.ProbeSCEP(context.Background(), "http://127.0.0.1:1/scep")
if err == nil {
t.Fatalf("expected an error, got result: %+v", res)
}
if res == nil {
t.Fatalf("expected non-nil result with error populated, got nil")
}
if res.Reachable {
t.Errorf("Reachable = true, want false")
}
if res.Error == "" {
t.Errorf("Error = empty, want a connection-failure message")
}
}
// TestProbeSCEP_RejectsReservedIP confirms the SSRF up-front check
// fires for literal reserved IPs. Run with the production HTTP client
// (the one wired by SafeHTTPDialContext) — the URL validation step
// rejects before any HTTP call.
func TestProbeSCEP_RejectsReservedIP(t *testing.T) {
svc := newScepProbeServiceForTest(t)
// Do NOT install the permissive client; we want the production
// SSRF path to fire on the first call.
res, err := svc.ProbeSCEP(context.Background(), "http://169.254.169.254/scep") // EC2 metadata
if err == nil {
t.Fatalf("expected SSRF rejection, got result: %+v", res)
}
if !errors.Is(err, errSSRFRejection) && !strings.Contains(err.Error(), "url validation") {
// Either pattern is acceptable — the underlying validator
// wraps its error string differently across versions; what
// matters is that the Error string mentions the validation
// failure and the result has Reachable=false.
t.Logf("err: %v (acceptable as long as Reachable=false + Error captured)", err)
}
if res == nil {
t.Fatalf("expected non-nil result with error populated, got nil")
}
if res.Reachable {
t.Errorf("Reachable = true, want false")
}
if !strings.Contains(res.Error, "url validation") {
t.Errorf("Error = %q, want it to mention url validation", res.Error)
}
}
// errSSRFRejection is a sentinel for the test's optional errors.Is
// match. The probe wraps validation errors in a generic fmt.Errorf so
// the underlying ValidateSafeURL error can vary; the test focuses on
// the visible behavior (Reachable=false + Error captured).
var errSSRFRejection = errors.New("url validation rejection")
// TestProbeSCEP_PEMWrappedCert exercises the fallback parse path: some
// servers return PEM-wrapped DER instead of raw DER for GetCACert.
// Probe should still parse the cert successfully.
func TestProbeSCEP_PEMWrappedCert(t *testing.T) {
cert, der := fixtureCACert(t, "pem-ca", time.Now().Add(-1*time.Hour), time.Now().Add(30*24*time.Hour))
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
fake := &fakeSCEPHandler{
caps: "SCEPStandard\nAES\n",
caCertDER: pemBytes, // server returned PEM, not DER
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if res.CACertSubject != cert.Subject.String() {
t.Errorf("CACertSubject = %q, want %q (PEM fallback parse)", res.CACertSubject, cert.Subject.String())
}
}
@@ -0,0 +1,5 @@
-- Down migration for 000021_scep_probe_results.
DROP INDEX IF EXISTS idx_scep_probe_results_target_url;
DROP INDEX IF EXISTS idx_scep_probe_results_probed_at;
DROP TABLE IF EXISTS scep_probe_results;
@@ -0,0 +1,49 @@
-- Migration 000021: SCEP probe results (Phase 11.5 of the SCEP RFC 8894
-- + Intune master bundle).
--
-- The control plane's network scanner can probe an SCEP server URL
-- (RFC 8894 §3.5.1 GetCACaps + GetCACert) and persist a structured
-- posture snapshot per run. Operators use this for:
-- 1. Pre-migration assessment — point the probe at an existing
-- EJBCA / NDES SCEP server to see what capabilities it advertises
-- (RFC 8894 / AES / POST / Renewal / SHA-256 / SHA-512) and what
-- the CA cert looks like (subject, issuer, expiry, algorithm).
-- 2. Compliance posture audits — periodic probes against the
-- operator's own SCEP servers to flag drift.
--
-- The probe deliberately does NOT POST a CSR — capability-only.
-- Standalone CLI for this same probe is explicitly out of scope for
-- this bundle; the GUI surface inside certctl is the only consumer
-- of this table at this time.
CREATE TABLE IF NOT EXISTS scep_probe_results (
id TEXT PRIMARY KEY,
target_url TEXT NOT NULL,
reachable BOOLEAN NOT NULL,
advertised_caps TEXT[] NOT NULL DEFAULT '{}',
supports_rfc8894 BOOLEAN NOT NULL DEFAULT FALSE,
supports_aes BOOLEAN NOT NULL DEFAULT FALSE,
supports_post_operation BOOLEAN NOT NULL DEFAULT FALSE,
supports_renewal BOOLEAN NOT NULL DEFAULT FALSE,
supports_sha256 BOOLEAN NOT NULL DEFAULT FALSE,
supports_sha512 BOOLEAN NOT NULL DEFAULT FALSE,
ca_cert_subject TEXT,
ca_cert_issuer TEXT,
ca_cert_not_before TIMESTAMPTZ,
ca_cert_not_after TIMESTAMPTZ,
ca_cert_expired BOOLEAN NOT NULL DEFAULT FALSE,
ca_cert_algorithm TEXT,
ca_cert_chain_length INTEGER NOT NULL DEFAULT 0,
probed_at TIMESTAMPTZ NOT NULL,
probe_duration_ms BIGINT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- The two query patterns the GUI uses:
-- - "show me the most recent N probes across any URL" → probed_at DESC
-- - "show me the probe history for this URL" → target_url + probed_at DESC
CREATE INDEX IF NOT EXISTS idx_scep_probe_results_probed_at
ON scep_probe_results(probed_at DESC);
CREATE INDEX IF NOT EXISTS idx_scep_probe_results_target_url
ON scep_probe_results(target_url, probed_at DESC);
+22 -1
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, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse } 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, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } from './types';
const BASE = '/api/v1'; const BASE = '/api/v1';
@@ -312,6 +312,27 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
body: JSON.stringify({ path_id: pathID }), body: JSON.stringify({ path_id: pathID }),
}); });
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP admin
// surface backing the Profiles tab on the SCEP Administration page.
// M-008 admin-gated; same gating semantics as the existing
// getAdminSCEPIntuneStats helper.
export const getAdminSCEPProfiles = () =>
fetchJSON<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
// (capability + posture). Synchronous — the caller blocks until the
// probe completes (cap: 30s server-side). Persists to the history
// table that listSCEPProbes reads from.
export const probeSCEPServer = (url: string) =>
fetchJSON<SCEPProbeResult>(`${BASE}/network-scan/scep-probe`, {
method: 'POST',
body: JSON.stringify({ url }),
});
export const listSCEPProbes = () =>
fetchJSON<SCEPProbesResponse>(`${BASE}/network-scan/scep-probes`);
// Agents // Agents
export const getAgents = (params: Record<string, string> = {}) => { export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+88
View File
@@ -655,6 +655,10 @@ export interface IntuneStatsSnapshot {
trust_anchors?: IntuneTrustAnchorInfo[]; trust_anchors?: IntuneTrustAnchorInfo[];
audience?: string; audience?: string;
challenge_validity_ns?: number; challenge_validity_ns?: number;
// Master prompt §15 hazard closure (2026-04-29): per-profile
// ±tolerance on iat/exp checks. Default 60s wired from
// CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
clock_skew_tolerance_ns?: number;
rate_limit_disabled: boolean; rate_limit_disabled: boolean;
replay_cache_size: number; replay_cache_size: number;
// Counter labels match intuneFailReason() in the backend dispatcher: // Counter labels match intuneFailReason() in the backend dispatcher:
@@ -676,3 +680,87 @@ export interface IntuneReloadTrustResponse {
path_id: string; path_id: string;
reloaded_at: string; reloaded_at: string;
} }
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP admin
// snapshot. Backs the new /api/v1/admin/scep/profiles endpoint and
// the Profiles tab on the SCEP Administration page.
//
// Distinct from IntuneStatsSnapshot (which mirrors the existing
// /admin/scep/intune/stats endpoint) so the existing endpoint's JSON
// shape stays byte-stable for external consumers — backward-compat
// for the Phase 9 admin contract. The Profiles endpoint nests Intune
// data under a single optional `intune` field; the legacy Intune
// endpoint keeps the flat shape.
export interface IntuneSection {
trust_anchor_path?: string;
trust_anchors?: IntuneTrustAnchorInfo[];
audience?: string;
challenge_validity_ns?: number;
// Master prompt §15 hazard closure (2026-04-29): per-profile
// ±tolerance on iat/exp checks. Default 60s.
clock_skew_tolerance_ns?: number;
rate_limit_disabled: boolean;
replay_cache_size: number;
counters: Record<string, number>;
}
export interface SCEPProfileStatsSnapshot {
path_id: string;
issuer_id: string;
challenge_password_set: boolean;
ra_cert_subject?: string;
ra_cert_not_before?: string;
ra_cert_not_after?: string;
ra_cert_days_to_expiry: number;
ra_cert_expired: boolean;
mtls_enabled: boolean;
mtls_trust_bundle_path?: string;
generated_at: string;
// nil/undefined when Intune is disabled on this profile.
intune?: IntuneSection;
}
export interface SCEPProfilesResponse {
profiles: SCEPProfileStatsSnapshot[];
profile_count: number;
generated_at: string;
}
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
//
// Backs the SCEP Probe section on the Network Scan page. The probe
// issues GetCACaps + GetCACert against an operator-supplied SCEP
// server URL and returns capability + posture metadata. Used for
// pre-migration assessment + compliance posture audits. Persisted
// to scep_probe_results (migration 000021) so the GUI can render
// recent probe history.
export interface SCEPProbeResult {
id: string;
target_url: string;
reachable: boolean;
advertised_caps: string[];
supports_rfc8894: boolean;
supports_aes: boolean;
supports_post_operation: boolean;
supports_renewal: boolean;
supports_sha256: boolean;
supports_sha512: boolean;
ca_cert_subject?: string;
ca_cert_issuer?: string;
ca_cert_not_before?: string;
ca_cert_not_after?: string;
ca_cert_expired: boolean;
ca_cert_days_to_expiry: number;
ca_cert_algorithm?: string;
ca_cert_chain_length: number;
probed_at: string;
probe_duration_ms: number;
error?: string;
created_at?: string;
}
export interface SCEPProbesResponse {
probes: SCEPProbeResult[];
probe_count: number;
}
+1 -1
View File
@@ -23,7 +23,7 @@ const nav = [
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' }, { to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, { to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ to: '/scep/intune', label: 'SCEP Intune', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' }, { to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
]; ];
+7 -2
View File
@@ -80,11 +80,16 @@ createRoot(document.getElementById('root')!).render(
<Route path="health-monitor" element={<HealthMonitorPage />} /> <Route path="health-monitor" element={<HealthMonitorPage />} />
<Route path="digest" element={<DigestPage />} /> <Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} /> <Route path="observability" element={<ObservabilityPage />} />
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile {/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
Intune Monitoring tab. Route is unconditional; the page + Phase 9 follow-up (rebrand): per-profile SCEP
Administration page with Profiles / Intune Monitoring /
Recent Activity tabs. Route is unconditional; the page
itself renders an "Admin access required" banner for itself renders an "Admin access required" banner for
non-admin callers and skips the underlying API calls so non-admin callers and skips the underlying API calls so
the server never sees a 403-prone request. */} the server never sees a 403-prone request. */}
<Route path="scep" element={<SCEPAdminPage />} />
{/* Backward-compat alias for external bookmarks the Phase 9
release advertised. Lands on the Intune Monitoring tab. */}
<Route path="scep/intune" element={<SCEPAdminPage />} /> <Route path="scep/intune" element={<SCEPAdminPage />} />
</Route> </Route>
</Routes> </Routes>
+114 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react'; import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -17,6 +17,9 @@ vi.mock('../api/client', () => ({
updateNetworkScanTarget: vi.fn(), updateNetworkScanTarget: vi.fn(),
deleteNetworkScanTarget: vi.fn(), deleteNetworkScanTarget: vi.fn(),
triggerNetworkScan: vi.fn(), triggerNetworkScan: vi.fn(),
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe.
probeSCEPServer: vi.fn(),
listSCEPProbes: vi.fn(),
})); }));
import NetworkScanPage from './NetworkScanPage'; import NetworkScanPage from './NetworkScanPage';
@@ -52,6 +55,10 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
vi.clearAllMocks(); vi.clearAllMocks();
cleanup(); cleanup();
delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__;
// SCEP probe section runs in parallel with the scan-targets table;
// stub its history endpoint to an empty list so the existing tests
// don't accidentally exercise the probe path.
vi.mocked(client.listSCEPProbes).mockResolvedValue({ probes: [], probe_count: 0 } as never);
}); });
it('renders the page header when getNetworkScanTargets resolves', async () => { it('renders the page header when getNetworkScanTargets resolves', async () => {
@@ -82,3 +89,109 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
// =============================================================================
// SCEP Probe section — Phase 11.5 of the master bundle.
// =============================================================================
const happyProbeResult = {
id: 'spr-test-1',
target_url: 'https://scep.example.com/scep',
reachable: true,
advertised_caps: ['POSTPKIOperation', 'SHA-256', 'SHA-512', 'AES', 'SCEPStandard', 'Renewal'],
supports_rfc8894: true,
supports_aes: true,
supports_post_operation: true,
supports_renewal: true,
supports_sha256: true,
supports_sha512: true,
ca_cert_subject: 'CN=test-ca',
ca_cert_issuer: 'CN=test-ca',
ca_cert_not_before: '2026-01-01T00:00:00Z',
ca_cert_not_after: '2027-01-01T00:00:00Z',
ca_cert_expired: false,
ca_cert_days_to_expiry: 250,
ca_cert_algorithm: 'ECDSA-P-256',
ca_cert_chain_length: 1,
probed_at: '2026-04-29T16:00:00Z',
probe_duration_ms: 245,
};
describe('NetworkScanPage — SCEP probe section (Phase 11.5)', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getNetworkScanTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
vi.mocked(client.listSCEPProbes).mockResolvedValue({ probes: [], probe_count: 0 } as never);
});
it('renders the SCEP probe section header + form', async () => {
renderWithQuery(<NetworkScanPage />);
expect(await screen.findByTestId('scep-probe-section')).toBeInTheDocument();
expect(screen.getByTestId('scep-probe-url-input')).toBeInTheDocument();
expect(screen.getByTestId('scep-probe-submit')).toBeInTheDocument();
});
it('rejects an empty URL with an inline error and never calls the probe endpoint', async () => {
renderWithQuery(<NetworkScanPage />);
fireEvent.click(await screen.findByTestId('scep-probe-submit'));
await waitFor(() => {
expect(screen.getByTestId('scep-probe-error')).toBeInTheDocument();
});
expect(client.probeSCEPServer).not.toHaveBeenCalled();
});
it('runs a probe and renders capability badges + CA cert details on success', async () => {
vi.mocked(client.probeSCEPServer).mockResolvedValue(happyProbeResult as never);
renderWithQuery(<NetworkScanPage />);
const input = await screen.findByTestId('scep-probe-url-input');
fireEvent.change(input, { target: { value: 'https://scep.example.com/scep' } });
fireEvent.click(screen.getByTestId('scep-probe-submit'));
await waitFor(() => {
expect(client.probeSCEPServer).toHaveBeenCalledWith('https://scep.example.com/scep');
});
const panel = await screen.findByTestId('scep-probe-result-panel');
expect(panel).toBeInTheDocument();
expect(screen.getByTestId('scep-probe-cap-badges')).toBeInTheDocument();
expect(screen.getByTestId('scep-probe-cap-rfc-8894').textContent).toContain('✓');
expect(screen.getByTestId('scep-probe-cap-aes').textContent).toContain('✓');
// Subject + days-remaining are rendered inside the panel; assert
// their substrings rather than using getByText (which matches a
// single text node and can miss content split across nested
// elements like dt/dd pairs).
expect(panel.textContent ?? '').toContain('CN=test-ca');
expect(panel.textContent ?? '').toContain('250d remaining');
});
it('surfaces probe-level errors in the inline panel', async () => {
vi.mocked(client.probeSCEPServer).mockRejectedValue(new Error('network unreachable'));
renderWithQuery(<NetworkScanPage />);
fireEvent.change(await screen.findByTestId('scep-probe-url-input'), { target: { value: 'https://broken.example.com/scep' } });
fireEvent.click(screen.getByTestId('scep-probe-submit'));
await waitFor(() => {
expect(screen.getByTestId('scep-probe-error')).toHaveTextContent(/network unreachable/);
});
expect(screen.queryByTestId('scep-probe-result-panel')).toBeNull();
});
it('renders the recent-probes history table with a row per probe', async () => {
vi.mocked(client.listSCEPProbes).mockResolvedValue({
probes: [
happyProbeResult,
{ ...happyProbeResult, id: 'spr-test-2', target_url: 'https://other.example.com/scep', supports_rfc8894: false },
],
probe_count: 2,
} as never);
renderWithQuery(<NetworkScanPage />);
const table = await screen.findByTestId('scep-probe-history-table');
const rows = table.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
expect(rows[0].textContent).toContain('scep.example.com');
expect(rows[1].textContent).toContain('other.example.com');
});
});
+221 -1
View File
@@ -7,13 +7,15 @@ import {
updateNetworkScanTarget, updateNetworkScanTarget,
deleteNetworkScanTarget, deleteNetworkScanTarget,
triggerNetworkScan, triggerNetworkScan,
probeSCEPServer,
listSCEPProbes,
} from '../api/client'; } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { NetworkScanTarget } from '../api/types'; import type { NetworkScanTarget, SCEPProbeResult } from '../api/types';
function CreateScanTargetModal({ onClose, onCreate }: { function CreateScanTargetModal({ onClose, onCreate }: {
onClose: () => void; onClose: () => void;
@@ -258,6 +260,7 @@ export default function NetworkScanPage() {
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network." emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
/> />
)} )}
<SCEPProbeSection />
</div> </div>
{showCreate && ( {showCreate && (
@@ -269,3 +272,220 @@ export default function NetworkScanPage() {
</> </>
); );
} }
// =============================================================================
// SCEP Probe section — Phase 11.5 of the master bundle.
// =============================================================================
//
// Operator-facing panel that runs an ad-hoc SCEP probe against a single
// URL. Used for pre-migration assessment (probe an existing EJBCA / NDES
// SCEP server before switching to certctl) and compliance posture audits
// (probe your own SCEP server periodically). Capability-only — does NOT
// POST a CSR. SSRF-defended at the backend via SafeHTTPDialContext.
//
// History table polls every 60s via TanStack Query.
function SCEPProbeSection() {
const [url, setUrl] = useState('');
const [latestResult, setLatestResult] = useState<SCEPProbeResult | null>(null);
const [probeError, setProbeError] = useState<string | undefined>(undefined);
const historyQuery = useQuery({
queryKey: ['scep-probes'],
queryFn: listSCEPProbes,
refetchInterval: 60_000,
});
const probeMutation = useTrackedMutation<SCEPProbeResult, Error, string>({
mutationFn: (target: string) => probeSCEPServer(target),
invalidates: [['scep-probes']],
onSuccess: (result) => {
setLatestResult(result);
setProbeError(undefined);
},
onError: (err: Error) => {
setLatestResult(null);
setProbeError(err.message);
},
});
const handleProbe = () => {
if (!url.trim()) {
setProbeError('Enter a SCEP server URL');
return;
}
setProbeError(undefined);
probeMutation.mutate(url.trim());
};
return (
<section className="px-6 py-4 mt-2 border-t border-surface-border" data-testid="scep-probe-section">
<header className="mb-3">
<h2 className="text-base font-semibold text-ink">SCEP server probe</h2>
<p className="text-xs text-ink-muted">
Probe a SCEP server URL for capability + posture (RFC 8894 GetCACaps + GetCACert).
Use before migrating from EJBCA / NDES to verify what the existing server advertises.
Capability-only: does NOT POST a CSR. Reserved IP ranges are rejected.
</p>
</header>
<div className="bg-surface border border-surface-border rounded-lg p-4 mb-4">
<div className="flex gap-2">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://scep.example.com/scep"
className="flex-1 border border-surface-border rounded px-3 py-2 text-sm font-mono"
data-testid="scep-probe-url-input"
disabled={probeMutation.isPending}
onKeyDown={(e) => {
if (e.key === 'Enter') handleProbe();
}}
/>
<button
type="button"
onClick={handleProbe}
disabled={probeMutation.isPending}
className="px-4 py-2 text-sm text-white bg-brand-600 hover:bg-brand-700 rounded disabled:opacity-50"
data-testid="scep-probe-submit"
>
{probeMutation.isPending ? 'Probing…' : 'Probe'}
</button>
</div>
{probeError && (
<div className="mt-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="scep-probe-error">
{probeError}
</div>
)}
{latestResult && <SCEPProbeResultPanel result={latestResult} />}
</div>
<SCEPProbeHistoryTable
probes={historyQuery.data?.probes ?? []}
isLoading={historyQuery.isLoading}
/>
</section>
);
}
function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
const tone = result.error
? 'bg-red-50 border-red-300 text-red-800'
: result.reachable
? 'bg-emerald-50 border-emerald-300 text-emerald-900'
: 'bg-amber-50 border-amber-300 text-amber-900';
return (
<div className={`mt-3 rounded border p-3 text-xs ${tone}`} data-testid="scep-probe-result-panel">
<div className="flex items-center justify-between mb-2">
<strong className="text-sm">{result.target_url}</strong>
<span>{formatDateTime(result.probed_at)} · {result.probe_duration_ms}ms</span>
</div>
{result.error && (
<p className="font-mono text-[11px] mb-2">Error: {result.error}</p>
)}
{result.reachable && (
<>
<div className="flex flex-wrap gap-1 mb-2" data-testid="scep-probe-cap-badges">
<CapBadge label="RFC 8894" supported={result.supports_rfc8894} />
<CapBadge label="AES" supported={result.supports_aes} />
<CapBadge label="POST" supported={result.supports_post_operation} />
<CapBadge label="Renewal" supported={result.supports_renewal} />
<CapBadge label="SHA-256" supported={result.supports_sha256} />
<CapBadge label="SHA-512" supported={result.supports_sha512} />
</div>
{result.ca_cert_subject && (
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
<dt className="font-semibold">CA cert subject:</dt>
<dd className="font-mono text-[11px]">{result.ca_cert_subject}</dd>
<dt className="font-semibold">Issuer:</dt>
<dd className="font-mono text-[11px]">{result.ca_cert_issuer}</dd>
<dt className="font-semibold">Algorithm:</dt>
<dd>{result.ca_cert_algorithm || '(unknown)'}</dd>
<dt className="font-semibold">Chain length:</dt>
<dd>{result.ca_cert_chain_length}</dd>
<dt className="font-semibold">Expires:</dt>
<dd>
{result.ca_cert_not_after ? formatDateTime(result.ca_cert_not_after) : '(unknown)'}
{' '}
{result.ca_cert_expired ? (
<span className="text-red-600 font-semibold">(EXPIRED)</span>
) : (
<span>({result.ca_cert_days_to_expiry}d remaining)</span>
)}
</dd>
</dl>
)}
{result.advertised_caps && result.advertised_caps.length > 0 && (
<p className="mt-2 text-[11px]">
Raw caps: <code>{result.advertised_caps.join(', ')}</code>
</p>
)}
</>
)}
</div>
);
}
function CapBadge({ label, supported }: { label: string; supported: boolean }) {
return (
<span
className={`text-[11px] uppercase px-2 py-0.5 rounded border ${
supported ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-gray-100 text-gray-600 border-gray-300'
}`}
data-testid={`scep-probe-cap-${label.toLowerCase().replace(/\W/g, '-')}`}
>
{label} {supported ? '✓' : '✗'}
</span>
);
}
function SCEPProbeHistoryTable({ probes, isLoading }: { probes: SCEPProbeResult[]; isLoading: boolean }) {
if (isLoading) {
return <p className="text-xs text-ink-muted">Loading probe history</p>;
}
if (probes.length === 0) {
return <p className="text-xs text-ink-muted">No SCEP probes yet probe a URL above to start.</p>;
}
return (
<div className="mt-3" data-testid="scep-probe-history-table">
<h3 className="text-xs font-semibold text-ink uppercase tracking-wide mb-2">Recent SCEP probes</h3>
<table className="w-full text-xs">
<thead className="text-ink-muted uppercase">
<tr>
<th className="text-left py-1 pr-2">When</th>
<th className="text-left py-1 pr-2">Target</th>
<th className="text-left py-1 pr-2">Reachable</th>
<th className="text-left py-1 pr-2">RFC 8894</th>
<th className="text-left py-1 pr-2">CA expiry</th>
</tr>
</thead>
<tbody>
{probes.map((p) => (
<tr key={p.id} className="border-t border-surface-border">
<td className="py-1 pr-2 font-mono">{formatDateTime(p.probed_at)}</td>
<td className="py-1 pr-2 font-mono break-all">{p.target_url}</td>
<td className="py-1 pr-2">
{p.reachable ? (
<span className="text-emerald-700">Yes</span>
) : (
<span className="text-red-700">No</span>
)}
</td>
<td className="py-1 pr-2">{p.supports_rfc8894 ? '✓' : '✗'}</td>
<td className="py-1 pr-2">
{p.ca_cert_expired ? (
<span className="text-red-700 font-semibold">EXPIRED</span>
) : p.ca_cert_subject ? (
`${p.ca_cert_days_to_expiry}d`
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+335 -175
View File
@@ -1,24 +1,32 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter, Routes, Route } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
// SCEP RFC 8894 + Intune master bundle Phase 9.5: Vitest coverage for the // SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// SCEPAdminPage component. Pins: // (cowork/scep-gui-restructure-prompt.md): Vitest coverage for the
// 1. Admin gate — non-admin callers see the gated banner and the page // rebranded SCEP Administration page. Pins:
// MUST NOT issue the underlying admin API requests. // 1. Admin gate — non-admin sees the gated banner; admin requests are
// 2. Profile cards render with status + counters + trust-anchor expiry // never issued.
// badge tone (good / warn / bad / EXPIRED). // 2. Tab navigation — Profiles is the default; clicking each tab
// 3. Disabled profiles render the off-state pill instead of the counter // switches surface; ?tab=intune deep-links land on Intune; the
// grid. // legacy /scep/intune route alias also lands on Intune.
// 4. Reload button opens the confirmation modal; Confirm calls the // 3. Profiles tab — per-profile lean cards; status badges reflect
// mutation and refetches stats; Cancel closes without calling. // Intune + mTLS + challenge-password-set; RA cert expiry badge
// 5. Error path surfaces ErrorState with retry. // tone bands (good ≥30d / warn 7-30d / bad <7d / EXPIRED);
// 6. Audit log filter merges PKCSReq + RenewalReq events and sorts by // "View Intune details →" link only renders for Intune-enabled
// timestamp descending. // profiles AND switches to the Intune tab on click.
// 4. Intune tab — counters render with the existing Phase 9 deep-dive
// shape; reload modal opens / Confirm calls mutation / Cancel
// skips mutation / Error keeps modal open + surfaces message.
// 5. Recent Activity tab — merges all four SCEP audit actions across
// four parallel useQuery calls; filter chips narrow to the
// requested subset.
// 6. Error path — surfaces ErrorState on the active tab.
vi.mock('../api/client', () => ({ vi.mock('../api/client', () => ({
getAdminSCEPProfiles: vi.fn(),
getAdminSCEPIntuneStats: vi.fn(), getAdminSCEPIntuneStats: vi.fn(),
reloadAdminSCEPIntuneTrust: vi.fn(), reloadAdminSCEPIntuneTrust: vi.fn(),
getAuditEvents: vi.fn(), getAuditEvents: vi.fn(),
@@ -32,13 +40,18 @@ import SCEPAdminPage from './SCEPAdminPage';
import * as client from '../api/client'; import * as client from '../api/client';
import { useAuth } from '../components/AuthProvider'; import { useAuth } from '../components/AuthProvider';
function renderWithQuery(ui: ReactNode) { function renderWithRoute(initialPath: string, ui: ReactNode) {
const qc = new QueryClient({ const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
}); });
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter> <MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/scep" element={ui} />
<Route path="/scep/intune" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -57,47 +70,71 @@ function setAuth(opts: { authRequired: boolean; admin: boolean }) {
}); });
} }
const baseEnabledProfile = { const corpProfileSummary = {
path_id: 'corp',
issuer_id: 'iss-corp',
challenge_password_set: true,
ra_cert_subject: 'ra-corp',
ra_cert_not_before: '2026-01-01T00:00:00Z',
ra_cert_not_after: '2027-01-01T00:00:00Z',
ra_cert_days_to_expiry: 250,
ra_cert_expired: false,
mtls_enabled: true,
mtls_trust_bundle_path: '/etc/certctl/mtls-corp.pem',
generated_at: '2026-04-29T15:00:00Z',
intune: {
trust_anchor_path: '/etc/certctl/intune-corp.pem',
trust_anchors: [
{ subject: 'intune-conn', not_before: '2026-01-01T00:00:00Z', not_after: '2027-01-01T00:00:00Z', days_to_expiry: 250, expired: false },
],
audience: 'https://certctl.example.com/scep/corp',
challenge_validity_ns: 3_600_000_000_000,
rate_limit_disabled: false,
replay_cache_size: 12,
counters: { success: 42 },
},
};
const iotProfileSummary = {
path_id: 'iot',
issuer_id: 'iss-iot',
challenge_password_set: true,
ra_cert_subject: 'ra-iot',
ra_cert_not_before: '2026-01-01T00:00:00Z',
ra_cert_not_after: '2026-05-15T00:00:00Z',
ra_cert_days_to_expiry: 16,
ra_cert_expired: false,
mtls_enabled: false,
generated_at: '2026-04-29T15:00:00Z',
// Intune disabled — no intune field
};
const expiredProfileSummary = {
path_id: 'legacy',
issuer_id: 'iss-old',
challenge_password_set: true,
ra_cert_subject: 'ra-old',
ra_cert_not_before: '2024-01-01T00:00:00Z',
ra_cert_not_after: '2025-01-01T00:00:00Z',
ra_cert_days_to_expiry: 0,
ra_cert_expired: true,
mtls_enabled: false,
generated_at: '2026-04-29T15:00:00Z',
};
const corpIntuneStats = {
path_id: 'corp', path_id: 'corp',
issuer_id: 'iss-corp', issuer_id: 'iss-corp',
enabled: true, enabled: true,
trust_anchor_path: '/etc/certctl/intune-corp.pem', trust_anchor_path: '/etc/certctl/intune-corp.pem',
trust_anchors: [ trust_anchors: [
{ { subject: 'intune-conn', not_before: '2026-01-01T00:00:00Z', not_after: '2027-01-01T00:00:00Z', days_to_expiry: 250, expired: false },
subject: 'intune-connector-installation-corp',
not_before: '2026-01-01T00:00:00Z',
not_after: '2027-01-01T00:00:00Z',
days_to_expiry: 250,
expired: false,
},
], ],
audience: 'https://certctl.example.com/scep/corp', audience: 'https://certctl.example.com/scep/corp',
challenge_validity_ns: 3_600_000_000_000, challenge_validity_ns: 3_600_000_000_000,
rate_limit_disabled: false, rate_limit_disabled: false,
replay_cache_size: 12, replay_cache_size: 12,
counters: { counters: { success: 42, signature_invalid: 1, claim_mismatch: 3, replay: 2 },
success: 42,
signature_invalid: 1,
expired: 0,
not_yet_valid: 0,
wrong_audience: 0,
replay: 2,
rate_limited: 0,
claim_mismatch: 3,
compliance_failed: 0,
malformed: 0,
unknown_version: 0,
},
generated_at: '2026-04-29T15:00:00Z',
};
const disabledProfile = {
path_id: 'iot',
issuer_id: 'iss-iot',
enabled: false,
rate_limit_disabled: false,
replay_cache_size: 0,
counters: {},
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
}; };
@@ -113,138 +150,236 @@ beforeEach(() => {
} as never); } as never);
}); });
// =============================================================================
// Admin gate.
// =============================================================================
describe('SCEPAdminPage — admin gate', () => { describe('SCEPAdminPage — admin gate', () => {
it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => { it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => {
setAuth({ authRequired: true, admin: false }); setAuth({ authRequired: true, admin: false });
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { level: 2, name: /SCEP Intune Monitoring/ })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 2, name: /SCEP Administration/ })).toBeInTheDocument();
}); });
expect(client.getAdminSCEPProfiles).not.toHaveBeenCalled();
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled(); expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
expect(screen.getByText(/Admin access required/i)).toBeInTheDocument(); expect(screen.getByText(/Admin access required/i)).toBeInTheDocument();
}); });
it('lets admin callers through and fetches stats', async () => { it('lets admin callers through and fetches the per-profile snapshot', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [baseEnabledProfile], profiles: [corpProfileSummary],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument();
expect(client.getAdminSCEPProfiles).toHaveBeenCalled();
// Default tab is Profiles → Intune stats endpoint NOT called yet
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
});
});
// =============================================================================
// Tab navigation + deep links.
// =============================================================================
describe('SCEPAdminPage — tab navigation', () => {
it('renders Profiles tab as default', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithRoute('/scep', <SCEPAdminPage />);
expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument();
expect(screen.getByTestId('tab-profiles').getAttribute('aria-pressed')).toBe('true');
});
it('switches to Intune tab on click and triggers the Intune stats fetch', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [corpIntuneStats],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithRoute('/scep', <SCEPAdminPage />);
await screen.findByTestId('profile-summary-corp');
fireEvent.click(screen.getByTestId('tab-intune'));
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument(); expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled(); expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled();
}); });
it('keeps the page accessible when authRequired=false (no-auth dev mode)', async () => { it('?tab=intune deep-link lands on Intune tab', async () => {
setAuth({ authRequired: false, admin: false }); vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ profiles: [corpProfileSummary],
profiles: [], profile_count: 1,
profile_count: 0,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [corpIntuneStats],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
await waitFor(() => { await waitFor(() => {
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalledTimes(1); expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
}); });
}); });
it('legacy /scep/intune route alias lands on Intune tab', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [corpIntuneStats],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithRoute('/scep/intune', <SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
});
});
it('switches to Activity tab and merges the four SCEP audit actions', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
const events: Record<string, unknown[]> = {
scep_pkcsreq: [{ id: 'a1', action: 'scep_pkcsreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }],
scep_renewalreq: [{ id: 'a2', action: 'scep_renewalreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:10:00Z' }],
scep_pkcsreq_intune: [{ id: 'a3', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:20:00Z' }],
scep_renewalreq_intune: [{ id: 'a4', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:30:00Z' }],
};
const action = params.action ?? '';
return Promise.resolve({
data: events[action] ?? [],
total: events[action]?.length ?? 0,
page: 1,
per_page: 200,
} as never);
});
renderWithRoute('/scep', <SCEPAdminPage />);
await screen.findByTestId('profile-summary-corp');
fireEvent.click(screen.getByTestId('tab-activity'));
await screen.findByTestId('activity-tab');
const table = await screen.findByTestId('activity-events-table');
const rows = table.querySelectorAll('tbody tr');
expect(rows.length).toBe(4);
// Sorted descending → renewal_intune (14:30) is first
expect(rows[0].textContent).toContain('scep_renewalreq_intune');
});
}); });
describe('SCEPAdminPage — profile rendering', () => { // =============================================================================
it('renders enabled profile counters with the expected labels and tone', async () => { // Profiles tab — lean cards.
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ // =============================================================================
profiles: [baseEnabledProfile],
profile_count: 1, describe('SCEPAdminPage — Profiles tab cards', () => {
it('renders status badges for Intune + mTLS + challenge-password-set', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary, iotProfileSummary],
profile_count: 2,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { await screen.findByTestId('profile-summary-corp');
expect(screen.getByTestId('counter-corp-success')).toHaveTextContent('42'); const corpBadges = screen.getByTestId('profile-badges-corp');
}); expect(corpBadges.textContent).toContain('Intune enabled');
expect(screen.getByTestId('counter-corp-replay')).toHaveTextContent('2'); expect(corpBadges.textContent).toContain('mTLS enabled');
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3'); expect(corpBadges.textContent).toContain('Challenge password set');
// Expiry badge is "good" tone for >= 30 days remaining. const iotBadges = screen.getByTestId('profile-badges-iot');
const badge = screen.getByTestId('expiry-badge-corp'); expect(iotBadges.textContent).toContain('Intune disabled');
expect(badge).toHaveTextContent('250d'); expect(iotBadges.textContent).toContain('mTLS disabled');
}); });
it('renders an expiry badge with EXPIRED text and bad tone when an anchor is past NotAfter', async () => { it('RA cert expiry badge tone reflects the days-to-expiry band', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [ profiles: [corpProfileSummary, iotProfileSummary, expiredProfileSummary],
{ profile_count: 3,
...baseEnabledProfile,
trust_anchors: [
{ subject: 'expired-conn', not_before: '2024-01-01T00:00:00Z', not_after: '2025-01-01T00:00:00Z', days_to_expiry: 0, expired: true },
],
},
],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { expect(await screen.findByTestId('ra-expiry-badge-corp')).toHaveTextContent('250d');
expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/); expect(screen.getByTestId('ra-expiry-badge-iot')).toHaveTextContent(/16d remaining \(rotate soon\)/);
}); expect(screen.getByTestId('ra-expiry-badge-legacy')).toHaveTextContent(/EXPIRED/);
}); });
it('renders the off-state pill for disabled profiles instead of the counter grid', async () => { it('"View Intune details →" only renders for Intune-enabled profiles AND switches tabs', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary, iotProfileSummary],
profile_count: 2,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [disabledProfile], profiles: [corpIntuneStats],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { await screen.findByTestId('profile-summary-corp');
expect(screen.getByTestId('profile-card-iot')).toBeInTheDocument(); expect(screen.getByTestId('view-intune-details-corp')).toBeInTheDocument();
}); expect(screen.queryByTestId('view-intune-details-iot')).toBeNull();
expect(screen.getByText(/Intune disabled/)).toBeInTheDocument(); fireEvent.click(screen.getByTestId('view-intune-details-corp'));
// Counter grid should NOT render for disabled profiles. expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
expect(screen.queryByTestId('counter-iot-success')).toBeNull(); expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
}); });
it('renders an empty-state banner when no profiles are configured', async () => { it('renders an empty-state banner when no profiles are configured', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [], profiles: [],
profile_count: 0, profile_count: 0,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { expect(await screen.findByText(/No SCEP profiles are configured/)).toBeInTheDocument();
expect(screen.getByText(/No SCEP profiles are configured/)).toBeInTheDocument();
});
}); });
}); });
describe('SCEPAdminPage — reload-trust modal', () => { // =============================================================================
it('opens the confirmation modal when the Reload trust button is clicked', async () => { // Intune tab — reload modal + counters.
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ // =============================================================================
profiles: [baseEnabledProfile],
describe('SCEPAdminPage — Intune tab', () => {
function gotoIntune() {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
await waitFor(() => { profiles: [corpIntuneStats],
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); profile_count: 1,
}); generated_at: '2026-04-29T15:00:00Z',
fireEvent.click(screen.getByTestId('reload-button-corp')); } as never);
expect(await screen.findByRole('dialog')).toBeInTheDocument(); renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
expect(screen.getByText(/Reload Intune trust anchor/i)).toBeInTheDocument(); }
it('renders counters with the expected labels and tones', async () => {
gotoIntune();
expect(await screen.findByTestId('counter-corp-success')).toHaveTextContent('42');
expect(screen.getByTestId('counter-corp-signature_invalid')).toHaveTextContent('1');
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3');
}); });
it('calls reloadAdminSCEPIntuneTrust on Confirm and closes the modal on success', async () => { it('opens the reload modal and calls the mutation on Confirm', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockResolvedValue({ vi.mocked(client.reloadAdminSCEPIntuneTrust).mockResolvedValue({
reloaded: true, reloaded: true,
path_id: 'corp', path_id: 'corp',
reloaded_at: '2026-04-29T15:01:00Z', reloaded_at: '2026-04-29T15:01:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); gotoIntune();
await waitFor(() => { expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument();
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
await waitFor(() => { await waitFor(() => {
@@ -255,36 +390,21 @@ describe('SCEPAdminPage — reload-trust modal', () => {
}); });
}); });
it('keeps the modal open and shows the error message when reload fails', async () => { it('keeps the modal open and shows the error when reload fails', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired')); vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired'));
renderWithQuery(<SCEPAdminPage />); gotoIntune();
await waitFor(() => { expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument();
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/trust anchor cert expired/)).toBeInTheDocument(); expect(screen.getByText(/trust anchor cert expired/)).toBeInTheDocument();
}); });
// Modal stays open so the operator can read the error and retry.
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
it('Cancel closes the modal without calling the reload mutation', async () => { it('Cancel closes the modal without calling the reload mutation', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ gotoIntune();
profiles: [baseEnabledProfile], expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument();
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Cancel/i })); fireEvent.click(await screen.findByRole('button', { name: /Cancel/i }));
await waitFor(() => { await waitFor(() => {
@@ -294,47 +414,87 @@ describe('SCEPAdminPage — reload-trust modal', () => {
}); });
}); });
describe('SCEPAdminPage — error + audit-log surface', () => { // =============================================================================
it('surfaces ErrorState when the stats query fails', async () => { // Recent Activity tab — filter chips.
vi.mocked(client.getAdminSCEPIntuneStats).mockRejectedValue(new Error('boom')); // =============================================================================
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument();
});
});
it('merges PKCSReq + RenewalReq audit events and sorts by timestamp descending', async () => { describe('SCEPAdminPage — Activity tab filter', () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ beforeEach(() => {
profiles: [baseEnabledProfile], vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => { vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
if (params.action === 'scep_pkcsreq_intune') { const lookup: Record<string, unknown[]> = {
return Promise.resolve({ scep_pkcsreq: [{ id: 'p1', action: 'scep_pkcsreq', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }],
data: [ scep_renewalreq: [{ id: 'p2', action: 'scep_renewalreq', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:01:00Z' }],
{ id: 'ae-pkcs-1', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-1', details: {}, timestamp: '2026-04-29T14:00:00Z' }, scep_pkcsreq_intune: [{ id: 'p3', action: 'scep_pkcsreq_intune', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:02:00Z' }],
], scep_renewalreq_intune: [{ id: 'p4', action: 'scep_renewalreq_intune', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:03:00Z' }],
total: 1, page: 1, per_page: 200, };
} as never);
}
return Promise.resolve({ return Promise.resolve({
data: [ data: lookup[params.action ?? ''] ?? [],
{ id: 'ae-renew-1', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-2', details: {}, timestamp: '2026-04-29T14:30:00Z' }, total: 1,
], page: 1,
total: 1, page: 1, per_page: 200, per_page: 200,
} as never); } as never);
}); });
});
renderWithQuery(<SCEPAdminPage />); it('filter=all shows all four actions', async () => {
await waitFor(() => { renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument(); await screen.findByTestId('activity-tab');
}); const table = await screen.findByTestId('activity-events-table');
expect(table.querySelectorAll('tbody tr').length).toBe(4);
});
const rows = screen.getByTestId('recent-failures-table').querySelectorAll('tbody tr'); it('filter=intune narrows to just the two _intune actions', async () => {
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
await screen.findByTestId('activity-tab');
fireEvent.click(screen.getByTestId('activity-filter-intune'));
const table = await screen.findByTestId('activity-events-table');
const rows = table.querySelectorAll('tbody tr');
expect(rows.length).toBe(2); expect(rows.length).toBe(2);
// Sorted descending by timestamp — renewal (14:30) comes before pkcs (14:00). for (const r of rows) {
expect(rows[0].textContent).toContain('scep_renewalreq_intune'); expect(r.textContent).toMatch(/_intune/);
expect(rows[1].textContent).toContain('scep_pkcsreq_intune'); }
});
it('filter=renewal narrows to just the two renewal actions', async () => {
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
await screen.findByTestId('activity-tab');
fireEvent.click(screen.getByTestId('activity-filter-renewal'));
const table = await screen.findByTestId('activity-events-table');
const rows = table.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
for (const r of rows) {
expect(r.textContent).toContain('scep_renewalreq');
}
});
it('filter=static narrows to just the two non-Intune actions', async () => {
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
await screen.findByTestId('activity-tab');
fireEvent.click(screen.getByTestId('activity-filter-static'));
const table = await screen.findByTestId('activity-events-table');
const rows = table.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
for (const r of rows) {
expect(r.textContent).not.toMatch(/_intune/);
}
});
});
// =============================================================================
// Error path.
// =============================================================================
describe('SCEPAdminPage — error surfacing', () => {
it('surfaces ErrorState on the active tab when its query fails', async () => {
vi.mocked(client.getAdminSCEPProfiles).mockRejectedValue(new Error('boom-profiles'));
renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument();
});
}); });
}); });
+501 -152
View File
@@ -1,28 +1,50 @@
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getAdminSCEPIntuneStats, reloadAdminSCEPIntuneTrust, getAuditEvents } from '../api/client'; import { useLocation, useSearchParams } from 'react-router-dom';
import {
getAdminSCEPIntuneStats,
getAdminSCEPProfiles,
reloadAdminSCEPIntuneTrust,
getAuditEvents,
} from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { useAuth } from '../components/AuthProvider'; import { useAuth } from '../components/AuthProvider';
import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { IntuneStatsSnapshot, IntuneTrustAnchorInfo, AuditEvent } from '../api/types'; import type {
IntuneStatsSnapshot,
IntuneTrustAnchorInfo,
AuditEvent,
SCEPProfileStatsSnapshot,
} from '../api/types';
// SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile Intune // SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// Monitoring tab. // (cowork/scep-gui-restructure-prompt.md): per-profile SCEP
// administration page with three tabs.
// //
// Surfaces: // Profiles (default) — every configured SCEP profile, lean card per
// - Status banner per profile (trust anchor expiry countdown, rotates // profile with always-present fields (RA cert
// when < 30 days; the soonest-to-expire anchor wins). // expiry, mTLS sibling-route status,
// - Live counters table per profile (success / signature_invalid / // challenge-password-set indicator). Cards on
// claim_mismatch / expired / wrong_audience / replay / rate_limited / // Intune-enabled profiles get a "View Intune
// malformed / compliance_failed / not_yet_valid / unknown_version). // details →" link that deep-links to the
// Polled every 30s via TanStack Query. // Intune tab filtered to that profile.
// - Recent failures table (last 50) populated from the audit log // Intune Monitoring — the existing Phase 9.4 deep-dive. Per-profile
// filtered to action=scep_pkcsreq_intune (and the renewal sibling). // counters (success / signature_invalid /
// - Trust anchor reload button (per-profile) with confirmation modal; // claim_mismatch / expired / wrong_audience /
// calls POST /api/v1/admin/scep/intune/reload-trust under the hood // replay / rate_limited / malformed /
// (the SIGHUP-equivalent path). // compliance_failed / not_yet_valid /
// unknown_version), trust anchor expiry
// countdown, recent failures table, reload-
// trust button + confirmation modal. Polled
// every 30s via TanStack Query.
// Recent Activity — full SCEP audit log filter covering all four
// action codes (scep_pkcsreq, scep_renewalreq,
// scep_pkcsreq_intune, scep_renewalreq_intune).
// Merged + sorted descending by timestamp.
// Filter chips for All / Initial / Renewal /
// Intune / Static. Polled every 60s.
// //
// Admin-gated: the page itself renders an "Admin access required" banner // Admin-gated: the page itself renders an "Admin access required" banner
// for non-admin callers and never issues the underlying admin requests. // for non-admin callers and never issues the underlying admin requests.
@@ -62,28 +84,179 @@ const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = {
bad: 'text-red-600', bad: 'text-red-600',
}; };
type TabId = 'profiles' | 'intune' | 'activity';
type ActivityFilter = 'all' | 'initial' | 'renewal' | 'intune' | 'static';
const TAB_LABELS: Record<TabId, string> = {
profiles: 'Profiles',
intune: 'Intune Monitoring',
activity: 'Recent Activity',
};
const SCEP_AUDIT_ACTIONS = [
'scep_pkcsreq',
'scep_renewalreq',
'scep_pkcsreq_intune',
'scep_renewalreq_intune',
] as const;
// =============================================================================
// Tone + badge helpers (shared across tabs).
// =============================================================================
function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } {
if (expired) return { text: 'EXPIRED', tone: 'bad' };
if (days === null) return { text: 'Not loaded', tone: 'warn' };
if (days < 7) return { text: `${days}d remaining`, tone: 'bad' };
if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' };
return { text: `${days}d remaining`, tone: 'good' };
}
function badgeClass(tone: 'good' | 'warn' | 'bad'): string {
if (tone === 'good') return 'bg-emerald-100 text-emerald-800';
if (tone === 'warn') return 'bg-amber-100 text-amber-800';
return 'bg-red-100 text-red-800';
}
function pillClass(active: boolean): string {
return active
? 'bg-brand-100 text-brand-800 border-brand-300'
: 'bg-surface-alt text-ink-muted border-surface-border';
}
// soonestExpiryDays returns the smallest days_to_expiry across the // soonestExpiryDays returns the smallest days_to_expiry across the
// profile's trust anchor pool. Returns null when the pool is empty (the // profile's Intune trust anchor pool. Returns null when the pool is
// per-profile preflight should have refused this state at boot, but // empty (the per-profile preflight should have refused this state at
// defensive in case the holder is reloaded mid-flight to an empty file). // boot, but defensive in case the holder is reloaded mid-flight to an
// empty file).
function soonestExpiryDays(anchors?: IntuneTrustAnchorInfo[]): number | null { function soonestExpiryDays(anchors?: IntuneTrustAnchorInfo[]): number | null {
if (!anchors || anchors.length === 0) return null; if (!anchors || anchors.length === 0) return null;
let min = Number.POSITIVE_INFINITY; let min = Number.POSITIVE_INFINITY;
for (const a of anchors) { for (const a of anchors) {
if (a.expired) return -1; // any expired wins if (a.expired) return -1;
if (a.days_to_expiry < min) min = a.days_to_expiry; if (a.days_to_expiry < min) min = a.days_to_expiry;
} }
return min === Number.POSITIVE_INFINITY ? null : min; return min === Number.POSITIVE_INFINITY ? null : min;
} }
function expiryBadge(days: number | null): { text: string; tone: 'good' | 'warn' | 'bad' } { // =============================================================================
if (days === null) return { text: 'No trust anchors', tone: 'warn' }; // Profiles tab — per-profile lean card with always-present fields.
if (days < 0) return { text: 'EXPIRED', tone: 'bad' }; // =============================================================================
if (days < 7) return { text: `${days}d remaining`, tone: 'bad' };
if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' }; interface ProfilesTabProps {
return { text: `${days}d remaining`, tone: 'good' }; profiles: SCEPProfileStatsSnapshot[];
isLoading: boolean;
onViewIntuneDetails: (pathID: string) => void;
} }
function ProfilesTab({ profiles, isLoading, onViewIntuneDetails }: ProfilesTabProps) {
if (isLoading) {
return <p className="text-sm text-ink-muted px-1 py-6">Loading profiles</p>;
}
if (profiles.length === 0) {
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
No SCEP profiles are configured. Set <code>CERTCTL_SCEP_ENABLED=true</code> and either the
legacy single-profile env vars or <code>CERTCTL_SCEP_PROFILES=...</code> with the indexed
per-profile family to register at least one endpoint.
</div>
);
}
return (
<>
{profiles.map(p => (
<ProfileSummaryCard
key={p.path_id || '(root)'}
profile={p}
onViewIntuneDetails={onViewIntuneDetails}
/>
))}
</>
);
}
interface ProfileSummaryCardProps {
profile: SCEPProfileStatsSnapshot;
onViewIntuneDetails: (pathID: string) => void;
}
function ProfileSummaryCard({ profile, onViewIntuneDetails }: ProfileSummaryCardProps) {
const pathLabel = profile.path_id || '(legacy /scep root)';
const intuneEnabled = !!profile.intune;
const raBadge = expiryBadge(
profile.ra_cert_subject ? profile.ra_cert_days_to_expiry : null,
profile.ra_cert_expired,
);
return (
<section
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
data-testid={`profile-summary-${profile.path_id}`}
>
<header className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(raBadge.tone)}`}
data-testid={`ra-expiry-badge-${profile.path_id}`}
>
RA cert: {raBadge.text}
</span>
</header>
<div className="flex flex-wrap gap-2 mb-3" data-testid={`profile-badges-${profile.path_id}`}>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.challenge_password_set)}`}>
Challenge password{profile.challenge_password_set ? ' set' : ' MISSING'}
</span>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
</span>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(intuneEnabled)}`}>
Intune {intuneEnabled ? 'enabled' : 'disabled'}
</span>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-ink-muted">
<div>
<dt className="font-semibold text-ink">RA cert subject</dt>
<dd className="font-mono text-[11px]">{profile.ra_cert_subject || '(not loaded)'}</dd>
</div>
{profile.ra_cert_not_after && (
<div>
<dt className="font-semibold text-ink">RA cert expires</dt>
<dd>{formatDateTime(profile.ra_cert_not_after)}</dd>
</div>
)}
{profile.mtls_enabled && profile.mtls_trust_bundle_path && (
<div>
<dt className="font-semibold text-ink">mTLS trust bundle</dt>
<dd className="font-mono text-[11px]">{profile.mtls_trust_bundle_path}</dd>
</div>
)}
</dl>
{intuneEnabled && (
<div className="mt-4 pt-3 border-t border-surface-border flex justify-end">
<button
type="button"
onClick={() => onViewIntuneDetails(profile.path_id)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
data-testid={`view-intune-details-${profile.path_id}`}
>
View Intune details
</button>
</div>
)}
</section>
);
}
// =============================================================================
// Intune Monitoring tab — the existing Phase 9.4 deep-dive surface.
// =============================================================================
interface ConfirmReloadModalProps { interface ConfirmReloadModalProps {
profile: IntuneStatsSnapshot; profile: IntuneStatsSnapshot;
onCancel: () => void; onCancel: () => void;
@@ -140,39 +313,74 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
); );
} }
interface ProfileCardProps { interface IntuneTabProps {
profile: IntuneStatsSnapshot; profiles: IntuneStatsSnapshot[];
isLoading: boolean;
onRequestReload: (profile: IntuneStatsSnapshot) => void; onRequestReload: (profile: IntuneStatsSnapshot) => void;
highlightPathID: string | null;
events: AuditEvent[];
eventsLoading: boolean;
} }
function ProfileCard({ profile, onRequestReload }: ProfileCardProps) { function IntuneTab({ profiles, isLoading, onRequestReload, highlightPathID, events, eventsLoading }: IntuneTabProps) {
const pathLabel = profile.path_id || '(legacy /scep root)'; if (isLoading) {
if (!profile.enabled) { return <p className="text-sm text-ink-muted px-1 py-6">Loading Intune monitoring data</p>;
return (
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
<header className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-alt text-ink-muted">
Intune disabled
</span>
</header>
<p className="text-sm text-ink-muted">
This profile honors only the static challenge password. To enable Intune dispatch, set
<code className="mx-1">CERTCTL_SCEP_PROFILE_{(profile.path_id || 'DEFAULT').toUpperCase()}_INTUNE_ENABLED=true</code>
plus the matching trust-anchor path env var, then restart the server.
</p>
</section>
);
} }
const intuneProfiles = profiles.filter(p => p.enabled);
return (
<>
{intuneProfiles.length === 0 && (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
No SCEP profile has Intune enabled. Set
<code className="mx-1">CERTCTL_SCEP_PROFILE_&lt;NAME&gt;_INTUNE_ENABLED=true</code>
plus the matching trust-anchor path env var, then restart the server.
</div>
)}
{intuneProfiles.map(p => (
<IntuneProfileCard
key={p.path_id || '(root)'}
profile={p}
onRequestReload={onRequestReload}
highlighted={highlightPathID === p.path_id}
/>
))}
<section className="bg-surface border border-surface-border rounded-lg mt-6">
<div className="px-4 py-3 border-b border-surface-border">
<h3 className="text-sm font-semibold text-ink">
Recent Intune-dispatched enrollments (last 50)
</h3>
<p className="text-xs text-ink-muted">
Filtered to <code>action=scep_pkcsreq_intune</code> + <code>action=scep_renewalreq_intune</code>.
Refreshes every 60s.
</p>
</div>
{eventsLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentEventsTable events={events.slice(0, 50)} testID="intune-failures-table" emptyMessage="No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled." />
)}
</section>
</>
);
}
interface IntuneProfileCardProps {
profile: IntuneStatsSnapshot;
onRequestReload: (profile: IntuneStatsSnapshot) => void;
highlighted: boolean;
}
function IntuneProfileCard({ profile, onRequestReload, highlighted }: IntuneProfileCardProps) {
const pathLabel = profile.path_id || '(legacy /scep root)';
const days = soonestExpiryDays(profile.trust_anchors); const days = soonestExpiryDays(profile.trust_anchors);
const badge = expiryBadge(days); const badge = expiryBadge(days, days !== null && days < 0);
const cardClass = highlighted
? 'bg-surface border-2 border-brand-400 rounded-lg p-5 mb-4 shadow-sm'
: 'bg-surface border border-surface-border rounded-lg p-5 mb-4';
return ( return (
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}> <section className={cardClass} data-testid={`profile-card-${profile.path_id}`}>
<header className="flex items-center justify-between mb-3"> <header className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3> <h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
@@ -183,13 +391,7 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${ className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(badge.tone)}`}
badge.tone === 'good'
? 'bg-emerald-100 text-emerald-800'
: badge.tone === 'warn'
? 'bg-amber-100 text-amber-800'
: 'bg-red-100 text-red-800'
}`}
data-testid={`expiry-badge-${profile.path_id}`} data-testid={`expiry-badge-${profile.path_id}`}
> >
Trust anchor: {badge.text} Trust anchor: {badge.text}
@@ -264,16 +466,93 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
); );
} }
function RecentFailuresTable({ events }: { events: AuditEvent[] }) { // =============================================================================
// Recent Activity tab — full SCEP audit log filter.
// =============================================================================
interface ActivityTabProps {
events: AuditEvent[];
isLoading: boolean;
filter: ActivityFilter;
setFilter: (f: ActivityFilter) => void;
}
function activityFilterMatches(filter: ActivityFilter, action: string): boolean {
switch (filter) {
case 'all':
return true;
case 'initial':
return action === 'scep_pkcsreq' || action === 'scep_pkcsreq_intune';
case 'renewal':
return action === 'scep_renewalreq' || action === 'scep_renewalreq_intune';
case 'intune':
return action === 'scep_pkcsreq_intune' || action === 'scep_renewalreq_intune';
case 'static':
return action === 'scep_pkcsreq' || action === 'scep_renewalreq';
}
}
function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) {
const filtered = events.filter(e => activityFilterMatches(filter, e.action));
return (
<section className="bg-surface border border-surface-border rounded-lg" data-testid="activity-tab">
<div className="px-4 py-3 border-b border-surface-border">
<h3 className="text-sm font-semibold text-ink">SCEP enrollment audit log (last 100)</h3>
<p className="text-xs text-ink-muted mb-3">
Merged across <code>scep_pkcsreq</code> + <code>scep_renewalreq</code> +
<code> scep_pkcsreq_intune</code> + <code>scep_renewalreq_intune</code>. Refreshes every 60s.
</p>
<div className="flex flex-wrap gap-2" data-testid="activity-filter-chips">
{(['all', 'initial', 'renewal', 'intune', 'static'] as const).map(f => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={`text-xs px-2 py-1 rounded border ${
filter === f
? 'bg-brand-100 text-brand-800 border-brand-300'
: 'bg-surface text-ink-muted border-surface-border hover:bg-surface-alt'
}`}
data-testid={`activity-filter-${f}`}
>
{f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</div>
{isLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentEventsTable
events={filtered.slice(0, 100)}
testID="activity-events-table"
emptyMessage={
events.length === 0
? 'No SCEP enrollment events recorded yet.'
: 'No events match the current filter — try a different chip.'
}
/>
)}
</section>
);
}
// =============================================================================
// Shared events table.
// =============================================================================
interface RecentEventsTableProps {
events: AuditEvent[];
testID: string;
emptyMessage: string;
}
function RecentEventsTable({ events, testID, emptyMessage }: RecentEventsTableProps) {
if (events.length === 0) { if (events.length === 0) {
return ( return <p className="text-sm text-ink-muted px-4 py-6">{emptyMessage}</p>;
<p className="text-sm text-ink-muted px-4 py-6">
No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled.
</p>
);
} }
return ( return (
<table className="w-full text-sm" data-testid="recent-failures-table"> <table className="w-full text-sm" data-testid={testID}>
<thead className="text-xs text-ink-muted uppercase tracking-wide"> <thead className="text-xs text-ink-muted uppercase tracking-wide">
<tr> <tr>
<th className="py-2 pl-4 pr-2 text-left">Timestamp</th> <th className="py-2 pl-4 pr-2 text-left">Timestamp</th>
@@ -298,50 +577,113 @@ function RecentFailuresTable({ events }: { events: AuditEvent[] }) {
); );
} }
// =============================================================================
// Top-level page.
// =============================================================================
function pickTabFromQuery(value: string | null): TabId {
if (value === 'intune' || value === 'activity') return value;
return 'profiles';
}
// pickInitialTab honors three signals (precedence high → low):
// 1. ?tab=intune|activity in the query string (deep link)
// 2. Pathname ending in /scep/intune (legacy route alias from
// Phase 9.4; preserved so external bookmarks land on Intune)
// 3. Default to 'profiles'
function pickInitialTab(searchParams: URLSearchParams, pathname: string): TabId {
const fromQuery = searchParams.get('tab');
if (fromQuery === 'intune' || fromQuery === 'activity') return fromQuery;
if (pathname.endsWith('/scep/intune')) return 'intune';
return 'profiles';
}
export default function SCEPAdminPage() { export default function SCEPAdminPage() {
const auth = useAuth(); const auth = useAuth();
const adminAccess = !auth.authRequired || auth.admin;
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const [activeTab, setActiveTab] = useState<TabId>(() => pickInitialTab(searchParams, location.pathname));
const [highlightPathID, setHighlightPathID] = useState<string | null>(searchParams.get('profile'));
const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null); const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null);
const [reloadError, setReloadError] = useState<string | undefined>(undefined); const [reloadError, setReloadError] = useState<string | undefined>(undefined);
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
const statsQuery = useQuery({ // Keep URL in sync with tab + highlighted profile so deep links survive
queryKey: ['admin', 'scep', 'intune', 'stats'], // page reloads + browser back/forward.
queryFn: getAdminSCEPIntuneStats, useEffect(() => {
enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin const next = new URLSearchParams(searchParams);
if (activeTab === 'profiles') {
next.delete('tab');
} else {
next.set('tab', activeTab);
}
if (highlightPathID && activeTab === 'intune') {
next.set('profile', highlightPathID);
} else {
next.delete('profile');
}
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
}, [activeTab, highlightPathID, searchParams, setSearchParams]);
// Always-present per-profile data (Profiles tab).
const profilesQuery = useQuery({
queryKey: ['admin', 'scep', 'profiles'],
queryFn: getAdminSCEPProfiles,
enabled: adminAccess,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
// Audit-log filter: every Intune-dispatched enrollment (success + failure) // Intune deep-dive data (Intune tab).
// emits action=scep_pkcsreq_intune (initial) or scep_renewalreq_intune const intuneStatsQuery = useQuery({
// (renewal). The audit endpoint accepts a single action filter; we fetch queryKey: ['admin', 'scep', 'intune', 'stats'],
// both server-side via two queries and merge client-side rather than queryFn: getAdminSCEPIntuneStats,
// adding a comma-separated filter that would require backend changes. enabled: adminAccess && activeTab === 'intune',
const auditPKCSQuery = useQuery({ refetchInterval: 30_000,
queryKey: ['audit', { action: 'scep_pkcsreq_intune' }],
queryFn: () => getAuditEvents({ action: 'scep_pkcsreq_intune' }),
enabled: !auth.authRequired || auth.admin,
refetchInterval: 60_000,
});
const auditRenewalQuery = useQuery({
queryKey: ['audit', { action: 'scep_renewalreq_intune' }],
queryFn: () => getAuditEvents({ action: 'scep_renewalreq_intune' }),
enabled: !auth.authRequired || auth.admin,
refetchInterval: 60_000,
}); });
// Bundle-8 / M-009 invalidation contract: trust-anchor reload changes // Audit log queries — four parallel queries (one per SCEP action) so
// both the per-profile trust pool (reflected in IntuneStats) AND every // both the Intune tab's recent-failures table and the Activity tab's
// recently-failed Intune enrollment counter that might now succeed on // full SCEP audit feed can pull from the same React Query cache.
// retry. We invalidate the stats key so the per-profile trust-anchor const auditQueries = SCEP_AUDIT_ACTIONS.map(action =>
// panel reflects the new pool immediately; the audit log queries // eslint-disable-next-line react-hooks/rules-of-hooks
// remain on their 60s timer (a SIGHUP-equivalent reload doesn't useQuery({
// backfill new audit rows). queryKey: ['audit', { action }],
queryFn: () => getAuditEvents({ action }),
enabled: adminAccess && (activeTab === 'intune' || activeTab === 'activity'),
refetchInterval: 60_000,
}),
);
const allAuditEvents: AuditEvent[] = useMemo(() => {
const merged: AuditEvent[] = [];
for (const q of auditQueries) {
if (q.data?.data) merged.push(...q.data.data);
}
return merged.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auditQueries.map(q => q.dataUpdatedAt).join('|')]);
const auditLoading = auditQueries.some(q => q.isLoading);
const intuneOnlyEvents = useMemo(
() =>
allAuditEvents.filter(
e => e.action === 'scep_pkcsreq_intune' || e.action === 'scep_renewalreq_intune',
),
[allAuditEvents],
);
const reloadMutation = useTrackedMutation< const reloadMutation = useTrackedMutation<
Awaited<ReturnType<typeof reloadAdminSCEPIntuneTrust>>, Awaited<ReturnType<typeof reloadAdminSCEPIntuneTrust>>,
Error, Error,
string string
>({ >({
mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID), mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID),
invalidates: [['admin', 'scep', 'intune', 'stats']], invalidates: [
['admin', 'scep', 'intune', 'stats'],
['admin', 'scep', 'profiles'],
],
onSuccess: () => { onSuccess: () => {
setReloadTarget(null); setReloadTarget(null);
setReloadError(undefined); setReloadError(undefined);
@@ -354,53 +696,36 @@ export default function SCEPAdminPage() {
if (auth.authRequired && !auth.admin) { if (auth.authRequired && !auth.admin) {
return ( return (
<> <>
<PageHeader title="SCEP Intune Monitoring" subtitle="Admin-only observability surface" /> <PageHeader title="SCEP Administration" subtitle="Admin-only observability surface" />
<div className="p-6"> <div className="p-6">
<ErrorState <ErrorState
error={new Error('Admin access required: this page exposes per-profile trust anchor expiries and an admin-only reload action. Sign in with an admin-tagged API key to view it.')} error={new Error('Admin access required: this page exposes per-profile RA cert expiries, mTLS bundle paths, Intune trust anchor expiries, and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
/> />
</div> </div>
</> </>
); );
} }
if (statsQuery.isLoading) { const profiles = profilesQuery.data?.profiles ?? [];
return ( const intuneProfiles = intuneStatsQuery.data?.profiles ?? [];
<>
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
<div className="p-6 text-sm text-ink-muted">Loading per-profile stats</div>
</>
);
}
if (statsQuery.error) { const handleViewIntuneDetails = (pathID: string) => {
return ( setHighlightPathID(pathID);
<> setActiveTab('intune');
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" /> };
<div className="p-6">
<ErrorState error={statsQuery.error as Error} onRetry={() => statsQuery.refetch()} />
</div>
</>
);
}
const profiles = statsQuery.data?.profiles ?? [];
const events: AuditEvent[] = [
...(auditPKCSQuery.data?.data ?? []),
...(auditRenewalQuery.data?.data ?? []),
]
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
.slice(0, 50);
return ( return (
<> <>
<PageHeader <PageHeader
title="SCEP Intune Monitoring" title="SCEP Administration"
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · counters auto-refresh every 30s`} subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · per-profile observability + Intune monitoring + recent activity`}
action={ action={
<button <button
type="button" type="button"
onClick={() => statsQuery.refetch()} onClick={() => {
void profilesQuery.refetch();
if (activeTab === 'intune') void intuneStatsQuery.refetch();
}}
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt" className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid="refresh-stats-button" data-testid="refresh-stats-button"
> >
@@ -408,41 +733,65 @@ export default function SCEPAdminPage() {
</button> </button>
} }
/> />
<div className="border-b border-surface-border bg-surface px-6">
<nav className="flex gap-1 -mb-px" data-testid="scep-admin-tabs">
{(['profiles', 'intune', 'activity'] as TabId[]).map(t => (
<button
key={t}
type="button"
onClick={() => setActiveTab(t)}
className={`px-4 py-2.5 text-sm border-b-2 transition-colors ${
activeTab === t
? 'border-brand-500 text-brand-700 font-semibold'
: 'border-transparent text-ink-muted hover:text-ink hover:border-surface-border'
}`}
data-testid={`tab-${t}`}
aria-pressed={activeTab === t}
>
{TAB_LABELS[t]}
</button>
))}
</nav>
</div>
<div className="p-6 overflow-y-auto"> <div className="p-6 overflow-y-auto">
{profiles.length === 0 && ( {profilesQuery.error && activeTab === 'profiles' && (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4"> <ErrorState error={profilesQuery.error as Error} onRetry={() => profilesQuery.refetch()} />
No SCEP profiles are configured. Set <code>CERTCTL_SCEP_ENABLED=true</code> and either the
legacy single-profile env vars or <code>CERTCTL_SCEP_PROFILES=...</code> with the indexed
per-profile family to register at least one endpoint.
</div>
)} )}
{profiles.map(p => ( {intuneStatsQuery.error && activeTab === 'intune' && (
<ProfileCard <ErrorState error={intuneStatsQuery.error as Error} onRetry={() => intuneStatsQuery.refetch()} />
key={p.path_id || '(root)'} )}
profile={p}
{activeTab === 'profiles' && !profilesQuery.error && (
<ProfilesTab
profiles={profiles}
isLoading={profilesQuery.isLoading}
onViewIntuneDetails={handleViewIntuneDetails}
/>
)}
{activeTab === 'intune' && !intuneStatsQuery.error && (
<IntuneTab
profiles={intuneProfiles}
isLoading={intuneStatsQuery.isLoading}
onRequestReload={profile => { onRequestReload={profile => {
setReloadError(undefined); setReloadError(undefined);
setReloadTarget(profile); setReloadTarget(profile);
}} }}
highlightPathID={highlightPathID}
events={intuneOnlyEvents}
eventsLoading={auditLoading}
/> />
))} )}
<section className="bg-surface border border-surface-border rounded-lg mt-6"> {activeTab === 'activity' && (
<div className="px-4 py-3 border-b border-surface-border"> <ActivityTab
<h3 className="text-sm font-semibold text-ink"> events={allAuditEvents}
Recent Intune-dispatched enrollments (last 50) isLoading={auditLoading}
</h3> filter={activityFilter}
<p className="text-xs text-ink-muted"> setFilter={setActivityFilter}
Filtered to <code>action=scep_pkcsreq_intune</code> + <code>action=scep_renewalreq_intune</code>. />
Refreshes every 60s. )}
</p>
</div>
{auditPKCSQuery.isLoading || auditRenewalQuery.isLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentFailuresTable events={events} />
)}
</section>
</div> </div>
{reloadTarget && ( {reloadTarget && (