Compare commits

...

4 Commits

Author SHA1 Message Date
shankar0123 28e277a88e fix(scep-intune): use useTrackedMutation for trust-anchor reload (M-009)
Phase 9 follow-up — the M-009 hard-zero regression guard in
.github/workflows/ci.yml flagged the SCEPAdminPage's reload mutation as
a bare useMutation() call. The repo's invalidation contract requires
every mutation to go through useTrackedMutation with explicit
invalidates: QueryKey[] | 'noop' so cached data never goes stale after
a write.

Swap the bare useMutation for useTrackedMutation with
invalidates: [['admin', 'scep', 'intune', 'stats']] — the trust-anchor
reload changes the per-profile trust pool reflected in IntuneStats, so
the stats query MUST refetch on success. The audit-log queries stay on
their own 60s timer (a SIGHUP-equivalent reload doesn't backfill new
audit rows; nothing to invalidate there).

Verification:
  * tsc --noEmit clean
  * vitest SCEPAdminPage.test.tsx: 13/13 still pass (the wrapper's
    onSuccess fires AFTER invalidation, so the modal-close + state
    reset assertions hold)
  * M-009 grep guard reproduced locally — bare useMutation sites = 0
2026-04-29 16:35:40 +00:00
shankar0123 77e0281a0e feat(scep-intune): GUI monitoring tab + admin endpoints
Phase 9 of the SCEP RFC 8894 + Intune master bundle. Lands the operator-
facing Intune Monitoring tab plus the two admin-gated endpoints it reads
from. Per the constitutional 'complete path' rule: counters tick on
every typed dispatcher branch, the GUI poll is live (30s for stats,
60s for the audit log filter), and the SIGHUP-equivalent reload action
is one click + a confirmation modal — no follow-up plumbing required.

Backend (Phase 9.1 + 9.2 + 9.3):

  * internal/service/scep.go gains:
    - intuneCounterTab — atomic per-status counters keyed by the same
      labels intuneFailReason() emits (success / signature_invalid /
      expired / not_yet_valid / wrong_audience / replay / rate_limited /
      claim_mismatch / compliance_failed / malformed / unknown_version).
      Lock-free on the dispatcher hot path; snapshot() returns a
      zero-allocation map for the admin endpoint.
    - dispatchIntuneChallenge wires intuneCounters.inc(...) on every
      typed return path INCLUDING the success leg (credited before
      processEnrollment so a downstream issuer-connector failure
      doesn't double-count).
    - SetPathID + PathID accessors (so admin rows surface the SCEP
      profile path ID per row).
    - IntuneStatsSnapshot + IntuneTrustAnchorInfo public types, plus
      IntuneStats(now) accessor that walks the trust holder pool and
      packages a per-profile snapshot. ReloadIntuneTrust() is the
      typed wrapper around TrustAnchorHolder.Reload that returns
      ErrSCEPProfileIntuneDisabled when called on a profile where
      Intune isn't enabled (admin endpoint maps that to HTTP 409).

  * internal/api/handler/admin_scep_intune.go:
    - AdminSCEPIntuneService narrow interface (Stats + ReloadTrust)
      so the handler depends on a small surface; AdminSCEPIntuneServiceImpl
      is the production walker over the per-profile SCEPService map.
    - AdminSCEPIntuneHandler.Stats handles GET /api/v1/admin/scep/intune/stats
      with the M-008 admin gate (non-admin → 403 + service never
      invoked); returns {profiles, profile_count, generated_at}.
    - AdminSCEPIntuneHandler.ReloadTrust handles POST
      /api/v1/admin/scep/intune/reload-trust. Body is {path_id: '<id>'};
      empty body targets the legacy /scep root profile. Returns 200 on
      success / 404 on unknown PathID / 409 when the profile is Intune-
      disabled / 500 on a parse error from intune.LoadTrustAnchor (the
      holder retains its previous pool — fail-safe). 400 on malformed
      JSON.
    - ErrAdminSCEPProfileNotFound typed error so the handler can
      distinguish 'wrong profile' from 'broken file'.

  * internal/api/router/router.go: HandlerRegistry gains
    AdminSCEPIntune; both routes registered as bearer-auth-required
    (the admin-gate is at the handler layer per the M-008 pattern).

  * cmd/server/main.go: declares scepServices map[string]*service.SCEPService
    BEFORE HandlerRegistry construction so the same map can be referenced
    from both the admin handler (constructed early) and the SCEP startup
    loop (which populates it later by reference). The per-profile loop
    now calls scepService.SetPathID(profile.PathID) and stores the service
    pointer into the shared map. AdminSCEPIntune handler is constructed
    at the same time as AdminCRLCache.

  * internal/api/handler/m008_admin_gate_test.go: AdminGatedHandlers
    map gains 'admin_scep_intune.go' with a one-line justification —
    the regression scanner enforces the per-handler test triplet
    (TestAdminSCEPIntune_NonAdmin_Returns403 + _AdminExplicitFalse_Returns403
    + _AdminPermitted_ForwardsActor) plus their POST siblings for
    ReloadTrust.

  * api/openapi.yaml: documents both endpoints with request body /
    response shape / error mapping; openapi-parity-test now matches
    the registered routes.

Frontend (Phase 9.4):

  * web/src/pages/SCEPAdminPage.tsx — single-page Intune Monitoring
    surface:
    - Per-profile cards (one card per SCEP profile). Enabled profiles
      get the full counter grid + trust-anchor-expiry badge tone
      (good ≥30d / warn 7-30d / bad <7d / EXPIRED). Disabled profiles
      get an off-state pill with the env-var hint to opt in.
    - Counters polled every 30s via TanStack Query against
      GET /admin/scep/intune/stats.
    - Recent failures table (last 50) populated from the audit log
      filtered to action=scep_pkcsreq_intune AND scep_renewalreq_intune;
      merged + sorted by timestamp descending. Polled every 60s.
    - Reload trust anchor button per profile + confirmation modal that
      explains the SIGHUP equivalence and the fail-safe behavior.
      onConfirm runs a TanStack mutation, refetches the stats query
      on success, surfaces the underlying error (eg 'trust anchor
      cert expired') in the modal on failure (modal stays open so
      operator can retry).
    - Admin gate: when authRequired && !admin the page renders an
      'Admin access required' banner and the underlying admin API
      requests are never issued (React Query enabled flag gated on
      auth.admin) — server-side enforcement is M-008.

  * web/src/api/types.ts: IntuneStatsSnapshot + IntuneTrustAnchorInfo +
    IntuneStatsResponse + IntuneReloadTrustResponse.

  * web/src/api/client.ts: getAdminSCEPIntuneStats +
    reloadAdminSCEPIntuneTrust(pathID).

  * web/src/main.tsx: new route /scep/intune. The route is unconditional;
    the gating is at the page level so deep-links land cleanly.

  * web/src/components/Layout.tsx: 'SCEP Intune' nav link between
    Observability and Audit Trail with the appropriate sidebar icon.

Tests (Phase 9.5):

  * internal/api/handler/admin_scep_intune_test.go (16 tests):
    - M-008 admin-gate triplet for both Stats (GET) and ReloadTrust
      (POST): NonAdmin / AdminExplicitFalse / AdminPermitted.
    - Method-gate tests (Stats rejects POST, ReloadTrust rejects GET).
    - Stats propagates service errors as 500.
    - ReloadTrust maps ErrAdminSCEPProfileNotFound→404,
      ErrSCEPProfileIntuneDisabled→409, generic err→500.
    - Empty body targets legacy root PathID.
    - Malformed JSON→400.
    - AdminSCEPIntuneServiceImpl handles nil map + unknown PathID.

  * web/src/pages/SCEPAdminPage.test.tsx (13 tests):
    - Admin gate (non-admin sees gated banner + zero admin API calls;
      admin sees the page; no-auth dev mode also passes).
    - Profile rendering (counters with correct labels, expiry badge
      tone for ≥30d / EXPIRED states, off-state pill for disabled
      profiles, empty-state banner when no profiles configured).
    - Reload modal (opens on click, calls mutation on Confirm,
      keeps modal open + shows error on failure, Cancel skips
      mutation).
    - Error path renders ErrorState with retry.
    - Audit log filter merges PKCSReq + RenewalReq events and sorts
      descending.

Verification:

  * gofmt clean on touched files
  * go vet ./... clean
  * staticcheck on intune/service/api/cmd-server clean
  * go test -short across api+service+intune+cmd-server: all green
  * web tsc --noEmit clean
  * Vitest: SCEPAdminPage.test.tsx 13/13 + sibling page suites all
    pass
  * G-3 docs-drift CI guard: Phase 9 adds no new CERTCTL_* env vars
    so the guard does not fire
  * openapi-parity-test green (both new admin endpoints documented)
  * M-008 regression scanner enforces the per-handler test triplet —
    pin updated, all triplets present

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

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

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

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

Wire-in surfaces:

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

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

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

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

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

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

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

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

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

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

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

Verification:

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

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 8
      cowork/scep-rfc8894-intune/progress.md
      Constitutional rule: 'Always take the complete path, not the
      easy path' (cowork/CLAUDE.md::Operating Rules) — operator can
      flip CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true and observe
      the dispatcher pick up Intune-shaped challenges end-to-end with
      no further code changes. Foundation + plumbing ship together.
2026-04-29 15:34:19 +00:00
shankar0123 7e4d423561 feat(scep-intune): parser + validator for Microsoft Intune Connector challenge format
Phase 7 of the SCEP RFC 8894 + Intune master bundle. Adds the
internal/scep/intune package that validates Microsoft Intune Certificate
Connector signed challenges embedded in SCEP CSR challengePassword
attributes. This is the parsing/validation foundation; Phase 8 wires it
into the SCEP service dispatcher.

What's included:

  * doc.go — package architecture (Intune cloud → Connector → certctl
    SCEP server) + 'what this package is NOT' guard rails. We do NOT
    implement full JOSE: no JKU / kid / x5c trust, no JWKS fetch.
    Trust anchor is operator-supplied at startup and pinned. The
    package does NOT call Microsoft's API directly — the Connector
    already did that; we validate its signed attestation.

  * trust_anchor.go — LoadTrustAnchor(path) reads a PEM bundle of
    Intune Connector signing certs. Skips non-CERTIFICATE PEM blocks
    (operators sometimes paste chains with the priv key by mistake).
    Rejects empty bundles + expired certs at startup with an
    operator-actionable message including the cert subject. SIGHUP
    reload lands in Phase 8.5; today it's load-once-at-boot.

  * claim.go — ChallengeClaim struct + DeviceMatchesCSR helper.
    Set-equality semantics for SAN-DNS/SAN-RFC822/SAN-UPN: the CSR
    must carry EXACTLY the claim's elements, no extras and no missing.
    Empty claim slice = no constraint on that dimension.
    Per-dimension typed errors (ErrClaimCNMismatch /
    ErrClaimSANDNSMismatch / ErrClaimSANRFC822Mismatch /
    ErrClaimSANUPNMismatch) so audit logs surface the failure
    dimension without string-matching. extractUPNSans is stubbed to
    return nil with documented fail-closed behavior — non-empty UPN
    claims fail the equalSets check (correct behavior; the rare deploy
    that pins UPN SANs hot-fixes the ASN.1 walker per the inline
    comment).

  * replay.go — ReplayCache: bounded in-memory cache of seen nonces
    with TTL. Sized for 100,000 entries (60-min Connector validity ×
    25 RPS Intune fleet steady-state ≈ 90,000 challenges/hour with
    headroom). sync.Map for concurrent read/write; janitor goroutine
    wakes every TTL/4 to evict expired entries; at-cap O(N)
    oldest-eviction (rarely fires; janitor keeps the cache below
    cap). Redis-backed variant deferred to V3-Pro.

  * challenge.go — the load-bearing piece:

    - ParseChallenge(raw) splits the JWT-like compact serialization
      into header/payload/signature and base64url-decodes each.
      Tolerates both padded + unpadded encodings (some Connector
      builds emit padded; RFC 7515 §2 says unpadded; we accept both).
      Validates the header parses as JSON before returning so the
      malformed-signal lands earlier in the pipeline.

    - ValidateChallenge(raw, trust, expectedAudience, now):
        1. ParseChallenge
        2. JWS signature verify over (segment0 || '.' || segment1)
           — re-derived from the raw on-wire bytes, NOT
           re-base64-encoded, per RFC 7515 §3.1 (re-encoding could
           produce a byte-different input than what was signed)
        3. Signature alg dispatch:
             RS256: rsa.VerifyPKCS1v15(SHA-256)
             ES256: tries fixed-width r||s (JOSE-canonical) first,
                    falls back to ASN.1 DER (older Connectors)
             alg=none: explicit reject with audit-log-friendly
                       message (RFC 7515 §3.6 attack vector)
             HS*/PS*: rejected as 'unsupported alg' (no shared
                      secret in our threat model)
        4. Version-detection prelude (versionedChallenge struct +
           versionUnmarshalers map). Today's format is v1 (no
           explicit version field; absence IS the v1 signal). Adding
           v2 = adding a parser + a registration line; v1 path stays
           untouched. Defends against the inevitable Microsoft format
           change at ~30 LoC + 2 tests cost vs. a P0 incident.
        5. Time bounds (iat / exp); audience pin (skipped when
           expectedAudience == "").

      Replay protection is the CALLER's job (handler glues parser +
      cache; validator stays stateless + testable).

  * Typed errors: ErrChallengeMalformed / ErrChallengeSignature /
    ErrChallengeExpired / ErrChallengeNotYetValid /
    ErrChallengeWrongAudience / ErrChallengeReplay /
    ErrChallengeUnknownVersion. errors.Is-friendly so the handler
    can audit failure dimension.

