Files
certctl/docs/scep-intune.md
T
shankar0123 0be889ff1d refactor(scep-gui): rebrand SCEP admin surface to per-profile tabbed interface (Profiles + Intune + Recent Activity)
Phase 9 follow-up to the SCEP RFC 8894 + Intune master bundle. The
Phase 9.4 GUI shipped 'SCEP Intune Monitoring' at /scep/intune, which
made the per-profile observability surface look Intune-only — operators
running EJBCA + Jamf would never click that nav link expecting per-
profile RA cert + mTLS observability. The page is per-profile keyed
under the hood; this commit rebrands + restructures so the surface
matches what operators actually need.

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

User-visible change:

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

Backend:

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

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

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

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

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

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

Backend tests (8 new):

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

Frontend:

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

Frontend tests (20 — full rebuild):

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

Docs:

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

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

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

Refs: cowork/scep-gui-restructure-prompt.md
      cowork/scep-rfc8894-intune-master-prompt.md::Phase 9
      Phase 11.5 (SCEP probe in scanner — opt-in) and Phase 12
      (release prep + tag) of the master bundle resume after this.
2026-04-29 17:46:42 +00:00

24 KiB

Microsoft Intune SCEP enrollment via certctl

Status (this document): Phase 11 of the SCEP RFC 8894 + Intune master bundle. The behavior described here is shipped on master and exercised end-to-end by internal/api/handler/scep_intune_e2e_test.go. The bundle is V2-free (community edition) — Conditional-Access compliance gating, native Microsoft Graph integration, and per-tenant trust anchors are documented under Limitations as V3-Pro features.

TL;DR

certctl is a drop-in NDES replacement for Microsoft Intune SCEP fleets. Intune-managed devices keep using the existing Intune Certificate Connector; only the SCEP server URL changes. certctl validates the Connector's signed challenge using its installation signing cert (no Microsoft API calls — the Connector already did that), binds the device claim to the inbound CSR, and issues through whichever certctl issuer connector you have configured (local CA, Vault, EJBCA, ADCS, etc.).

What you get over NDES:

  • Per-profile SCEP endpoints (/scep/corp vs. /scep/iot etc.) so a single certctl deploy serves multiple device fleets with distinct challenge passwords + trust anchors.
  • Audit log entries with the device GUID, claim subject, and CSR binding details — much better forensics than NDES + IIS logs.
  • Trust anchor reload via SIGHUP (no service restart) when the Connector signing cert rotates.
  • A built-in admin GUI tab (Intune Monitoring) showing per-profile enrollment counters, trust-anchor expiry countdowns, and the recent failures table.
  • Per-device rate limit (sliding window log keyed by Subject + Issuer) that catches a compromised Connector signing key issuing many different valid challenges for the same device.

Architecture

┌──────────────┐       ┌──────────────────────┐       ┌──────────────┐
│ Intune cloud │──────▶│ Intune Certificate   │──────▶│ certctl SCEP │
│              │       │ Connector            │       │ server       │
│ (Microsoft)  │       │ (customer infra)     │       │ (you)        │
└──────────────┘       └──────────────────────┘       └──────┬───────┘
                                                              │
                                                              ▼
                                                       ┌──────────────┐
                                                       │ issuer       │
                                                       │ connector    │
                                                       │ (local CA /  │
                                                       │  Vault /     │
                                                       │  EJBCA / …)  │
                                                       └──────────────┘

certctl replaces NDES, not the Connector. The Intune Certificate Connector is the bridge between the Intune cloud and your on-prem PKI; Microsoft installs and maintains it. What you replace is the Network Device Enrollment Service (NDES) — the SCEP server historically deployed on a Windows host, sitting between the Connector and an Active Directory Certificate Services CA. certctl sits in exactly that slot and speaks SCEP RFC 8894 to the Connector.

What certctl validates per request