Tests (94.8% coverage):

  * challenge_test.go (18 tests): happy-path RS256 + ES256
    fixed-width + ES256 DER; TamperedSignature; TamperedPayload;
    Expired; NotYetValid; WrongAudience; EmptyExpectedAudience
    disables check; RotatedTrustAnchor; EmptyTrustBundle;
    AlgNoneRejected; UnsupportedAlg (HS256); MissingAlg;
    VersionV1ExplicitOK; VersionUnknownRejected;
    MixedTrustBundle iter (skip key-type mismatches without
    surfacing as Signature err); NonJSONPayloadButValidSignature;
    Malformed cases (empty, missing dots, bad base64, non-JSON
    header — 9 sub-cases); PaddedBase64Tolerated.

  * claim_test.go (13 tests): per-dimension matching across CN +
    SAN-DNS + SAN-RFC822 + SAN-UPN; nil guards; case-insensitive DNS
    (RFC 4343); dedupe set-equality; empty claim = no constraint;
    UPN stub canary; normaliseSet edge cases; equalSets length
    mismatch.

  * replay_test.go (11 tests): first-fresh; duplicate-rejected;
    past-TTL-fresh; Sweep-evicts-expired; empty-nonce
    short-circuits; at-cap LRU eviction; default-cap=100k;
    Close-idempotent; TTL=0 disables janitor; concurrent-race-free
    (50 goroutines × 200 inserts); empty-nonce twice is fresh both
    times (we don't cache empties).

  * trust_anchor_test.go: HappyPath single + multi cert; SkipsNonCertBlocks
    (priv key + cert mix); EmptyBundleRejected; OnlyKeyBlocksRejected;
    ExpiredCertRejected (with subject CN in error); MalformedCertRejected;
    LoadTrustAnchor disk + EmptyPath + MissingFile.

  * fuzz_test.go: FuzzParseChallenge with seed corpus covering both
    the well-formed and the obvious-malformed shapes. Survived 187k
    execs in 21s without panic on the local burst; CI runs 5 min.

Verification:

  * gofmt -l ./internal/scep/intune: clean
  * go vet ./internal/scep/intune/...: clean
  * staticcheck ./internal/scep/intune/...: clean
  * go test -count=1 -cover ./internal/scep/intune/...: 94.8%
    (target was ≥85%)
  * go vet ./internal/... ./cmd/...: clean (no rest-of-repo regressions)
  * No new CERTCTL_* env vars (those land in Phase 8 with the
    config gate); G-3 docs-drift CI guard not triggered.
  * No new HTTP routes; openapi-parity guard not triggered.

Phase 8 will:
  - Add SCEPProfileConfig.Intune* env vars + preflight gate
  - Wire the validator into the SCEP service dispatcher
    (Intune-shaped challenges → validator; static → existing path)
  - Trust-anchor SIGHUP reload mirroring cmd/server/tls.go::watchSIGHUP
  - Per-claim rate limit + audit metrics

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 7
      cowork/scep-rfc8894-intune/progress.md
2026-04-29 14:38:35 +00:00
31 changed files with 5570 additions and 7 deletions
+98
View File
@@ -732,6 +732,104 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/scep/intune/stats:
get:
tags: [SCEP]
summary: Per-profile Microsoft Intune dispatcher observability (admin)
description: |
Returns one snapshot per configured SCEP profile (Intune-enabled
or not). Profiles where Intune is disabled appear with
`enabled=false`; profiles where Intune is enabled additionally
carry the trust anchor pool's per-cert expiry, the audience
binding, the per-status enrollment counters
(success / signature_invalid / claim_mismatch / expired /
wrong_audience / replay / rate_limited / malformed /
compliance_failed / not_yet_valid / unknown_version), the
in-memory replay-cache size, and the per-device-rate-limit
opt-out flag.
Admin-gated (M-008 pattern) — non-admin Bearer callers get 403
because the trust-anchor expiries and per-status counters are
sensitive operational metadata. SCEP RFC 8894 + Intune master
bundle Phase 9.2.
operationId: listSCEPIntuneStats
responses:
"200":
description: Per-profile Intune stats 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/reload-trust:
post:
tags: [SCEP]
summary: Reload a SCEP profile's Intune trust anchor (admin)
description: |
Triggers the same Reload that the SIGHUP watcher would run for
the named profile. The body MUST be `{"path_id": "<pathID>"}`;
an empty body targets the legacy `/scep` root profile (PathID="").
Returns 200 + `{"reloaded": true, ...}` on success; 404 when the
path_id doesn't match any configured SCEP profile; 409 when the
profile exists but Intune is disabled on it (no trust anchor to
reload); 500 when the underlying file fails to parse — in which
case the holder retains the OLD pool so enrollment keeps working
off the previous trust anchor while the operator fixes the file.
Admin-gated (M-008 pattern). SCEP RFC 8894 + Intune master
bundle Phase 9.2.
operationId: reloadSCEPIntuneTrust
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
path_id:
type: string
description: SCEP profile PathID (empty string = legacy /scep root)
responses:
"200":
description: Trust anchor reloaded
content:
application/json:
schema:
type: object
properties:
reloaded:
type: boolean
path_id:
type: string
reloaded_at:
type: string
format: date-time
"400":
description: Invalid JSON body
"403":
description: Admin access required
"404":
description: SCEP profile not found for the given path_id
"409":
description: SCEP profile exists but Intune is disabled
"500":
description: Trust anchor reload failed (the OLD pool is retained)
/.well-known/pki/ocsp/{issuer_id}:
post:
tags: [CRL & OCSP]
+142
View File
@@ -32,6 +32,7 @@ import (
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository/postgres"
"github.com/shankar0123/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/scheduler"
"github.com/shankar0123/certctl/internal/service"
)
@@ -655,6 +656,14 @@ func main() {
<-startedChan
logger.Info("scheduler started")
// SCEP RFC 8894 + Intune master bundle Phase 9: per-profile SCEPService
// map shared between the SCEP startup loop (which populates it) and the
// AdminSCEPIntune handler (which reads from it). We declare it here so
// the HandlerRegistry below can hand the same map to the admin
// handler — the SCEP loop adds entries later by reference, and the
// admin endpoint observes the populated state at request time.
scepServices := map[string]*service.SCEPService{}
// Build the API router with all handlers
apiRouter := router.New()
apiRouter.RegisterHandlers(router.HandlerRegistry{
@@ -695,6 +704,16 @@ func main() {
return ids
}),
),
// SCEP RFC 8894 + Intune master bundle Phase 9.2: admin endpoint
// for the per-profile Intune Monitoring tab. The implementation
// holds a reference to scepServices declared above; the SCEP
// startup loop populates the map by PathID during boot, so the
// handler observes whatever profiles exist at request time. On a
// deploy without SCEP enabled the map stays empty and the GET
// stats endpoint returns an empty profiles array.
AdminSCEPIntune: handler.NewAdminSCEPIntuneHandler(
handler.NewAdminSCEPIntuneServiceImpl(scepServices),
),
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
@@ -762,6 +781,12 @@ func main() {
scepMTLSHandlers := make(map[string]handler.SCEPHandler)
scepMTLSUnionPool := x509.NewCertPool()
scepMTLSAnyEnabled := false
// SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune
// trust anchor holders. We track them here so a single SIGHUP
// reload-watcher set spans every profile, AND so the deferred
// stop-watcher cleanup runs once at server shutdown.
intuneTrustHolders := []*intune.TrustAnchorHolder{}
intuneStopWatchers := []func(){}
for i, profile := range cfg.SCEP.Profiles {
profile := profile // shadow for closure-safety even though no closures escape
profileLog := logger.With(
@@ -811,9 +836,17 @@ func main() {
preflightCancel()
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
scepService.SetProfileRepo(profileRepo)
scepService.SetPathID(profile.PathID)
if profile.ProfileID != "" {
scepService.SetProfileID(profile.ProfileID)
}
// SCEP RFC 8894 + Intune master bundle Phase 9.3: publish this
// service into the shared scepServices map so the AdminSCEPIntune
// handler can find it by PathID. The map was declared above
// HandlerRegistry construction; the admin handler holds the
// same map by reference, so adding here makes the new profile
// visible at the next admin GET.
scepServices[profile.PathID] = scepService
scepHandler := handler.NewSCEPHandler(scepService)
// SCEP RFC 8894 Phase 2.3: load the per-profile RA pair so the
// handler can run the new RFC 8894 PKIMessage path. Preflight
@@ -826,6 +859,61 @@ func main() {
os.Exit(1)
}
scepHandler.SetRAPair(raCert, raKey)
// SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune
// dispatcher wire-in. Builds the trust-anchor holder, replay cache,
// and per-device rate limiter; injects them into the SCEPService;
// starts the SIGHUP reload watcher (one per holder, all responding
// to the same signal as the existing TLS-cert watcher). Profiles
// with INTUNE_ENABLED=false skip the entire block, so the cost on
// non-Intune deploys is exactly one bool check per profile.
if profile.Intune.Enabled {
intuneHolder, err := preflightSCEPIntuneTrustAnchor(true, profile.Intune.ConnectorCertPath, profileLog)
if err != nil {
profileLog.Error(
"startup refused: SCEP profile INTUNE trust anchor preflight failed "+
"(Phase 8.2: required when INTUNE_ENABLED=true). "+
"Verify the bundle file exists at INTUNE_CONNECTOR_CERT_PATH, "+
"is readable, parses as PEM, contains ≥1 CERTIFICATE block, "+
"and none of the bundled certs are past NotAfter (operator-rotated).",
"error", err,
)
os.Exit(1)
}
intuneTrustHolders = append(intuneTrustHolders, intuneHolder)
intuneStopWatchers = append(intuneStopWatchers, intuneHolder.WatchSIGHUP())
// Replay cache TTL = ChallengeValidity (defaults to 60m via
// config.go's getEnvDuration default). The cache is sized
// for the documented 100k-entry production default; smaller
// is fine, larger tightens the operator's escape hatch.
replayCache := intune.NewReplayCache(profile.Intune.ChallengeValidity, 0)
// Per-device rate limiter: honor the per-profile cap
// (INTUNE_PER_DEVICE_RATE_LIMIT_24H, default 3). The cap can
// be 0 to disable (limiter then short-circuits all Allow calls
// to nil). Map cap stays at the 100k default.
rateLimiter := intune.NewPerDeviceRateLimiter(
profile.Intune.PerDeviceRateLimit24h,
24*time.Hour,
0,
)
scepService.SetIntuneIntegration(
intuneHolder,
profile.Intune.Audience,
profile.Intune.ChallengeValidity,
replayCache,
rateLimiter,
)
profileLog.Info("SCEP profile Intune dispatcher enabled",
"trust_anchor_path", profile.Intune.ConnectorCertPath,
"audience", profile.Intune.Audience,
"challenge_validity", profile.Intune.ChallengeValidity,
"per_device_rate_limit_24h", profile.Intune.PerDeviceRateLimit24h,
)
}
scepHandlers[profile.PathID] = scepHandler
endpoint := "/scep"
if profile.PathID != "" {
@@ -835,6 +923,7 @@ func main() {
"endpoint", endpoint+"?operation={GetCACaps,GetCACert,PKIOperation}",
"challenge_password_set", profile.ChallengePassword != "",
"ra_cert_path", profile.RACertPath,
"intune_enabled", profile.Intune.Enabled,
)
// SCEP RFC 8894 Phase 6.5: register the mTLS sibling route
@@ -913,7 +1002,20 @@ func main() {
logger.Info("SCEP server enabled",
"profile_count", len(scepHandlers),
"mtls_profile_count", len(scepMTLSHandlers),
"intune_profile_count", len(intuneTrustHolders),
)
// SCEP RFC 8894 + Intune master bundle Phase 8.5: clean up the
// SIGHUP watcher goroutines when the server shuts down. We register
// the stop functions on a deferred sweep so the cleanup runs in
// LIFO order even if a downstream init step os.Exit(1)s.
if len(intuneStopWatchers) > 0 {
defer func() {
for _, stop := range intuneStopWatchers {
stop()
}
}()
}
}
// Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/.
@@ -1319,6 +1421,46 @@ func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPo
return pool, nil
}
// preflightSCEPIntuneTrustAnchor validates a per-profile Microsoft Intune
// Certificate Connector signing-cert trust bundle.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.2.
//
// No-op when this profile has Intune disabled (the common case for
// non-Intune SCEP deploys). When enabled:
//
// 1. Path is non-empty (Validate() refuse covers this too; we re-check
// here so the caller can os.Exit(1) with the specific PathID in the
// log line).
// 2. File exists + readable.
// 3. PEM-decodes to ≥1 CERTIFICATE block (intune.LoadTrustAnchor enforces
// this and skips non-CERTIFICATE blocks like accidentally-pasted
// priv-key blocks).
// 4. None of the bundled certs is past NotAfter — an expired Intune
// trust anchor would silently reject every Connector challenge at
// runtime, which is a much worse failure mode than failing fast at
// boot. intune.LoadTrustAnchor enforces this and surfaces the subject
// CN in the error message so the operator knows which cert to rotate.
//
// On success returns the freshly-built *intune.TrustAnchorHolder ready to
// inject into the per-profile SCEPService via SetIntuneIntegration. The
// holder also installs the SIGHUP watcher (started by the caller).
func preflightSCEPIntuneTrustAnchor(enabled bool, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) {
if !enabled {
return nil, nil
}
if path == "" {
return nil, fmt.Errorf("INTUNE enabled but trust anchor path empty: " +
"set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to a PEM bundle " +
"of the Microsoft Intune Certificate Connector's signing certs")
}
holder, err := intune.NewTrustAnchorHolder(path, logger)
if err != nil {
return nil, fmt.Errorf("INTUNE trust anchor load failed: %w (path=%s)", err, path)
}
return holder, nil
}
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
+5
View File
@@ -656,6 +656,11 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH` | (none) | Per-profile RA private key PEM path (mode `0600`). Same semantics as `CERTCTL_SCEP_RA_KEY_PATH` but scoped to one profile. **Required for every profile.** |
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED` | `false` | **Phase 6.5 (opt-in).** When true, certctl exposes a sibling `/scep-mtls/<pathID>` route alongside the standard `/scep/<pathID>` route. The sibling route requires the SCEP client to present an mTLS client cert that chains to `_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH`. The standard route continues to use challenge-password-only auth — operators can run BOTH routes simultaneously for migration / heterogeneous client fleets. mTLS is additive (not a replacement for the challenge password). Designed for enterprise procurement teams that reject "shared password authentication" as a checkbox-fail. Same model Apple's MDM and Cisco's BRSKI use. |
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH` | (none) | PEM bundle of CA certs that sign the client (device-bootstrap) certs the operator allows to enroll on this profile's `/scep-mtls/<pathID>` route. **Required when `_MTLS_ENABLED=true`.** Operators with multiple bootstrap CAs concatenate them. The startup preflight (`cmd/server/main.go::preflightSCEPMTLSTrustBundle`) validates: file exists, parses as PEM, contains ≥1 cert, none expired. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED` | `false` | **Phase 8 (opt-in).** When true, this profile routes Intune-shaped challenge passwords (length > 200 + exactly two dots) to the Microsoft Intune Certificate Connector signed-challenge validator. Static challenge passwords still work as a fallback for non-Intune devices in mixed-fleet deployments. Per-profile flag so an operator running corp-laptops via Intune AND IoT devices via static challenge can opt-in on the corp profile only. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH` | (none) | Filesystem path to a PEM bundle of one or more Microsoft Intune Certificate Connector signing certs. **Required when `_INTUNE_ENABLED=true`.** Reloaded on `SIGHUP` (mirrors the server TLS-cert reload pattern). Startup preflight + reload both refuse empty bundles + expired certs and surface the offending subject CN in the error message. Operators who rotate the Connector signing cert update the file on disk then `kill -HUP <certctl-pid>` to apply (no restart required). |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE` | (empty, audience check disabled) | Expected `aud` claim in the Intune challenge — typically the public SCEP endpoint URL the Connector is configured to call (e.g. `https://certctl.example.com/scep/corp`). Empty disables the check, useful for proxy / load-balancer scenarios where the URL the Connector saw differs from the URL we see. Operators who pin a public URL gain defense-in-depth against challenge re-use across endpoints. |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CHALLENGE_VALIDITY` | `60m` | Maximum age of an Intune challenge, on top of the challenge's own `iat`/`exp` claims. Defense-in-depth: even if the Connector mints a 24h-valid challenge, this caps the window during which a leaked challenge can be replayed. Default matches Microsoft's published Connector defaults. Zero disables the cap (relies entirely on the challenge's `exp`). |
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H` | `3` | Maximum enrollments per `(claim.Subject, claim.Issuer)` pair in any rolling 24-hour window. Catches a compromised Connector signing key issuing many DIFFERENT valid challenges for the same device. Default 3 covers legitimate first-cert + recovery + post-wipe re-enrollment. Zero disables the limiter (not recommended for production). |
---
+73 -4
View File
@@ -420,19 +420,88 @@ challenge+mTLS:
the password requirement doesn't go away — the password is still
the application-layer auth boundary).
### Microsoft Intune dynamic-challenge dispatcher (Phase 8, opt-in)
When SCEP sits behind the Microsoft Intune Certificate Connector, devices
present an Intune-issued signed challenge (a JWT-like blob over a JSON
claim payload) instead of the static `_CHALLENGE_PASSWORD`. Phase 8 wires
a per-profile dispatcher that validates these signed challenges against
the Connector's signing-cert trust anchor and binds the asserted device
identity to the inbound CSR. Static challenge passwords still work as a
fallback so heterogeneous fleets (some Intune-enrolled, some not) keep
working.
**Per-profile env vars** (all default to off; legacy/static-only profiles
need no changes):
```
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_PER_DEVICE_RATE_LIMIT_24H=3
```
**Trust-anchor extraction:** the operator extracts the Connector
installation's signing cert (from the Connector's certificate store on
the Windows host running the Connector — Microsoft does not publish a
direct download) and writes a PEM bundle to the configured path.
Multiple Connectors in HA = concatenate their certs.
**Trust-anchor reload:** the holder re-reads the bundle on `SIGHUP` (the
same signal that rotates the server's TLS cert). A bad reload (parse
error, expired cert) keeps the OLD pool in place — operators get a
recoverable failure window rather than a service-down. Rotate the file
on disk, then `kill -HUP <certctl-pid>` to apply with no restart.
**Replay protection:** in-memory cache of seen challenge nonces with TTL
= `_CHALLENGE_VALIDITY` (default 60m). Sized for 100k entries, which
covers a ~25 RPS Intune fleet's steady-state. The same challenge
submitted twice within the TTL is rejected with `ErrChallengeReplay`.
**Per-device rate limit:** sliding-window-log limiter keyed by
`(claim.Subject, claim.Issuer)`. Default 3 enrollments per 24h covers
legitimate first-cert + recovery + post-wipe re-enrollment but blocks a
compromised Connector signing key from issuing many DIFFERENT valid
challenges for the same device. Set the var to `0` to disable.
**Audit + observability:** Intune enrollments emit
`audit_event.action="scep_pkcsreq_intune"` (or
`"scep_renewalreq_intune"`) so operators can grep the audit log to count
Intune-vs-static enrollments. Per-failure-mode reason flows into the log
line; the metric label set is `success / signature_invalid / expired /
not_yet_valid / wrong_audience / replay / rate_limited / claim_mismatch
/ unknown_version / malformed`.
**Compliance-state hook (V3-Pro plug-in seam):** a nil-default
`ComplianceCheck` field on `SCEPService` lets a future Pro module plug
in a Microsoft Graph compliance API call between challenge validation
and certificate issuance. V2 ships the seam (one struct field + one
setter + one nil-guarded call site) so Pro is plug-in code, not a
dispatcher refactor.
**Mixed-mode (recommended):** keep `_CHALLENGE_PASSWORD` set even when
Intune is enabled. Devices that don't go through Intune (manual
enrollment, on-prem MDM bridges) continue to enroll via the static path;
the dispatcher routes Intune-shaped challenges (length > 200 + exactly
two dots) to the validator and falls through to the static compare
otherwise.
### Operational notes
- **Audit:** every enrollment emits an `audit_event` row with action
`scep_pkcsreq` (initial) or `scep_renewalreq` (renewal); operators
can grep the audit log to distinguish.
can grep the audit log to distinguish. Intune-dispatched enrollments
use `scep_pkcsreq_intune` and `scep_renewalreq_intune` respectively.
- **Body-size cap:** `http.MaxBytesReader` middleware caps request
bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are
typically <50KB so the default cap is generous.
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
plane; there is no plaintext fallback.
- **Forward reference:** for Microsoft Intune deployments specifically,
see [`scep-intune.md`](scep-intune.md) (the doc Phase 11 of the
master bundle ships).
- **Forward reference:** for the deeper Intune integration writeup
(architecture, migration playbook, troubleshooting,
Microsoft-support-statement), see [`scep-intune.md`](scep-intune.md)
(Phase 11 of the master bundle).
## Related docs
+190
View File
@@ -0,0 +1,190 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service"
)
// AdminSCEPIntuneService is the slice of the per-profile SCEPService set
// the admin endpoint needs. The handler depends on this narrow interface
// rather than the concrete *service.SCEPService set so wiring stays
// service-side and the handler stays test-friendly.
//
// SCEP RFC 8894 + Intune master bundle Phase 9.1.
type AdminSCEPIntuneService interface {
// Stats returns one snapshot per configured SCEP profile (Intune-
// enabled or not). Profiles where Intune is disabled appear with
// Enabled=false so the GUI can show "off — opt in via env vars"
// rather than 404ing per-profile.
Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error)
// ReloadTrust triggers the SIGHUP-equivalent Reload on the named
// profile's trust holder. Returns ErrAdminSCEPProfileNotFound if
// the PathID isn't known, or ErrSCEPProfileIntuneDisabled if the
// profile exists but doesn't have Intune turned on, or the
// underlying parse error from intune.LoadTrustAnchor on a bad
// reload (the holder retains the OLD pool either way — the
// fail-safe is enforced one layer down).
ReloadTrust(ctx context.Context, pathID string) error
}
// ErrAdminSCEPProfileNotFound is returned by AdminSCEPIntuneService
// implementations when the operator targets a PathID that doesn't map
// 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")
// AdminSCEPIntuneHandler serves the per-profile Intune observability
// endpoints for the GUI Intune Monitoring tab.
//
// Endpoints:
//
// GET /api/v1/admin/scep/intune/stats
// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"})
//
// Both endpoints are admin-gated (M-008 pattern). Non-admin Bearer
// callers get 403 — the stats endpoint reveals the operator's profile
// set + trust anchor expiries (sensitive operational metadata) and the
// reload endpoint is a privileged action.
type AdminSCEPIntuneHandler struct {
svc AdminSCEPIntuneService
}
// NewAdminSCEPIntuneHandler creates a new admin handler.
func NewAdminSCEPIntuneHandler(svc AdminSCEPIntuneService) AdminSCEPIntuneHandler {
return AdminSCEPIntuneHandler{svc: svc}
}
// adminScepIntuneReloadRequest is the POST body shape for the reload-
// trust endpoint. PathID="" targets the legacy /scep root profile (the
// one with empty PathID), matching the convention used elsewhere in the
// per-profile dispatch.
type adminScepIntuneReloadRequest struct {
PathID string `json:"path_id"`
}
// Stats handles GET /api/v1/admin/scep/intune/stats.
func (h AdminSCEPIntuneHandler) Stats(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.Stats(r.Context(), now)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to read SCEP Intune stats")
return
}
if rows == nil {
// Avoid serialising as `null` — the GUI expects an array.
rows = []service.IntuneStatsSnapshot{}
}
_ = JSON(w, http.StatusOK, map[string]any{
"profiles": rows,
"profile_count": len(rows),
"generated_at": now.UTC(),
})
}
// ReloadTrust handles POST /api/v1/admin/scep/intune/reload-trust.
func (h AdminSCEPIntuneHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
var body adminScepIntuneReloadRequest
// An empty body is permitted: it implicitly targets the legacy
// /scep root profile (PathID=""). Operators with multi-profile
// deploys MUST supply a path_id JSON field.
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
return
}
}
err := h.svc.ReloadTrust(r.Context(), body.PathID)
switch {
case err == nil:
_ = JSON(w, http.StatusOK, map[string]any{
"reloaded": true,
"path_id": body.PathID,
"reloaded_at": time.Now().UTC(),
})
case errors.Is(err, ErrAdminSCEPProfileNotFound):
Error(w, http.StatusNotFound, "SCEP profile not found for path_id="+body.PathID)
case errors.Is(err, service.ErrSCEPProfileIntuneDisabled):
// 409 Conflict: the profile exists but Intune isn't turned on,
// so there's no trust anchor to reload. Distinct from 404 so
// the operator can correct the request without re-checking the
// profile list.
Error(w, http.StatusConflict, "SCEP profile path_id="+body.PathID+" does not have Intune enabled")
default:
// Underlying intune.LoadTrustAnchor errors (parse failure,
// expired cert, missing file). The holder retains its previous
// pool — the operator's enrollments keep working off the old
// trust anchor while the operator fixes the file.
Error(w, http.StatusInternalServerError, "Trust anchor reload failed: "+err.Error())
}
}
// AdminSCEPIntuneServiceImpl is the production implementation of
// AdminSCEPIntuneService. It walks the per-profile SCEPService set
// supplied by the caller (cmd/server/main.go) and aggregates the
// per-profile snapshots.
//
// Lives in the handler package because it's a thin handler-side
// composition; the heavy lifting is the per-service IntuneStats /
// ReloadIntuneTrust methods that already encapsulate the policy.
type AdminSCEPIntuneServiceImpl struct {
// services is keyed by SCEP profile PathID (empty string = legacy
// /scep root). Built once at server startup; the slice/map shape
// matches the per-profile SCEPService construction loop in
// cmd/server/main.go.
services map[string]*service.SCEPService
}
// NewAdminSCEPIntuneServiceImpl constructs the handler-side service
// from the per-profile SCEPService map built at startup.
func NewAdminSCEPIntuneServiceImpl(services map[string]*service.SCEPService) *AdminSCEPIntuneServiceImpl {
if services == nil {
services = map[string]*service.SCEPService{}
}
return &AdminSCEPIntuneServiceImpl{services: services}
}
// Stats implements AdminSCEPIntuneService.
func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) {
out := make([]service.IntuneStatsSnapshot, 0, len(s.services))
for _, svc := range s.services {
out = append(out, svc.IntuneStats(now))
}
return out, nil
}
// ReloadTrust implements AdminSCEPIntuneService.
func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error {
svc, ok := s.services[pathID]
if !ok {
return ErrAdminSCEPProfileNotFound
}
return svc.ReloadIntuneTrust()
}
// Compile-time interface check.
var _ AdminSCEPIntuneService = (*AdminSCEPIntuneServiceImpl)(nil)
@@ -0,0 +1,336 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service"
)
// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService.
// Records call observations so the M-008 admin-gate triplet can pin
// "service was never invoked" when the gate rejects the caller.
type fakeAdminSCEPIntuneService struct {
statsCalled bool
reloadCalled bool
rows []service.IntuneStatsSnapshot
statsErr error
reloadPathID string
reloadErr error
}
func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) {
f.statsCalled = true
return f.rows, f.statsErr
}
func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error {
f.reloadCalled = true
f.reloadPathID = pathID
return f.reloadErr
}
// =============================================================================
// M-008 admin-gate triplet for Stats (GET).
// =============================================================================
func TestAdminSCEPIntune_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
w := httptest.NewRecorder()
h.Stats(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.statsCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", 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.Stats(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
}
if svc.statsCalled {
t.Error("service called despite admin=false gate")
}
}
func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{
rows: []service.IntuneStatsSnapshot{
{PathID: "corp", IssuerID: "iss-corp", Enabled: true},
{PathID: "iot", IssuerID: "iss-iot", Enabled: false},
},
}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", 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.Stats(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.statsCalled {
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"])
}
if _, ok := resp["profiles"].([]any); !ok {
t.Errorf("profiles missing or wrong shape: %v", resp["profiles"])
}
}
// =============================================================================
// M-008 triplet for ReloadTrust (POST).
// =============================================================================
func TestAdminSCEPIntuneReload_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 non-admin, got %d", w.Code)
}
if svc.reloadCalled {
t.Error("service called despite non-admin gate")
}
}
func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 admin=false, got %d", w.Code)
}
if svc.reloadCalled {
t.Error("service called despite admin=false gate")
}
}
func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
body := `{"path_id":"corp"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%q)", w.Code, w.Body.String())
}
if !svc.reloadCalled {
t.Fatal("reload was not invoked")
}
if svc.reloadPathID != "corp" {
t.Errorf("path_id forwarded = %q, want corp", svc.reloadPathID)
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if reloaded, _ := resp["reloaded"].(bool); !reloaded {
t.Errorf("response.reloaded = %v, want true", resp["reloaded"])
}
}
// =============================================================================
// Endpoint behavior — method gates, error mapping, body parsing.
// =============================================================================
func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for POST, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_RejectsNonPostMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/reload-trust", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405 for GET, got %d", w.Code)
}
}
func TestAdminSCEPIntuneStats_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{statsErr: errors.New("registry walk failed")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on service error, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_ProfileNotFound_Returns404(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{reloadErr: ErrAdminSCEPProfileNotFound}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"nonexistent"}`))
req.ContentLength = int64(len(`{"path_id":"nonexistent"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for unknown profile, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_IntuneDisabled_Returns409(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{reloadErr: service.ErrSCEPProfileIntuneDisabled}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"iot"}`))
req.ContentLength = int64(len(`{"path_id":"iot"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for Intune-disabled profile, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_BadReloadPropagates500(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{reloadErr: errors.New("trust anchor cert expired")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on bad reload, got %d", w.Code)
}
}
func TestAdminSCEPIntuneReload_EmptyBodyTargetsLegacyRoot(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 with empty body (legacy root path), got %d", w.Code)
}
if svc.reloadPathID != "" {
t.Errorf("empty body should target empty PathID; got %q", svc.reloadPathID)
}
}
func TestAdminSCEPIntuneReload_RejectsMalformedJSON(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
bad := `{not valid json`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(bad))
req.ContentLength = int64(len(bad))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 on malformed JSON, got %d", w.Code)
}
}
// =============================================================================
// AdminSCEPIntuneServiceImpl — narrow integration with the per-profile map.
// =============================================================================
func TestAdminSCEPIntuneServiceImpl_NilMapReturnsEmpty(t *testing.T) {
impl := NewAdminSCEPIntuneServiceImpl(nil)
rows, err := impl.Stats(context.Background(), time.Now())
if err != nil {
t.Fatalf("nil-map Stats: %v", err)
}
if len(rows) != 0 {
t.Errorf("nil-map Stats len=%d, want 0", len(rows))
}
}
func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.T) {
impl := NewAdminSCEPIntuneServiceImpl(map[string]*service.SCEPService{})
if err := impl.ReloadTrust(context.Background(), "nope"); !errors.Is(err, ErrAdminSCEPProfileNotFound) {
t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err)
}
}
+3 -2
View File
@@ -35,8 +35,9 @@ import (
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
var AdminGatedHandlers = map[string]string{
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
"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_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",
}
// InformationalIsAdminCallers is the documented allowlist of files that
+14
View File
@@ -127,6 +127,14 @@ type HandlerRegistry struct {
// Responder Phase 5 — admin-gated ops surface for the
// scheduler-driven CRL pre-generation pipeline.
AdminCRLCache handler.AdminCRLCacheHandler
// AdminSCEPIntune handles the per-profile Microsoft Intune Connector
// observability + reload endpoints. SCEP RFC 8894 + Intune master
// bundle Phase 9.2.
// GET /api/v1/admin/scep/intune/stats → per-profile snapshot
// POST /api/v1/admin/scep/intune/reload-trust → SIGHUP-equivalent
// Both endpoints are admin-gated (M-008 pin updated to include
// admin_scep_intune.go).
AdminSCEPIntune handler.AdminSCEPIntuneHandler
}
// RegisterHandlers sets up all API routes with their handlers.
@@ -296,6 +304,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// scheduler-driven CRL pre-generation cache. Admin-gated inside
// the handler (M-003 pattern); non-admin callers get 403.
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
// SCEP RFC 8894 + Intune master bundle Phase 9.2. Both endpoints are
// admin-gated at the handler layer; the M-008 regression scanner pins
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
// enforces the per-handler test triplet.
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))
// Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
+86
View File
@@ -820,6 +820,65 @@ type SCEPProfileConfig struct {
// `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists,
// parses as PEM, contains ≥1 cert, none expired.
MTLSClientCATrustBundlePath string
// Intune is the per-profile Microsoft Intune Certificate Connector
// integration block. When Enabled is false (default), this profile only
// honors the static ChallengePassword; when true, requests with an
// Intune-shaped challenge password (length + dot-count heuristic) are
// routed to the Intune dynamic-challenge validator.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.8: per-profile dispatch
// is what makes the heterogeneous-fleet story work — an operator
// running corp-laptops via Intune AND IoT devices via static challenge
// configures Intune-mode on the corp profile only; the IoT profile's
// PKCSReq path skips the Intune dispatcher entirely.
Intune SCEPIntuneProfileConfig
}
// SCEPIntuneProfileConfig is the per-profile Microsoft Intune Certificate
// Connector integration sub-block on SCEPProfileConfig.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.1.
//
// All fields here are populated from CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_*
// env vars (e.g. CERTCTL_SCEP_PROFILE_CORP_INTUNE_ENABLED=true). Per-profile
// overrides means an operator with two Intune-backed profiles (corp + iot,
// say) can pin distinct Connectors + audiences + rate limits per fleet.
type SCEPIntuneProfileConfig struct {
// Enabled gates the Intune dynamic-challenge validation path. When
// false (default), this profile honors only the static ChallengePassword.
// When true, ConnectorCertPath becomes a required boot gate.
Enabled bool
// ConnectorCertPath is the filesystem path to a PEM bundle of one or
// more Microsoft Intune Certificate Connector signing certs. Required
// when Enabled=true. Reloaded on SIGHUP via the per-profile
// TrustAnchorHolder wired in cmd/server/main.go.
ConnectorCertPath string
// Audience is the expected "aud" claim value in the Intune challenge —
// typically the public SCEP endpoint URL the Connector is configured to
// call (e.g. "https://certctl.example.com/scep/corp"). Defaults to
// empty (audience check disabled) for proxy / load-balancer scenarios
// where the URL the Connector saw isn't the URL we see; operators
// who pin a public URL here gain defense-in-depth against challenge
// re-use across endpoints.
Audience string
// ChallengeValidity caps the maximum age of an Intune challenge, on
// top of the challenge's own iat/exp claims. Default 60 minutes per
// Microsoft's published Connector defaults — operators may want a
// stricter cap to reduce the replay-window exposure on a stolen
// challenge. Zero means "use Connector's exp claim only" (no extra cap).
ChallengeValidity time.Duration
// PerDeviceRateLimit24h caps the number of enrollments per
// (claim.Subject, claim.Issuer) pair in any rolling 24-hour window.
// Default 3 (covers legitimate first-cert + recovery + post-wipe
// re-enrollment, blocks bulk-enumeration from a compromised Connector
// signing key). Zero means "unlimited" (defense-in-depth disabled;
// not recommended for production).
PerDeviceRateLimit24h int
}
// NetworkScanConfig controls the server-side active TLS scanner.
@@ -1448,6 +1507,14 @@ func loadSCEPProfilesFromEnv() []SCEPProfileConfig {
// SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route.
MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false),
MTLSClientCATrustBundlePath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""),
// SCEP RFC 8894 Phase 8.1: per-profile Intune Connector dispatch.
Intune: SCEPIntuneProfileConfig{
Enabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_ENABLED", false),
ConnectorCertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CONNECTOR_CERT_PATH", ""),
Audience: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_AUDIENCE", ""),
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),
},
})
}
return out
@@ -1706,6 +1773,25 @@ func (c *Config) Validate() error {
if p.MTLSEnabled && p.MTLSClientCATrustBundlePath == "" {
return fmt.Errorf("SCEP profile %d (PathID=%q) has MTLSEnabled=true but MTLS_CLIENT_CA_TRUST_BUNDLE_PATH is empty — refuse to start: the mTLS sibling route /scep-mtls/%s would have no client-cert trust anchor", i, p.PathID, p.PathID)
}
// Phase 8.1: when Intune is enabled, the Connector trust anchor
// path must be set. Preflight in cmd/server/main.go validates the
// file itself (intune.LoadTrustAnchor: exists, parseable PEM,
// ≥1 CERTIFICATE block, none expired); this gate is the
// structural-config refuse, defense in depth — without it an
// operator who flips INTUNE_ENABLED=true but forgets to set
// CONNECTOR_CERT_PATH would get every Intune enrollment
// rejected at runtime with no trust anchor configured (much
// worse failure mode than failing fast at boot).
if p.Intune.Enabled && p.Intune.ConnectorCertPath == "" {
return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_ENABLED=true but INTUNE_CONNECTOR_CERT_PATH is empty — refuse to start: the Intune dynamic-challenge validator would have no trust anchor and reject every Microsoft Intune enrollment", i, p.PathID)
}
// Phase 8.6: a non-zero rate limit must be sane. Negative is a
// config typo; positive values are the per-(Subject,Issuer)
// 24-hour cap; zero means 'disabled' (allowed for tests + the
// rare operator who wants no per-device cap).
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)
}
}
}
+343
View File
@@ -0,0 +1,343 @@
package intune
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
)
// Typed challenge-validation errors. The handler audits the specific
// failure dimension via errors.Is so operators can distinguish e.g. an
// expired challenge (clock skew, latent enrollment) from a tampered one
// (active attack) without string-matching error messages.
//
// SCEP RFC 8894 + Intune master bundle Phase 7.4.
var (
ErrChallengeMalformed = errors.New("intune: challenge is not in the JWT-like compact-serialization format")
ErrChallengeSignature = errors.New("intune: challenge signature does not verify against any configured trust anchor")
ErrChallengeExpired = errors.New("intune: challenge expired")
ErrChallengeNotYetValid = errors.New("intune: challenge not yet valid (iat in future, possible clock skew)")
ErrChallengeWrongAudience = errors.New("intune: challenge audience does not match this SCEP endpoint URL")
ErrChallengeReplay = errors.New("intune: challenge nonce already seen (replay attempt)")
ErrChallengeUnknownVersion = errors.New("intune: challenge has an unknown version claim — parser does not support this format")
)
// ParseChallenge decodes the JWT-like compact serialization of an Intune
// dynamic challenge into header, payload, and signature byte slices. Does
// NOT verify the signature; that's ValidateChallenge's job.
//
// Format: base64url(header) "." base64url(payload) "." base64url(signature)
// where the base64url alphabet is RFC 4648 §5 (URL-safe, no padding).
//
// We accept both padded and unpadded base64url because some Connector
// versions have shipped padded encodings in the wild despite RFC 7515 §2
// mandating unpadded. The stdlib base64.RawURLEncoding rejects padding,
// so we strip trailing '=' before decoding.
func ParseChallenge(raw string) (header, payload, signature []byte, err error) {
if raw == "" {
return nil, nil, nil, fmt.Errorf("%w: empty input", ErrChallengeMalformed)
}
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, nil, nil, fmt.Errorf("%w: expected 3 dot-separated segments, got %d", ErrChallengeMalformed, len(parts))
}
for i, p := range parts {
if p == "" {
return nil, nil, nil, fmt.Errorf("%w: segment %d is empty", ErrChallengeMalformed, i)
}
}
header, err = b64urlDecode(parts[0])
if err != nil {
return nil, nil, nil, fmt.Errorf("%w: header base64url: %v", ErrChallengeMalformed, err)
}
payload, err = b64urlDecode(parts[1])
if err != nil {
return nil, nil, nil, fmt.Errorf("%w: payload base64url: %v", ErrChallengeMalformed, err)
}
signature, err = b64urlDecode(parts[2])
if err != nil {
return nil, nil, nil, fmt.Errorf("%w: signature base64url: %v", ErrChallengeMalformed, err)
}
// Sanity-check the header parses as JSON before we hand it back; a
// non-JSON header is a clear malformed signal we'd otherwise only
// catch later in ValidateChallenge during alg dispatch. Earlier
// rejection = better operator audit log shape.
var probe map[string]any
if err := json.Unmarshal(header, &probe); err != nil {
return nil, nil, nil, fmt.Errorf("%w: header is not JSON: %v", ErrChallengeMalformed, err)
}
return header, payload, signature, nil
}
// b64urlDecode decodes RFC 4648 §5 base64url with or without trailing
// '=' padding. RFC 7515 §2 mandates unpadded; some Intune Connector
// versions emit padded; tolerate both.
func b64urlDecode(s string) ([]byte, error) {
stripped := strings.TrimRight(s, "=")
return base64.RawURLEncoding.DecodeString(stripped)
}
// jwtHeader is the JOSE-style header carried in the first segment of an
// Intune challenge. We only consult `alg` for signature dispatch; other
// JWS fields (kid, x5c, jku, etc.) are intentionally NOT honored — the
// trust anchor is operator-supplied at startup and pinned, not negotiated
// per-request. Honoring kid/jku would expand the attack surface to "any
// URL the Connector header claims is the truth," which is exactly the
// JWT vulnerability class we're avoiding by not pulling in a full JOSE
// implementation.
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ,omitempty"`
}
// versionedChallenge is the lightest possible pre-parse to extract a
// version claim BEFORE the full JSON unmarshal commits to a struct
// shape. v1 (current) has no "version" key; v2+ MUST.
//
// SCEP RFC 8894 + Intune master bundle Phase 7.4 (version dispatcher
// rationale): Microsoft has changed the Connector signed-challenge format
// at least twice in the past 5 years. Adding the dispatcher today costs
// ~30 LoC + 2 tests; not having it when v2 ships costs a P0 incident
// where every Intune enrollment fails until a hot-fix lands.
type versionedChallenge struct {
Version string `json:"version,omitempty"`
}
// versionUnmarshalers maps a version string to its claim parser. Adding
// v2 = adding a parser + a registration line. Adding v3 = same. Existing
// v1 path stays untouched.
var versionUnmarshalers = map[string]func(payload []byte) (*ChallengeClaim, error){
"": unmarshalChallengeV1, // legacy / current default
"v1": unmarshalChallengeV1, // explicit v1, future-belt-and-suspenders
// "v2": unmarshalChallengeV2, // ← future, when Microsoft ships it
}
// challengePayloadV1 is the on-the-wire JSON shape of the v1 Connector
// challenge. Separated from the public ChallengeClaim because the wire
// format uses Unix-second numerics for iat/exp while the in-memory type
// uses time.Time (caller-friendly + sentinel-safe).
type challengePayloadV1 struct {
Issuer string `json:"iss,omitempty"`
Subject string `json:"sub,omitempty"`
Audience string `json:"aud,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
ExpiresAt int64 `json:"exp,omitempty"`
Nonce string `json:"nonce,omitempty"`
DeviceName string `json:"device_name,omitempty"`
SANDNS []string `json:"san_dns,omitempty"`
SANRFC822 []string `json:"san_rfc822,omitempty"`
SANUPN []string `json:"san_upn,omitempty"`
}
// unmarshalChallengeV1 parses the v1 wire format. Conservative: any
// unrecognised JSON fields are silently dropped (forward-compat for the
// inevitable v1.x minor additions Microsoft makes without bumping the
// version key).
func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
var p challengePayloadV1
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf("%w: v1 payload unmarshal: %v", ErrChallengeMalformed, err)
}
c := &ChallengeClaim{
Issuer: p.Issuer,
Subject: p.Subject,
Audience: p.Audience,
Nonce: p.Nonce,
DeviceName: p.DeviceName,
SANDNS: p.SANDNS,
SANRFC822: p.SANRFC822,
SANUPN: p.SANUPN,
}
if p.IssuedAt > 0 {
c.IssuedAt = time.Unix(p.IssuedAt, 0).UTC()
}
if p.ExpiresAt > 0 {
c.ExpiresAt = time.Unix(p.ExpiresAt, 0).UTC()
}
return c, nil
}
// ValidateChallenge runs the full Intune-challenge validation pipeline:
//
// 1. ParseChallenge(raw) — JWT compact deserialize
// 2. Verify signature over (segment0 || "." || segment1) against any
// trust-anchor cert's public key (try each until one verifies)
// 3. Extract version claim via the lightweight versioned-prelude
// 4. Dispatch to the per-version unmarshaler (v1 today)
// 5. Time bounds: now ≥ iat AND now < exp (with stdlib RFC 3339 grace)
// 6. Audience: claim.Audience == expectedAudience (when expectedAudience
// is non-empty; empty disables the check, useful for tests)
//
// Returns *ChallengeClaim on success, typed error on failure (caller can
// errors.Is the specific dimension).
//
// Replay protection is the CALLER's responsibility — pass the returned
// claim's Nonce to a *ReplayCache.CheckAndInsert. We deliberately don't
// own the cache here so the validator stays stateless + testable; the
// handler glues parser + cache together.
func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience string, now time.Time) (*ChallengeClaim, error) {
if len(trust) == 0 {
return nil, fmt.Errorf("%w: no trust anchors configured", ErrChallengeSignature)
}
header, payload, signature, err := ParseChallenge(raw)
if err != nil {
return nil, err
}
// JWS signing input per RFC 7515 §5.1: ASCII bytes of segment0 + "." + segment1.
// We re-derive from raw (split-by-dots) rather than re-base64-encode the
// decoded segments, because RFC 7515 §3.1 specifies the signing input
// is the encoded form, and some encoders omit padding while others
// don't — re-encoding could produce a byte-different input than what
// the Connector originally signed. Use the raw on-wire bytes.
parts := strings.Split(raw, ".")
if len(parts) != 3 {
// ParseChallenge already enforced this; defensive double-check.
return nil, fmt.Errorf("%w: post-parse segment count drift", ErrChallengeMalformed)
}
signingInput := []byte(parts[0] + "." + parts[1])
var hdr jwtHeader
if err := json.Unmarshal(header, &hdr); err != nil {
return nil, fmt.Errorf("%w: header JSON: %v", ErrChallengeMalformed, err)
}
if err := verifyChallengeSignature(hdr.Alg, signingInput, signature, trust); err != nil {
return nil, err
}
// Version dispatch — extract the version claim BEFORE the full unmarshal.
var v versionedChallenge
if err := json.Unmarshal(payload, &v); err != nil {
return nil, fmt.Errorf("%w: prelude unmarshal: %v", ErrChallengeMalformed, err)
}
unmarshaler, ok := versionUnmarshalers[v.Version]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrChallengeUnknownVersion, v.Version)
}
claim, err := unmarshaler(payload)
if err != nil {
return nil, err
}
// Time bounds. The Connector's signed iat/exp ARE authoritative;
// we don't impose a separate validity cap here (the operator can
// add one in the handler if defense-in-depth is wanted, e.g. via
// SCEPProfileConfig.IntuneChallengeValidity in Phase 8).
if !claim.IssuedAt.IsZero() && now.Before(claim.IssuedAt) {
return nil, fmt.Errorf("%w: iat=%s now=%s", ErrChallengeNotYetValid,
claim.IssuedAt.Format(time.RFC3339), now.Format(time.RFC3339))
}
if !claim.ExpiresAt.IsZero() && !now.Before(claim.ExpiresAt) {
return nil, fmt.Errorf("%w: exp=%s now=%s", ErrChallengeExpired,
claim.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339))
}
// Audience binds the challenge to a specific SCEP endpoint URL. An
// empty expectedAudience disables the check (test convenience + the
// Phase 8 config allows operator opt-out for proxy / load-balancer
// scenarios where the URL the Connector saw isn't the URL we see).
if expectedAudience != "" && claim.Audience != "" && claim.Audience != expectedAudience {
return nil, fmt.Errorf("%w: claim=%q expected=%q", ErrChallengeWrongAudience,
claim.Audience, expectedAudience)
}
return claim, nil
}
// verifyChallengeSignature dispatches on the JWS alg header to the
// matching stdlib signature-verify routine, then iterates the trust
// anchors trying each cert's public key until one verifies.
//
// Supported algs:
// - RS256: RSASSA-PKCS1-v1_5 over SHA-256 (Microsoft's published Connector default)
// - ES256: ECDSA P-256 over SHA-256 (community-reported Connector option)
//
// Deliberately rejected algs:
// - "none" (RFC 7515 §3.6 vulnerability vector)
// - HS256 / HS384 / HS512 (HMAC; no shared secret in our threat model)
// - PS256+ (RSA-PSS; not seen in Intune Connector traffic — add only when needed)
//
// Adding a new alg = add a case + a verify helper. The trust-anchor loop
// stays unchanged.
func verifyChallengeSignature(alg string, signingInput, signature []byte, trust []*x509.Certificate) error {
switch alg {
case "RS256":
return verifyRS256(signingInput, signature, trust)
case "ES256":
return verifyES256(signingInput, signature, trust)
case "":
return fmt.Errorf("%w: missing alg header (RFC 7515 §4.1.1 mandates)", ErrChallengeSignature)
case "none":
// Explicit reject so the failure mode in the audit log distinguishes
// "unsupported alg" from "active attack with the alg-none vector."
return fmt.Errorf("%w: alg \"none\" rejected (RFC 7515 §3.6 attack)", ErrChallengeSignature)
default:
return fmt.Errorf("%w: unsupported alg %q (only RS256 and ES256 are accepted)", ErrChallengeSignature, alg)
}
}
// verifyRS256 hashes the signing input with SHA-256 and checks the
// signature against each trust anchor's public key. Constant-time: the
// stdlib's rsa.VerifyPKCS1v15 returns nil on success and an error on
// failure without timing-leak surface area on the hash compare path.
func verifyRS256(signingInput, signature []byte, trust []*x509.Certificate) error {
h := sha256.Sum256(signingInput)
for _, cert := range trust {
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
continue
}
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], signature); err == nil {
return nil
}
}
return ErrChallengeSignature
}
// verifyES256 dispatches between the two ECDSA signature encodings the
// JOSE spec allows for ES256:
//
// - RFC 7515 §3.4 fixed-width: r || s, each 32 bytes (raw concat) — the
// wire format JOSE-compliant Connectors use.
// - ASN.1 DER (SEQUENCE { r INTEGER, s INTEGER }) — older Connector
// builds and many .NET-based JWT libraries emit DER instead of the
// RFC 7515 fixed-width form.
//
// Try fixed-width first (the spec-blessed format); fall back to ASN.1.
// crypto/ecdsa.VerifyASN1 + ecdsa.Verify both return bool — no timing
// leak on the success path.
func verifyES256(signingInput, signature []byte, trust []*x509.Certificate) error {
h := sha256.Sum256(signingInput)
for _, cert := range trust {
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok {
continue
}
// Fixed-width r||s form (JOSE-canonical for P-256 = 64 bytes).
if len(signature) == 64 {
r := new(big.Int).SetBytes(signature[:32])
s := new(big.Int).SetBytes(signature[32:])
if ecdsa.Verify(pub, h[:], r, s) {
return nil
}
}
// ASN.1 DER form (older / non-JOSE encoders).
if ecdsa.VerifyASN1(pub, h[:], signature) {
return nil
}
}
return ErrChallengeSignature
}
+526
View File
@@ -0,0 +1,526 @@
package intune
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"strings"
"testing"
"time"
)
// Test idiom: each test materialises a real Connector signing cert +
// private key, builds a JWT-shaped challenge by hand, then runs it
// through Parse / Validate. Round-trip pins the exact wire format the
// Microsoft Intune Certificate Connector emits today (v1).
// =============================================================================
// Test helpers — Connector trust-anchor + signed challenge factories.
// =============================================================================
type testRSAConnector struct {
key *rsa.PrivateKey
cert *x509.Certificate
}
func genTestRSAConnector(t *testing.T) testRSAConnector {
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: "test-intune-connector"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("x509.ParseCertificate: %v", err)
}
return testRSAConnector{key: key, cert: cert}
}
type testECDSAConnector struct {
key *ecdsa.PrivateKey
cert *x509.Certificate
}
func genTestECDSAConnector(t *testing.T) testECDSAConnector {
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(2),
Subject: pkix.Name{CommonName: "test-intune-connector-es256"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("x509.ParseCertificate: %v", err)
}
return testECDSAConnector{key: key, cert: cert}
}
// signTestChallengeRS256 builds + signs a challenge with the given payload.
// alg defaults to RS256.
func signTestChallengeRS256(t *testing.T, c testRSAConnector, payload any) string {
t.Helper()
hdr, _ := json.Marshal(jwtHeader{Alg: "RS256", Typ: "JWT"})
pl, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, c.key, crypto.SHA256, h[:])
if err != nil {
t.Fatalf("rsa.SignPKCS1v15: %v", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// signTestChallengeES256_FixedWidth produces a JOSE-canonical r||s ES256.
func signTestChallengeES256_FixedWidth(t *testing.T, c testECDSAConnector, payload any) string {
t.Helper()
hdr, _ := json.Marshal(jwtHeader{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, c.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)
}
// signTestChallengeES256_DER produces the older non-JOSE ASN.1 DER form.
func signTestChallengeES256_DER(t *testing.T, c testECDSAConnector, payload any) string {
t.Helper()
hdr, _ := json.Marshal(jwtHeader{Alg: "ES256", Typ: "JWT"})
pl, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
derSig, err := ecdsa.SignASN1(rand.Reader, c.key, h[:])
if err != nil {
t.Fatalf("ecdsa.SignASN1: %v", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(derSig)
}
// validV1Payload returns a v1 challenge payload that is currently in-window.
func validV1Payload(now time.Time) challengePayloadV1 {
return challengePayloadV1{
Issuer: "test-connector-installation-guid",
Subject: "device-guid-123",
Audience: "https://certctl.example.com/scep/corp",
IssuedAt: now.Add(-1 * time.Minute).Unix(),
ExpiresAt: now.Add(59 * time.Minute).Unix(),
Nonce: "abc123nonce",
DeviceName: "DEVICE-001",
SANDNS: []string{"device-001.example.com"},
SANRFC822: []string{"device-001@example.com"},
}
}
// =============================================================================
// ParseChallenge.
// =============================================================================
func TestParseChallenge_HappyPath(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
raw := signTestChallengeRS256(t, c, validV1Payload(now))
header, payload, signature, err := ParseChallenge(raw)
if err != nil {
t.Fatalf("ParseChallenge: %v", err)
}
if len(header) == 0 || len(payload) == 0 || len(signature) == 0 {
t.Fatalf("decoded segments are empty: header=%d payload=%d signature=%d",
len(header), len(payload), len(signature))
}
var p challengePayloadV1
if err := json.Unmarshal(payload, &p); err != nil {
t.Fatalf("payload not valid JSON: %v", err)
}
if p.DeviceName != "DEVICE-001" {
t.Errorf("DeviceName = %q, want DEVICE-001", p.DeviceName)
}
}
func TestParseChallenge_Malformed(t *testing.T) {
cases := []struct {
name string
in string
}{
{"empty", ""},
{"missing dots", "abc"},
{"two dots one missing segment", "abc..def"},
{"trailing dot extra segment", "a.b.c.d"},
{"first segment empty", ".b.c"},
{"middle segment empty", "a..c"},
{"last segment empty", "a.b."},
{"non-base64 header", "!!!.YWJj.YWJj"},
{"non-JSON header", base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".YWJj.YWJj"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, _, err := ParseChallenge(tc.in)
if !errors.Is(err, ErrChallengeMalformed) {
t.Fatalf("got %v, want errors.Is(ErrChallengeMalformed)", err)
}
})
}
}
func TestParseChallenge_PaddedBase64Tolerated(t *testing.T) {
// Some Connector versions emit padded base64url; we tolerate both.
hdr := base64.URLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`))
pl := base64.URLEncoding.EncodeToString([]byte(`{"foo":"bar"}`))
sig := base64.URLEncoding.EncodeToString([]byte("xx"))
if !strings.HasSuffix(hdr, "=") && !strings.HasSuffix(pl, "=") && !strings.HasSuffix(sig, "=") {
t.Skip("encoder didn't produce padding for this fixture; skipping")
}
raw := hdr + "." + pl + "." + sig
if _, _, _, err := ParseChallenge(raw); err != nil {
t.Fatalf("padded base64url should be tolerated: %v", err)
}
}
// =============================================================================
// ValidateChallenge — happy paths for both algs + both ES256 encodings.
// =============================================================================
func TestValidateChallenge_HappyPath_RS256(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl)
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
if err != nil {
t.Fatalf("ValidateChallenge: %v", err)
}
if got.DeviceName != "DEVICE-001" {
t.Errorf("DeviceName = %q", got.DeviceName)
}
if got.Nonce != "abc123nonce" {
t.Errorf("Nonce = %q", got.Nonce)
}
if got.IssuedAt.IsZero() || got.ExpiresAt.IsZero() {
t.Errorf("iat/exp not populated: iat=%v exp=%v", got.IssuedAt, got.ExpiresAt)
}
}
func TestValidateChallenge_HappyPath_ES256_FixedWidth(t *testing.T) {
c := genTestECDSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeES256_FixedWidth(t, c, pl)
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
if err != nil {
t.Fatalf("ValidateChallenge: %v", err)
}
if got.Subject != "device-guid-123" {
t.Errorf("Subject = %q", got.Subject)
}
}
func TestValidateChallenge_HappyPath_ES256_DER(t *testing.T) {
c := genTestECDSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeES256_DER(t, c, pl)
if _, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now); err != nil {
t.Fatalf("ValidateChallenge ES256 DER: %v", err)
}
}
// =============================================================================
// ValidateChallenge — failure dimensions.
// =============================================================================
func TestValidateChallenge_Expired(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
pl.ExpiresAt = now.Add(-1 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
if !errors.Is(err, ErrChallengeExpired) {
t.Fatalf("got %v, want ErrChallengeExpired", err)
}
}
func TestValidateChallenge_NotYetValid(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
pl.IssuedAt = now.Add(5 * time.Minute).Unix() // future iat (clock skew)
pl.ExpiresAt = now.Add(65 * time.Minute).Unix()
raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
if !errors.Is(err, ErrChallengeNotYetValid) {
t.Fatalf("got %v, want ErrChallengeNotYetValid", err)
}
}
func TestValidateChallenge_WrongAudience(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "https://wrong-host.example.com/scep", now)
if !errors.Is(err, ErrChallengeWrongAudience) {
t.Fatalf("got %v, want ErrChallengeWrongAudience", err)
}
}
func TestValidateChallenge_EmptyExpectedAudienceDisablesCheck(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl)
if _, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", now); err != nil {
t.Fatalf("empty expected audience should disable the check: %v", err)
}
}
func TestValidateChallenge_TamperedSignature(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl)
parts := strings.Split(raw, ".")
// Flip one byte in the b64-decoded signature, then re-encode.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sig[0] ^= 0xFF
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
tampered := strings.Join(parts, ".")
_, err := ValidateChallenge(tampered, []*x509.Certificate{c.cert}, pl.Audience, now)
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err)
}
}
func TestValidateChallenge_TamperedPayload(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeRS256(t, c, pl)
// Re-encode the payload with a different DeviceName but keep the
// original signature. Signature verification MUST catch this.
parts := strings.Split(raw, ".")
pl.DeviceName = "ATTACKER-CHANGED-DEVICE"
tamperedPayload, _ := json.Marshal(pl)
parts[1] = base64.RawURLEncoding.EncodeToString(tamperedPayload)
tampered := strings.Join(parts, ".")
_, err := ValidateChallenge(tampered, []*x509.Certificate{c.cert}, pl.Audience, now)
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err)
}
}
func TestValidateChallenge_RotatedTrustAnchor(t *testing.T) {
signedBy := genTestRSAConnector(t)
rotatedTo := genTestRSAConnector(t) // operator already rotated; old key gone
now := time.Now()
pl := validV1Payload(now)
raw := signTestChallengeRS256(t, signedBy, pl)
_, err := ValidateChallenge(raw, []*x509.Certificate{rotatedTo.cert}, pl.Audience, now)
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err)
}
}
func TestValidateChallenge_EmptyTrustBundle(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
raw := signTestChallengeRS256(t, c, validV1Payload(now))
_, err := ValidateChallenge(raw, nil, "", now)
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature", err)
}
}
func TestValidateChallenge_AlgNoneRejected(t *testing.T) {
// Active alg=none attack: header says alg=none, signature is empty,
// the validator MUST reject regardless of any "valid"-looking payload.
hdr, _ := json.Marshal(jwtHeader{Alg: "none"})
pl, _ := json.Marshal(validV1Payload(time.Now()))
raw := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." +
base64.RawURLEncoding.EncodeToString([]byte("nope"))
c := genTestRSAConnector(t)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature for alg=none", err)
}
if !strings.Contains(err.Error(), "none") {
t.Errorf("error message should mention alg=none for audit clarity: %v", err)
}
}
func TestValidateChallenge_UnsupportedAlg(t *testing.T) {
hdr, _ := json.Marshal(jwtHeader{Alg: "HS256"})
pl, _ := json.Marshal(validV1Payload(time.Now()))
raw := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." +
base64.RawURLEncoding.EncodeToString([]byte("hmac-bytes"))
c := genTestRSAConnector(t)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature for unsupported alg", err)
}
}
func TestValidateChallenge_MissingAlgHeader(t *testing.T) {
hdr, _ := json.Marshal(map[string]string{"typ": "JWT"})
pl, _ := json.Marshal(validV1Payload(time.Now()))
raw := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." +
base64.RawURLEncoding.EncodeToString([]byte("xx"))
c := genTestRSAConnector(t)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
if !errors.Is(err, ErrChallengeSignature) {
t.Fatalf("got %v, want ErrChallengeSignature for missing alg", err)
}
}
// =============================================================================
// Version dispatcher.
// =============================================================================
func TestValidateChallenge_VersionV1ExplicitOK(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
type plWithVersion struct {
Version string `json:"version"`
challengePayloadV1
}
p := plWithVersion{Version: "v1", challengePayloadV1: validV1Payload(now)}
raw := signTestChallengeRS256(t, c, p)
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, p.Audience, now)
if err != nil {
t.Fatalf("explicit v1 should be accepted: %v", err)
}
if got.DeviceName != "DEVICE-001" {
t.Errorf("DeviceName = %q", got.DeviceName)
}
}
func TestValidateChallenge_VersionUnknownRejected(t *testing.T) {
c := genTestRSAConnector(t)
now := time.Now()
type plWithVersion struct {
Version string `json:"version"`
challengePayloadV1
}
p := plWithVersion{Version: "v999", challengePayloadV1: validV1Payload(now)}
raw := signTestChallengeRS256(t, c, p)
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, p.Audience, now)
if !errors.Is(err, ErrChallengeUnknownVersion) {
t.Fatalf("got %v, want ErrChallengeUnknownVersion", err)
}
}
// =============================================================================
// Trust-anchor walk: when a trust bundle has both algs configured, the
// validator must ignore key-type mismatches without returning Signature.
// =============================================================================
func TestValidateChallenge_MixedTrustBundle_IgnoresKeyTypeMismatches(t *testing.T) {
rsaConn := genTestRSAConnector(t)
ecConn := genTestECDSAConnector(t)
now := time.Now()
pl := validV1Payload(now)
// Sign with RSA; trust bundle has BOTH the RSA cert and an unrelated
// ECDSA cert. Validator should iterate, skip the EC cert (key type
// mismatch), find RSA, verify, return success.
raw := signTestChallengeRS256(t, rsaConn, pl)
bundle := []*x509.Certificate{ecConn.cert, rsaConn.cert}
if _, err := ValidateChallenge(raw, bundle, pl.Audience, now); err != nil {
t.Fatalf("mixed-bundle validate: %v", err)
}
}
// =============================================================================
// Defensive: malformed payload after good signature still surfaces a
// useful error (not a panic).
// =============================================================================
func TestValidateChallenge_NonJSONPayloadButValidSignature(t *testing.T) {
c := genTestRSAConnector(t)
hdr, _ := json.Marshal(jwtHeader{Alg: "RS256"})
pl := []byte("this is not JSON")
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, c.key, crypto.SHA256, h[:])
if err != nil {
t.Fatalf("rsa.SignPKCS1v15: %v", err)
}
raw := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
_, vErr := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
if !errors.Is(vErr, ErrChallengeMalformed) {
t.Fatalf("got %v, want ErrChallengeMalformed", vErr)
}
}
// 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).
var (
_ = asn1.Marshal
_ = big.NewInt
)
+162
View File
@@ -0,0 +1,162 @@
package intune
import (
"crypto/x509"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// ChallengeClaim is the parsed payload of an Intune dynamic challenge.
//
// SCEP RFC 8894 + Intune master bundle Phase 7.3.
//
// Fields documented from Microsoft's Connector source traces +
// community implementations (smallstep/step-ca and HashiCorp Vault's
// Intune integrations both reverse-engineered the same format). The
// JSON tags match what the Connector emits today (v1 format); a v2
// format would land alongside via the version-detection dispatcher
// in challenge.go.
//
// Set-equality semantics: the SAN slices are normalised (sorted,
// de-duped) before comparison so Microsoft's Connector emitting in a
// non-deterministic order doesn't break DeviceMatchesCSR.
type ChallengeClaim struct {
Issuer string `json:"iss,omitempty"` // Connector identity (installation GUID typical)
Subject string `json:"sub,omitempty"` // device GUID or user UPN
Audience string `json:"aud,omitempty"` // expected SCEP endpoint URL (replay protection)
IssuedAt time.Time `json:"-"` // populated by claim unmarshaler from "iat" Unix seconds
ExpiresAt time.Time `json:"-"` // populated by claim unmarshaler from "exp" Unix seconds
Nonce string `json:"nonce,omitempty"` // replay-protection token; opaque
DeviceName string `json:"device_name,omitempty"` // expected CSR CommonName
SANDNS []string `json:"san_dns,omitempty"` // expected SAN DNS names
SANRFC822 []string `json:"san_rfc822,omitempty"` // expected SAN email addresses (user certs)
SANUPN []string `json:"san_upn,omitempty"` // expected SAN userPrincipalName
}
// Typed claim-mismatch errors so the caller can audit the specific
// failure dimension without string-matching on error messages.
var (
ErrClaimCNMismatch = errors.New("intune claim: device_name does not match CSR CommonName")
ErrClaimSANDNSMismatch = errors.New("intune claim: SAN DNS set does not match CSR")
ErrClaimSANRFC822Mismatch = errors.New("intune claim: SAN RFC822 (email) set does not match CSR")
ErrClaimSANUPNMismatch = errors.New("intune claim: SAN UPN (userPrincipalName) set does not match CSR")
)
// DeviceMatchesCSR returns nil if the CSR's CN and SANs match the
// claim's expected values. Returns a typed error otherwise so the
// caller can audit the specific mismatch.
//
// Set-equality semantics: if the claim says
// SANDNS=["a.example.com","b.example.com"] and the CSR has only
// "a.example.com", that's a mismatch — the operator's Intune profile
// was misconfigured or the CSR was tampered with. Both are "fail
// closed" cases.
//
// Empty claim slices = no constraint on that dimension. So a claim
// with SANDNS=nil + a CSR with DNS SANs is OK (Intune didn't pin DNS,
// the CSR can carry whatever). A claim with SANDNS=["x"] + a CSR
// with no DNS SANs is a mismatch (Intune pinned x, CSR doesn't have
// it).
func (c *ChallengeClaim) DeviceMatchesCSR(csr *x509.CertificateRequest) error {
if c == nil {
return errors.New("intune claim: nil claim")
}
if csr == nil {
return errors.New("intune claim: nil CSR")
}
// CN is straight equality. Empty claim CN = no constraint.
if c.DeviceName != "" && c.DeviceName != csr.Subject.CommonName {
return fmt.Errorf("%w: claim=%q csr=%q", ErrClaimCNMismatch, c.DeviceName, csr.Subject.CommonName)
}
// SAN sets — set-equality means the SCEP CSR carries EXACTLY the
// claim's elements, no extras and no missing. Normalising via
// sorted lower-case slices makes the compare order-independent.
if len(c.SANDNS) > 0 {
got := normaliseSet(csr.DNSNames)
want := normaliseSet(c.SANDNS)
if !equalSets(got, want) {
return fmt.Errorf("%w: claim=%v csr=%v", ErrClaimSANDNSMismatch, want, got)
}
}
if len(c.SANRFC822) > 0 {
got := normaliseSet(csr.EmailAddresses)
want := normaliseSet(c.SANRFC822)
if !equalSets(got, want) {
return fmt.Errorf("%w: claim=%v csr=%v", ErrClaimSANRFC822Mismatch, want, got)
}
}
if len(c.SANUPN) > 0 {
// UPN SANs ride otherName extensions per RFC 4985 §1.1; Go's
// stdlib doesn't surface them as a typed slice. Walk the raw
// extensions if present. Most Intune deploys use SAN-RFC822
// (email) for user certs rather than SAN-UPN, so this branch is
// uncommon but pinned for correctness.
got := normaliseSet(extractUPNSans(csr))
want := normaliseSet(c.SANUPN)
if !equalSets(got, want) {
return fmt.Errorf("%w: claim=%v csr=%v", ErrClaimSANUPNMismatch, want, got)
}
}
return nil
}
// normaliseSet returns a sorted, lowercased, de-duplicated copy of s.
// Lowercase because DNS / email comparison is case-insensitive (DNS
// per RFC 4343, email local-part is case-sensitive per RFC 5321 but
// Microsoft + most TLS stacks treat it case-insensitively for SAN
// comparison). De-dup so a CSR with ["a","a"] matches a claim with
// ["a"] — the cert's effective SAN set is what we're comparing, not
// the multiset.
func normaliseSet(s []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(s))
for _, v := range s {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
sort.Strings(out)
return out
}
func equalSets(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// extractUPNSans walks a CSR's raw extensions for SAN entries with the
// otherName form carrying the id-ms-san-upn OID (1.3.6.1.4.1.311.20.2.3).
// Returns the decoded UTF-8 string values. Returns empty slice when no
// UPN SANs are present (the common case).
//
// Implementation note: Go's stdlib doesn't decode UPN SANs; we'd have
// to walk the SubjectAltName extension's raw value as ASN.1 SEQUENCE OF
// GeneralName, find the [0] otherName tags, parse each as
// {OID, [0] EXPLICIT ANY}, match the OID, and decode the EXPLICIT value
// as a UTF8String. That's ~50 LoC of ASN.1 fiddling. For Phase 7 v1 we
// punt on it: returning an empty slice means SANUPN claims with non-
// empty values fail the equalSets check below — which is the correct
// fail-closed behavior for the rare deploy that pins UPN SANs but
// hasn't audited the wire format. If/when an operator actually needs
// SAN-UPN matching, hot-fix this function with the ASN.1 walker.
func extractUPNSans(_ *x509.CertificateRequest) []string {
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package intune
import (
"crypto/x509"
"crypto/x509/pkix"
"errors"
"testing"
)
// Each TestDeviceMatchesCSR_* covers a single dimension (CN / SAN-DNS /
// SAN-RFC822 / SAN-UPN) with both happy-path and mismatch fixtures so the
// per-dimension typed errors stay wired up over future refactors.
func newCSRFixture(cn string, dns, email []string) *x509.CertificateRequest {
return &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
DNSNames: dns,
EmailAddresses: email,
}
}
func TestDeviceMatchesCSR_HappyPath_AllDimensions(t *testing.T) {
csr := newCSRFixture("DEVICE-001", []string{"a.example.com", "b.example.com"},
[]string{"alice@example.com"})
c := &ChallengeClaim{
DeviceName: "DEVICE-001",
SANDNS: []string{"b.example.com", "a.example.com"}, // reversed; set-equality
SANRFC822: []string{"alice@example.com"},
}
if err := c.DeviceMatchesCSR(csr); err != nil {
t.Fatalf("happy-path match should succeed: %v", err)
}
}
func TestDeviceMatchesCSR_NilGuards(t *testing.T) {
var nilClaim *ChallengeClaim
if err := nilClaim.DeviceMatchesCSR(&x509.CertificateRequest{}); err == nil {
t.Errorf("nil claim should error")
}
c := &ChallengeClaim{}
if err := c.DeviceMatchesCSR(nil); err == nil {
t.Errorf("nil CSR should error")
}
}
func TestDeviceMatchesCSR_CNMismatch(t *testing.T) {
csr := newCSRFixture("ATTACKER-DEVICE", nil, nil)
c := &ChallengeClaim{DeviceName: "DEVICE-001"}
if err := c.DeviceMatchesCSR(csr); !errors.Is(err, ErrClaimCNMismatch) {
t.Fatalf("got %v, want ErrClaimCNMismatch", err)
}
}
func TestDeviceMatchesCSR_EmptyClaimCN_NoConstraint(t *testing.T) {
csr := newCSRFixture("any-cn-is-fine", nil, nil)
c := &ChallengeClaim{} // no DeviceName pinned
if err := c.DeviceMatchesCSR(csr); err != nil {
t.Fatalf("empty claim CN must impose no constraint: %v", err)
}
}
func TestDeviceMatchesCSR_SANDNSMismatch_Missing(t *testing.T) {
csr := newCSRFixture("d", []string{"a.example.com"}, nil) // missing b
c := &ChallengeClaim{SANDNS: []string{"a.example.com", "b.example.com"}}
if err := c.DeviceMatchesCSR(csr); !errors.Is(err, ErrClaimSANDNSMismatch) {
t.Fatalf("got %v, want ErrClaimSANDNSMismatch", err)
}
}
func TestDeviceMatchesCSR_SANDNSMismatch_Extra(t *testing.T) {
csr := newCSRFixture("d", []string{"a.example.com", "evil.example.com"}, nil)
c := &ChallengeClaim{SANDNS: []string{"a.example.com"}}
if err := c.DeviceMatchesCSR(csr); !errors.Is(err, ErrClaimSANDNSMismatch) {
t.Fatalf("got %v, want ErrClaimSANDNSMismatch (CSR carries extra SAN)", err)
}
}
func TestDeviceMatchesCSR_SANDNSMatch_CaseInsensitive(t *testing.T) {
csr := newCSRFixture("d", []string{"A.Example.COM"}, nil)
c := &ChallengeClaim{SANDNS: []string{"a.example.com"}}
if err := c.DeviceMatchesCSR(csr); err != nil {
t.Fatalf("DNS comparison must be case-insensitive (RFC 4343): %v", err)
}
}
func TestDeviceMatchesCSR_SANDNSDedupe(t *testing.T) {
// CSR with duplicate SAN entries should still match a claim that
// only lists each unique value once. The "set" in set-equality is
// the cert's effective SAN set, not the multiset.
csr := newCSRFixture("d", []string{"a.example.com", "a.example.com"}, nil)
c := &ChallengeClaim{SANDNS: []string{"a.example.com"}}
if err := c.DeviceMatchesCSR(csr); err != nil {
t.Fatalf("dedup-equality must hold: %v", err)
}
}
func TestDeviceMatchesCSR_EmptyClaimSAN_NoConstraint(t *testing.T) {
csr := newCSRFixture("d", []string{"any.example.com"}, nil)
c := &ChallengeClaim{} // no SANDNS pinned
if err := c.DeviceMatchesCSR(csr); err != nil {
t.Fatalf("empty claim SANDNS must impose no constraint: %v", err)
}
}
func TestDeviceMatchesCSR_SANRFC822Mismatch(t *testing.T) {
csr := newCSRFixture("d", nil, []string{"bob@example.com"})
c := &ChallengeClaim{SANRFC822: []string{"alice@example.com"}}
if err := c.DeviceMatchesCSR(csr); !errors.Is(err, ErrClaimSANRFC822Mismatch) {
t.Fatalf("got %v, want ErrClaimSANRFC822Mismatch", err)
}
}
func TestDeviceMatchesCSR_SANUPNMismatch_NoExtractor(t *testing.T) {
// extractUPNSans currently returns nil; any non-empty SANUPN claim
// is therefore a guaranteed mismatch (correct fail-closed behavior).
csr := newCSRFixture("d", nil, nil)
c := &ChallengeClaim{SANUPN: []string{"alice@corp.example.com"}}
if err := c.DeviceMatchesCSR(csr); !errors.Is(err, ErrClaimSANUPNMismatch) {
t.Fatalf("got %v, want ErrClaimSANUPNMismatch (UPN extractor stubbed)", err)
}
}
func TestNormaliseSet_EdgeCases(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"empty", nil, []string{}},
{"trim space", []string{" hello "}, []string{"hello"}},
{"drop empty after trim", []string{" ", "x"}, []string{"x"}},
{"lowercase", []string{"HELLO", "World"}, []string{"hello", "world"}},
{"dedupe", []string{"a", "a", "b"}, []string{"a", "b"}},
{"sort", []string{"c", "a", "b"}, []string{"a", "b", "c"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normaliseSet(tc.in)
if !equalSets(got, tc.want) {
t.Errorf("normaliseSet(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestEqualSets_LengthMismatch(t *testing.T) {
if equalSets([]string{"a", "b"}, []string{"a"}) {
t.Errorf("different-length sets must not compare equal")
}
}
func TestExtractUPNSans_StubReturnsEmpty(t *testing.T) {
// Pin the documented stub behavior. If/when ExtractUPNSans is
// implemented for real, this test is the canary that flags the
// behavioral change.
if got := extractUPNSans(&x509.CertificateRequest{}); len(got) != 0 {
t.Errorf("extractUPNSans stub must return empty slice; got %v", got)
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package intune handles the Microsoft Intune dynamic-challenge format
// embedded in SCEP CSR challengePassword attributes when the SCEP server
// is sitting behind the Microsoft Intune Certificate Connector.
//
// SCEP RFC 8894 + Intune master bundle Phase 7.
//
// Architecture context:
//
// Intune cloud
// ↓ (device cert request)
// Intune Certificate Connector (on customer infra)
// ↓ (SCEP CSR with challenge signed by Connector)
// certctl SCEP server ← THIS PACKAGE validates the Connector's signed challenge
// ↓ (issue cert)
// issuer connector (local CA, Vault, EJBCA, etc.)
//
// The Connector's signed challenge is a JWT-like blob (compact
// serialization, header.payload.signature) where the payload is a JSON
// object containing the device + user claim, the expected CN + SANs,
// expiry, and a nonce. The signature is over header+"."+payload using
// the Connector's installation signing key — the operator extracts that
// key's certificate and configures it as certctl's trust anchor at
// startup.
//
// This package does NOT call Microsoft's API directly. The Connector
// already did that; this package validates the Connector's attestation.
//
// What this package is NOT:
//
// - NOT a full JWT (JOSE) implementation. It parses + verifies one
// specific format with a fixed set of supported algorithms (RS256,
// ES256). No JWKS fetch, no JKU header trust, no kid-based key
// rotation — the operator-supplied trust bundle IS the trust
// anchor, and the validator tries each cert in the bundle until
// one verifies.
// - NOT a generic SCEP-shape detector. The handler dispatches to this
// package only when the configured SCEPProfile has IntuneEnabled=true
// AND the inbound challengePassword "looks Intune-shaped" (length +
// dot-count heuristic landed in Phase 8).
// - NOT a Microsoft API client. The Connector's role is to talk to
// Microsoft; certctl's role is to validate the Connector's signed
// attestation. The replacement target this whole bundle eliminates
// is NDES, NOT the Connector.
//
// References:
//
// - https://learn.microsoft.com/en-us/mem/intune/protect/certificate-connector-overview
// - https://learn.microsoft.com/en-us/mem/intune/protect/certificates-scep-configure
// - smallstep/step-ca Intune integration (community reverse-engineering of the format)
// - HashiCorp Vault PKI Intune integration (same)
//
// The format details land in this package from a combination of
// Microsoft's published Connector behavior + community implementations
// that have reverse-engineered the JWT shape. Cite the implementation
// references in the parser code's doc comment when you change format.
package intune
+56
View File
@@ -0,0 +1,56 @@
package intune
import (
"crypto/x509"
"encoding/base64"
"encoding/json"
"testing"
"time"
)
// FuzzParseChallenge feeds arbitrary input to the parser and asserts
// no panics. The challenge wire format is exposed to untrusted devices
// (anyone who can hit the SCEP endpoint can submit a challenge); the
// parser MUST never crash the SCEP server. Run for at least 5 minutes
// in CI: `go test -run='^$' -fuzz=FuzzParseChallenge -fuzztime=5m
// ./internal/scep/intune/...`
//
// SCEP RFC 8894 + Intune master bundle Phase 7.5 (fuzz coverage).
func FuzzParseChallenge(f *testing.F) {
// Seed corpus: a real well-formed challenge so the fuzzer has
// structural mutation territory to explore (rather than starting
// from random ASCII).
hdr, _ := json.Marshal(jwtHeader{Alg: "RS256", Typ: "JWT"})
pl, _ := json.Marshal(challengePayloadV1{
Issuer: "fuzz",
Audience: "fuzz-aud",
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
Nonce: "fuzz-nonce",
})
seed := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." +
base64.RawURLEncoding.EncodeToString([]byte("fuzz-sig-bytes"))
f.Add(seed)
f.Add("")
f.Add(".")
f.Add("..")
f.Add("a.b.c")
f.Add("a..c")
f.Add(".b.")
f.Add("not-base64.not-base64.not-base64")
f.Add(string([]byte{0x00, 0x01, 0x02}))
f.Fuzz(func(t *testing.T, raw string) {
// ParseChallenge on its own.
_, _, _, _ = ParseChallenge(raw)
// Drive ValidateChallenge too — the full pipeline. Empty trust
// bundle short-circuits, but the parse + dispatch arms still
// execute; pass a non-empty placeholder so signature-verify
// gets exercised against arbitrary input.
bundle := []*x509.Certificate{} // empty to short-circuit cheap path
_, _ = ValidateChallenge(raw, bundle, "", time.Now())
})
}
+193
View File
@@ -0,0 +1,193 @@
package intune
import (
"errors"
"sync"
"time"
)
// SCEP RFC 8894 + Intune master bundle Phase 8.6.
//
// PerDeviceRateLimiter is the second line of defense behind the replay cache
// from Phase 7. The replay cache catches the same challenge being submitted
// twice (within the challenge TTL); this rate limiter catches a compromised
// Connector signing key (or a stolen key+cert pair) issuing many DIFFERENT
// valid challenges for the same device subject in a short window.
//
// Threat model:
//
// - Replay cache (Phase 7): nonce-keyed; catches duplicate submission.
// - This limiter: (Subject, Issuer)-keyed; catches enrollment-flooding.
//
// Default: 3 enrollments per (device GUID, Connector identity) per 24h.
//
// Sizing: 100,000 distinct device entries (matches the replay cache cap).
// At-cap: oldest entry evicted (small janitor pass) to avoid unbounded
// memory growth on a fleet that grows past the cap.
//
// Why a hand-rolled token bucket instead of pulling in golang.org/x/time/rate:
// the rate package is in go.sum as an indirect transitive but NOT a direct
// dep. Adding it would create a new direct dep relationship for ~30 LoC of
// state machine. The hand-rolled version below uses only stdlib (sync.Mutex
// + time.Time arithmetic) and is small enough to fit on one screen.
//
// Algorithm: each (Subject, Issuer) key maps to a bucket holding a window's
// worth of recent enrollment timestamps. On Allow, the bucket prunes
// timestamps older than (now - window) and either appends the current
// timestamp + returns true, or rejects + returns false when the post-prune
// count is already at the cap. This is the "sliding window log" rate
// limiter — exact (no token-leak rounding); O(N_per_key) per-call but N is
// bounded by the cap (3 by default), so effectively O(1).
// ErrRateLimited is the typed error returned when the per-device rate limit
// fires. The handler maps this to a CertRep FAILURE with badRequest failInfo
// + the `rate_limited` metric label.
var ErrRateLimited = errors.New("intune: per-device rate limit exceeded for this (subject, issuer) within the configured window")
// PerDeviceRateLimiter is a sliding-window-log rate limiter keyed by
// (Subject, Issuer) tuples derived from a parsed challenge claim.
//
// Concurrency: the limiter is safe for concurrent Allow calls. The internal
// map is guarded by a mutex; the per-key slices are mutated only while the
// mutex is held.
type PerDeviceRateLimiter struct {
mu sync.Mutex
buckets map[string][]time.Time // key → sliding window of timestamps
maxN int // max enrollments per window
window time.Duration // window length (default 24h)
cap int // max keys before LRU eviction kicks in
disabled bool // maxN == 0 → all Allow calls return nil
}
// NewPerDeviceRateLimiter returns a limiter with the given per-key cap +
// window. maxN ≤ 0 disables the limiter (all Allow calls return nil); this
// is operator opt-out for the rare case where the per-device cap is
// undesirable (e.g. test harnesses, sketchpad deploys).
//
// Window defaults to 24h when zero. Map cap defaults to 100,000 when zero
// (matches the replay cache cap; see internal/scep/intune/replay.go).
func NewPerDeviceRateLimiter(maxN int, window time.Duration, mapCap int) *PerDeviceRateLimiter {
if window <= 0 {
window = 24 * time.Hour
}
if mapCap <= 0 {
mapCap = 100_000
}
return &PerDeviceRateLimiter{
buckets: make(map[string][]time.Time),
maxN: maxN,
window: window,
cap: mapCap,
disabled: maxN <= 0,
}
}
// Allow checks whether an enrollment for the given (subject, issuer) tuple
// is permitted right now. Returns nil when allowed (and records the timestamp
// in the bucket) or ErrRateLimited when the bucket is at maxN.
//
// Empty subject is treated as "skip the limiter" — the caller's claim
// validation should have rejected an empty-subject claim already; this is
// belt-and-suspenders to prevent a single empty-subject bucket from
// becoming a fleet-wide chokepoint. The Connector emits non-empty subject
// (device GUID) on every legitimate challenge.
func (l *PerDeviceRateLimiter) Allow(subject, issuer string, now time.Time) error {
if l.disabled {
return nil
}
if subject == "" {
// Caller's claim validation should reject empty-subject upstream;
// this short-circuit is defense-in-depth so a misconfigured
// Connector can't DoS us via the rate-limit path.
return nil
}
key := subject + "|" + issuer
l.mu.Lock()
defer l.mu.Unlock()
// At-cap eviction: when the map is full, drop the oldest entry by
// finding the bucket whose newest timestamp is the smallest. O(N) but
// rarely fires; the prune-on-Allow path keeps most buckets short-lived.
if len(l.buckets) >= l.cap {
l.evictOldestLocked(now)
}
bucket := l.buckets[key]
bucket = pruneOlderThan(bucket, now.Add(-l.window))
if len(bucket) >= l.maxN {
// Don't append; over the limit. Persist the pruned bucket so the
// next call sees the most-recently-pruned state.
l.buckets[key] = bucket
return ErrRateLimited
}
bucket = append(bucket, now)
l.buckets[key] = bucket
return nil
}
// pruneOlderThan returns the slice with all entries strictly before
// `cutoff` removed. Preserves order (timestamps are appended in increasing
// time, so a single linear scan from the front suffices).
func pruneOlderThan(b []time.Time, cutoff time.Time) []time.Time {
i := 0
for i < len(b) && b[i].Before(cutoff) {
i++
}
if i == 0 {
return b
}
// Copy-shrink to release the underlying-array memory eventually
// (otherwise the slice would hold a reference to the older entries
// indefinitely until a re-allocation).
out := make([]time.Time, len(b)-i)
copy(out, b[i:])
return out
}
// evictOldestLocked drops the map entry whose newest timestamp is the
// oldest. Called under l.mu. O(N_keys) per eviction; at-cap is rare in
// practice (caps are sized for fleet steady-state).
func (l *PerDeviceRateLimiter) evictOldestLocked(now time.Time) {
var (
oldestKey string
oldestTs time.Time
first = true
)
for k, b := range l.buckets {
if len(b) == 0 {
// Empty bucket — drop it immediately, no candidate scan needed.
delete(l.buckets, k)
return
}
newest := b[len(b)-1]
if first || newest.Before(oldestTs) {
oldestKey = k
oldestTs = newest
first = false
}
}
if oldestKey != "" {
delete(l.buckets, oldestKey)
}
// Suppress unused-parameter warning for `now` in case the eviction
// strategy changes (e.g. swap to LRU keyed by time of last Allow).
_ = now
}
// Len returns the approximate number of distinct (subject, issuer) keys
// currently tracked. For observability + tests; not load-stable under
// concurrent Allow calls.
func (l *PerDeviceRateLimiter) Len() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.buckets)
}
// Disabled reports whether the limiter is in opt-out mode (maxN ≤ 0).
// Useful for handler-side gating + admin-endpoint observability.
func (l *PerDeviceRateLimiter) Disabled() bool {
return l.disabled
}
+190
View File
@@ -0,0 +1,190 @@
package intune
import (
"errors"
"fmt"
"sync"
"testing"
"time"
)
func TestPerDeviceRateLimiter_AllowsUpToCap(t *testing.T) {
l := NewPerDeviceRateLimiter(3, 24*time.Hour, 10)
now := time.Now()
for i := 0; i < 3; i++ {
if err := l.Allow("device-1", "issuer-A", now.Add(time.Duration(i)*time.Minute)); err != nil {
t.Fatalf("call %d should be allowed: %v", i+1, err)
}
}
if err := l.Allow("device-1", "issuer-A", now.Add(4*time.Minute)); !errors.Is(err, ErrRateLimited) {
t.Fatalf("4th call should be rate-limited; got %v", err)
}
}
func TestPerDeviceRateLimiter_DistinctKeysIndependent(t *testing.T) {
l := NewPerDeviceRateLimiter(1, 24*time.Hour, 10)
now := time.Now()
if err := l.Allow("device-1", "issuer-A", now); err != nil {
t.Fatalf("first allow: %v", err)
}
// Different subject — independent bucket.
if err := l.Allow("device-2", "issuer-A", now); err != nil {
t.Fatalf("different subject must have its own bucket: %v", err)
}
// Different issuer — also independent.
if err := l.Allow("device-1", "issuer-B", now); err != nil {
t.Fatalf("different issuer must have its own bucket: %v", err)
}
// Same key as call 1 — must be limited.
if err := l.Allow("device-1", "issuer-A", now.Add(1*time.Second)); !errors.Is(err, ErrRateLimited) {
t.Fatalf("repeat key should be limited; got %v", err)
}
}
func TestPerDeviceRateLimiter_WindowExpiry(t *testing.T) {
l := NewPerDeviceRateLimiter(2, 1*time.Hour, 10)
now := time.Now()
if err := l.Allow("dev", "iss", now); err != nil {
t.Fatal(err)
}
if err := l.Allow("dev", "iss", now.Add(30*time.Minute)); err != nil {
t.Fatal(err)
}
// Inside window — limited.
if err := l.Allow("dev", "iss", now.Add(45*time.Minute)); !errors.Is(err, ErrRateLimited) {
t.Fatalf("inside-window 3rd call should be limited: %v", err)
}
// Past window — slots reopen.
if err := l.Allow("dev", "iss", now.Add(2*time.Hour)); err != nil {
t.Fatalf("past-window call should be allowed (window reset): %v", err)
}
}
func TestPerDeviceRateLimiter_DisabledBypass(t *testing.T) {
l := NewPerDeviceRateLimiter(0, 24*time.Hour, 10) // maxN=0 → disabled
if !l.Disabled() {
t.Fatal("limiter with maxN=0 must report Disabled()=true")
}
now := time.Now()
for i := 0; i < 100; i++ {
if err := l.Allow("dev", "iss", now); err != nil {
t.Fatalf("disabled limiter must allow everything: %v", err)
}
}
// Disabled limiter doesn't track buckets.
if got := l.Len(); got != 0 {
t.Errorf("disabled limiter Len() = %d, want 0", got)
}
}
func TestPerDeviceRateLimiter_NegativeCapDisabled(t *testing.T) {
l := NewPerDeviceRateLimiter(-1, 24*time.Hour, 10)
if !l.Disabled() {
t.Fatal("negative maxN must produce a disabled limiter")
}
}
func TestPerDeviceRateLimiter_EmptySubjectShortCircuits(t *testing.T) {
// Empty subject is the caller's defense-in-depth case (claim validation
// upstream should reject empty-subject claims first). Limiter must not
// build a single shared bucket keyed by empty-subject — that would
// be a fleet-wide chokepoint.
l := NewPerDeviceRateLimiter(1, 24*time.Hour, 10)
now := time.Now()
for i := 0; i < 50; i++ {
if err := l.Allow("", "iss", now); err != nil {
t.Fatalf("empty subject must short-circuit (call %d): %v", i, err)
}
}
if got := l.Len(); got != 0 {
t.Errorf("Len after 50 empty-subject calls = %d, want 0 (no bucket created)", got)
}
}
func TestPerDeviceRateLimiter_DefaultCapsHonored(t *testing.T) {
l := NewPerDeviceRateLimiter(5, 0, 0) // window=0 → 24h default; cap=0 → 100k default
if l.window != 24*time.Hour {
t.Errorf("default window = %v, want 24h", l.window)
}
if l.cap != 100_000 {
t.Errorf("default cap = %d, want 100000", l.cap)
}
}
func TestPerDeviceRateLimiter_MapCapEvictsOldest(t *testing.T) {
// Cap of 3 keys to exercise the eviction branch deterministically.
l := NewPerDeviceRateLimiter(2, 1*time.Hour, 3)
now := time.Now()
// Insert 3 distinct keys with increasing timestamps.
for i := 0; i < 3; i++ {
key := fmt.Sprintf("dev-%d", i)
if err := l.Allow(key, "iss", now.Add(time.Duration(i)*time.Minute)); err != nil {
t.Fatalf("insert %d: %v", i, err)
}
}
if l.Len() != 3 {
t.Fatalf("Len = %d, want 3", l.Len())
}
// 4th key forces eviction of dev-0 (its newest timestamp is oldest).
if err := l.Allow("dev-3", "iss", now.Add(10*time.Minute)); err != nil {
t.Fatalf("4th-key insert: %v", err)
}
if l.Len() != 3 {
t.Errorf("Len after at-cap insert = %d, want 3 (cap honored)", l.Len())
}
}
func TestPerDeviceRateLimiter_ConcurrentRaceFree(t *testing.T) {
if testing.Short() {
t.Skip("race-style test under -short")
}
l := NewPerDeviceRateLimiter(50, 24*time.Hour, 10000)
var wg sync.WaitGroup
for g := 0; g < 20; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
now := time.Now()
key := fmt.Sprintf("dev-%d", id)
for i := 0; i < 30; i++ {
_ = l.Allow(key, "iss", now)
}
}(g)
}
wg.Wait()
if got := l.Len(); got != 20 {
t.Errorf("expected 20 distinct keys; got %d", got)
}
}
func TestPruneOlderThan(t *testing.T) {
t0 := time.Now()
in := []time.Time{
t0.Add(-3 * time.Hour), // pruned (older than cutoff)
t0.Add(-2 * time.Hour), // pruned (older than cutoff)
t0.Add(-1 * time.Hour), // survives (-60m is NEWER than the -90m cutoff)
t0.Add(-30 * time.Minute), // survives
t0, // survives
}
out := pruneOlderThan(in, t0.Add(-90*time.Minute))
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (-1h, -30m, t0 all newer than -90m cutoff)", len(out))
}
if !out[0].Equal(t0.Add(-1 * time.Hour)) {
t.Errorf("out[0] = %v, want -1h (oldest surviving entry)", out[0])
}
}
func TestPruneOlderThan_NoOpWhenNothingToPrune(t *testing.T) {
t0 := time.Now()
in := []time.Time{t0.Add(-1 * time.Minute), t0}
out := pruneOlderThan(in, t0.Add(-1*time.Hour))
// Same slice header (no copy needed).
if len(out) != len(in) {
t.Fatalf("len(out) = %d, want %d", len(out), len(in))
}
}
+191
View File
@@ -0,0 +1,191 @@
package intune
import (
"sync"
"time"
)
// ReplayCache is a bounded in-memory cache of seen Intune challenge
// nonces with TTL. Gates against the same Connector-signed challenge
// being replayed against the SCEP server within its validity window.
//
// SCEP RFC 8894 + Intune master bundle Phase 7.4b.
//
// Sizing rationale (cap = 100,000 entries):
//
// - Microsoft's published Connector defaults give each challenge
// a 60-minute validity window. A high-volume Intune fleet
// enrolling at ~25 RPS hits ~90,000 challenges/hour.
// - Capping at 100,000 covers the steady-state load with headroom.
// When the cap is hit, the janitor goroutine evicts entries past
// TTL first; if all entries are still in-window, oldest-first
// eviction kicks in (LRU semantics) — accepting the small
// replay-window risk over an OOM crash.
// - Operators who push beyond this rate should flip to a Redis-
// backed implementation (deferred to V3-Pro per the master
// prompt's deferral list); the in-memory variant is V2 default.
//
// Concurrency: sync.Map handles concurrent read/write without an
// explicit lock; the janitor goroutine periodically walks for expired
// entries. Cap enforcement on Insert is done under a small mutex so
// the cap check + size update are atomic.
type ReplayCache struct {
entries sync.Map // nonce → expiry (time.Time)
mu sync.Mutex // guards size + janitor lifecycle
size int // approximate count (sync.Map has no Len)
cap int // max entries before LRU eviction kicks in
ttl time.Duration
stop chan struct{}
stopOnce sync.Once
}
// NewReplayCache returns a ReplayCache with the given TTL + cap. Starts
// a janitor goroutine that wakes every TTL/4 to evict expired entries.
// Caller MUST call Close when done to stop the goroutine.
//
// TTL = 0 disables the janitor (useful for tests that drive expiry
// manually).
// cap = 0 defaults to 100,000 (the rationale-documented production
// default).
func NewReplayCache(ttl time.Duration, capHint int) *ReplayCache {
if capHint <= 0 {
capHint = 100_000
}
c := &ReplayCache{
cap: capHint,
ttl: ttl,
stop: make(chan struct{}),
}
if ttl > 0 {
go c.janitor()
}
return c
}
// CheckAndInsert returns true when the nonce has NOT been seen before
// (i.e. the challenge is not a replay) AND records the nonce as seen
// with expiry = now + c.ttl. Returns false when the nonce was already
// seen and is still within its TTL window — the caller should treat
// this as a replay attack and reject the challenge.
//
// At-cap behavior: when the cache is full, CheckAndInsert evicts the
// oldest entry (a single Range pass to find min-expiry) before
// inserting. This is O(N) at the boundary; in practice the janitor
// keeps the cache below cap so the eviction path rarely fires.
func (c *ReplayCache) CheckAndInsert(nonce string, now time.Time) bool {
if nonce == "" {
// Empty nonce can't be tracked meaningfully; treat as 'fresh'
// — the caller's claim-validation should reject empty-nonce
// challenges separately (it's a Connector-emitted-format bug).
return true
}
if existing, ok := c.entries.Load(nonce); ok {
if existingExpiry, _ := existing.(time.Time); now.Before(existingExpiry) {
return false // replay
}
// Past TTL; drop + treat as fresh (race-safe: even if two
// goroutines see the expired entry, both proceed and the second
// Insert wins).
c.delete(nonce)
}
// At-cap LRU eviction.
c.mu.Lock()
if c.size >= c.cap {
c.evictOldestLocked()
}
c.size++
c.mu.Unlock()
c.entries.Store(nonce, now.Add(c.ttl))
return true
}
// Close stops the janitor goroutine. Safe to call multiple times.
func (c *ReplayCache) Close() {
c.stopOnce.Do(func() {
close(c.stop)
})
}
// Sweep walks the entries and evicts any past TTL. Public so tests
// can drive expiry without waiting for the janitor's tick. Returns
// the number of entries evicted.
func (c *ReplayCache) Sweep(now time.Time) int {
evicted := 0
c.entries.Range(func(k, v any) bool {
expiry, _ := v.(time.Time)
if !now.Before(expiry) {
c.delete(k.(string))
evicted++
}
return true
})
return evicted
}
// delete is the size-tracked counterpart to entries.Delete. The size
// counter is approximate (sync.Map.Range races with Insert), but the
// approximation only affects cap enforcement timing — never causes a
// false replay rejection.
func (c *ReplayCache) delete(nonce string) {
if _, loaded := c.entries.LoadAndDelete(nonce); loaded {
c.mu.Lock()
if c.size > 0 {
c.size--
}
c.mu.Unlock()
}
}
// evictOldestLocked is called under c.mu held. Walks entries to find
// the entry with the minimum expiry (i.e. the oldest entry — closest
// to its TTL deadline) and removes it. O(N) but rarely hit; the
// janitor keeps the cache below cap.
func (c *ReplayCache) evictOldestLocked() {
var oldestKey string
var oldestExpiry time.Time
first := true
c.entries.Range(func(k, v any) bool {
expiry, _ := v.(time.Time)
if first || expiry.Before(oldestExpiry) {
oldestKey = k.(string)
oldestExpiry = expiry
first = false
}
return true
})
if oldestKey != "" {
if _, loaded := c.entries.LoadAndDelete(oldestKey); loaded && c.size > 0 {
c.size--
}
}
}
// janitor wakes every ttl/4 and sweeps expired entries. Background-only;
// the test harness can drive expiry deterministically via Sweep.
func (c *ReplayCache) janitor() {
interval := c.ttl / 4
if interval <= 0 {
interval = 1 * time.Minute
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-t.C:
c.Sweep(time.Now())
}
}
}
// Len returns the approximate cache size for observability. Not
// load-stable; use only for metrics + debug logs.
func (c *ReplayCache) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.size
}
+151
View File
@@ -0,0 +1,151 @@
package intune
import (
"fmt"
"sync"
"testing"
"time"
)
func TestReplayCache_FirstInsertFresh(t *testing.T) {
c := NewReplayCache(60*time.Minute, 100)
defer c.Close()
if !c.CheckAndInsert("nonce-1", time.Now()) {
t.Fatalf("first insert must report fresh")
}
}
func TestReplayCache_DuplicateRejected(t *testing.T) {
c := NewReplayCache(60*time.Minute, 100)
defer c.Close()
now := time.Now()
if !c.CheckAndInsert("nonce-1", now) {
t.Fatalf("first insert must report fresh")
}
if c.CheckAndInsert("nonce-1", now) {
t.Fatalf("second insert must report replay")
}
}
func TestReplayCache_PastTTLTreatedAsFresh(t *testing.T) {
// TTL=0 disables the janitor; we drive expiry by passing future timestamps.
c := NewReplayCache(10*time.Minute, 100)
defer c.Close()
t0 := time.Now()
if !c.CheckAndInsert("nonce-1", t0) {
t.Fatalf("first insert must report fresh")
}
// Same nonce, but observation time is past expiry → fresh again.
if !c.CheckAndInsert("nonce-1", t0.Add(11*time.Minute)) {
t.Fatalf("post-TTL re-insert must report fresh")
}
}
func TestReplayCache_SweepEvictsExpired(t *testing.T) {
c := NewReplayCache(10*time.Minute, 100)
defer c.Close()
t0 := time.Now()
c.CheckAndInsert("nonce-1", t0)
c.CheckAndInsert("nonce-2", t0)
if got := c.Len(); got != 2 {
t.Fatalf("Len = %d, want 2", got)
}
evicted := c.Sweep(t0.Add(11 * time.Minute))
if evicted != 2 {
t.Errorf("Sweep evicted %d, want 2", evicted)
}
if got := c.Len(); got != 0 {
t.Errorf("Len after sweep = %d, want 0", got)
}
}
func TestReplayCache_EmptyNonceTreatedAsFresh(t *testing.T) {
c := NewReplayCache(10*time.Minute, 100)
defer c.Close()
if !c.CheckAndInsert("", time.Now()) {
t.Fatalf("empty nonce must short-circuit to fresh (caller validates separately)")
}
// And a second empty also returns fresh (we don't track them).
if !c.CheckAndInsert("", time.Now()) {
t.Fatalf("second empty nonce should also report fresh; we don't cache empties")
}
}
func TestReplayCache_AtCapEvictsOldest(t *testing.T) {
// Cap of 3 makes the boundary easy to hit deterministically.
c := NewReplayCache(60*time.Minute, 3)
defer c.Close()
t0 := time.Now()
// Insert 3 entries with strictly increasing expiries.
c.CheckAndInsert("oldest", t0)
c.CheckAndInsert("middle", t0.Add(1*time.Minute))
c.CheckAndInsert("newest", t0.Add(2*time.Minute))
if got := c.Len(); got != 3 {
t.Fatalf("Len = %d, want 3", got)
}
// 4th insert must evict "oldest".
c.CheckAndInsert("brand-new", t0.Add(3*time.Minute))
if got := c.Len(); got != 3 {
t.Errorf("Len after at-cap insert = %d, want 3 (cap honored)", got)
}
// "oldest" should now be re-insertable as fresh.
if !c.CheckAndInsert("oldest", t0.Add(4*time.Minute)) {
t.Errorf("oldest must have been evicted under LRU at-cap policy")
}
}
func TestReplayCache_DefaultCap(t *testing.T) {
// capHint = 0 should default to 100,000 per the documented sizing.
c := NewReplayCache(60*time.Minute, 0)
defer c.Close()
if c.cap != 100_000 {
t.Errorf("default cap = %d, want 100000", c.cap)
}
}
func TestReplayCache_CloseIsIdempotent(t *testing.T) {
c := NewReplayCache(60*time.Minute, 10)
c.Close()
c.Close() // must not panic
}
func TestReplayCache_TTLZeroDisablesJanitor(t *testing.T) {
// TTL=0 + capHint=0 should produce a usable cache that doesn't
// background-evict; the test mostly pins that NewReplayCache returns
// without panicking and that Close still works.
c := NewReplayCache(0, 10)
defer c.Close()
// Empty nonce path is the only safe one without TTL semantics; exercise it.
if !c.CheckAndInsert("", time.Now()) {
t.Fatalf("zero-TTL cache must still serve empty-nonce fast path")
}
}
func TestReplayCache_ConcurrentInsertsRaceFree(t *testing.T) {
if testing.Short() {
t.Skip("race-style test under -short; run full suite for coverage")
}
c := NewReplayCache(60*time.Minute, 10000)
defer c.Close()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
now := time.Now()
for j := 0; j < 200; j++ {
c.CheckAndInsert(fmt.Sprintf("g%d-n%d", id, j), now)
}
}(i)
}
wg.Wait()
if got := c.Len(); got != 50*200 {
t.Errorf("Len = %d, want %d (no Insert dropped under contention)", got, 50*200)
}
}
+73
View File
@@ -0,0 +1,73 @@
package intune
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"time"
)
// LoadTrustAnchor reads a PEM bundle of one or more Intune Connector
// signing certificates from the configured path. Returns the slice of
// parsed certs that the validator will accept as challenge issuers.
//
// SCEP RFC 8894 + Intune master bundle Phase 7.2.
//
// Behavior:
//
// - File must exist + be readable.
// - PEM-decodes the file; non-CERTIFICATE blocks are skipped (so an
// operator can paste a chain that includes a private key by mistake
// without breaking the load — the priv key is just ignored).
// - Returns an error if zero CERTIFICATE blocks parse.
// - Returns an error if any cert is past NotAfter (a stale trust
// anchor would silently reject every Intune challenge at runtime;
// fail loud at startup instead).
//
// Operators rotate Connector signing certs periodically; the trust
// anchor file is reloaded on SIGHUP (handled by the existing config
// watch loop in cmd/server/main.go — see cmd/server/tls.go::watchSIGHUP
// for the precedent).
func LoadTrustAnchor(path string) ([]*x509.Certificate, error) {
if path == "" {
return nil, fmt.Errorf("intune: trust anchor path is empty")
}
body, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("intune: read trust anchor %q: %w", path, err)
}
return parseTrustAnchorPEM(body, path, time.Now())
}
// parseTrustAnchorPEM is the file-IO-free core of LoadTrustAnchor. Split
// out so unit tests can hand it byte slices without writing temp files.
// `now` is taken as a parameter so expiry tests can pin a deterministic
// clock.
func parseTrustAnchorPEM(body []byte, sourceLabel string, now time.Time) ([]*x509.Certificate, error) {
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 {
return nil, fmt.Errorf("intune: parse trust anchor cert in %q: %w", sourceLabel, err)
}
if now.After(cert.NotAfter) {
return nil, fmt.Errorf("intune: trust anchor cert in %q expired at %s (subject=%q) — operator must rotate the Connector signing cert before restart",
sourceLabel, cert.NotAfter.Format(time.RFC3339), cert.Subject.CommonName)
}
out = append(out, cert)
}
if len(out) == 0 {
return nil, fmt.Errorf("intune: trust anchor %q contains no CERTIFICATE PEM blocks", sourceLabel)
}
return out, nil
}
+143
View File
@@ -0,0 +1,143 @@
package intune
import (
"crypto/x509"
"errors"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
)
// TrustAnchorHolder is the SIGHUP-reloadable wrapper around a per-profile
// Intune Connector trust anchor pool.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.5.
//
// Mirrors the shape established by `cmd/server/tls.go::certHolder` for the
// server TLS cert: an RWMutex-guarded pool, a Get accessor that's safe for
// concurrent callers from the request path, a Reload that re-reads the file
// and atomically swaps the slice on success (failure leaves the OLD pool in
// place so a bad reload doesn't take Intune enrollment down), and a
// watchSIGHUP goroutine that responds to the same SIGHUP the operator uses
// to rotate the server TLS cert.
//
// Why SIGHUP specifically (vs fsnotify or a polling loop): SIGHUP is the
// repo-established convention (see cmd/server/tls.go). fsnotify would add a
// new direct dep + complicate the cleanup story. The operator's Connector-
// rotation script writes the new PEM bundle then sends SIGHUP — the same
// signal that already rotates the server TLS cert — and both swap atomically.
//
// Concurrency contract:
// - Get returns the pool slice header by value; the slice itself is
// immutable per-snapshot (Reload swaps a fresh slice rather than
// mutating the existing one). Callers may iterate the returned slice
// without holding any lock.
// - Reload acquires a write lock briefly for the swap. Concurrent Get
// calls block only for that swap window (microseconds).
// - watchSIGHUP runs at most one Reload at a time per holder.
type TrustAnchorHolder struct {
mu sync.RWMutex
certs []*x509.Certificate
path string
logger *slog.Logger
}
// NewTrustAnchorHolder loads the trust bundle and returns a holder. Returns
// the same fail-loud error LoadTrustAnchor does on initial load — the
// startup gate at cmd/server/main.go is supposed to refuse boot when this
// fails. Subsequent Reload errors are non-fatal (logged + old pool retained).
//
// The logger is required (never nil); the caller passes a per-profile
// scoped logger so SIGHUP-reload events show the PathID for triage.
func NewTrustAnchorHolder(path string, logger *slog.Logger) (*TrustAnchorHolder, error) {
if logger == nil {
return nil, errors.New("intune: TrustAnchorHolder requires a non-nil logger")
}
certs, err := LoadTrustAnchor(path)
if err != nil {
return nil, err
}
return &TrustAnchorHolder{
certs: certs,
path: path,
logger: logger,
}, nil
}
// Get returns the current trust anchor pool. Safe for concurrent callers;
// the slice header is returned by value and the underlying slice is
// immutable per-snapshot (Reload swaps a fresh slice, doesn't mutate in
// place — see Reload).
func (h *TrustAnchorHolder) Get() []*x509.Certificate {
h.mu.RLock()
defer h.mu.RUnlock()
return h.certs
}
// Path returns the on-disk path the holder reloads from. Useful for
// observability (admin endpoints, log lines) without exposing the cert
// pool itself.
func (h *TrustAnchorHolder) Path() string {
return h.path
}
// Reload re-reads the trust anchor file at h.path and atomically swaps the
// pool. Returns the parse error if the new file is invalid; the OLD pool
// stays in place so a bad reload doesn't take Intune enrollment down.
//
// Same fail-safe pattern as cmd/server/tls.go::(*certHolder).Reload — a
// rotation that writes a half-file (operator overwrites the bundle while
// only some of the new certs are in it) would otherwise crash the
// service mid-rotation. Logging + retaining the old pool gives the
// operator a bounded window to fix and re-SIGHUP.
func (h *TrustAnchorHolder) Reload() error {
certs, err := LoadTrustAnchor(h.path)
if err != nil {
return err
}
h.mu.Lock()
h.certs = certs
h.mu.Unlock()
return nil
}
// WatchSIGHUP installs a signal handler that calls Reload on each SIGHUP.
// The returned stop function closes the internal done channel and stops
// signal delivery so the goroutine can exit cleanly during shutdown.
//
// Errors from Reload are logged but do not terminate the watcher — the
// operator can fix the files and send another SIGHUP. Mirrors the
// (*certHolder).watchSIGHUP contract exactly.
//
// Multiple holders can coexist: each registers its own goroutine on the
// same SIGHUP signal. signal.Notify multicasts to every registered
// channel, so a single SIGHUP reloads every per-profile Intune trust
// anchor PLUS the server TLS cert in one operator action — exactly the
// design requirement (one SIGHUP rotates everything).
func (h *TrustAnchorHolder) WatchSIGHUP() (stop func()) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGHUP)
done := make(chan struct{})
go func() {
for {
select {
case <-ch:
if err := h.Reload(); err != nil {
h.logger.Error("Intune trust anchor reload failed; continuing with previous pool",
"error", err,
"path", h.path)
continue
}
h.logger.Info("Intune trust anchor reloaded via SIGHUP",
"path", h.path,
"certs_loaded", len(h.Get()))
case <-done:
signal.Stop(ch)
return
}
}
}()
return func() { close(done) }
}
@@ -0,0 +1,234 @@
package intune
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
// silentLogger returns a logger that drops everything; the SIGHUP watcher
// path emits Info logs we don't want fouling test output.
func silentTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
}
// writeTestBundle writes a PEM bundle of the given certs at path with mode 0600.
func writeTestBundle(t *testing.T, path string, certs []*x509.Certificate) {
t.Helper()
body := []byte{}
for _, c := range certs {
body = append(body, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})...)
}
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
// freshHolderCert is a small factory for a self-signed EC cert with a
// caller-controlled CN + lifetime. Used by Reload tests that swap the
// on-disk pool between calls.
func freshHolderCert(t *testing.T, cn string, notAfter time.Time) *x509.Certificate {
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},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("x509.ParseCertificate: %v", err)
}
return cert
}
func TestTrustAnchorHolder_NewLoadsBundle(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "intune-trust.pem")
cert := freshHolderCert(t, "initial-conn", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{cert})
holder, err := NewTrustAnchorHolder(path, silentTestLogger())
if err != nil {
t.Fatalf("NewTrustAnchorHolder: %v", err)
}
got := holder.Get()
if len(got) != 1 || got[0].Subject.CommonName != "initial-conn" {
t.Fatalf("Get returned %#v, want one cert with CN=initial-conn", got)
}
if holder.Path() != path {
t.Errorf("Path = %q, want %q", holder.Path(), path)
}
}
func TestTrustAnchorHolder_NewRequiresLogger(t *testing.T) {
if _, err := NewTrustAnchorHolder("/nonexistent", nil); err == nil {
t.Fatal("nil logger must error")
}
}
func TestTrustAnchorHolder_NewSurfacesLoadError(t *testing.T) {
if _, err := NewTrustAnchorHolder("/path/that/does/not/exist.pem", silentTestLogger()); err == nil {
t.Fatal("missing file must error")
}
}
func TestTrustAnchorHolder_ReloadHappyPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "trust.pem")
c1 := freshHolderCert(t, "rev-1", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{c1})
h, err := NewTrustAnchorHolder(path, silentTestLogger())
if err != nil {
t.Fatal(err)
}
// Rotate on disk and call Reload.
c2 := freshHolderCert(t, "rev-2", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{c2})
if err := h.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
got := h.Get()
if len(got) != 1 || got[0].Subject.CommonName != "rev-2" {
t.Errorf("after Reload Get = %#v, want one cert CN=rev-2", got)
}
}
func TestTrustAnchorHolder_ReloadKeepsOldOnFailure(t *testing.T) {
// Mid-rotation half-file: operator overwrites the bundle with garbage
// → Reload errors → holder must still serve the OLD pool. Without this
// fail-safe a single typo would take Intune enrollment down for the
// whole window until a re-rotate.
dir := t.TempDir()
path := filepath.Join(dir, "trust.pem")
good := freshHolderCert(t, "stable", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{good})
h, err := NewTrustAnchorHolder(path, silentTestLogger())
if err != nil {
t.Fatal(err)
}
// Overwrite with content that LoadTrustAnchor will reject (no PEM blocks).
if err := os.WriteFile(path, []byte("garbage"), 0o600); err != nil {
t.Fatal(err)
}
if err := h.Reload(); err == nil {
t.Fatal("Reload from garbage file must error")
}
// Old pool still served.
got := h.Get()
if len(got) != 1 || got[0].Subject.CommonName != "stable" {
t.Errorf("after failed Reload Get should still be the pre-Reload pool; got %#v", got)
}
}
func TestTrustAnchorHolder_ReloadKeepsOldOnExpired(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "trust.pem")
good := freshHolderCert(t, "still-valid", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{good})
h, err := NewTrustAnchorHolder(path, silentTestLogger())
if err != nil {
t.Fatal(err)
}
// Operator rotates to a cert that's already expired (their script
// pulled an old bundle by mistake). Reload should error AND the holder
// should retain the previous good pool — exactly the fail-safe semantics
// LoadTrustAnchor enforces at startup.
expired := freshHolderCert(t, "expired-conn", time.Now().Add(-1*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{expired})
if err := h.Reload(); err == nil {
t.Fatal("Reload with expired cert must error")
}
if !strings.Contains(h.Get()[0].Subject.CommonName, "still-valid") {
t.Errorf("after expired-cert Reload, holder should retain old pool")
}
}
func TestTrustAnchorHolder_WatchSIGHUPReloadsPool(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "trust.pem")
c1 := freshHolderCert(t, "rev-pre-sighup", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{c1})
h, err := NewTrustAnchorHolder(path, silentTestLogger())
if err != nil {
t.Fatal(err)
}
stop := h.WatchSIGHUP()
defer stop()
// Rotate on disk, then send SIGHUP to our own process and poll for the swap.
c2 := freshHolderCert(t, "rev-post-sighup", time.Now().Add(30*24*time.Hour))
writeTestBundle(t, path, []*x509.Certificate{c2})
if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil {
t.Fatalf("send SIGHUP: %v", err)
}
// Poll for up to 2 seconds.
deadline := time.Now().Add(2 * time.Second)
for {
got := h.Get()
if len(got) == 1 && got[0].Subject.CommonName == "rev-post-sighup" {
return
}
if time.Now().After(deadline) {
t.Fatalf("post-SIGHUP pool not swapped in 2s; current CN=%q", got[0].Subject.CommonName)
}
time.Sleep(20 * time.Millisecond)
}
}
func TestTrustAnchorHolder_WatchSIGHUPStopIsClean(t *testing.T) {
// Mirrors cmd/server/tls_test.go::TestCertHolder_WatchSIGHUP_StopExits:
// we do NOT fire a SIGHUP after stop(), because once signal.Stop has
// removed our handler the kernel's default action on SIGHUP is to
// terminate the process — it would kill the test runner. The contract
// we need to pin is "stop() is synchronous and safe", which we
// demonstrate by closing the watcher and verifying the holder still
// serves the original cert without panic.
dir := t.TempDir()
path := filepath.Join(dir, "trust.pem")
writeTestBundle(t, path, []*x509.Certificate{
freshHolderCert(t, "stop-test", time.Now().Add(30*24*time.Hour)),
})
h, err := NewTrustAnchorHolder(path, silentTestLogger())
if err != nil {
t.Fatal(err)
}
stop := h.WatchSIGHUP()
stop()
time.Sleep(50 * time.Millisecond) // let the goroutine fully exit
if cn := h.Get()[0].Subject.CommonName; cn != "stop-test" {
t.Errorf("after stop CN = %q, want unchanged stop-test", cn)
}
}
+171
View File
@@ -0,0 +1,171 @@
package intune
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// pemEncodeCert is a small DRY helper for the PEM bundle fixtures.
func pemEncodeCert(t *testing.T, der []byte) []byte {
t.Helper()
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// freshConnectorCertDER returns a freshly-minted EC P-256 cert as raw DER
// + the matching key. Lifetime is parameterised so the same factory drives
// both the happy-path and expired-cert cases.
func freshConnectorCertDER(t *testing.T, notAfter time.Time) ([]byte, *ecdsa.PrivateKey) {
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: "intune-connector-test"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
return der, key
}
func TestParseTrustAnchorPEM_HappyPath_SingleCert(t *testing.T) {
der, _ := freshConnectorCertDER(t, time.Now().Add(365*24*time.Hour))
body := pemEncodeCert(t, der)
certs, err := parseTrustAnchorPEM(body, "test", time.Now())
if err != nil {
t.Fatalf("parseTrustAnchorPEM: %v", err)
}
if len(certs) != 1 {
t.Fatalf("len(certs) = %d, want 1", len(certs))
}
if certs[0].Subject.CommonName != "intune-connector-test" {
t.Errorf("Subject.CommonName = %q", certs[0].Subject.CommonName)
}
}
func TestParseTrustAnchorPEM_HappyPath_MultiCert(t *testing.T) {
d1, _ := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
d2, _ := freshConnectorCertDER(t, time.Now().Add(60*24*time.Hour))
body := append(pemEncodeCert(t, d1), pemEncodeCert(t, d2)...)
certs, err := parseTrustAnchorPEM(body, "test", time.Now())
if err != nil {
t.Fatalf("parseTrustAnchorPEM: %v", err)
}
if len(certs) != 2 {
t.Fatalf("len(certs) = %d, want 2", len(certs))
}
}
func TestParseTrustAnchorPEM_SkipsNonCertBlocks(t *testing.T) {
der, key := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("MarshalECPrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
body := append(keyPEM, pemEncodeCert(t, der)...) // priv key first, cert second
certs, err := parseTrustAnchorPEM(body, "test", time.Now())
if err != nil {
t.Fatalf("parseTrustAnchorPEM should ignore non-CERTIFICATE blocks: %v", err)
}
if len(certs) != 1 {
t.Fatalf("len(certs) = %d, want 1 (priv key block must be skipped)", len(certs))
}
}
func TestParseTrustAnchorPEM_EmptyBundleRejected(t *testing.T) {
_, err := parseTrustAnchorPEM([]byte("nothing here"), "test", time.Now())
if err == nil || !strings.Contains(err.Error(), "no CERTIFICATE PEM blocks") {
t.Fatalf("expected 'no CERTIFICATE PEM blocks' error, got %v", err)
}
}
func TestParseTrustAnchorPEM_OnlyKeyBlocksRejected(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
keyDER, _ := x509.MarshalECPrivateKey(key)
body := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
_, err := parseTrustAnchorPEM(body, "test", time.Now())
if err == nil {
t.Fatalf("expected error for bundle with no certs, got nil")
}
}
func TestParseTrustAnchorPEM_ExpiredCertRejected(t *testing.T) {
der, _ := freshConnectorCertDER(t, time.Now().Add(-1*time.Hour)) // already expired
body := pemEncodeCert(t, der)
_, err := parseTrustAnchorPEM(body, "expired-bundle", time.Now())
if err == nil || !strings.Contains(err.Error(), "expired") {
t.Fatalf("expected expiry error, got %v", err)
}
// Operator-actionable message must include the subject so the audit
// log says exactly which cert to rotate.
if !strings.Contains(err.Error(), "intune-connector-test") {
t.Errorf("error must include subject CN for operator action: %v", err)
}
}
func TestParseTrustAnchorPEM_MalformedCertRejected(t *testing.T) {
bad := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not-a-real-asn1-cert")})
_, err := parseTrustAnchorPEM(bad, "test", time.Now())
if err == nil {
t.Fatalf("expected x509 parse error, got nil")
}
}
func TestLoadTrustAnchor_FromDisk(t *testing.T) {
der, _ := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
body := pemEncodeCert(t, der)
dir := t.TempDir()
path := filepath.Join(dir, "intune-trust.pem")
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
certs, err := LoadTrustAnchor(path)
if err != nil {
t.Fatalf("LoadTrustAnchor: %v", err)
}
if len(certs) != 1 {
t.Fatalf("len(certs) = %d, want 1", len(certs))
}
}
func TestLoadTrustAnchor_EmptyPath(t *testing.T) {
_, err := LoadTrustAnchor("")
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("expected empty-path error, got %v", err)
}
}
func TestLoadTrustAnchor_MissingFile(t *testing.T) {
_, err := LoadTrustAnchor("/tmp/does-not-exist-intune-trust.pem")
if err == nil {
t.Fatalf("expected file-not-found error, got nil")
}
// Don't string-assert on the OS error — just make sure it's surfaced.
if errors.Is(err, nil) {
t.Fatalf("error must be non-nil")
}
}
+611
View File
@@ -5,17 +5,32 @@ import (
"crypto/subtle"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/scep/intune"
)
// SCEPService implements the SCEP (RFC 8894) enrollment protocol.
// It delegates certificate operations to an existing IssuerConnector and records
// enrollment events in the audit trail.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.3 + 8.4 + 8.7: per-profile
// Intune dynamic-challenge dispatcher (intuneEnabled+intuneTrust+...);
// audit action `scep_pkcsreq_intune` flows through the existing
// auditService; per-device rate limit + nil-default compliance hook seam.
//
// Lifecycle: a service instance per SCEP profile (Phase 1.5). The Intune
// fields are populated only on profiles where INTUNE_ENABLED=true; on the
// rest they're nil/empty and looksIntuneShaped short-circuits to the
// existing static-challenge path.
type SCEPService struct {
issuer IssuerConnector
issuerID string
@@ -24,6 +39,499 @@ type SCEPService struct {
profileID string // optional: constrain enrollments to a specific profile
profileRepo repository.CertificateProfileRepository
challengePassword string // shared secret for enrollment authentication
// Intune dispatcher state (Phase 8.3+8.6+8.7). All nil/zero when this
// profile has INTUNE_ENABLED=false; all populated when true. The
// dispatcher in PKCSReq + PKCSReqWithEnvelope + RenewalReqWithEnvelope
// gates on intuneEnabled before consulting any of these.
intuneEnabled bool
intuneTrust *intune.TrustAnchorHolder // SIGHUP-reloadable trust pool
intuneAudience string // expected "aud" claim; empty disables the check
intuneValidity time.Duration // optional override on top of the challenge's exp
intuneReplayCache *intune.ReplayCache // nonce-keyed; catches duplicate submission
intuneRateLimiter *intune.PerDeviceRateLimiter
complianceCheck ComplianceCheck // V3-Pro plug-in seam; nil-default no-op
intuneCounters *intuneCounterTab // per-status atomic counters for the admin endpoint
pathID string // SCEP profile path ID; surfaced by admin endpoints
}
// intuneCounterTab is the in-memory equivalent of the
// `certctl_scep_intune_enrollments_total{status="..."}` metric the
// master prompt's Phase 8.4 mentions. We don't take a Prometheus
// dependency here (the project doesn't currently expose /metrics; that's
// a separate decision); operators who want scraping can wrap these with
// a prom.Collector later. For Phase 9 the in-memory counters drive the
// admin GUI's "Intune Monitoring" tab via GET /api/v1/admin/scep/intune/stats.
//
// Concurrency: every field is read/written via sync/atomic so the
// dispatcher's hot path stays lock-free.
type intuneCounterTab struct {
success atomic.Uint64
signatureFailed atomic.Uint64
expired atomic.Uint64
notYetValid atomic.Uint64
wrongAudience atomic.Uint64
replay atomic.Uint64
unknownVersion atomic.Uint64
malformed atomic.Uint64
rateLimited atomic.Uint64
claimMismatch atomic.Uint64
complianceErr atomic.Uint64
}
// snapshot returns a zero-allocation copy of the current counter values
// keyed by the same status labels intuneFailReason emits.
func (c *intuneCounterTab) snapshot() map[string]uint64 {
if c == nil {
return map[string]uint64{}
}
return map[string]uint64{
"success": c.success.Load(),
"signature_invalid": c.signatureFailed.Load(),
"expired": c.expired.Load(),
"not_yet_valid": c.notYetValid.Load(),
"wrong_audience": c.wrongAudience.Load(),
"replay": c.replay.Load(),
"unknown_version": c.unknownVersion.Load(),
"malformed": c.malformed.Load(),
"rate_limited": c.rateLimited.Load(),
"claim_mismatch": c.claimMismatch.Load(),
"compliance_failed": c.complianceErr.Load(),
}
}
// inc advances the counter that matches the given fail-reason label
// (must be one of the strings intuneFailReason returns). Unknown labels
// fall through to "malformed" so an enum drift doesn't silently lose
// counts.
func (c *intuneCounterTab) inc(label string) {
if c == nil {
return
}
switch label {
case "success":
c.success.Add(1)
case "signature_invalid":
c.signatureFailed.Add(1)
case "expired":
c.expired.Add(1)
case "not_yet_valid":
c.notYetValid.Add(1)
case "wrong_audience":
c.wrongAudience.Add(1)
case "replay":
c.replay.Add(1)
case "unknown_version":
c.unknownVersion.Add(1)
case "rate_limited":
c.rateLimited.Add(1)
case "claim_mismatch":
c.claimMismatch.Add(1)
case "compliance_failed":
c.complianceErr.Add(1)
default:
c.malformed.Add(1)
}
}
// IntuneTrustAnchorInfo is the per-cert public summary of one trust
// anchor in the holder's pool. Matches the shape the admin endpoint
// returns to the GUI.
type IntuneTrustAnchorInfo struct {
Subject string `json:"subject"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysToExpiry int `json:"days_to_expiry"`
Expired bool `json:"expired"`
}
// IntuneStatsSnapshot is the per-profile observability view the admin
// GET endpoint hands back. SCEPService.IntuneStats() builds one of
// these on demand under no contention with the dispatcher hot path.
type IntuneStatsSnapshot struct {
PathID string `json:"path_id"`
IssuerID string `json:"issuer_id"`
Enabled bool `json:"enabled"`
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"`
RateLimitDisabled bool `json:"rate_limit_disabled"`
ReplayCacheSize int `json:"replay_cache_size"`
Counters map[string]uint64 `json:"counters"`
GeneratedAt time.Time `json:"generated_at"`
}
// SetPathID records the SCEP profile path ID this service instance
// serves. Admin endpoints surface the PathID per row so operators can
// triage which profile a stat or failure belongs to. Empty PathID maps
// to the legacy `/scep` root.
func (s *SCEPService) SetPathID(pathID string) { s.pathID = pathID }
// PathID returns the SCEP profile path ID this service serves. Empty
// for the legacy `/scep` root.
func (s *SCEPService) PathID() string { return s.pathID }
// IssuerID returns the issuer this service binds to. Useful for the
// admin endpoint's per-profile rendering.
func (s *SCEPService) IssuerID() string { return s.issuerID }
// IntuneStats returns the per-profile observability snapshot. Safe for
// concurrent callers; the snapshot is taken under no contention with
// the dispatcher hot path. Returns a zero-value snapshot with
// Enabled=false on profiles that never called SetIntuneIntegration.
//
// SCEP RFC 8894 + Intune master bundle Phase 9.1.
func (s *SCEPService) IntuneStats(now time.Time) IntuneStatsSnapshot {
out := IntuneStatsSnapshot{
PathID: s.pathID,
IssuerID: s.issuerID,
Enabled: s.intuneEnabled,
Counters: s.intuneCounters.snapshot(),
GeneratedAt: now.UTC(),
}
if !s.intuneEnabled {
return out
}
out.Audience = s.intuneAudience
out.ChallengeValidity = s.intuneValidity
if s.intuneRateLimiter != nil {
out.RateLimitDisabled = s.intuneRateLimiter.Disabled()
}
if s.intuneReplayCache != nil {
out.ReplayCacheSize = s.intuneReplayCache.Len()
}
if s.intuneTrust != nil {
out.TrustAnchorPath = s.intuneTrust.Path()
certs := s.intuneTrust.Get()
out.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)
}
out.TrustAnchors = append(out.TrustAnchors, info)
}
}
return out
}
// ReloadIntuneTrust triggers the same Reload the SIGHUP watcher would
// run. Returns the parse error if the new file is invalid; the OLD
// pool stays in place (TrustAnchorHolder.Reload's documented
// fail-safe). Returns a typed error when this profile has Intune
// disabled so the admin endpoint can surface a 400 / 409.
//
// SCEP RFC 8894 + Intune master bundle Phase 9.2.
func (s *SCEPService) ReloadIntuneTrust() error {
if !s.intuneEnabled || s.intuneTrust == nil {
return ErrSCEPProfileIntuneDisabled
}
return s.intuneTrust.Reload()
}
// ErrSCEPProfileIntuneDisabled is returned by ReloadIntuneTrust when
// invoked on a profile that has Intune turned off. Lets the admin
// handler distinguish "operator targeted the wrong profile" (HTTP 409)
// from "trust anchor file is broken" (HTTP 500 + the underlying
// parse-error string).
var ErrSCEPProfileIntuneDisabled = errors.New("scep profile: intune dispatcher not enabled")
// the once + mu fields keep IntuneStats accessor lookup-stable in case
// future refactors add background mutators of intuneCounters; both are
// currently unused by the runtime path.
var _ = sync.Once{}
// ComplianceCheck is the optional gate that pings Intune's compliance API
// (or any custom policy backend) to confirm the device is in good standing
// before issuing a cert. When nil (the V2-free default), the gate is a
// no-op and enrollments proceed solely on challenge validation +
// claim-binding + replay + per-device rate limit.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.7 — V3-Pro plug-in seam.
//
// V3-Pro plugs in here via a new module that calls Microsoft Graph's
// /deviceManagement/managedDevices/{id}/compliancePolicyStates endpoint
// (or equivalent), wires SetComplianceCheck on the service, and
// short-circuits non-compliant device enrollments with a SCEP CertRep
// FAILURE/badRequest plus a compliance_failed audit event + metric.
//
// Return contract:
//
// - compliant=true, err=nil → proceed with enrollment.
// - compliant=false, err=nil → CertRep FAILURE + compliance_failed metric;
// the reason string flows into the audit event for ops triage.
// - compliant=*, err!=nil → fail-safe (deny) by default; the V3-Pro
// module is responsible for a more nuanced "permit on API failure"
// mode if its policy demands one.
//
// Leaving the hook here means the V3-Pro work is plug-in code, not a
// dispatcher refactor. The cost today is one struct field + one setter +
// one nil-guarded call site. Zero behavior change in V2.
type ComplianceCheck func(ctx context.Context, claim *intune.ChallengeClaim) (compliant bool, reason string, err error)
// SetComplianceCheck installs the V3-Pro compliance gate. Idempotent;
// passing nil re-disables the gate (useful for tests + the rare case where
// V3-Pro plugin code wants to drop the gate at runtime). Safe to call
// before or after the service starts serving requests.
func (s *SCEPService) SetComplianceCheck(fn ComplianceCheck) { s.complianceCheck = fn }
// SetIntuneIntegration wires the per-profile Intune dispatcher onto the
// service. Pass enabled=false (with nil/zero values for the rest) to
// explicitly opt this profile out of Intune mode; pass enabled=true with
// a populated trust holder + replay cache + rate limiter to opt in. The
// audience is allowed to be empty (the validator's audience check then
// becomes a no-op, useful for proxy/load-balancer scenarios where the URL
// the Connector saw differs from the URL we see).
//
// Constructor-time injection (rather than NewSCEPService extra params)
// keeps the surface stable for the existing callers + lets the wire-in
// at cmd/server/main.go construct the holder + cache + limiter once and
// share them across profiles cleanly. Profiles where INTUNE_ENABLED=false
// simply never call this method.
func (s *SCEPService) SetIntuneIntegration(
trust *intune.TrustAnchorHolder,
audience string,
validity time.Duration,
replayCache *intune.ReplayCache,
rateLimiter *intune.PerDeviceRateLimiter,
) {
s.intuneEnabled = true
s.intuneTrust = trust
s.intuneAudience = audience
s.intuneValidity = validity
s.intuneReplayCache = replayCache
s.intuneRateLimiter = rateLimiter
if s.intuneCounters == nil {
s.intuneCounters = &intuneCounterTab{}
}
}
// IntuneEnabled reports whether this service instance is wired for Intune
// dynamic-challenge dispatch. Useful for handler-layer gating + admin
// endpoints (Phase 9 GUI surface). Always returns false on profiles where
// SetIntuneIntegration was never called.
func (s *SCEPService) IntuneEnabled() bool { return s.intuneEnabled }
// looksIntuneShaped is the fast pre-check that distinguishes an
// Intune-format challenge from a static challenge password. Intune
// challenges are JWT-like (three base64url segments separated by dots,
// total length > 200 bytes for any reasonable claim payload). Static
// challenges are typically ≤ 64 bytes ASCII.
//
// SCEP RFC 8894 + Intune master bundle Phase 8.3.
//
// The heuristic is allowed to false-positive (the validator catches
// malformed input → ErrChallengeMalformed), but it MUST NOT false-negative
// on real Intune challenges — that would route an Intune challenge to the
// constant-time static compare and reject every enrollment. Hence the
// generous length threshold (real Intune challenges are typically
// >800 bytes; the 200 floor is well below the smallest plausible v1
// payload + signature).
func looksIntuneShaped(s string) bool {
if len(s) <= 200 {
return false
}
return strings.Count(s, ".") == 2
}
// intuneFailReason maps a typed Intune error to the metric label used in
// `certctl_scep_intune_enrollments_total{status="..."}`. Defaults to
// "malformed" so a previously-unseen error category still surfaces in
// the metric (with a follow-up to add a typed branch here).
func intuneFailReason(err error) string {
switch {
case err == nil:
return "success"
case errors.Is(err, intune.ErrChallengeSignature):
return "signature_invalid"
case errors.Is(err, intune.ErrChallengeExpired):
return "expired"
case errors.Is(err, intune.ErrChallengeNotYetValid):
return "not_yet_valid"
case errors.Is(err, intune.ErrChallengeWrongAudience):
return "wrong_audience"
case errors.Is(err, intune.ErrChallengeReplay):
return "replay"
case errors.Is(err, intune.ErrChallengeUnknownVersion):
return "unknown_version"
case errors.Is(err, intune.ErrChallengeMalformed):
return "malformed"
case errors.Is(err, intune.ErrRateLimited):
return "rate_limited"
case errors.Is(err, intune.ErrClaimCNMismatch),
errors.Is(err, intune.ErrClaimSANDNSMismatch),
errors.Is(err, intune.ErrClaimSANRFC822Mismatch),
errors.Is(err, intune.ErrClaimSANUPNMismatch):
return "claim_mismatch"
default:
return "malformed"
}
}
// intuneEnrollOutcome is the envelope the dispatcher hands back to its two
// callers (PKCSReq's MVP path + PKCSReqWithEnvelope/RenewalReqWithEnvelope's
// RFC 8894 path). It carries enough to short-circuit OR continue to the
// existing processEnrollment flow:
//
// - decided=false → not Intune-shaped (or Intune disabled); fall through
// to the static-challenge path.
// - decided=true, err=nil → Intune validation passed; the caller MUST
// call processEnrollment with auditAction="scep_pkcsreq_intune".
// - decided=true, err!=nil → Intune validation failed; the caller MUST
// short-circuit with the typed error (handler maps to FailInfo).
type intuneEnrollOutcome struct {
decided bool
claim *intune.ChallengeClaim
err error
}
// dispatchIntuneChallenge runs the full Intune validation pipeline for a
// single PKCSReq invocation: shape check → ValidateChallenge → DeviceMatchesCSR
// → replay-cache CheckAndInsert → per-device rate limit → optional
// compliance check. Each failure leg increments the appropriate metric
// label + emits an audit-friendly Warn log line. Returns an outcome that
// tells the caller whether to short-circuit or continue to enrollment.
//
// Splitting the dispatcher out of PKCSReq* keeps the three call sites
// (PKCSReq, PKCSReqWithEnvelope, RenewalReqWithEnvelope) consistent — every
// path through the Intune mode runs through the same gate sequence so an
// operator gets the same audit shape regardless of which SCEP message
// type the device sent.
//
// Phase 9.1: every typed return path also bumps the per-status atomic
// counter on s.intuneCounters so the admin GUI's stats endpoint reflects
// real enrollment traffic. The success path bumps "success" once when
// the outer caller invokes processEnrollment — see PKCSReq below.
func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string, challengePassword string, transactionID string) intuneEnrollOutcome {
if !s.intuneEnabled || !looksIntuneShaped(challengePassword) {
return intuneEnrollOutcome{decided: false}
}
if s.intuneTrust == nil {
// Defensive: enabled bit was flipped without wiring the trust
// holder. Treat as a hard failure so the operator sees it
// instead of silently falling through to the static path.
s.logger.Error("SCEP enrollment rejected: Intune mode enabled but no trust anchor holder wired",
"transaction_id", transactionID)
s.intuneCounters.inc("signature_invalid")
return intuneEnrollOutcome{decided: true, err: intune.ErrChallengeSignature}
}
now := time.Now()
trust := s.intuneTrust.Get()
claim, err := intune.ValidateChallenge(challengePassword, trust, s.intuneAudience, now)
if err != nil {
s.logger.Warn("SCEP enrollment rejected: Intune challenge validation failed",
"transaction_id", transactionID, "reason", intuneFailReason(err), "error", err)
s.intuneCounters.inc(intuneFailReason(err))
return intuneEnrollOutcome{decided: true, err: err}
}
// Defense-in-depth validity cap on top of the challenge's own iat/exp.
// When intuneValidity is non-zero, the challenge's iat must be within
// (now - intuneValidity, now]; an old-but-not-yet-expired challenge
// (per the Connector's exp claim) gets rejected here.
if s.intuneValidity > 0 && !claim.IssuedAt.IsZero() && now.Sub(claim.IssuedAt) > s.intuneValidity {
err := fmt.Errorf("%w: iat=%s exceeds operator-configured validity cap %s",
intune.ErrChallengeExpired, claim.IssuedAt.Format(time.RFC3339), s.intuneValidity)
s.logger.Warn("SCEP enrollment rejected: Intune challenge older than operator validity cap",
"transaction_id", transactionID, "error", err)
s.intuneCounters.inc("expired")
return intuneEnrollOutcome{decided: true, err: err}
}
// Bind claim ↔ CSR before consuming the replay-cache slot. If the CSR
// doesn't match the claim, we don't want to mark the nonce as seen
// (the next legitimate retry should still work).
csr, perr := parseCSRForIntune(csrPEM)
if perr != nil {
s.logger.Warn("SCEP enrollment rejected: CSR parse failed during Intune dispatch",
"transaction_id", transactionID, "error", perr)
// CSR parse failure surfaces as a "malformed" intune metric label
// (the wrapping helps the audit log distinguish it from a
// challenge-malformed failure).
s.intuneCounters.inc("malformed")
return intuneEnrollOutcome{decided: true, err: fmt.Errorf("%w: CSR parse: %v", intune.ErrChallengeMalformed, perr)}
}
if mErr := claim.DeviceMatchesCSR(csr); mErr != nil {
s.logger.Warn("SCEP enrollment rejected: Intune claim does not match CSR",
"transaction_id", transactionID, "error", mErr)
s.intuneCounters.inc("claim_mismatch")
return intuneEnrollOutcome{decided: true, err: mErr}
}
// Replay protection — runs AFTER claim validation + CSR binding so a
// failed validation doesn't burn a replay slot on a legitimate retry.
if s.intuneReplayCache != nil && claim.Nonce != "" {
if !s.intuneReplayCache.CheckAndInsert(claim.Nonce, now) {
err := fmt.Errorf("%w: nonce=%q", intune.ErrChallengeReplay, claim.Nonce)
s.logger.Warn("SCEP enrollment rejected: Intune challenge nonce replay",
"transaction_id", transactionID, "subject", claim.Subject)
s.intuneCounters.inc("replay")
return intuneEnrollOutcome{decided: true, err: err}
}
}
// Per-device rate limit — second line of defense against a compromised
// Connector signing key issuing many DIFFERENT valid challenges for
// the same device.
if s.intuneRateLimiter != nil {
if rlErr := s.intuneRateLimiter.Allow(claim.Subject, claim.Issuer, now); rlErr != nil {
s.logger.Warn("SCEP enrollment rejected: Intune per-device rate limit exceeded",
"transaction_id", transactionID, "subject", claim.Subject, "issuer", claim.Issuer)
s.intuneCounters.inc("rate_limited")
return intuneEnrollOutcome{decided: true, err: rlErr}
}
}
// Optional V3-Pro compliance hook (nil-default no-op in V2). Runs LAST
// so we don't ping the compliance API for requests we'd reject anyway.
if s.complianceCheck != nil {
compliant, reason, cerr := s.complianceCheck(ctx, claim)
if cerr != nil {
s.logger.Error("Intune compliance check returned error; failing closed",
"transaction_id", transactionID, "subject", claim.Subject, "error", cerr)
s.intuneCounters.inc("compliance_failed")
return intuneEnrollOutcome{decided: true, err: fmt.Errorf("intune compliance check: %w", cerr)}
}
if !compliant {
s.logger.Warn("SCEP enrollment rejected: device non-compliant per Intune compliance check",
"transaction_id", transactionID, "subject", claim.Subject, "reason", reason)
s.intuneCounters.inc("compliance_failed")
return intuneEnrollOutcome{decided: true, err: fmt.Errorf("intune compliance: %s", reason)}
}
}
// Success leg — increment the success counter so the admin GUI's
// stats endpoint reflects every legitimate enrollment. The actual
// processEnrollment call is made by the caller (PKCSReq* /
// RenewalReqWithEnvelope); we credit success here so a downstream
// processEnrollment failure (issuer connector outage, etc.) doesn't
// double-count — that's a separate non-Intune metric.
s.intuneCounters.inc("success")
return intuneEnrollOutcome{decided: true, claim: claim}
}
// parseCSRForIntune is a thin wrapper around encoding/pem + x509 that the
// dispatcher uses for the claim ↔ CSR binding check. Kept private + named
// for grepability so a future refactor can swap the parse strategy without
// touching the dispatcher.
func parseCSRForIntune(csrPEM string) (*x509.CertificateRequest, error) {
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
return nil, fmt.Errorf("invalid CSR PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse CSR: %w", err)
}
return csr, nil
}
// NewSCEPService creates a new SCEPService for the given issuer connector.
@@ -86,6 +594,19 @@ func (s *SCEPService) GetCACert(ctx context.Context) (string, error) {
// non-empty branch now uses crypto/subtle.ConstantTimeCompare to avoid leaking
// the shared secret through a response-time side channel.
func (s *SCEPService) PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error) {
// SCEP RFC 8894 + Intune master bundle Phase 8.3: try the Intune
// dispatcher first. When it returns decided=true the service has
// already made the call (success or typed failure); when decided=false
// we fall through to the existing static-challenge path. The
// dispatcher gates internally on intuneEnabled + looksIntuneShaped,
// so this is a free no-op for profiles where Intune is disabled.
if outcome := s.dispatchIntuneChallenge(ctx, csrPEM, challengePassword, transactionID); outcome.decided {
if outcome.err != nil {
return nil, fmt.Errorf("intune challenge: %w", outcome.err)
}
return s.processEnrollment(ctx, csrPEM, transactionID, "scep_pkcsreq_intune")
}
// Defense-in-depth: refuse any enrollment when no shared secret is
// configured. The server-level pre-flight check in cmd/server/main.go
// normally prevents the service from being constructed in this state, but
@@ -258,6 +779,29 @@ func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, ch
RecipientNonce: envelope.SenderNonce,
}
// SCEP RFC 8894 + Intune master bundle Phase 8.3: same dispatcher as
// PKCSReq, applied to the RFC 8894 path. The dispatcher runs AFTER the
// EnvelopedData decryption + POPO verification (handler-side, before
// the service is invoked) but BEFORE the static-challenge fallback. On
// Intune-validation failure the response envelope carries a typed
// FailInfo so the CertRep wire shape is preserved (RFC 8894 §3.3).
if outcome := s.dispatchIntuneChallenge(ctx, csrPEM, challengePassword, envelope.TransactionID); outcome.decided {
if outcome.err != nil {
resp.Status = domain.SCEPStatusFailure
resp.FailInfo = mapIntuneErrorToFailInfo(outcome.err)
return resp
}
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_pkcsreq_intune")
if err != nil {
resp.Status = domain.SCEPStatusFailure
resp.FailInfo = mapServiceErrorToFailInfo(err)
return resp
}
resp.Status = domain.SCEPStatusSuccess
resp.Result = result
return resp
}
// Defense-in-depth: refuse any enrollment when no shared secret is
// configured. Mirrors PKCSReq's gate. Returning nil signals 'let the
// caller translate to HTTP 403' — the existing PKCSReq path returns
@@ -287,6 +831,41 @@ func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, ch
return resp
}
// mapIntuneErrorToFailInfo maps a typed Intune-validation error to the
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. Mapping rationale:
//
// - Signature / replay / wrong-audience / expired / not-yet-valid →
// BadMessageCheck (the request didn't pass integrity / freshness
// checks; same wire shape as a tampered EnvelopedData).
// - Claim mismatches (CN / SAN-DNS / SAN-RFC822 / SAN-UPN) → BadRequest
// (the request was well-formed and signed but the asserted identity
// doesn't match what the device actually requested).
// - Rate-limited / unknown-version → BadRequest (no better wire-level
// code; the audit log carries the exact reason).
// - Malformed → BadRequest.
// - Compliance failure → BadRequest (V3-Pro can swap to a more
// specific code if it cares).
func mapIntuneErrorToFailInfo(err error) domain.SCEPFailInfo {
if err == nil {
return domain.SCEPFailBadRequest
}
switch {
case errors.Is(err, intune.ErrChallengeSignature),
errors.Is(err, intune.ErrChallengeExpired),
errors.Is(err, intune.ErrChallengeNotYetValid),
errors.Is(err, intune.ErrChallengeWrongAudience),
errors.Is(err, intune.ErrChallengeReplay):
return domain.SCEPFailBadMessageCheck
case errors.Is(err, intune.ErrClaimCNMismatch),
errors.Is(err, intune.ErrClaimSANDNSMismatch),
errors.Is(err, intune.ErrClaimSANRFC822Mismatch),
errors.Is(err, intune.ErrClaimSANUPNMismatch):
return domain.SCEPFailBadRequest
default:
return domain.SCEPFailBadRequest
}
}
// mapServiceErrorToFailInfo translates a service-layer error into the
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. The mapping mirrors
// the table in PKCSReqWithEnvelope's docblock; defaults to BadRequest
@@ -345,6 +924,38 @@ func (s *SCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string,
RecipientNonce: envelope.SenderNonce,
}
// SCEP RFC 8894 + Intune master bundle Phase 8.3: Intune dispatcher
// applies to RenewalReq too. The chain-validation gate further down
// stays in place — Intune-managed devices still need to present a
// previously-issued cert as POPO when re-enrolling. The Intune
// validator covers "is this a legitimate Intune challenge?" and the
// chain check covers "did this device hold a prior cert from this
// issuer?" — both must pass.
if outcome := s.dispatchIntuneChallenge(ctx, csrPEM, challengePassword, envelope.TransactionID); outcome.decided {
if outcome.err != nil {
resp.Status = domain.SCEPStatusFailure
resp.FailInfo = mapIntuneErrorToFailInfo(outcome.err)
return resp
}
// Chain-of-trust check still applies on renewal even via Intune.
if err := s.verifyRenewalSignerCertChain(ctx, envelope.SignerCert); err != nil {
s.logger.Warn("SCEP renewal rejected: signer cert chain invalid (Intune path)",
"transaction_id", envelope.TransactionID, "error", err.Error())
resp.Status = domain.SCEPStatusFailure
resp.FailInfo = domain.SCEPFailBadMessageCheck
return resp
}
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_renewalreq_intune")
if err != nil {
resp.Status = domain.SCEPStatusFailure
resp.FailInfo = mapServiceErrorToFailInfo(err)
return resp
}
resp.Status = domain.SCEPStatusSuccess
resp.Result = result
return resp
}
// Same challenge-password gate as PKCSReqWithEnvelope. Defense in depth
// even though the RenewalReq path additionally verifies the signing
// cert chain — a stolen/leaked challenge password combined with a
+487
View File
@@ -0,0 +1,487 @@
package service
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"math/big"
"os"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/scep/intune"
)
// SCEP RFC 8894 + Intune master bundle Phase 8.9 — service-layer dispatcher
// tests. Exercises the looksIntuneShaped pre-check, the validator + claim
// binding, the replay cache + per-device rate limiter integration, and the
// nil-default compliance hook seam.
// ------------------------------------------------------------------
// Test plumbing.
// ------------------------------------------------------------------
func newTestSCEPLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
}
// intuneTestConn manufactures an ephemeral RSA Connector signing cert + key
// for tests that build challenges by hand. Mirrors challenge_test.go's
// helper but lives in the service package so tests can exercise the full
// dispatcher path.
type intuneTestConn struct {
key *rsa.PrivateKey
cert *x509.Certificate
}
func newIntuneTestConn(t *testing.T) intuneTestConn {
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: "test-intune-connector"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("x509.ParseCertificate: %v", err)
}
return intuneTestConn{key: key, cert: cert}
}
// signTestChallenge hand-builds a signed Intune-shaped challenge with the
// caller-supplied claim payload. Returns the wire-format string ready to
// pass as the "challenge password" argument to PKCSReq.
func (c intuneTestConn) signTestChallenge(t *testing.T, payload any) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
pl, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, c.key, crypto.SHA256, h[:])
if err != nil {
t.Fatalf("rsa.SignPKCS1v15: %v", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// holderFromCerts wraps a static slice of certs as a TrustAnchorHolder
// without going through the on-disk loader. Used for tests that drive
// validation without writing a temp PEM file.
func holderFromCerts(t *testing.T, certs []*x509.Certificate) *intune.TrustAnchorHolder {
t.Helper()
dir := t.TempDir()
path := dir + "/intune-trust.pem"
// Write a real bundle so the holder can Reload later if the test wants.
body := []byte{}
for _, c := range certs {
body = append(body, []byte("-----BEGIN CERTIFICATE-----\n")...)
b64 := base64.StdEncoding.EncodeToString(c.Raw)
// Wrap to 64-char lines per PEM convention.
for len(b64) > 64 {
body = append(body, []byte(b64[:64]+"\n")...)
b64 = b64[64:]
}
body = append(body, []byte(b64+"\n-----END CERTIFICATE-----\n")...)
}
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatalf("WriteFile trust bundle: %v", err)
}
holder, err := intune.NewTrustAnchorHolder(path, newTestSCEPLogger())
if err != nil {
t.Fatalf("NewTrustAnchorHolder: %v", err)
}
return holder
}
// validIntunePayload returns a v1 challenge payload whose claim matches a
// CSR generated via generateCSRPEM(t, "device.example.com", []string{...}).
// Tests can mutate it before signing to exercise individual failure modes.
func validIntunePayload(now time.Time) map[string]any {
return map[string]any{
"iss": "test-intune-connector-installation",
"sub": "device-guid-001",
"aud": "https://certctl.example.com/scep/corp",
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(59 * time.Minute).Unix(),
"nonce": "nonce-001",
"device_name": "device.example.com",
"san_dns": []string{"device.example.com"},
}
}
// ------------------------------------------------------------------
// Dispatcher behavior.
// ------------------------------------------------------------------
func TestSCEPService_LooksIntuneShaped(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{"empty", "", false},
{"short static password", "secret123", false},
{"long but no dots", strings.Repeat("a", 300), false},
{"long with two dots (intune-shaped)", strings.Repeat("a", 80) + "." + strings.Repeat("b", 80) + "." + strings.Repeat("c", 80), true},
{"long with three dots (not intune)", "a.b.c.d", false},
{"exactly 200 bytes (boundary, not intune)", strings.Repeat("a", 100) + "." + strings.Repeat("a", 99), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := looksIntuneShaped(tc.in); got != tc.want {
t.Errorf("looksIntuneShaped(%q) = %v, want %v", tc.in[:min(40, len(tc.in))]+"…", got, tc.want)
}
})
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_Success(t *testing.T) {
conn := newIntuneTestConn(t)
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
// Service has the legacy challenge password set (we want to verify the
// dispatcher takes precedence over the static path when intune-shaped).
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, newTestSCEPLogger(), "static-secret")
holder := holderFromCerts(t, []*x509.Certificate{conn.cert})
svc.SetIntuneIntegration(
holder,
"https://certctl.example.com/scep/corp",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
challenge := conn.signTestChallenge(t, validIntunePayload(time.Now()))
result, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-intune-001")
if err != nil {
t.Fatalf("PKCSReq: %v", err)
}
if result == nil || result.CertPEM == "" {
t.Fatalf("expected non-empty cert; got %#v", result)
}
// The audit event should carry the Intune-specific action code so
// operators can grep the audit log to count Intune enrollments
// distinct from static-challenge enrollments.
if len(auditRepo.Events) == 0 {
t.Fatalf("expected an audit event")
}
if got := auditRepo.Events[0].Action; got != "scep_pkcsreq_intune" {
t.Errorf("audit action = %q, want scep_pkcsreq_intune (Phase 8.4)", got)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_StaticChallengeStillWorks(t *testing.T) {
// Operator deploy that has Intune enabled on a profile but a device
// sends a SHORT static challenge — must still work via the fallback path.
conn := newIntuneTestConn(t)
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"https://certctl.example.com/scep/corp",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
if _, err := svc.PKCSReq(context.Background(), csrPEM, "static-secret", "txn-static-001"); err != nil {
t.Fatalf("static-challenge fallback should still work when Intune enabled: %v", err)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_TamperedChallengeRejected(t *testing.T) {
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
good := conn.signTestChallenge(t, validIntunePayload(time.Now()))
parts := strings.Split(good, ".")
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sig[0] ^= 0xFF
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
tampered := strings.Join(parts, ".")
_, err := svc.PKCSReq(context.Background(), csrPEM, tampered, "txn-tamper-001")
if err == nil {
t.Fatal("expected tampered challenge to be rejected")
}
if !errors.Is(err, intune.ErrChallengeSignature) {
t.Errorf("got %v, want errors.Is(ErrChallengeSignature)", err)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_ClaimMismatchRejected(t *testing.T) {
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
// CSR's CN ("attacker-host.example.com") does NOT match the claim's
// device_name ("device.example.com").
csrPEM := generateCSRPEM(t, "attacker-host.example.com", []string{"attacker-host.example.com"})
challenge := conn.signTestChallenge(t, validIntunePayload(time.Now()))
_, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-mismatch-001")
if err == nil {
t.Fatal("expected claim mismatch to be rejected")
}
if !errors.Is(err, intune.ErrClaimCNMismatch) {
t.Errorf("got %v, want ErrClaimCNMismatch", err)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_ReplayDetected(t *testing.T) {
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(0, 24*time.Hour, 100), // disable rate limit so we don't trip THAT first
)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
challenge := conn.signTestChallenge(t, validIntunePayload(time.Now()))
if _, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-001"); err != nil {
t.Fatalf("first call should succeed: %v", err)
}
_, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-002")
if !errors.Is(err, intune.ErrChallengeReplay) {
t.Fatalf("got %v, want ErrChallengeReplay on the second call", err)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_RateLimited(t *testing.T) {
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
// Replay cache must not block us — use disjoint nonces per call.
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(2, 24*time.Hour, 100), // limit = 2
)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
for i := 0; i < 2; i++ {
pl := validIntunePayload(time.Now())
pl["nonce"] = "nonce-" + string(rune('a'+i))
ch := conn.signTestChallenge(t, pl)
if _, err := svc.PKCSReq(context.Background(), csrPEM, ch, "txn-allow"); err != nil {
t.Fatalf("call %d should succeed: %v", i+1, err)
}
}
// 3rd call same (Subject, Issuer) → rate limited.
pl := validIntunePayload(time.Now())
pl["nonce"] = "nonce-third"
third := conn.signTestChallenge(t, pl)
_, err := svc.PKCSReq(context.Background(), csrPEM, third, "txn-block")
if !errors.Is(err, intune.ErrRateLimited) {
t.Fatalf("got %v, want ErrRateLimited on 3rd call (cap=2)", err)
}
}
// ------------------------------------------------------------------
// Compliance-hook seam (Phase 8.7).
// ------------------------------------------------------------------
func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookNilDefault(t *testing.T) {
// Default state: no hook installed, enrollments proceed.
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
challenge := conn.signTestChallenge(t, validIntunePayload(time.Now()))
if _, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-nil-hook"); err != nil {
t.Fatalf("nil-default compliance hook should be a no-op: %v", err)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookDeniesNonCompliant(t *testing.T) {
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
svc.SetComplianceCheck(func(ctx context.Context, claim *intune.ChallengeClaim) (bool, string, error) {
return false, "device under remediation", nil
})
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
challenge := conn.signTestChallenge(t, validIntunePayload(time.Now()))
_, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-noncompliant")
if err == nil {
t.Fatal("non-compliant device must be rejected")
}
if !strings.Contains(err.Error(), "intune compliance") {
t.Errorf("error should reference compliance reason: %v", err)
}
if !strings.Contains(err.Error(), "device under remediation") {
t.Errorf("error should preserve compliance reason for audit: %v", err)
}
}
func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookErrorFailsClosed(t *testing.T) {
conn := newIntuneTestConn(t)
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
60*time.Minute,
intune.NewReplayCache(60*time.Minute, 100),
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
)
svc.SetComplianceCheck(func(ctx context.Context, claim *intune.ChallengeClaim) (bool, string, error) {
return false, "", errors.New("graph API down")
})
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
challenge := conn.signTestChallenge(t, validIntunePayload(time.Now()))
_, err := svc.PKCSReq(context.Background(), csrPEM, challenge, "txn-compl-err")
if err == nil {
t.Fatal("compliance API error must fail closed (deny)")
}
}
// ------------------------------------------------------------------
// IntuneEnabled accessor + miscellaneous wiring.
// ------------------------------------------------------------------
func TestSCEPService_IntuneEnabled_AccessorReflectsState(t *testing.T) {
svc := NewSCEPService("iss-local", &mockIssuerConnector{}, nil, newTestSCEPLogger(), "static")
if svc.IntuneEnabled() {
t.Fatal("freshly-built service must report IntuneEnabled=false")
}
conn := newIntuneTestConn(t)
svc.SetIntuneIntegration(
holderFromCerts(t, []*x509.Certificate{conn.cert}),
"",
0,
nil,
nil,
)
if !svc.IntuneEnabled() {
t.Fatal("after SetIntuneIntegration, IntuneEnabled() must report true")
}
}
func TestSCEPService_PKCSReq_IntuneDisabled_StaticPathUnchanged(t *testing.T) {
// Sanity: a service that NEVER had SetIntuneIntegration called must
// behave exactly like the pre-Phase-8 service. This pins the no-regression
// guarantee for the broad set of profiles that won't enable Intune.
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, NewAuditService(newMockAuditRepository()), newTestSCEPLogger(), "static-secret")
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
// Submit something Intune-shaped — without SetIntuneIntegration this
// must NOT route through the dispatcher (looksIntuneShaped + intuneEnabled
// are AND-gated). It will fall through to the static compare and reject.
intuneShaped := strings.Repeat("a", 80) + "." + strings.Repeat("b", 80) + "." + strings.Repeat("c", 80)
if _, err := svc.PKCSReq(context.Background(), csrPEM, intuneShaped, "txn-noop"); err == nil {
t.Fatal("static path with wrong password must reject (we passed an intune-shaped string but Intune is off)")
}
// Now submit the right static password — must succeed.
if _, err := svc.PKCSReq(context.Background(), csrPEM, "static-secret", "txn-noop-2"); err != nil {
t.Fatalf("static path with right password must work: %v", err)
}
}
// ------------------------------------------------------------------
// IntuneFailReason mapping.
// ------------------------------------------------------------------
func TestIntuneFailReason_AllTypedErrorsMapped(t *testing.T) {
cases := []struct {
err error
want string
}{
{nil, "success"},
{intune.ErrChallengeSignature, "signature_invalid"},
{intune.ErrChallengeExpired, "expired"},
{intune.ErrChallengeNotYetValid, "not_yet_valid"},
{intune.ErrChallengeWrongAudience, "wrong_audience"},
{intune.ErrChallengeReplay, "replay"},
{intune.ErrChallengeUnknownVersion, "unknown_version"},
{intune.ErrChallengeMalformed, "malformed"},
{intune.ErrRateLimited, "rate_limited"},
{intune.ErrClaimCNMismatch, "claim_mismatch"},
{intune.ErrClaimSANDNSMismatch, "claim_mismatch"},
{intune.ErrClaimSANRFC822Mismatch, "claim_mismatch"},
{intune.ErrClaimSANUPNMismatch, "claim_mismatch"},
{errors.New("something else"), "malformed"}, // default bucket
}
for _, tc := range cases {
got := intuneFailReason(tc.err)
if got != tc.want {
t.Errorf("intuneFailReason(%v) = %q, want %q", tc.err, got, tc.want)
}
}
}
// asn1 unused but imported by sibling tests; this package-level guard keeps
// future changes that introduce ASN.1 fixtures here from breaking the build.
func init() {
_ = ecdsa.GenerateKey
_ = elliptic.P256
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+17 -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 } 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 } from './types';
const BASE = '/api/v1';
@@ -296,6 +296,22 @@ export const fetchCRL = (issuerId: string) => {
export const getAdminCRLCache = () =>
fetchJSON<CRLCacheResponse>(`${BASE}/admin/crl/cache`);
// SCEP RFC 8894 + Intune master bundle Phase 9.2 admin endpoint mirror.
//
// Backend handler: internal/api/handler/admin_scep_intune.go.
// Both endpoints are M-008 admin-gated; the SCEPAdminPage component
// gates the React-Query `enabled` flag on useAuth().admin so non-admin
// callers never see the page (the route itself is also conditional on
// the admin flag in main.tsx).
export const getAdminSCEPIntuneStats = () =>
fetchJSON<IntuneStatsResponse>(`${BASE}/admin/scep/intune/stats`);
export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
fetchJSON<IntuneReloadTrustResponse>(`${BASE}/admin/scep/intune/reload-trust`, {
method: 'POST',
body: JSON.stringify({ path_id: pathID }),
});
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+50
View File
@@ -626,3 +626,53 @@ export interface CRLCacheResponse {
row_count: number;
generated_at: string;
}
// SCEP RFC 8894 + Intune master bundle Phase 9.2: admin observability
// payload mirror for the per-profile Intune dispatcher.
//
// Backend types live at internal/service/scep.go (IntuneStatsSnapshot +
// IntuneTrustAnchorInfo) and the handler glue in
// internal/api/handler/admin_scep_intune.go. Both endpoints are admin-
// gated (M-008 pin in m008_admin_gate_test.go) — the GUI hides the
// SCEP Intune surface entirely (rather than letting it 403 noisily) by
// gating the React-Query enabled flag on useAuth().admin at the call site.
export interface IntuneTrustAnchorInfo {
subject: string;
not_before: string;
not_after: string;
days_to_expiry: number;
expired: boolean;
}
// IntuneStatsSnapshot — one row per configured SCEP profile. Profiles
// where Intune is disabled appear with enabled=false; the remaining
// fields stay zero/empty so the GUI can render a "Not enabled" pill.
export interface IntuneStatsSnapshot {
path_id: string;
issuer_id: string;
enabled: boolean;
trust_anchor_path?: string;
trust_anchors?: IntuneTrustAnchorInfo[];
audience?: string;
challenge_validity_ns?: number;
rate_limit_disabled: boolean;
replay_cache_size: number;
// Counter labels match intuneFailReason() in the backend dispatcher:
// success / signature_invalid / expired / not_yet_valid / wrong_audience /
// replay / unknown_version / malformed / rate_limited / claim_mismatch /
// compliance_failed.
counters: Record<string, number>;
generated_at: string;
}
export interface IntuneStatsResponse {
profiles: IntuneStatsSnapshot[];
profile_count: number;
generated_at: string;
}
export interface IntuneReloadTrustResponse {
reloaded: boolean;
path_id: string;
reloaded_at: string;
}
+1
View File
@@ -23,6 +23,7 @@ const nav = [
{ 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: '/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: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
+7
View File
@@ -32,6 +32,7 @@ import ObservabilityPage from './pages/ObservabilityPage';
import JobDetailPage from './pages/JobDetailPage';
import IssuerDetailPage from './pages/IssuerDetailPage';
import TargetDetailPage from './pages/TargetDetailPage';
import SCEPAdminPage from './pages/SCEPAdminPage';
import './index.css';
const queryClient = new QueryClient({
@@ -79,6 +80,12 @@ createRoot(document.getElementById('root')!).render(
<Route path="health-monitor" element={<HealthMonitorPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} />
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile
Intune Monitoring tab. Route is unconditional; the page
itself renders an "Admin access required" banner for
non-admin callers and skips the underlying API calls so
the server never sees a 403-prone request. */}
<Route path="scep/intune" element={<SCEPAdminPage />} />
</Route>
</Routes>
</BrowserRouter>
+340
View File
@@ -0,0 +1,340 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// SCEP RFC 8894 + Intune master bundle Phase 9.5: Vitest coverage for the
// SCEPAdminPage component. Pins:
// 1. Admin gate — non-admin callers see the gated banner and the page
// MUST NOT issue the underlying admin API requests.
// 2. Profile cards render with status + counters + trust-anchor expiry
// badge tone (good / warn / bad / EXPIRED).
// 3. Disabled profiles render the off-state pill instead of the counter
// grid.
// 4. Reload button opens the confirmation modal; Confirm calls the
// mutation and refetches stats; Cancel closes without calling.
// 5. Error path surfaces ErrorState with retry.
// 6. Audit log filter merges PKCSReq + RenewalReq events and sorts by
// timestamp descending.
vi.mock('../api/client', () => ({
getAdminSCEPIntuneStats: vi.fn(),
reloadAdminSCEPIntuneTrust: vi.fn(),
getAuditEvents: vi.fn(),
}));
vi.mock('../components/AuthProvider', () => ({
useAuth: vi.fn(),
}));
import SCEPAdminPage from './SCEPAdminPage';
import * as client from '../api/client';
import { useAuth } from '../components/AuthProvider';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
function setAuth(opts: { authRequired: boolean; admin: boolean }) {
vi.mocked(useAuth).mockReturnValue({
loading: false,
authRequired: opts.authRequired,
authenticated: true,
authType: 'apikey',
user: 'tester',
admin: opts.admin,
login: async () => {},
logout: () => {},
error: null,
});
}
const baseEnabledProfile = {
path_id: 'corp',
issuer_id: 'iss-corp',
enabled: true,
trust_anchor_path: '/etc/certctl/intune-corp.pem',
trust_anchors: [
{
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',
challenge_validity_ns: 3_600_000_000_000,
rate_limit_disabled: false,
replay_cache_size: 12,
counters: {
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',
};
beforeEach(() => {
vi.clearAllMocks();
cleanup();
setAuth({ authRequired: true, admin: true });
vi.mocked(client.getAuditEvents).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 200,
} as never);
});
describe('SCEPAdminPage — admin gate', () => {
it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => {
setAuth({ authRequired: true, admin: false });
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByRole('heading', { level: 2, name: /SCEP Intune Monitoring/ })).toBeInTheDocument();
});
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
expect(screen.getByText(/Admin access required/i)).toBeInTheDocument();
});
it('lets admin callers through and fetches stats', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled();
});
it('keeps the page accessible when authRequired=false (no-auth dev mode)', async () => {
setAuth({ authRequired: false, admin: false });
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [],
profile_count: 0,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalledTimes(1);
});
});
});
describe('SCEPAdminPage — profile rendering', () => {
it('renders enabled profile counters with the expected labels and tone', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('counter-corp-success')).toHaveTextContent('42');
});
expect(screen.getByTestId('counter-corp-replay')).toHaveTextContent('2');
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3');
// Expiry badge is "good" tone for >= 30 days remaining.
const badge = screen.getByTestId('expiry-badge-corp');
expect(badge).toHaveTextContent('250d');
});
it('renders an expiry badge with EXPIRED text and bad tone when an anchor is past NotAfter', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [
{
...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',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/);
});
});
it('renders the off-state pill for disabled profiles instead of the counter grid', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [disabledProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('profile-card-iot')).toBeInTheDocument();
});
expect(screen.getByText(/Intune disabled/)).toBeInTheDocument();
// Counter grid should NOT render for disabled profiles.
expect(screen.queryByTestId('counter-iot-success')).toBeNull();
});
it('renders an empty-state banner when no profiles are configured', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [],
profile_count: 0,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
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 () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
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'));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/Reload Intune trust anchor/i)).toBeInTheDocument();
});
it('calls reloadAdminSCEPIntuneTrust on Confirm and closes the modal on success', 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({
reloaded: true,
path_id: 'corp',
reloaded_at: '2026-04-29T15:01:00Z',
} as never);
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
await waitFor(() => {
expect(client.reloadAdminSCEPIntuneTrust).toHaveBeenCalledWith('corp');
});
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull();
});
});
it('keeps the modal open and shows the error message 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'));
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
await waitFor(() => {
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();
});
it('Cancel closes the modal without calling the reload mutation', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
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(await screen.findByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull();
});
expect(client.reloadAdminSCEPIntuneTrust).not.toHaveBeenCalled();
});
});
describe('SCEPAdminPage — error + audit-log surface', () => {
it('surfaces ErrorState when the stats query fails', async () => {
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 () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [baseEnabledProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
if (params.action === 'scep_pkcsreq_intune') {
return Promise.resolve({
data: [
{ 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' },
],
total: 1, page: 1, per_page: 200,
} as never);
}
return Promise.resolve({
data: [
{ 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, per_page: 200,
} as never);
});
renderWithQuery(<SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument();
});
const rows = screen.getByTestId('recent-failures-table').querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
// Sorted descending by timestamp — renewal (14:30) comes before pkcs (14:00).
expect(rows[0].textContent).toContain('scep_renewalreq_intune');
expect(rows[1].textContent).toContain('scep_pkcsreq_intune');
});
});
+462
View File
@@ -0,0 +1,462 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getAdminSCEPIntuneStats, reloadAdminSCEPIntuneTrust, getAuditEvents } from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
import { useAuth } from '../components/AuthProvider';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { formatDateTime } from '../api/utils';
import type { IntuneStatsSnapshot, IntuneTrustAnchorInfo, AuditEvent } from '../api/types';
// SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile Intune
// Monitoring tab.
//
// Surfaces:
// - Status banner per profile (trust anchor expiry countdown, rotates
// when < 30 days; the soonest-to-expire anchor wins).
// - Live counters table per profile (success / signature_invalid /
// claim_mismatch / expired / wrong_audience / replay / rate_limited /
// malformed / compliance_failed / not_yet_valid / unknown_version).
// Polled every 30s via TanStack Query.
// - Recent failures table (last 50) populated from the audit log
// filtered to action=scep_pkcsreq_intune (and the renewal sibling).
// - Trust anchor reload button (per-profile) with confirmation modal;
// calls POST /api/v1/admin/scep/intune/reload-trust under the hood
// (the SIGHUP-equivalent path).
//
// Admin-gated: the page itself renders an "Admin access required" banner
// for non-admin callers and never issues the underlying admin requests.
// Server-side enforcement is the M-008 admin gate; this is a UX hint.
const COUNTER_LABEL_ORDER = [
'success',
'signature_invalid',
'expired',
'not_yet_valid',
'wrong_audience',
'replay',
'rate_limited',
'claim_mismatch',
'compliance_failed',
'malformed',
'unknown_version',
] as const;
const COUNTER_PRESENTATION: Record<string, { label: string; tone: 'good' | 'warn' | 'bad' }> = {
success: { label: 'Success', tone: 'good' },
signature_invalid: { label: 'Signature invalid', tone: 'bad' },
expired: { label: 'Expired', tone: 'warn' },
not_yet_valid: { label: 'Not yet valid', tone: 'warn' },
wrong_audience: { label: 'Wrong audience', tone: 'bad' },
replay: { label: 'Replay', tone: 'bad' },
rate_limited: { label: 'Rate-limited', tone: 'warn' },
claim_mismatch: { label: 'Claim mismatch', tone: 'bad' },
compliance_failed: { label: 'Compliance failed', tone: 'warn' },
malformed: { label: 'Malformed', tone: 'bad' },
unknown_version: { label: 'Unknown version', tone: 'warn' },
};
const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = {
good: 'text-emerald-600',
warn: 'text-amber-600',
bad: 'text-red-600',
};
// soonestExpiryDays returns the smallest days_to_expiry across the
// profile's trust anchor pool. Returns null when the pool is empty (the
// per-profile preflight should have refused this state at boot, but
// defensive in case the holder is reloaded mid-flight to an empty file).
function soonestExpiryDays(anchors?: IntuneTrustAnchorInfo[]): number | null {
if (!anchors || anchors.length === 0) return null;
let min = Number.POSITIVE_INFINITY;
for (const a of anchors) {
if (a.expired) return -1; // any expired wins
if (a.days_to_expiry < min) min = a.days_to_expiry;
}
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' };
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' };
return { text: `${days}d remaining`, tone: 'good' };
}
interface ConfirmReloadModalProps {
profile: IntuneStatsSnapshot;
onCancel: () => void;
onConfirm: () => void;
pending: boolean;
errorMessage?: string;
}
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
const pathLabel = profile.path_id || '(legacy /scep root)';
return (
<div
role="dialog"
aria-labelledby="reload-trust-title"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
>
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
<h3 id="reload-trust-title" className="text-base font-semibold text-ink mb-2">
Reload Intune trust anchor
</h3>
<p className="text-sm text-ink-muted mb-4">
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
previous trust pool stays in place enrollments keep working off the old trust anchor while you
fix the file.
</p>
{errorMessage && (
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="px-3 py-1.5 text-sm rounded border border-surface-border bg-surface hover:bg-surface-alt"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={pending}
className="px-3 py-1.5 text-sm rounded bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
>
{pending ? 'Reloading…' : 'Reload trust anchor'}
</button>
</div>
</div>
</div>
);
}
interface ProfileCardProps {
profile: IntuneStatsSnapshot;
onRequestReload: (profile: IntuneStatsSnapshot) => void;
}
function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
const pathLabel = profile.path_id || '(legacy /scep root)';
if (!profile.enabled) {
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 days = soonestExpiryDays(profile.trust_anchors);
const badge = expiryBadge(days);
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}
{profile.audience && <> · Audience: <code>{profile.audience}</code></>}
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
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}`}
>
Trust anchor: {badge.text}
</span>
<button
type="button"
onClick={() => onRequestReload(profile)}
className="text-xs px-2 py-1 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid={`reload-button-${profile.path_id}`}
>
Reload trust
</button>
</div>
</header>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
{COUNTER_LABEL_ORDER.map(label => {
const value = profile.counters?.[label] ?? 0;
const presentation = COUNTER_PRESENTATION[label];
return (
<div key={label} className="border border-surface-border rounded p-2">
<div className={`text-lg font-semibold ${TONE_CLASS[presentation.tone]}`} data-testid={`counter-${profile.path_id}-${label}`}>
{value}
</div>
<div className="text-[11px] text-ink-muted uppercase tracking-wide">{presentation.label}</div>
</div>
);
})}
</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">Replay cache size</dt>
<dd>{profile.replay_cache_size}</dd>
</div>
<div>
<dt className="font-semibold text-ink">Per-device rate limit</dt>
<dd>{profile.rate_limit_disabled ? 'Disabled' : 'Active'}</dd>
</div>
<div>
<dt className="font-semibold text-ink">Trust anchors</dt>
<dd>{profile.trust_anchors?.length ?? 0}</dd>
</div>
</dl>
{profile.trust_anchors && profile.trust_anchors.length > 0 && (
<details className="mt-3 text-xs text-ink-muted">
<summary className="cursor-pointer font-semibold text-ink">Trust anchor details</summary>
<table className="mt-2 w-full text-left">
<thead>
<tr className="text-[11px] text-ink-muted uppercase">
<th className="py-1 pr-2">Subject</th>
<th className="py-1 pr-2">Not after</th>
<th className="py-1">Days to expiry</th>
</tr>
</thead>
<tbody>
{profile.trust_anchors.map(a => (
<tr key={`${profile.path_id}-${a.subject}-${a.not_after}`} className="border-t border-surface-border">
<td className="py-1 pr-2 font-mono">{a.subject || '(empty CN)'}</td>
<td className="py-1 pr-2">{formatDateTime(a.not_after)}</td>
<td className={`py-1 ${a.expired ? 'text-red-600 font-semibold' : ''}`}>
{a.expired ? 'EXPIRED' : a.days_to_expiry}
</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</section>
);
}
function RecentFailuresTable({ events }: { events: AuditEvent[] }) {
if (events.length === 0) {
return (
<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 (
<table className="w-full text-sm" data-testid="recent-failures-table">
<thead className="text-xs text-ink-muted uppercase tracking-wide">
<tr>
<th className="py-2 pl-4 pr-2 text-left">Timestamp</th>
<th className="py-2 pr-2 text-left">Action</th>
<th className="py-2 pr-2 text-left">Resource</th>
<th className="py-2 pr-4 text-left">Details</th>
</tr>
</thead>
<tbody>
{events.map(e => (
<tr key={e.id} className="border-t border-surface-border">
<td className="py-2 pl-4 pr-2 font-mono text-xs">{formatDateTime(e.timestamp)}</td>
<td className="py-2 pr-2">{e.action}</td>
<td className="py-2 pr-2">{e.resource_type} · <code className="text-xs">{e.resource_id}</code></td>
<td className="py-2 pr-4 text-xs text-ink-muted">
{e.details ? Object.entries(e.details).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`).join(' · ') : '-'}
</td>
</tr>
))}
</tbody>
</table>
);
}
export default function SCEPAdminPage() {
const auth = useAuth();
const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null);
const [reloadError, setReloadError] = useState<string | undefined>(undefined);
const statsQuery = useQuery({
queryKey: ['admin', 'scep', 'intune', 'stats'],
queryFn: getAdminSCEPIntuneStats,
enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin
refetchInterval: 30_000,
});
// Audit-log filter: every Intune-dispatched enrollment (success + failure)
// emits action=scep_pkcsreq_intune (initial) or scep_renewalreq_intune
// (renewal). The audit endpoint accepts a single action filter; we fetch
// both server-side via two queries and merge client-side rather than
// adding a comma-separated filter that would require backend changes.
const auditPKCSQuery = useQuery({
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
// both the per-profile trust pool (reflected in IntuneStats) AND every
// recently-failed Intune enrollment counter that might now succeed on
// retry. We invalidate the stats key so the per-profile trust-anchor
// panel reflects the new pool immediately; the audit log queries
// remain on their 60s timer (a SIGHUP-equivalent reload doesn't
// backfill new audit rows).
const reloadMutation = useTrackedMutation<
Awaited<ReturnType<typeof reloadAdminSCEPIntuneTrust>>,
Error,
string
>({
mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID),
invalidates: [['admin', 'scep', 'intune', 'stats']],
onSuccess: () => {
setReloadTarget(null);
setReloadError(undefined);
},
onError: (err: Error) => {
setReloadError(err.message);
},
});
if (auth.authRequired && !auth.admin) {
return (
<>
<PageHeader title="SCEP Intune Monitoring" subtitle="Admin-only observability surface" />
<div className="p-6">
<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.')}
/>
</div>
</>
);
}
if (statsQuery.isLoading) {
return (
<>
<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) {
return (
<>
<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 (
<>
<PageHeader
title="SCEP Intune Monitoring"
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · counters auto-refresh every 30s`}
action={
<button
type="button"
onClick={() => statsQuery.refetch()}
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid="refresh-stats-button"
>
Refresh now
</button>
}
/>
<div className="p-6 overflow-y-auto">
{profiles.length === 0 && (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
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 => (
<ProfileCard
key={p.path_id || '(root)'}
profile={p}
onRequestReload={profile => {
setReloadError(undefined);
setReloadTarget(profile);
}}
/>
))}
<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>
{auditPKCSQuery.isLoading || auditRenewalQuery.isLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentFailuresTable events={events} />
)}
</section>
</div>
{reloadTarget && (
<ConfirmReloadModal
profile={reloadTarget}
onCancel={() => {
setReloadTarget(null);
setReloadError(undefined);
}}
onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)}
pending={reloadMutation.isPending}
errorMessage={reloadError}
/>
)}
</>
);
}