For every Intune-flavored SCEP request the dispatcher in internal/service/scep.go::dispatchIntuneChallenge walks the following gates in order. A failure on any gate produces a CertRep PKIMessage with the documented pkiStatus/failInfo codes (per RFC 8894 §3.2.1.4.5) and increments the corresponding metric counter.

  1. Shape pre-checklooksIntuneShaped(challengePassword): length > 200 + exactly two dots. False positives are fine; false negatives on real Intune challenges would route them to the static compare and reject. The pre-check just decides whether to invoke the full validator.
  2. JWS signatureintune.ValidateChallenge re-derives the signing input from the raw on-wire bytes (per RFC 7515 §3.1, NOT re-base64-encoded segments) and verifies against every cert in the trust anchor pool. Supports RS256 and ES256 (both fixed-width r||s and ASN.1-DER form). Explicitly rejects alg=none and HMAC algs.
  3. Version dispatch — extracts the version claim from the payload prelude. v1 (current Connector format, no version key) routes to unmarshalChallengeV1. Future v2 plugs in a sibling parser without touching the validator.
  4. Time boundsnow ≥ iat AND now < exp. Configurable cap on top via INTUNE_CHALLENGE_VALIDITY (defense-in-depth against a Connector that mints long-validity challenges).
  5. Audience pinclaim.aud == INTUNE_AUDIENCE (skipped when INTUNE_AUDIENCE is empty for proxy/load-balancer scenarios).
  6. CSR bindingclaim.DeviceMatchesCSR(csr) checks set-equality between the claim's device_name / san_dns / san_rfc822 / san_upn and the CSR's CN + SANs. Set-equality means the CSR carries EXACTLY the claim's values, no extras and no missing.
  7. Replayintune.ReplayCache.CheckAndInsert rejects duplicates within the configured TTL. Sized for 100k entries (covers a ~25 RPS Intune fleet's steady-state).
  8. Per-device rate limit — sliding window log keyed by (claim.Subject, claim.Issuer). Catches a compromised Connector issuing many DIFFERENT valid challenges for the same device. Default 3 enrollments per 24h covers legitimate first-cert + recovery + post-wipe.
  9. Optional compliance check — V3-Pro plug-in seam (nil-default no-op). When set, the gate calls Microsoft Graph's compliance API and short-circuits non-compliant devices with FAILURE+BadRequest.

A request that passes all nine gates flows to processEnrollment, which builds the issuance request, calls the configured issuer connector, and emits a CertRep PKIMessage with the issued cert encrypted to the device's transient signing cert per RFC 8894 §3.3.2.

Migration from NDES + EJBCA (or NDES + ADCS)

The migration plan below is conservative — install certctl alongside your existing NDES so you can flip Intune profiles fleet-by-fleet without a flag day. Validated against a fresh docker compose up stack; the docker-compose.test.yml stack does not currently bake Intune in (Phase 10.2 ships a hermetic in-process e2e test instead), so the production validation step is a manual run-book item.

  1. Install certctl alongside existing NDES. Stand up the certctl server on a separate host (or as a Kubernetes deployment) reachable from the Connector host. Use the existing operator-run-book in docs/tls.md for the TLS bootstrap.

  2. Configure a per-profile SCEP endpoint. Pick a path id (e.g. corp — referenced as <NAME> below; the value gets uppercased for the env-var key and lowercased for the URL path) and set:

    CERTCTL_SCEP_ENABLED=true
    CERTCTL_SCEP_PROFILES=corp
    CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID=iss-local           # or your existing issuer
    CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD=<random>   # Intune still requires this
    CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH=/etc/certctl/ra-corp.pem
    CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH=/etc/certctl/ra-corp.key
    

    The endpoint will be served at https://certctl.example.com/scep/corp — the URL path uses the lowercased name and the env-var keys use the uppercased form. Concrete env-var name mappings are listed in features.md.

  3. Extract the Intune Connector's signing cert. On the Connector host (Windows), the Connector's installation creates a self-signed cert in the local machine's Personal cert store with subject CN=Microsoft Intune Certificate Connector (path documented by Microsoft — see Microsoft Learn link in the Microsoft support statement below). Export the public cert (no private key) as a base64 .cer file.

  4. Configure the trust anchor. Copy the .cer to the certctl host (or mount via your secret manager) and set:

    CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true
    CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH=/etc/certctl/intune-corp.pem
    CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE=https://certctl.example.com/scep/corp
    

    Restart certctl. The startup preflight refuses to boot if the trust anchor file is missing, unparseable, or contains an expired cert — failure is loud at boot rather than silent at request time.

  5. Configure the issuer connector. If you're keeping EJBCA, point CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID at your EJBCA issuer profile (see docs/connectors.md). For a clean cut-over to the built-in local CA, follow docs/tls.md to bootstrap a sub-CA cert.

  6. Migrate one Intune SCEP profile to certctl. In the Intune admin center, edit the SCEP profile for a small canary device group and update the SCEP server URL to https://certctl.example.com/scep/corp. Push the profile and wait for the canary devices to rotate (24-48h).

  7. Verify enrollment. Open the certctl admin GUI's SCEP Intune Monitoring tab and watch the success counter tick on the corp profile card. The recent failures table surfaces any rejected enrollments with the exact reason (e.g. signature_invalid, claim_mismatch).

  8. Roll out the rest of the fleet. Once the canary is clean, migrate the remaining Intune SCEP profiles in batches.

  9. Decommission NDES. After all fleets are migrated and a few renewal cycles have completed cleanly, take down the NDES role and the IIS site. The existing certs continue to chain to your issuer; only the enrollment path changes.

Intune SCEP profile fields → certctl behavior

The Intune admin center's SCEP profile editor exposes a fixed set of fields. The mapping below is what each field controls relative to certctl's behavior.

Intune profile field certctl behavior
Certificate type Treated as device or user; surfaces in the claim's subject field (device GUID vs. user UPN). certctl doesn't gate on type; the issuer's certificate profile decides.
Subject name format Drives the CSR's CN. The Intune Connector sets claim.device_name from this value; certctl's CSR-binding gate enforces equality.
Subject alternative name Drives the CSR's SAN list. Intune supports DNS / RFC 822 / UPN; certctl's claim binding checks set-equality per dimension. Mismatches surface as ErrClaimSANDNSMismatch / _SANRFC822Mismatch / _SANUPNMismatch.
Certificate validity period Honored by the issuer connector. certctl caps via the per-profile CertificateProfile.MaxTTLSeconds; the smaller of the two wins.
Key storage provider Device-side concern (the Connector negotiates with the device's TPM / Software KSP). certctl never sees the device's private key — it only signs the CSR.
Key usage / Extended key usage Honored by the issuer connector via the bound CertificateProfile.AllowedEKUs. CSRs requesting an EKU outside the allowed set are rejected by the crypto-policy gate (ValidateCSRAgainstProfile).
Hash algorithm The CSR's signature hash (SHA-256 typical). The SCEP GetCACaps advertises SHA-256 + SHA-512; the device picks.
SCEP server URL The endpoint URL the Connector posts to. Set to https://certctl.example.com/scep/<profile-name>.

Trust anchor extraction

The Intune Certificate Connector self-signs an installation cert at install time. To configure certctl, extract this cert (PUBLIC ONLY, no private key) as PEM:

  1. On the Connector host (Windows), open certlm.msc (Local Machine Certificate Manager).
  2. Navigate to PersonalCertificates. Find the cert with subject CN=Microsoft Intune Certificate Connector.
  3. Right-click → All Tasks → Export. Choose No, do not export the private key. Format: Base-64 encoded X.509 (.CER).
  4. Copy the resulting .cer file to the certctl host. Rename to .pem (the bytes are identical; certctl's PEM loader accepts either extension).
  5. Set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to the file path.
  6. If you have multiple Connectors in HA, repeat steps 1-3 on each and concatenate the PEM blocks into one bundle file.

When the operator rotates the Connector signing cert (typically once every few years per Microsoft's Connector lifecycle), repeat the extraction, overwrite the on-disk file, then send SIGHUP to the certctl process. The trust holder swaps atomically; bad files (parse error, expired cert) keep the OLD pool in place so a half-rotation doesn't take Intune enrollment down.

Troubleshooting

The dispatcher emits a typed metric label per failure mode plus a matching audit-log entry. The table below maps the label to the most common root cause and the operator action.

Counter label Symptom Root cause + fix
signature_invalid Every enrollment from a specific profile failing Trust anchor mismatch — the Connector's signing cert was rotated and certctl wasn't reloaded. Re-extract the cert (trust anchor extraction), overwrite the file, send SIGHUP.
claim_mismatch Some enrollments from one Intune SCEP profile failing The Intune SCEP profile's SAN config doesn't match what the device CSR actually has. Compare the recent failures table's claim row to the device's CSR; usually a SAN format mismatch (e.g. claim wants UPN, CSR has DNS).
expired All enrollments failing on a date boundary Either clock skew between the Connector host and certctl (NTP both ends) OR the Connector's signing cert is past NotAfter. The certctl preflight catches an expired trust anchor at boot; check the Monitoring tab's expiry countdown.
not_yet_valid All enrollments failing Reverse clock skew (certctl's clock is BEHIND the Connector's). Sync via NTP.
wrong_audience All enrollments from a profile failing INTUNE_AUDIENCE doesn't match the URL the Connector is configured to call. Either fix INTUNE_AUDIENCE to match the operator URL, or unset it (defense-in-depth then disabled — the claim's exp + sig still gate).
replay Sporadic per-device failures, mostly during retries The device retried the SAME challenge after the first one failed. The replay cache TTL is INTUNE_CHALLENGE_VALIDITY (default 60m). Either widen the device's retry window (Intune-side) or shorten validity.
rate_limited A specific device hitting 429-equivalent failures The device exceeded INTUNE_PER_DEVICE_RATE_LIMIT_24H (default 3). If legitimate (post-wipe + recovery + first-cert all in 24h), bump the cap. If suspicious, this is the limiter doing its job — investigate the device.
unknown_version Sudden onset of failures across the entire fleet Microsoft shipped a new Connector version with a version claim certctl doesn't understand. Open an issue on the certctl repo with the failing claim payload (anonymized); the parser dispatcher accepts new versions in ~30 LoC.
malformed Sporadic, low-volume Malformed challenge bytes — almost always a network proxy mangling the request body, or the Connector logging itself out mid-handshake. Capture a packet trace; the Connector should re-emit on the next device retry.
compliance_failed V3-Pro only The pluggable compliance check returned non-compliant. The audit-log details carries the reason string from Microsoft Graph. V2 deployments never see this counter tick.

Operational monitoring (SCEP Administration → Intune Monitoring tab)

The admin GUI surface for SCEP lives at /scep and is structured as three tabs: Profiles (default landing — every configured SCEP profile, lean cards with always-present fields), Intune Monitoring (the Intune-specific deep-dive described below), and Recent Activity (full SCEP audit log filter). Operators monitoring an Intune deployment spend most of their time on the Intune Monitoring tab, deep-linkable via /scep?tab=intune or the legacy alias /scep/intune. The Profiles tab gives the at-a-glance per-profile health (RA cert expiry, mTLS status, Intune enabled/disabled badge, challenge-password-set indicator) and a "View Intune details →" link from each Intune-enabled card that switches into this tab filtered to that profile.

The Intune Monitoring tab shows:

  • Per-profile cards — one card per SCEP profile, with the trust anchor expiry countdown badge:
    • green ≥ 30 days remaining
    • amber 7-30 days remaining (rotate soon)
    • red < 7 days remaining
    • EXPIRED past NotAfter
  • Live counters — the per-status enrollment counts polled every 30s. The order in the grid puts success first (vanity) and failure modes after.
  • Recent failures table — the last 50 audit-log events with action scep_pkcsreq_intune or scep_renewalreq_intune, sorted by timestamp descending. Polled every 60s.
  • Trust anchor reload button — confirms via modal then issues POST /api/v1/admin/scep/intune/reload-trust (the SIGHUP-equivalent). Bad reloads keep the OLD pool in place; the modal stays open with the underlying error so the operator can correct the file and retry.

Three admin endpoints back the page:

  • GET /api/v1/admin/scep/profiles — per-profile snapshot for the Profiles tab; surfaces RA cert subject + NotAfter + days-to-expiry, mTLS sibling-route status + bundle path, challenge-password-set flag, and an optional intune sub-block for Intune-enabled profiles.
  • GET /api/v1/admin/scep/intune/stats — Intune-specific deep-dive for the Intune Monitoring tab; per-status counters + trust anchor pool details. Backward-compat shape preserved from Phase 9.
  • POST /api/v1/admin/scep/intune/reload-trust — SIGHUP-equivalent trust anchor reload, body {"path_id": "<pathID>"}.

All three are M-008 admin-gated. Non-admin Bearer callers get HTTP 403

  • a clear message; the GUI hides the page entirely for non-admin users (UX hint; server-side enforcement is independent).

The counters are exposed in the GUI as snapshots; if you wrap them in a Prometheus exporter (V3-Pro plug-in seam — V2 doesn't ship a /metrics surface today), reasonable starting thresholds:

  • signature_invalid rate > 0 for > 5 minutes → page on-call. The trust anchor is stale; the operator missed a SIGHUP after a Connector rotation.
  • claim_mismatch rate > 0 sustained > 1 hour → notify (not page). An Intune SCEP profile is misconfigured; an admin needs to fix the SAN definition or the operator's CertificateProfile.
  • replay rate climbing → notify. Either an aggressive retry policy on the device side OR active replay attempts. Cross-reference source IPs in the audit log.
  • rate_limited for a single device > 1 per hour → notify. Either legitimate enrollment storm (post-wipe scenarios) or a compromised Connector signing key.
  • Trust anchor days_to_expiry < 30 on any profile → notify; rotate the Connector's signing cert before the cliff.

Limitations

This bundle is V2-free. The following capabilities are deferred to V3-Pro:

  • Native Microsoft Graph integration. certctl validates the Connector's signed challenge but doesn't call Microsoft's API directly — the Connector already did that. V3-Pro could ship a Graph client that pulls device-compliance state in addition to the challenge claim.
  • Conditional Access compliance gating. The dispatcher exposes a nil-default ComplianceCheck hook. V3-Pro plugs in a Microsoft Graph compliance lookup before issuance; non-compliant devices fail with a typed compliance_failed failInfo.
  • Per-tenant trust anchors. V2 has one trust anchor pool per SCEP profile; V3-Pro could support per-AAD-tenant anchor scoping for MSPs running shared certctl deployments across customers.
  • OCSP stapling at SCEP-response time. The CertRep doesn't carry a stapled OCSP response today; certificate validators look up OCSP via the id-pkix-ocsp extension on the issued cert. V3-Pro could staple inline.
  • Auto-discovery of the Connector signing cert. V2 requires the operator to extract the cert manually and configure the path. V3-Pro could pull from a Microsoft-published endpoint (with the appropriate trust constraints).

These deferrals are deliberate, not oversights. The V2 surface covers every operationally-required path for a single-tenant enterprise replacing NDES; V3-Pro adds the multi-tenant + native-API features procurement teams sometimes ask for.

Microsoft support statement

Microsoft documents the Intune Certificate Connector as RFC-8894-compliant and supports its use against any RFC 8894 SCEP server. The relevant Microsoft Learn pages:

certctl's role per Microsoft's framing: a third-party SCEP server that the Connector posts to. Microsoft supports this topology; only certctl's own RFC 8894 implementation is in scope for certctl support. The end-to-end Connector → certctl → issuer flow is exercised in internal/api/handler/scep_intune_e2e_test.go and the golden-file fixtures in internal/scep/intune/testdata/.

  • legacy-est-scep.md — the per-profile SCEP setup guide + RFC 8894 reference + mTLS sibling route. Read this first if you're not already running certctl SCEP for non-Intune fleets.
  • architecture.md — overall control-plane architecture; Security Model section calls out the Intune trust anchor as a sensitive operator-configured surface.
  • features.md — every CERTCTL_* env var, including the per-profile CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_* family.
  • tls.md — TLS bootstrap for the certctl control plane; prerequisite for any production deploy.