mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 12:48:53 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 530593507b | |||
| 84fac19f98 | |||
| 506cff137d | |||
| 0be889ff1d | |||
| 5d080c86fd | |||
| e0d00717c7 | |||
| 28e277a88e | |||
| 77e0281a0e | |||
| 7612da783a | |||
| 7e4d423561 |
@@ -108,6 +108,7 @@ gantt
|
|||||||
|----------|----------|----------|
|
|----------|----------|----------|
|
||||||
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT |
|
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT |
|
||||||
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
|
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
|
||||||
|
| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. |
|
||||||
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
||||||
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
||||||
|
|
||||||
|
|||||||
@@ -732,6 +732,255 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/network-scan/scep-probe:
|
||||||
|
post:
|
||||||
|
tags: [SCEP]
|
||||||
|
summary: Probe an SCEP server for capability + posture
|
||||||
|
description: |
|
||||||
|
Synchronous probe against an SCEP server URL. Issues
|
||||||
|
`GET ?operation=GetCACaps` and `GET ?operation=GetCACert`
|
||||||
|
and returns the structured `SCEPProbeResult` (reachable,
|
||||||
|
advertised caps, RFC 8894 / AES / POST / Renewal / SHA-256 /
|
||||||
|
SHA-512 support flags, CA cert subject + issuer + NotBefore +
|
||||||
|
NotAfter + days-to-expiry + algorithm + chain length).
|
||||||
|
|
||||||
|
Capability-only — does NOT POST a CSR (would consume slot
|
||||||
|
allocations on the target server + create audit noise). Used
|
||||||
|
for pre-migration assessment + compliance posture audits.
|
||||||
|
|
||||||
|
SSRF-defended: the URL is validated up-front (reserved IPs
|
||||||
|
rejected) AND the underlying HTTP client uses the
|
||||||
|
SafeHTTPDialContext that re-resolves the host at dial time
|
||||||
|
(defends against DNS rebinding).
|
||||||
|
|
||||||
|
Result is persisted to the `scep_probe_results` table via
|
||||||
|
migration 000021 so the GUI can show recent probe history.
|
||||||
|
SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
operationId: probeSCEP
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
description: Base SCEP server URL (no `?operation=...` suffix needed; the probe appends its own operations).
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Probe completed (the result body's `error` field carries any sub-step failure)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
target_url:
|
||||||
|
type: string
|
||||||
|
reachable:
|
||||||
|
type: boolean
|
||||||
|
advertised_caps:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
supports_rfc8894: { type: boolean }
|
||||||
|
supports_aes: { type: boolean }
|
||||||
|
supports_post_operation: { type: boolean }
|
||||||
|
supports_renewal: { type: boolean }
|
||||||
|
supports_sha256: { type: boolean }
|
||||||
|
supports_sha512: { type: boolean }
|
||||||
|
ca_cert_subject: { type: string }
|
||||||
|
ca_cert_issuer: { type: string }
|
||||||
|
ca_cert_not_before: { type: string, format: date-time }
|
||||||
|
ca_cert_not_after: { type: string, format: date-time }
|
||||||
|
ca_cert_expired: { type: boolean }
|
||||||
|
ca_cert_days_to_expiry: { type: integer }
|
||||||
|
ca_cert_algorithm: { type: string }
|
||||||
|
ca_cert_chain_length: { type: integer }
|
||||||
|
probed_at: { type: string, format: date-time }
|
||||||
|
probe_duration_ms: { type: integer }
|
||||||
|
error: { type: string }
|
||||||
|
"400":
|
||||||
|
description: Missing or malformed `url` field
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/network-scan/scep-probes:
|
||||||
|
get:
|
||||||
|
tags: [SCEP]
|
||||||
|
summary: List recent SCEP probe results
|
||||||
|
description: |
|
||||||
|
Returns the most recent 50 SCEP probe results across any
|
||||||
|
target URL, ordered by `probed_at` descending. Backs the
|
||||||
|
GUI's "Recent SCEP probes" history table on the Network
|
||||||
|
Scan page. SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
operationId: listSCEPProbes
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Recent probe results
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
probes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
probe_count:
|
||||||
|
type: integer
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/admin/scep/profiles:
|
||||||
|
get:
|
||||||
|
tags: [SCEP]
|
||||||
|
summary: Per-profile SCEP administration overview (admin)
|
||||||
|
description: |
|
||||||
|
Returns one snapshot per configured SCEP profile in the
|
||||||
|
SCEPProfileStatsSnapshot shape: always-present per-profile
|
||||||
|
fields (path_id, issuer_id, challenge_password_set, RA cert
|
||||||
|
subject + NotBefore/NotAfter + days-to-expiry, mTLS
|
||||||
|
sibling-route status, mTLS trust bundle path) plus an
|
||||||
|
optional `intune` sub-block when the profile has
|
||||||
|
INTUNE_ENABLED=true.
|
||||||
|
|
||||||
|
Profiles where Intune is disabled appear with the `intune`
|
||||||
|
field omitted (rather than null) so the GUI's per-profile
|
||||||
|
card can render the lean shape without an Intune deep-dive
|
||||||
|
button. Profiles where Intune is enabled also appear in the
|
||||||
|
sibling /api/v1/admin/scep/intune/stats endpoint with the
|
||||||
|
flat Phase 9.2 shape preserved for backward compat.
|
||||||
|
|
||||||
|
Admin-gated (M-008 pattern). Non-admin Bearer callers get
|
||||||
|
HTTP 403 — the snapshot reveals the operator's profile set,
|
||||||
|
RA cert expiries, and mTLS bundle paths (sensitive
|
||||||
|
operational metadata). SCEP RFC 8894 + Intune master bundle
|
||||||
|
Phase 9 follow-up.
|
||||||
|
operationId: listSCEPProfiles
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Per-profile SCEP administration snapshot
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
profiles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
profile_count:
|
||||||
|
type: integer
|
||||||
|
generated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
"403":
|
||||||
|
description: Admin access required
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/admin/scep/intune/stats:
|
||||||
|
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}:
|
/.well-known/pki/ocsp/{issuer_id}:
|
||||||
post:
|
post:
|
||||||
tags: [CRL & OCSP]
|
tags: [CRL & OCSP]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
"github.com/shankar0123/certctl/internal/crypto/signer"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository/postgres"
|
"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/scheduler"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
)
|
)
|
||||||
@@ -355,6 +356,12 @@ func main() {
|
|||||||
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
|
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
|
||||||
networkScanRepo := postgres.NewNetworkScanRepository(db)
|
networkScanRepo := postgres.NewNetworkScanRepository(db)
|
||||||
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
|
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — wire the SCEP
|
||||||
|
// probe persistence repo onto the network scan service so the new
|
||||||
|
// /api/v1/network-scan/scep-probe endpoint can persist results to
|
||||||
|
// scep_probe_results (migration 000021).
|
||||||
|
scepProbeRepo := postgres.NewSCEPProbeResultRepository(db)
|
||||||
|
networkScanService.SetSCEPProbeRepo(scepProbeRepo)
|
||||||
logger.Info("initialized network scan service")
|
logger.Info("initialized network scan service")
|
||||||
|
|
||||||
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
|
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
|
||||||
@@ -655,6 +662,14 @@ func main() {
|
|||||||
<-startedChan
|
<-startedChan
|
||||||
logger.Info("scheduler started")
|
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
|
// Build the API router with all handlers
|
||||||
apiRouter := router.New()
|
apiRouter := router.New()
|
||||||
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
||||||
@@ -695,6 +710,16 @@ func main() {
|
|||||||
return ids
|
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
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
@@ -762,6 +787,12 @@ func main() {
|
|||||||
scepMTLSHandlers := make(map[string]handler.SCEPHandler)
|
scepMTLSHandlers := make(map[string]handler.SCEPHandler)
|
||||||
scepMTLSUnionPool := x509.NewCertPool()
|
scepMTLSUnionPool := x509.NewCertPool()
|
||||||
scepMTLSAnyEnabled := false
|
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 {
|
for i, profile := range cfg.SCEP.Profiles {
|
||||||
profile := profile // shadow for closure-safety even though no closures escape
|
profile := profile // shadow for closure-safety even though no closures escape
|
||||||
profileLog := logger.With(
|
profileLog := logger.With(
|
||||||
@@ -811,9 +842,23 @@ func main() {
|
|||||||
preflightCancel()
|
preflightCancel()
|
||||||
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
|
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
|
||||||
scepService.SetProfileRepo(profileRepo)
|
scepService.SetProfileRepo(profileRepo)
|
||||||
|
scepService.SetPathID(profile.PathID)
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up:
|
||||||
|
// surface mTLS sibling-route status in the per-profile snapshot
|
||||||
|
// the new /admin/scep/profiles endpoint emits. The actual mTLS
|
||||||
|
// trust pool wiring lives further down in the if profile.MTLSEnabled
|
||||||
|
// block; this just records the flag + bundle path for observability.
|
||||||
|
scepService.SetMTLSConfig(profile.MTLSEnabled, profile.MTLSClientCATrustBundlePath)
|
||||||
if profile.ProfileID != "" {
|
if profile.ProfileID != "" {
|
||||||
scepService.SetProfileID(profile.ProfileID)
|
scepService.SetProfileID(profile.ProfileID)
|
||||||
}
|
}
|
||||||
|
// 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)
|
scepHandler := handler.NewSCEPHandler(scepService)
|
||||||
// SCEP RFC 8894 Phase 2.3: load the per-profile RA pair so the
|
// SCEP RFC 8894 Phase 2.3: load the per-profile RA pair so the
|
||||||
// handler can run the new RFC 8894 PKIMessage path. Preflight
|
// handler can run the new RFC 8894 PKIMessage path. Preflight
|
||||||
@@ -826,6 +871,68 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
scepHandler.SetRAPair(raCert, raKey)
|
scepHandler.SetRAPair(raCert, raKey)
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up:
|
||||||
|
// surface RA cert metadata (subject + NotBefore + NotAfter) in
|
||||||
|
// the per-profile snapshot so the new /admin/scep/profiles
|
||||||
|
// endpoint can drive the GUI's RA expiry countdown badge.
|
||||||
|
scepService.SetRACert(raCert)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune
|
||||||
|
// 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.PathID, 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,
|
||||||
|
profile.Intune.ClockSkewTolerance,
|
||||||
|
replayCache,
|
||||||
|
rateLimiter,
|
||||||
|
)
|
||||||
|
profileLog.Info("SCEP profile Intune dispatcher enabled",
|
||||||
|
"trust_anchor_path", profile.Intune.ConnectorCertPath,
|
||||||
|
"audience", profile.Intune.Audience,
|
||||||
|
"challenge_validity", profile.Intune.ChallengeValidity,
|
||||||
|
"clock_skew_tolerance", profile.Intune.ClockSkewTolerance,
|
||||||
|
"per_device_rate_limit_24h", profile.Intune.PerDeviceRateLimit24h,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
scepHandlers[profile.PathID] = scepHandler
|
scepHandlers[profile.PathID] = scepHandler
|
||||||
endpoint := "/scep"
|
endpoint := "/scep"
|
||||||
if profile.PathID != "" {
|
if profile.PathID != "" {
|
||||||
@@ -835,6 +942,7 @@ func main() {
|
|||||||
"endpoint", endpoint+"?operation={GetCACaps,GetCACert,PKIOperation}",
|
"endpoint", endpoint+"?operation={GetCACaps,GetCACert,PKIOperation}",
|
||||||
"challenge_password_set", profile.ChallengePassword != "",
|
"challenge_password_set", profile.ChallengePassword != "",
|
||||||
"ra_cert_path", profile.RACertPath,
|
"ra_cert_path", profile.RACertPath,
|
||||||
|
"intune_enabled", profile.Intune.Enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEP RFC 8894 Phase 6.5: register the mTLS sibling route
|
// SCEP RFC 8894 Phase 6.5: register the mTLS sibling route
|
||||||
@@ -913,7 +1021,20 @@ func main() {
|
|||||||
logger.Info("SCEP server enabled",
|
logger.Info("SCEP server enabled",
|
||||||
"profile_count", len(scepHandlers),
|
"profile_count", len(scepHandlers),
|
||||||
"mtls_profile_count", len(scepMTLSHandlers),
|
"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/.
|
// Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/.
|
||||||
@@ -1319,6 +1440,52 @@ func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPo
|
|||||||
return pool, nil
|
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, pathID, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) {
|
||||||
|
if !enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// pathIDLabel renders the empty-string PathID as "<root>" so the
|
||||||
|
// operator's boot-log error doesn't read like a missing variable.
|
||||||
|
pathIDLabel := pathID
|
||||||
|
if pathIDLabel == "" {
|
||||||
|
pathIDLabel = "<root>"
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE enabled but trust anchor path empty: "+
|
||||||
|
"set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to a PEM bundle "+
|
||||||
|
"of the Microsoft Intune Certificate Connector's signing certs", pathIDLabel)
|
||||||
|
}
|
||||||
|
holder, err := intune.NewTrustAnchorHolder(path, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE trust anchor load failed: %w (path=%s)", pathIDLabel, err, path)
|
||||||
|
}
|
||||||
|
return holder, nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
||||||
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
||||||
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master prompt §13 line 1853 acceptance —
|
||||||
|
// boot regression tests for preflightSCEPIntuneTrustAnchor. Closed in
|
||||||
|
// the 2026-04-29 audit-closure bundle (Phase F).
|
||||||
|
//
|
||||||
|
// Spec text:
|
||||||
|
// "clean boot with Intune disabled (backward compat)" and
|
||||||
|
// "refuses-to-start with broken per-profile config (PathID logged)."
|
||||||
|
//
|
||||||
|
// These three tests exercise the function the cmd/server/main.go boot
|
||||||
|
// loop calls per profile. We can't (and don't want to) run main()
|
||||||
|
// itself in a unit test — that would require docker compose + a real
|
||||||
|
// listener. Instead we drive the function directly and assert its
|
||||||
|
// contract holds: nil error on disabled, structured error containing
|
||||||
|
// the PathID on enabled-but-broken.
|
||||||
|
|
||||||
|
func discardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat — when
|
||||||
|
// the profile has Intune disabled, preflight returns (nil, nil) and
|
||||||
|
// MUST NOT touch the filesystem. This is the dominant path in
|
||||||
|
// production: most operators run SCEP without Intune. A regression
|
||||||
|
// here would make every non-Intune deploy fail boot with a confusing
|
||||||
|
// "trust anchor missing" error.
|
||||||
|
func TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat(t *testing.T) {
|
||||||
|
holder, err := preflightSCEPIntuneTrustAnchor(false, "corp", "", discardLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("disabled preflight should be a no-op, got error: %v", err)
|
||||||
|
}
|
||||||
|
if holder != nil {
|
||||||
|
t.Errorf("disabled preflight should return nil holder, got %#v", holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the no-touch contract: even if PathID + path are both
|
||||||
|
// non-empty, disabled=false short-circuits before any I/O. Pass a
|
||||||
|
// path that doesn't exist — the call MUST still succeed.
|
||||||
|
holder, err = preflightSCEPIntuneTrustAnchor(false, "iot", "/tmp/this-file-does-not-exist-12345.pem", discardLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("disabled preflight with non-existent path should still succeed: %v", err)
|
||||||
|
}
|
||||||
|
if holder != nil {
|
||||||
|
t.Error("disabled preflight should return nil holder even with non-existent path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID —
|
||||||
|
// when the profile has Intune enabled but the trust-anchor file
|
||||||
|
// doesn't exist, preflight returns an error whose text contains the
|
||||||
|
// literal PathID. Operators grep their boot log for the PathID to
|
||||||
|
// triage which profile is broken in a multi-profile deploy.
|
||||||
|
func TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID(t *testing.T) {
|
||||||
|
missingPath := filepath.Join(t.TempDir(), "this-trust-anchor-was-never-written.pem")
|
||||||
|
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp", missingPath, discardLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when trust anchor file is missing, got nil")
|
||||||
|
}
|
||||||
|
if holder != nil {
|
||||||
|
t.Errorf("expected nil holder on broken config, got %#v", holder)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `PathID="corp"`) {
|
||||||
|
t.Errorf("error should contain PathID for operator log-grep: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), missingPath) {
|
||||||
|
t.Errorf("error should contain the path for operator log-grep: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty PathID (legacy /scep root) — the error MUST surface a
|
||||||
|
// readable label, not an empty quoted string that looks like a
|
||||||
|
// missing variable.
|
||||||
|
_, err = preflightSCEPIntuneTrustAnchor(true, "", missingPath, discardLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on broken legacy-root config")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `PathID="<root>"`) {
|
||||||
|
t.Errorf("error should label empty PathID as <root>: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty path with enabled=true — distinct error path (path-empty
|
||||||
|
// vs file-missing). Spec requires this branch ALSO surfaces the
|
||||||
|
// PathID so the operator's grep narrows to the profile.
|
||||||
|
_, err = preflightSCEPIntuneTrustAnchor(true, "iot", "", discardLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when trust anchor path is empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `PathID="iot"`) {
|
||||||
|
t.Errorf("empty-path error should contain PathID for operator log-grep: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses — an
|
||||||
|
// expired Connector signing cert in the trust anchor file is the
|
||||||
|
// silent-failure mode this preflight is built to catch. Without the
|
||||||
|
// gate, the SCEP server boots cleanly and then rejects every Intune
|
||||||
|
// enrollment at runtime with "no trust anchor recognizes this
|
||||||
|
// signature" — confusing for the operator whose Connector is healthy
|
||||||
|
// (the cert just expired without rotation). Pin the contract: the
|
||||||
|
// boot MUST refuse with an error that names the expired cert's
|
||||||
|
// subject CN so the operator knows what to rotate.
|
||||||
|
func TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses(t *testing.T) {
|
||||||
|
// Build a deterministic ECDSA cert with NotAfter 1 hour in the past.
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "intune-connector-rotated-must-replace"},
|
||||||
|
NotBefore: now.Add(-2 * time.Hour),
|
||||||
|
NotAfter: now.Add(-1 * time.Hour), // expired
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundlePath := filepath.Join(t.TempDir(), "intune-expired.pem")
|
||||||
|
if err := os.WriteFile(bundlePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||||
|
t.Fatalf("write expired cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp-expired", bundlePath, discardLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected refuse-to-start on expired trust anchor cert, got nil error")
|
||||||
|
}
|
||||||
|
if holder != nil {
|
||||||
|
t.Errorf("expected nil holder on expired-cert refusal, got %#v", holder)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `PathID="corp-expired"`) {
|
||||||
|
t.Errorf("error should contain PathID for operator log-grep: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "intune-connector-rotated-must-replace") {
|
||||||
|
t.Errorf("error should contain the expired cert's subject CN so the operator knows what to rotate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,6 +284,27 @@ services:
|
|||||||
CERTCTL_EST_ENABLED: "true"
|
CERTCTL_EST_ENABLED: "true"
|
||||||
CERTCTL_EST_ISSUER_ID: iss-local
|
CERTCTL_EST_ISSUER_ID: iss-local
|
||||||
|
|
||||||
|
# SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance
|
||||||
|
# (deploy/test/scep_intune_e2e_test.go integration variant).
|
||||||
|
# Closed in the 2026-04-29 audit-closure bundle (Phase I).
|
||||||
|
#
|
||||||
|
# Publishes /scep/e2eintune?operation=... with the Intune
|
||||||
|
# dispatcher enabled. The deterministic Connector signing cert
|
||||||
|
# is bind-mounted at the path below; the matching private key
|
||||||
|
# lives ONLY on the test side (see
|
||||||
|
# deploy/test/scep_intune_e2e_test.go::generateE2EIntuneTrustAnchor).
|
||||||
|
CERTCTL_SCEP_ENABLED: "true"
|
||||||
|
CERTCTL_SCEP_PROFILES: "e2eintune"
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_ISSUER_ID: iss-local
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_RA_CERT_PATH: /etc/certctl/scep/ra.crt
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_RA_KEY_PATH: /etc/certctl/scep/ra.key
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_ENABLED: "true"
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH: /etc/certctl/scep/intune_trust_anchor.pem
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE: https://localhost:8443/scep/e2eintune
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CHALLENGE_VALIDITY: 60m
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CLOCK_SKEW_TOLERANCE: 60s
|
||||||
|
CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_PER_DEVICE_RATE_LIMIT_24H: 3
|
||||||
|
|
||||||
# Dynamic issuer/target config encryption (M34/M35)
|
# Dynamic issuer/target config encryption (M34/M35)
|
||||||
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!!
|
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-32chars!!
|
||||||
|
|
||||||
@@ -305,6 +326,15 @@ services:
|
|||||||
# agent mounts the same host path at the same container path (see below)
|
# agent mounts the same host path at the same container path (see below)
|
||||||
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
|
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
|
||||||
- ./test/certs:/etc/certctl/tls:ro
|
- ./test/certs:/etc/certctl/tls:ro
|
||||||
|
# SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance: the
|
||||||
|
# e2eintune profile's RA cert/key + Intune Connector trust anchor
|
||||||
|
# PEM. The PEM is the deterministic public cert matching the test-
|
||||||
|
# side private key in deploy/test/scep_intune_e2e_test.go (re-run
|
||||||
|
# `go test -tags integration -run='^TestRegenerateE2EIntuneFixture$'
|
||||||
|
# -update-fixture ./deploy/test/...` to regenerate after a seed
|
||||||
|
# change). RA cert/key live alongside; tls-init container generates
|
||||||
|
# them at boot.
|
||||||
|
- ./test/fixtures:/etc/certctl/scep:ro
|
||||||
networks:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
ipv4_address: 10.30.50.6
|
ipv4_address: 10.30.50.6
|
||||||
|
|||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# deploy/test/fixtures — integration-test material
|
||||||
|
|
||||||
|
This folder holds the fixture material that
|
||||||
|
`deploy/docker-compose.test.yml` mounts into the certctl container's
|
||||||
|
`/etc/certctl/scep/` for the SCEP-RFC-8894 + Intune integration test
|
||||||
|
suite. Test-only material; **do not use in production**.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Generated by | Purpose |
|
||||||
|
| ---- | ------------ | ------- |
|
||||||
|
| `intune_trust_anchor.pem` | `deploy/test/scep_intune_e2e_test.go::generateE2EIntuneTrustAnchor` (deterministic ECDSA-P256 from `e2eintuneSeed`) | Mounted at `CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH`. The matching private key is re-derived inside the integration test from the same deterministic seed, so the test can mint valid Intune challenges that the running container accepts. |
|
||||||
|
| `ra.crt` + `ra.key` | `setup-trust.sh` at compose boot OR generated once and committed | RA cert + private key the SCEP server uses to decrypt EnvelopedData per RFC 8894 §3.2.2. Mode 0600 enforced on `ra.key` by `preflightSCEPRACertKey`. |
|
||||||
|
|
||||||
|
## Regeneration
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Trust anchor (deterministic — re-run produces byte-identical PEM):
|
||||||
|
cd certctl && go test -tags integration \
|
||||||
|
-run='^TestRegenerateE2EIntuneFixture$' -update-fixture \
|
||||||
|
./deploy/test/...
|
||||||
|
|
||||||
|
# RA pair (one-off — committed):
|
||||||
|
openssl ecparam -genkey -name prime256v1 -noout \
|
||||||
|
-out deploy/test/fixtures/ra.key && chmod 600 deploy/test/fixtures/ra.key
|
||||||
|
openssl req -new -x509 -key deploy/test/fixtures/ra.key \
|
||||||
|
-days 3650 -subj '/CN=certctl-test-ra' \
|
||||||
|
-out deploy/test/fixtures/ra.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why these are committed (test-only material)
|
||||||
|
|
||||||
|
The integration test runs against the running container and needs to
|
||||||
|
mint Intune challenges that the container's trust anchor pool
|
||||||
|
recognizes. The deterministic-key approach gives us:
|
||||||
|
|
||||||
|
- A static PEM the operator can grep + inspect.
|
||||||
|
- A test-side private key derived in-process so we don't commit a
|
||||||
|
raw private key file.
|
||||||
|
|
||||||
|
Real production deploys MUST NOT use this trust anchor — the matching
|
||||||
|
private key is in the certctl source tree and effectively public.
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance
|
||||||
|
// (deploy/test/ integration variant). Closed in the 2026-04-29
|
||||||
|
// audit-closure bundle (Phase I).
|
||||||
|
//
|
||||||
|
// What this test does:
|
||||||
|
//
|
||||||
|
// - Boots ON TOP OF the live docker-compose.test.yml stack (the
|
||||||
|
// standard integration-test prerequisite — see integration_test.go
|
||||||
|
// for the same precedent). The compose file mounts a deterministic
|
||||||
|
// Connector signing-cert PEM into the certctl container and sets
|
||||||
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_ENABLED=true +
|
||||||
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH +
|
||||||
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE.
|
||||||
|
// - Re-derives the matching deterministic ECDSA private key on the
|
||||||
|
// test side (same sha256-seeded PRNG approach as
|
||||||
|
// internal/scep/intune/golden_helper_test.go::generateGoldenTrustAnchor)
|
||||||
|
// so the test can mint valid challenges that the running certctl
|
||||||
|
// container will accept.
|
||||||
|
// - Builds a real PKCSReq PKIMessage and POSTs it to
|
||||||
|
// /scep/e2eintune/pkiclient.exe?operation=PKIOperation over HTTPS.
|
||||||
|
// - Decodes the CertRep response and asserts pkiStatus = SUCCESS for
|
||||||
|
// a well-formed enrollment + FAILURE+badRequest for the
|
||||||
|
// rate-limited 4th attempt (cap=3 by default; 4th call exceeds).
|
||||||
|
//
|
||||||
|
// Skip conditions:
|
||||||
|
//
|
||||||
|
// - INTEGRATION env var not set (matches the convention in
|
||||||
|
// integration_test.go::TestMain).
|
||||||
|
// - The compose stack hasn't been brought up with the Intune env
|
||||||
|
// vars — the test detects this by probing
|
||||||
|
// /scep/e2eintune?operation=GetCACaps and skipping if the route
|
||||||
|
// returns 404.
|
||||||
|
//
|
||||||
|
// CI runs this in the same job that already runs integration_test.go;
|
||||||
|
// the docker-compose.test.yml addition + the fixture trust anchor PEM
|
||||||
|
// land in the same commit so a fresh `make integration-test` works
|
||||||
|
// without operator intervention.
|
||||||
|
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// e2eintuneSeed is the deterministic seed for the integration-test
|
||||||
|
// trust anchor key. MUST stay byte-identical to the seed in
|
||||||
|
// internal/scep/intune/golden_helper_test.go::goldenFixtureSeed if you
|
||||||
|
// want one regen pass to cover both fixtures; today the strings are
|
||||||
|
// kept distinct so a future change to the unit-level seed doesn't
|
||||||
|
// silently invalidate the integration-test trust anchor (the operator
|
||||||
|
// has to consciously regenerate both).
|
||||||
|
var e2eintuneSeed = []byte("scep-intune-integration-test-fixture-seed-v1-do-not-change-without-regenerating-deploy-test-fixtures")
|
||||||
|
|
||||||
|
// e2eintunePathID is the SCEP profile name the docker-compose.test.yml
|
||||||
|
// configures for this test. Picked to be unambiguous in compose env
|
||||||
|
// vars and route grep ("e2eintune" is highly unlikely to clash with a
|
||||||
|
// real operator profile name).
|
||||||
|
const e2eintunePathID = "e2eintune"
|
||||||
|
|
||||||
|
// e2eintuneAudience MUST match
|
||||||
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE in
|
||||||
|
// docker-compose.test.yml (or the host the test server is reachable at
|
||||||
|
// when CERTCTL_TEST_SERVER_URL is overridden).
|
||||||
|
const e2eintuneAudience = "https://localhost:8443/scep/e2eintune"
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_Integration runs the full PKCSReq path
|
||||||
|
// against the live docker-compose certctl container. Asserts the
|
||||||
|
// CertRep wire shape is SUCCESS for a well-formed enrollment.
|
||||||
|
func TestSCEPIntuneEnrollment_Integration(t *testing.T) {
|
||||||
|
requireIntuneIntegrationStack(t)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
connectorKey, _ := generateE2EIntuneTrustAnchor(t)
|
||||||
|
cli := newTestClient()
|
||||||
|
|
||||||
|
// 1. Mint a valid challenge signed by the deterministic Connector key.
|
||||||
|
challenge := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, "integration-nonce-001"))
|
||||||
|
|
||||||
|
// 2. Build the PKIMessage with the challenge embedded.
|
||||||
|
pkiMessage := buildE2EIntunePKIMessage(t, cli, "integration-txn-001", challenge, "device-integration-001.example.com")
|
||||||
|
|
||||||
|
// 3. POST + assert SUCCESS.
|
||||||
|
body := postE2EIntuneOp(t, cli, pkiMessage)
|
||||||
|
if got, want := decodeE2EPKIStatus(t, body), "0"; got != want {
|
||||||
|
// "0" is the SCEP SUCCESS pkiStatus per RFC 8894 §3.3.2.1.
|
||||||
|
t.Fatalf("integration enrollment: pkiStatus = %q, want %q (SUCCESS)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_RateLimited_Integration drives 4
|
||||||
|
// PKIMessages for the same (Subject, Issuer) past the documented
|
||||||
|
// cap=3 default. The 4th MUST be rejected with FAILURE+badRequest.
|
||||||
|
func TestSCEPIntuneEnrollment_RateLimited_Integration(t *testing.T) {
|
||||||
|
requireIntuneIntegrationStack(t)
|
||||||
|
|
||||||
|
connectorKey, _ := generateE2EIntuneTrustAnchor(t)
|
||||||
|
cli := newTestClient()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// First 3 enrollments succeed (cap=3 → ≤3 in 24h).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
nonce := fmt.Sprintf("integration-rate-allow-%d", i)
|
||||||
|
ch := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, nonce))
|
||||||
|
txn := fmt.Sprintf("integration-rate-txn-%d", i)
|
||||||
|
msg := buildE2EIntunePKIMessage(t, cli, txn, ch, "device-rate-001.example.com")
|
||||||
|
body := postE2EIntuneOp(t, cli, msg)
|
||||||
|
if got := decodeE2EPKIStatus(t, body); got != "0" {
|
||||||
|
t.Fatalf("integration rate-limited test: attempt %d/3 SHOULD succeed, got pkiStatus=%q", i+1, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th attempt for the same (Subject, Issuer) MUST be rate-limited.
|
||||||
|
tripCh := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, "integration-rate-deny-4"))
|
||||||
|
tripMsg := buildE2EIntunePKIMessage(t, cli, "integration-rate-txn-deny", tripCh, "device-rate-001.example.com")
|
||||||
|
body := postE2EIntuneOp(t, cli, tripMsg)
|
||||||
|
status := decodeE2EPKIStatus(t, body)
|
||||||
|
if status != "2" {
|
||||||
|
// "2" is FAILURE per RFC 8894 §3.3.2.1.
|
||||||
|
t.Fatalf("integration rate-limited 4th attempt: pkiStatus = %q, want %q (FAILURE)", status, "2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireIntuneIntegrationStack short-circuits the test when the
|
||||||
|
// integration stack hasn't been started OR hasn't been configured
|
||||||
|
// with the e2eintune profile (the operator only enabled the legacy
|
||||||
|
// integration_test.go set, not this one). Saves a confusing failure
|
||||||
|
// chain the first time someone runs the integration suite without
|
||||||
|
// the new compose env vars.
|
||||||
|
func requireIntuneIntegrationStack(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cli := newTestClient()
|
||||||
|
resp, err := cli.http.Get(serverURL + "/scep/" + e2eintunePathID + "?operation=GetCACaps")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("integration stack not reachable at %s: %v — start docker-compose.test.yml first", serverURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
t.Skipf("/scep/%s not configured — see deploy/docker-compose.test.yml for the e2eintune profile env vars", e2eintunePathID)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Skipf("/scep/%s GetCACaps returned %d — Intune profile may not be enabled in compose env", e2eintunePathID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "SCEPStandard") {
|
||||||
|
t.Skipf("/scep/%s GetCACaps body=%q does NOT advertise SCEPStandard — Intune profile may be misconfigured", e2eintunePathID, string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Deterministic trust-anchor key generation. MUST match what the
|
||||||
|
// docker-compose.test.yml mounts as the Connector trust anchor PEM.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// generateE2EIntuneTrustAnchor returns a deterministic ECDSA P-256
|
||||||
|
// keypair + cert. The committed
|
||||||
|
// deploy/test/fixtures/intune_trust_anchor.pem MUST be the same cert
|
||||||
|
// (re-run with `go test -tags integration -run='^TestRegenerateE2EIntuneFixture$' -update-fixture
|
||||||
|
// ./deploy/test/...` to refresh after a seed change).
|
||||||
|
func generateE2EIntuneTrustAnchor(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
prng := newE2EDeterministicReader(e2eintuneSeed)
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), prng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deterministic ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "intune-connector-integration-fixture"},
|
||||||
|
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
NotAfter: time.Date(2055, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(prng, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deterministic CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return key, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// signE2EIntuneChallenge builds a JWT-shape ES256 challenge using the
|
||||||
|
// deterministic Connector key. Mirrors
|
||||||
|
// internal/api/handler/scep_intune_e2e_test.go::signIntuneChallengeES256
|
||||||
|
// but lives in the integration_test package (no shared imports across
|
||||||
|
// internal/ and deploy/test/).
|
||||||
|
func signE2EIntuneChallenge(t *testing.T, key *ecdsa.PrivateKey, payload map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
hdr, _ := json.Marshal(map[string]string{"alg": "ES256", "typ": "JWT"})
|
||||||
|
pl, _ := json.Marshal(payload)
|
||||||
|
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||||
|
base64.RawURLEncoding.EncodeToString(pl)
|
||||||
|
h := sha256.Sum256([]byte(signingInput))
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.Sign: %v", err)
|
||||||
|
}
|
||||||
|
rb, sb := r.Bytes(), s.Bytes()
|
||||||
|
sig := make([]byte, 64)
|
||||||
|
copy(sig[32-len(rb):], rb)
|
||||||
|
copy(sig[64-len(sb):], sb)
|
||||||
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eIntuneClaim returns the v1 challenge payload shape that matches
|
||||||
|
// a CSR with CN=device-integration-001.example.com (or whatever CN the
|
||||||
|
// caller passes to buildE2EIntunePKIMessage).
|
||||||
|
func e2eIntuneClaim(now time.Time, nonce string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"iss": "intune-connector-integration-fixture",
|
||||||
|
"sub": "device-guid-integration-001",
|
||||||
|
"aud": e2eintuneAudience,
|
||||||
|
"iat": now.Add(-1 * time.Minute).Unix(),
|
||||||
|
"exp": now.Add(59 * time.Minute).Unix(),
|
||||||
|
"nonce": nonce,
|
||||||
|
"device_name": "device-integration-001.example.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PKIMessage builder. Mirrors the in-tree handler test's helpers but
|
||||||
|
// stripped down for the integration test's hermetic needs (single profile,
|
||||||
|
// AES-256-CBC content encryption, fixture RA cert fetched from /scep/<pathID>?operation=GetCACert).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// buildE2EIntunePKIMessage fetches the running container's RA cert via
|
||||||
|
// GetCACert (which doubles as the cert clients encrypt the CSR's
|
||||||
|
// content-encryption key to per RFC 8894 §3.2.2), builds an
|
||||||
|
// EnvelopedData around an AES-256-CBC-encrypted CSR, then wraps the
|
||||||
|
// EnvelopedData in a SignedData with a transient signerInfo signature.
|
||||||
|
func buildE2EIntunePKIMessage(t *testing.T, cli *testClient, transactionID, challengePassword, csrCN string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Fetch the RA cert from GetCACert.
|
||||||
|
resp, err := cli.http.Get(serverURL + "/scep/" + e2eintunePathID + "?operation=GetCACert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCACert: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raCertBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read GetCACert: %v", err)
|
||||||
|
}
|
||||||
|
raCert, err := parseGetCACertForE2EIntune(raCertBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse RA cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a transient device key + cert (the CSR's signer + the
|
||||||
|
// signerInfo's signer; production devices often use one key for
|
||||||
|
// both).
|
||||||
|
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("device key: %v", err)
|
||||||
|
}
|
||||||
|
deviceCert := selfSignedRSACertForE2EIntune(t, deviceKey, "device-transient-integration")
|
||||||
|
|
||||||
|
csrDER := buildE2EIntuneCSR(t, deviceKey, csrCN, challengePassword)
|
||||||
|
|
||||||
|
symKey := bytes.Repeat([]byte{0x42}, 32) // AES-256
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand iv: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext := aesCBCEncryptForE2EIntune(t, symKey, iv, csrDER)
|
||||||
|
|
||||||
|
rsaPub, ok := raCert.PublicKey.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("RA cert public key is %T, want *rsa.PublicKey", raCert.PublicKey)
|
||||||
|
}
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa encrypt symKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelopedData := buildEnvelopedDataForE2EIntune(t, raCert, encryptedKey, iv, ciphertext)
|
||||||
|
signedData := buildSignedDataForE2EIntune(t, deviceKey, deviceCert, transactionID, envelopedData)
|
||||||
|
return signedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// postE2EIntuneOp POSTs the PKIMessage to the running certctl container
|
||||||
|
// and returns the raw response body. Fails the test on non-200 because
|
||||||
|
// every RFC 8894 PKIOperation MUST return a CertRep PKIMessage even on
|
||||||
|
// failure — anything other than 200 means the handler choked.
|
||||||
|
func postE2EIntuneOp(t *testing.T, cli *testClient, pkiMessage []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
url := serverURL + "/scep/" + e2eintunePathID + "?operation=PKIOperation"
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(pkiMessage))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-pki-message")
|
||||||
|
resp, err := cli.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post PKIOperation: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep on every PKIOperation including failures", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeE2EPKIStatus extracts the SCEP pkiStatus auth-attribute from
|
||||||
|
// a CertRep PKIMessage. Returns the printable-string value ("0" =
|
||||||
|
// SUCCESS, "2" = FAILURE, "3" = PENDING per RFC 8894 §3.3.2.1).
|
||||||
|
//
|
||||||
|
// This is a minimal CMS SignedData walker — we don't pull in the
|
||||||
|
// internal/pkcs7 package because deploy/test/ is intentionally a
|
||||||
|
// stand-alone package. The walker hunts for the OID
|
||||||
|
// 2.16.840.1.113733.1.9.3 (id-attribute-pkiStatus, RFC 8894 §3.3.2.1)
|
||||||
|
// and returns its first SET-member value as a string.
|
||||||
|
func decodeE2EPKIStatus(t *testing.T, certRepDER []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
// pkiStatus OID is 2.16.840.1.113733.1.9.3 → DER:
|
||||||
|
// 06 0a 60 86 48 01 86 f8 45 01 09 03
|
||||||
|
// Search the certRep DER for this byte pattern; the next 2 bytes
|
||||||
|
// after the OID land in the auth-attr's SET ("31 ?? ..."), and the
|
||||||
|
// pkiStatus value is a PrintableString inside.
|
||||||
|
pkiStatusOID := []byte{0x06, 0x0a, 0x60, 0x86, 0x48, 0x01, 0x86, 0xf8, 0x45, 0x01, 0x09, 0x03}
|
||||||
|
idx := bytes.Index(certRepDER, pkiStatusOID)
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("decodeE2EPKIStatus: pkiStatus OID not found in CertRep (body len=%d)", len(certRepDER))
|
||||||
|
}
|
||||||
|
// After the OID DER (12 bytes), expect SET (0x31) of length L,
|
||||||
|
// then PrintableString (0x13) of length M, then the M chars.
|
||||||
|
cursor := idx + len(pkiStatusOID)
|
||||||
|
if cursor+4 >= len(certRepDER) {
|
||||||
|
t.Fatalf("decodeE2EPKIStatus: truncated DER after pkiStatus OID")
|
||||||
|
}
|
||||||
|
if certRepDER[cursor] != 0x31 {
|
||||||
|
t.Fatalf("decodeE2EPKIStatus: expected SET tag 0x31 after OID, got 0x%02x", certRepDER[cursor])
|
||||||
|
}
|
||||||
|
// Skip SET tag + length byte.
|
||||||
|
cursor += 2
|
||||||
|
if certRepDER[cursor] != 0x13 {
|
||||||
|
t.Fatalf("decodeE2EPKIStatus: expected PrintableString tag 0x13, got 0x%02x", certRepDER[cursor])
|
||||||
|
}
|
||||||
|
strLen := int(certRepDER[cursor+1])
|
||||||
|
cursor += 2
|
||||||
|
return string(certRepDER[cursor : cursor+strLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Deterministic PRNG. Replicates the sha256-counter pattern from
|
||||||
|
// internal/scep/intune/golden_helper_test.go::deterministicReader so
|
||||||
|
// the integration test can derive the SAME ECDSA key bytes from the
|
||||||
|
// same seed. No shared imports across the internal/ and deploy/test/
|
||||||
|
// boundaries.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type e2eDeterministicReader struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state []byte
|
||||||
|
cursor int
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newE2EDeterministicReader(seed []byte) *e2eDeterministicReader {
|
||||||
|
return &e2eDeterministicReader{state: append([]byte(nil), seed...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *e2eDeterministicReader) Read(p []byte) (int, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
for n := 0; n < len(p); {
|
||||||
|
if d.cursor >= len(d.buf) {
|
||||||
|
h := sha256.Sum256(append(d.state, e2eByteCounter(len(p)+n)...))
|
||||||
|
d.buf = h[:]
|
||||||
|
d.cursor = 0
|
||||||
|
d.state = d.buf
|
||||||
|
}
|
||||||
|
c := copy(p[n:], d.buf[d.cursor:])
|
||||||
|
n += c
|
||||||
|
d.cursor += c
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func e2eByteCounter(i int) []byte {
|
||||||
|
out := make([]byte, 8)
|
||||||
|
for k := 0; k < 8; k++ {
|
||||||
|
out[k] = byte(i >> (8 * k))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CMS / SCEP byte builders. Stripped-down equivalents of
|
||||||
|
// internal/pkcs7/{enveloped,signedinfo}.go for the integration test's
|
||||||
|
// hermetic needs. Distinct names from the in-tree helpers (no import
|
||||||
|
// crossing internal/ → deploy/test/).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func parseGetCACertForE2EIntune(body []byte) (*x509.Certificate, error) {
|
||||||
|
// Try raw DER first.
|
||||||
|
if cert, err := x509.ParseCertificate(body); err == nil {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
// Try PEM fallback.
|
||||||
|
if block, _ := pem.Decode(body); block != nil && block.Type == "CERTIFICATE" {
|
||||||
|
return x509.ParseCertificate(block.Bytes)
|
||||||
|
}
|
||||||
|
// Try PKCS#7 SignedData certs-only.
|
||||||
|
type signedData struct {
|
||||||
|
Version int
|
||||||
|
DigestAlgorithms asn1.RawValue
|
||||||
|
ContentInfo asn1.RawValue
|
||||||
|
Certificates asn1.RawValue `asn1:"optional,implicit,tag:0"`
|
||||||
|
}
|
||||||
|
var outer struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"explicit,tag:0"`
|
||||||
|
}
|
||||||
|
if _, err := asn1.Unmarshal(body, &outer); err == nil {
|
||||||
|
var sd signedData
|
||||||
|
if _, err := asn1.Unmarshal(outer.Content.Bytes, &sd); err == nil {
|
||||||
|
if cert, err := x509.ParseCertificate(sd.Certificates.Bytes); err == nil {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("could not parse GetCACert response (len=%d)", len(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfSignedRSACertForE2EIntune(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, _ := x509.ParseCertificate(der)
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildE2EIntuneCSR(t *testing.T, key *rsa.PrivateKey, cn, challengePassword string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
tmpl := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
Attributes: []pkix.AttributeTypeAndValueSET{
|
||||||
|
{
|
||||||
|
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||||
|
Value: [][]pkix.AttributeTypeAndValue{
|
||||||
|
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||||
|
}
|
||||||
|
return der
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesCBCEncryptForE2EIntune(t *testing.T, key, iv, plaintext []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes.NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
bs := block.BlockSize()
|
||||||
|
padLen := bs - len(plaintext)%bs
|
||||||
|
padded := append([]byte{}, plaintext...)
|
||||||
|
for i := 0; i < padLen; i++ {
|
||||||
|
padded = append(padded, byte(padLen))
|
||||||
|
}
|
||||||
|
enc := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
out := make([]byte, len(padded))
|
||||||
|
enc.CryptBlocks(out, padded)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1WrapForE2EIntune wraps body in an ASN.1 TLV with the given tag
|
||||||
|
// and a definite-length encoding. Mirrors the in-tree
|
||||||
|
// internal/pkcs7.ASN1Wrap helper but stays inside this package (no
|
||||||
|
// cross-package import).
|
||||||
|
func asn1WrapForE2EIntune(tag byte, body []byte) []byte {
|
||||||
|
var lenBytes []byte
|
||||||
|
switch {
|
||||||
|
case len(body) < 128:
|
||||||
|
lenBytes = []byte{byte(len(body))}
|
||||||
|
case len(body) < 256:
|
||||||
|
lenBytes = []byte{0x81, byte(len(body))}
|
||||||
|
case len(body) < 65536:
|
||||||
|
lenBytes = []byte{0x82, byte(len(body) >> 8), byte(len(body))}
|
||||||
|
default:
|
||||||
|
lenBytes = []byte{0x83, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
|
||||||
|
}
|
||||||
|
out := append([]byte{tag}, lenBytes...)
|
||||||
|
return append(out, body...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDs used in the integration-test PKIMessage builders.
|
||||||
|
var (
|
||||||
|
oidRSAEncryptionE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1}
|
||||||
|
oidAES256CBCE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42}
|
||||||
|
oidSHA256E2E = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
|
||||||
|
oidRSAWithSHA256E2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
|
||||||
|
oidContentTypeE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
|
||||||
|
oidMessageDigestE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
|
||||||
|
oidSCEPMessageTypeE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2}
|
||||||
|
oidSCEPTransactionE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7}
|
||||||
|
oidSCEPSenderNonceE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5}
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildEnvelopedDataForE2EIntune(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
serialDER, err := asn1.Marshal(raCert.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal serial: %v", err)
|
||||||
|
}
|
||||||
|
risBody := append([]byte{}, raCert.RawIssuer...)
|
||||||
|
risBody = append(risBody, serialDER...)
|
||||||
|
risBytes := asn1WrapForE2EIntune(0x30, risBody)
|
||||||
|
|
||||||
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: oidRSAEncryptionE2E, Parameters: asn1.NullRawValue}
|
||||||
|
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal keyEncAlg: %v", err)
|
||||||
|
}
|
||||||
|
encryptedKeyBytes := asn1WrapForE2EIntune(0x04, encryptedKey)
|
||||||
|
|
||||||
|
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||||
|
ktriBody = append(ktriBody, risBytes...)
|
||||||
|
ktriBody = append(ktriBody, keyEncAlgBytes...)
|
||||||
|
ktriBody = append(ktriBody, encryptedKeyBytes...)
|
||||||
|
ktriBytes := asn1WrapForE2EIntune(0x30, ktriBody)
|
||||||
|
recipientInfosBytes := asn1WrapForE2EIntune(0x31, ktriBytes)
|
||||||
|
|
||||||
|
ivOctet := asn1WrapForE2EIntune(0x04, iv)
|
||||||
|
contentAlg := pkix.AlgorithmIdentifier{
|
||||||
|
Algorithm: oidAES256CBCE2E,
|
||||||
|
Parameters: asn1.RawValue{FullBytes: ivOctet},
|
||||||
|
}
|
||||||
|
contentAlgBytes, err := asn1.Marshal(contentAlg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal contentAlg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encContentField := asn1WrapForE2EIntune(0x80, ciphertext)
|
||||||
|
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
eciBody := append([]byte{}, oidDataBytes...)
|
||||||
|
eciBody = append(eciBody, contentAlgBytes...)
|
||||||
|
eciBody = append(eciBody, encContentField...)
|
||||||
|
eciBytes := asn1WrapForE2EIntune(0x30, eciBody)
|
||||||
|
|
||||||
|
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||||
|
envBody = append(envBody, recipientInfosBytes...)
|
||||||
|
envBody = append(envBody, eciBytes...)
|
||||||
|
innerEnvBytes := asn1WrapForE2EIntune(0x30, envBody)
|
||||||
|
|
||||||
|
// Wrap in a ContentInfo: SEQ { OID envelopedData, [0] EXPLICIT inner }.
|
||||||
|
envelopedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}
|
||||||
|
contentInfoBody := append([]byte{}, envelopedDataOID...)
|
||||||
|
contentInfoBody = append(contentInfoBody, asn1WrapForE2EIntune(0xa0, innerEnvBytes)...)
|
||||||
|
return asn1WrapForE2EIntune(0x30, contentInfoBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSignedDataForE2EIntune(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, transactionID string, encapContent []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
contentDigest := sha256.Sum256(encapContent)
|
||||||
|
|
||||||
|
var attrSetBody []byte
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidContentTypeE2E, asn1WrapForE2EIntune(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}))...) // envelopedData
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidMessageDigestE2E, asn1WrapForE2EIntune(0x04, contentDigest[:]))...)
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPMessageTypeE2E, asn1WrapForE2EIntune(0x13, []byte("19")))...) // PKCSReq=19
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPTransactionE2E, asn1WrapForE2EIntune(0x13, []byte(transactionID)))...)
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPSenderNonceE2E, asn1WrapForE2EIntune(0x04, []byte("0123456789abcdef")))...)
|
||||||
|
|
||||||
|
signedAttrsForSig := asn1WrapForE2EIntune(0x31, attrSetBody)
|
||||||
|
digest := sha256.Sum256(signedAttrsForSig)
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, signerKey, 5, digest[:]) // 5 = crypto.SHA256
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionBytes := []byte{0x02, 0x01, 0x01}
|
||||||
|
serialDER, _ := asn1.Marshal(signerCert.SerialNumber)
|
||||||
|
sidBody := append([]byte{}, signerCert.RawIssuer...)
|
||||||
|
sidBody = append(sidBody, serialDER...)
|
||||||
|
sidBytes := asn1WrapForE2EIntune(0x30, sidBody)
|
||||||
|
|
||||||
|
digestAlg := pkix.AlgorithmIdentifier{Algorithm: oidSHA256E2E, Parameters: asn1.NullRawValue}
|
||||||
|
digestAlgBytes, _ := asn1.Marshal(digestAlg)
|
||||||
|
|
||||||
|
signedAttrsImplicit := asn1WrapForE2EIntune(0xa0, attrSetBody)
|
||||||
|
|
||||||
|
sigAlg := pkix.AlgorithmIdentifier{Algorithm: oidRSAWithSHA256E2E, Parameters: asn1.NullRawValue}
|
||||||
|
sigAlgBytes, _ := asn1.Marshal(sigAlg)
|
||||||
|
sigOctet := asn1WrapForE2EIntune(0x04, sig)
|
||||||
|
|
||||||
|
signerInfoBody := append([]byte{}, versionBytes...)
|
||||||
|
signerInfoBody = append(signerInfoBody, sidBytes...)
|
||||||
|
signerInfoBody = append(signerInfoBody, digestAlgBytes...)
|
||||||
|
signerInfoBody = append(signerInfoBody, signedAttrsImplicit...)
|
||||||
|
signerInfoBody = append(signerInfoBody, sigAlgBytes...)
|
||||||
|
signerInfoBody = append(signerInfoBody, sigOctet...)
|
||||||
|
signerInfoBytes := asn1WrapForE2EIntune(0x30, signerInfoBody)
|
||||||
|
signerInfosSet := asn1WrapForE2EIntune(0x31, signerInfoBytes)
|
||||||
|
|
||||||
|
digestAlgsSet := asn1WrapForE2EIntune(0x31, digestAlgBytes)
|
||||||
|
|
||||||
|
envelopedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}
|
||||||
|
innerContent := asn1WrapForE2EIntune(0xa0, encapContent)
|
||||||
|
encapContentInfo := asn1WrapForE2EIntune(0x30, append(envelopedDataOID, innerContent...))
|
||||||
|
|
||||||
|
signerCertWrapped := asn1WrapForE2EIntune(0xa0, signerCert.Raw)
|
||||||
|
|
||||||
|
sdBody := append([]byte{}, versionBytes...)
|
||||||
|
sdBody = append(sdBody, digestAlgsSet...)
|
||||||
|
sdBody = append(sdBody, encapContentInfo...)
|
||||||
|
sdBody = append(sdBody, signerCertWrapped...)
|
||||||
|
sdBody = append(sdBody, signerInfosSet...)
|
||||||
|
innerSDBytes := asn1WrapForE2EIntune(0x30, sdBody)
|
||||||
|
|
||||||
|
signedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
contentInfoBody := append([]byte{}, signedDataOID...)
|
||||||
|
contentInfoBody = append(contentInfoBody, asn1WrapForE2EIntune(0xa0, innerSDBytes)...)
|
||||||
|
return asn1WrapForE2EIntune(0x30, contentInfoBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attrSeqHelperE2E(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
oidBytes, err := asn1.Marshal(oid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal oid: %v", err)
|
||||||
|
}
|
||||||
|
valueSet := asn1WrapForE2EIntune(0x31, value)
|
||||||
|
body := append(oidBytes, valueSet...)
|
||||||
|
return asn1WrapForE2EIntune(0x30, body)
|
||||||
|
}
|
||||||
@@ -831,6 +831,52 @@ The control plane only handles public material: certificates, chains, and CSRs.
|
|||||||
|
|
||||||
**Server keygen mode (`CERTCTL_KEYGEN_MODE=server`, demo only):** The control plane generates RSA-2048 keys server-side within `processRenewalServerKeygen`. Private keys are stored in `certificate_versions.csr_pem`. A log warning is emitted at startup. Use only for Local CA development/demo.
|
**Server keygen mode (`CERTCTL_KEYGEN_MODE=server`, demo only):** The control plane generates RSA-2048 keys server-side within `processRenewalServerKeygen`. Private keys are stored in `certificate_versions.csr_pem`. A log warning is emitted at startup. Use only for Local CA development/demo.
|
||||||
|
|
||||||
|
### Microsoft Intune Connector trust anchor (per-profile, opt-in)
|
||||||
|
|
||||||
|
When the SCEP server is sitting behind a Microsoft Intune Certificate
|
||||||
|
Connector — i.e. certctl is acting as a drop-in NDES replacement —
|
||||||
|
each per-profile dispatcher carries its own **trust anchor pool**:
|
||||||
|
the public certs the operator extracted from the Connector's
|
||||||
|
installation. Every Intune-flavored enrollment goes through:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Per-profile TrustAnchorHolder │
|
||||||
|
│ (RWMutex pool, SIGHUP-reloadable) │
|
||||||
|
└────────────┬────────────────────┘
|
||||||
|
│ Get()
|
||||||
|
▼
|
||||||
|
device → SCEP PKIMessage → handler → SCEPService.dispatchIntuneChallenge
|
||||||
|
│
|
||||||
|
├─► intune.ValidateChallenge (sig + iat/exp + audience)
|
||||||
|
├─► claim.DeviceMatchesCSR (set-equality)
|
||||||
|
├─► intune.ReplayCache.CheckAndInsert
|
||||||
|
├─► intune.PerDeviceRateLimiter.Allow
|
||||||
|
└─► (V3-Pro) ComplianceCheck hook
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
processEnrollment → IssuerConnector
|
||||||
|
```
|
||||||
|
|
||||||
|
The trust anchor file is mode-0600 on disk; certctl loads it at
|
||||||
|
startup via `intune.LoadTrustAnchor` (refuses to boot on empty
|
||||||
|
bundle / parse error / past-`NotAfter` cert) and reloads atomically
|
||||||
|
on `SIGHUP` (mirrors the server TLS-cert hot-reload pattern). A bad
|
||||||
|
reload keeps the OLD pool in place — operators get a recoverable
|
||||||
|
failure window rather than a service-down. The admin GUI's
|
||||||
|
**Intune Monitoring** tab inside the SCEP Administration page (`/scep`)
|
||||||
|
and the parallel admin endpoints
|
||||||
|
(`GET /api/v1/admin/scep/profiles` for the always-present per-profile
|
||||||
|
overview that drives the Profiles tab,
|
||||||
|
`GET /api/v1/admin/scep/intune/stats` for the Intune deep dive,
|
||||||
|
`POST /api/v1/admin/scep/intune/reload-trust` for the SIGHUP-equivalent)
|
||||||
|
are all M-008 admin-gated; non-admin Bearer callers get HTTP 403
|
||||||
|
because the trust-anchor expiries + RA cert expiries + mTLS bundle
|
||||||
|
paths are sensitive operational metadata.
|
||||||
|
|
||||||
|
See [`scep-intune.md`](scep-intune.md) for the full migration playbook
|
||||||
|
+ Microsoft support statement.
|
||||||
|
|
||||||
### CA Signing Abstraction
|
### CA Signing Abstraction
|
||||||
|
|
||||||
The local issuer's CA private key is wrapped behind the `signer.Signer` interface in `internal/crypto/signer/`. Every CA-signing call site — leaf certificate issuance (`x509.CreateCertificate`), CRL generation (`x509.CreateRevocationList`), and OCSP response signing (`ocsp.CreateResponse`) — accesses the key through this interface rather than touching `crypto.Signer` directly. The interface embeds the stdlib `crypto.Signer` and adds a single `Algorithm() Algorithm` method so call sites can pick the matching `x509.SignatureAlgorithm` without reflecting on the concrete key type.
|
The local issuer's CA private key is wrapped behind the `signer.Signer` interface in `internal/crypto/signer/`. Every CA-signing call site — leaf certificate issuance (`x509.CreateCertificate`), CRL generation (`x509.CreateRevocationList`), and OCSP response signing (`ocsp.CreateResponse`) — accesses the key through this interface rather than touching `crypto.Signer` directly. The interface embeds the stdlib `crypto.Signer` and adds a single `Algorithm() Algorithm` method so call sites can pick the matching `x509.SignatureAlgorithm` without reflecting on the concrete key type.
|
||||||
|
|||||||
@@ -331,6 +331,58 @@ Note: EST and SCEP are not connectors — they are protocol handlers (`internal/
|
|||||||
|
|
||||||
**SCEP RA cert + key (post-2026-04-29):** the SCEP server's RFC 8894 path requires an RA cert/key pair (`CERTCTL_SCEP_RA_CERT_PATH` + `CERTCTL_SCEP_RA_KEY_PATH`, mode 0600) — clients encrypt their CSR to the RA cert's public key per RFC 8894 §3.2.2. Multi-profile deployments configure per-profile pairs via `CERTCTL_SCEP_PROFILES=corp,iot` + `CERTCTL_SCEP_PROFILE_<NAME>_RA_*_PATH`. See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the openssl recipe + ChromeOS Admin Console pointer + must-staple per-profile policy.
|
**SCEP RA cert + key (post-2026-04-29):** the SCEP server's RFC 8894 path requires an RA cert/key pair (`CERTCTL_SCEP_RA_CERT_PATH` + `CERTCTL_SCEP_RA_KEY_PATH`, mode 0600) — clients encrypt their CSR to the RA cert's public key per RFC 8894 §3.2.2. Multi-profile deployments configure per-profile pairs via `CERTCTL_SCEP_PROFILES=corp,iot` + `CERTCTL_SCEP_PROFILE_<NAME>_RA_*_PATH`. See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the openssl recipe + ChromeOS Admin Console pointer + must-staple per-profile policy.
|
||||||
|
|
||||||
|
#### Multi-profile SCEP dispatch
|
||||||
|
|
||||||
|
A single certctl deploy can publish multiple SCEP endpoints — one per fleet, one per device class, or one per Connector — by setting `CERTCTL_SCEP_PROFILES=<comma-separated>` and a matching set of `CERTCTL_SCEP_PROFILE_<NAME>_*` environment variables. The router publishes `/scep/<pathID>?operation=...` for every profile whose `<NAME>` appears in the list (or `/scep` for the legacy single-profile shape when `CERTCTL_SCEP_PROFILES` is unset). Each profile carries its OWN issuer binding, RA cert/key pair, challenge password, must-staple policy, optional mTLS sibling route, and optional Microsoft Intune Connector trust anchor — heterogeneous fleets share one server, distinct credentials.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SCEP_PROFILES` | No | — | Comma-separated profile names (e.g. `corp,iot`). When unset, the legacy single-profile config (`CERTCTL_SCEP_*` without the `_PROFILE_<NAME>_` infix) is used. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID` | Yes | — | Issuer connector ID this profile dispatches to (e.g. `iss-local`, `iss-ejbca-corp`). |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_PROFILE_ID` | No | — | Optional certificate profile ID for fine-grained issuance policy. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD` | No | — | Static challenge password for the legacy SCEP auth path. Set to "" when only Intune dynamic challenges are expected. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH` | Yes | — | RA cert PEM path (mode 0600 enforced). |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH` | Yes | — | RA private key PEM path (mode 0600 enforced). |
|
||||||
|
|
||||||
|
See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the full per-profile env-var list and the mTLS / Intune extensions.
|
||||||
|
|
||||||
|
#### SCEP mTLS sibling route (opt-in)
|
||||||
|
|
||||||
|
For deploys that already have a previously-issued certctl client cert and want a stronger renewal binding than the static challenge password, certctl exposes an opt-in mTLS sibling route at `/scep-mtls/<pathID>`. The TLS handshake is configured with `tls.VerifyClientCertIfGiven` against an operator-supplied trust bundle; presented client certs are validated against the bundle before the SCEP handler runs. The standard `/scep/<pathID>` route stays open for new-enrollment devices that don't yet have a client cert.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED` | No | `false` | Set `true` to publish `/scep-mtls/<pathID>` alongside `/scep/<pathID>`. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH` | When MTLS enabled | — | PEM bundle of CAs that may sign client certs. Preflight refuses a missing/empty bundle. |
|
||||||
|
|
||||||
|
See [`legacy-est-scep.md`](legacy-est-scep.md#scep-mtls-sibling-route-phase-65) for the operator recipe + threat-model rationale.
|
||||||
|
|
||||||
|
#### Microsoft Intune Certificate Connector dispatcher
|
||||||
|
|
||||||
|
When a profile has `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true`, certctl validates the Microsoft Intune Certificate Connector's signed-challenge JWS natively as a drop-in NDES replacement (the Intune Connector documents itself as RFC 8894-compliant and works against any RFC 8894 SCEP server). The dispatcher walks parse → JWS signature verify (RS256 + ES256, alg=none rejected) → version dispatch → time bounds with ±tolerance → audience pin → CSR ↔ claim binding → replay cache → per-device rate limit → optional V3-Pro compliance hook. The trust anchor file is reloaded on `SIGHUP` (operator rotates the on-disk PEM, then `kill -HUP <certctl-pid>`); a parse failure during reload keeps the OLD pool so a half-rotation doesn't take Intune down.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED` | No | `false` | Gate the dispatcher. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH` | When enabled | — | PEM bundle of the Connector's signing certs. Preflight refuses a missing/expired bundle. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE` | No | — | Expected `aud` claim (typically the public SCEP URL the Connector calls). Empty disables the audience check. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CHALLENGE_VALIDITY` | No | `60m` | Defense-in-depth cap on top of the challenge's own `exp`. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE` | No | `60s` | ±tolerance on iat/exp checks. Raise on poorly-NTP-synced fleets, lower to enforce strict time. Refused at boot when ≥ `INTUNE_CHALLENGE_VALIDITY`. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H` | No | `3` | Max enrollments per `(claim.Subject, claim.Issuer)` in any rolling 24h window. Zero disables. |
|
||||||
|
|
||||||
|
See [`scep-intune.md`](scep-intune.md) for the full deployment guide — NDES + EJBCA migration playbook, Intune SCEP profile field mapping, trust-anchor extraction recipe, monitoring + Prometheus alert thresholds, and the Microsoft Learn citations operators paste into procurement-team requests.
|
||||||
|
|
||||||
|
#### SCEP probe in network scanner
|
||||||
|
|
||||||
|
The Network Scans GUI surface includes a one-click "Probe SCEP" form that runs a capability + posture check against any reachable SCEP server URL — `GetCACaps` + `GetCACert` (NEVER `PKCSReq`) so the probe is read-only and safe to run against production endpoints. Result fields surface advertised caps (POSTPKIOperation, SHA-256, SHA-512, AES, SCEPStandard, Renewal), CA cert subject + issuer + algorithm + days-to-expiry + chain length, and a probe duration. Results persist to `scep_probe_results` (migration `000021`) and the probe history is paginated under `GET /api/v1/network-scan/scep-probes`. Useful for pre-migration assessment ("what does the existing NDES advertise?") and compliance-posture audits.
|
||||||
|
|
||||||
|
| Endpoint | Auth | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `POST /api/v1/network-scan/scep-probe` | Bearer | Body `{"url":"https://..."}`. Synchronous probe; returns `SCEPProbeResult`. |
|
||||||
|
| `GET /api/v1/network-scan/scep-probes` | Bearer | Recent probe history, paginated `[1, 200]`. |
|
||||||
|
|
||||||
|
The probe goes through the same dual-layer SSRF defense (`validation.ValidateSafeURL` up-front + `SafeHTTPDialContext` at dial time) as the rest of the network scanner. Standalone CLI binary is explicitly deferred — the in-tree network scanner is the only entrypoint today.
|
||||||
|
|
||||||
### Built-in: Vault PKI
|
### Built-in: Vault PKI
|
||||||
|
|
||||||
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
||||||
|
|||||||
@@ -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>_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_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>_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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+80
-4
@@ -420,19 +420,95 @@ challenge+mTLS:
|
|||||||
the password requirement doesn't go away — the password is still
|
the password requirement doesn't go away — the password is still
|
||||||
the application-layer auth boundary).
|
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
|
### Operational notes
|
||||||
|
|
||||||
- **Audit:** every enrollment emits an `audit_event` row with action
|
- **Audit:** every enrollment emits an `audit_event` row with action
|
||||||
`scep_pkcsreq` (initial) or `scep_renewalreq` (renewal); operators
|
`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
|
- **Body-size cap:** `http.MaxBytesReader` middleware caps request
|
||||||
bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are
|
bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are
|
||||||
typically <50KB so the default cap is generous.
|
typically <50KB so the default cap is generous.
|
||||||
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
|
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
|
||||||
plane; there is no plaintext fallback.
|
plane; there is no plaintext fallback.
|
||||||
- **Forward reference:** for Microsoft Intune deployments specifically,
|
- **For Microsoft Intune deployments, see [`scep-intune.md`](scep-intune.md)** —
|
||||||
see [`scep-intune.md`](scep-intune.md) (the doc Phase 11 of the
|
architecture, NDES-replacement migration playbook, Intune SCEP profile
|
||||||
master bundle ships).
|
field mapping, trust-anchor extraction recipe, troubleshooting matrix,
|
||||||
|
operational monitoring, V3-Pro deferrals, and the Microsoft support
|
||||||
|
statement (with Microsoft Learn URLs procurement teams ask for).
|
||||||
|
- **For per-profile SCEP observability** (RA cert expiry countdown,
|
||||||
|
mTLS sibling-route status, challenge-password-set indicator, and
|
||||||
|
the full SCEP audit log filter), the admin GUI page lives at `/scep`
|
||||||
|
with three tabs: **Profiles** (default), **Intune Monitoring**,
|
||||||
|
**Recent Activity**. See `scep-intune.md::Operational monitoring`
|
||||||
|
for the Intune-specific tab inside it.
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
# Microsoft Intune SCEP enrollment via certctl
|
||||||
|
|
||||||
|
> **Status (this document):** Phase 11 of the SCEP RFC 8894 + Intune master
|
||||||
|
> bundle. The behavior described here is shipped on `master` and exercised
|
||||||
|
> end-to-end by `internal/api/handler/scep_intune_e2e_test.go`. The
|
||||||
|
> bundle is V2-free (community edition) — Conditional-Access compliance
|
||||||
|
> gating, native Microsoft Graph integration, and per-tenant trust
|
||||||
|
> anchors are documented under [Limitations](#limitations) as V3-Pro
|
||||||
|
> features.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
certctl is a **drop-in NDES replacement** for Microsoft Intune SCEP fleets.
|
||||||
|
Intune-managed devices keep using the existing Intune Certificate Connector;
|
||||||
|
only the SCEP server URL changes. certctl validates the Connector's
|
||||||
|
signed challenge using its installation signing cert (no Microsoft API
|
||||||
|
calls — the Connector already did that), binds the device claim to the
|
||||||
|
inbound CSR, and issues through whichever certctl issuer connector you
|
||||||
|
have configured (local CA, Vault, EJBCA, ADCS, etc.).
|
||||||
|
|
||||||
|
What you get over NDES:
|
||||||
|
|
||||||
|
- Per-profile SCEP endpoints (`/scep/corp` vs. `/scep/iot` etc.) so a
|
||||||
|
single certctl deploy serves multiple device fleets with distinct
|
||||||
|
challenge passwords + trust anchors.
|
||||||
|
- Audit log entries with the device GUID, claim subject, and CSR
|
||||||
|
binding details — much better forensics than NDES + IIS logs.
|
||||||
|
- Trust anchor reload via `SIGHUP` (no service restart) when the
|
||||||
|
Connector signing cert rotates.
|
||||||
|
- A built-in admin GUI tab (Intune Monitoring) showing per-profile
|
||||||
|
enrollment counters, trust-anchor expiry countdowns, and the recent
|
||||||
|
failures table.
|
||||||
|
- Per-device rate limit (sliding window log keyed by Subject + Issuer)
|
||||||
|
that catches a compromised Connector signing key issuing many
|
||||||
|
different valid challenges for the same device.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
|
||||||
|
│ Intune cloud │──────▶│ Intune Certificate │──────▶│ certctl SCEP │
|
||||||
|
│ │ │ Connector │ │ server │
|
||||||
|
│ (Microsoft) │ │ (customer infra) │ │ (you) │
|
||||||
|
└──────────────┘ └──────────────────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ issuer │
|
||||||
|
│ connector │
|
||||||
|
│ (local CA / │
|
||||||
|
│ Vault / │
|
||||||
|
│ EJBCA / …) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**certctl replaces NDES, not the Connector.** The Intune Certificate
|
||||||
|
Connector is the bridge between the Intune cloud and your on-prem PKI;
|
||||||
|
Microsoft installs and maintains it. What you replace is the
|
||||||
|
**Network Device Enrollment Service** (NDES) — the SCEP server
|
||||||
|
historically deployed on a Windows host, sitting between the Connector
|
||||||
|
and an Active Directory Certificate Services CA. certctl sits in
|
||||||
|
exactly that slot and speaks SCEP RFC 8894 to the Connector.
|
||||||
|
|
||||||
|
### What certctl validates per request
|
||||||
|
|
||||||
|
For every Intune-flavored SCEP request the dispatcher in
|
||||||
|
`internal/service/scep.go::dispatchIntuneChallenge` walks the
|
||||||
|
following gates in order. A failure on any gate produces a CertRep
|
||||||
|
PKIMessage with the documented `pkiStatus`/`failInfo` codes (per RFC
|
||||||
|
8894 §3.2.1.4.5) and increments the corresponding metric counter.
|
||||||
|
|
||||||
|
1. **Shape pre-check** — `looksIntuneShaped(challengePassword)`:
|
||||||
|
length > 200 + exactly two dots. False positives are fine; false
|
||||||
|
negatives on real Intune challenges would route them to the static
|
||||||
|
compare and reject. The pre-check just decides whether to invoke
|
||||||
|
the full validator.
|
||||||
|
2. **JWS signature** — `intune.ValidateChallenge` re-derives the
|
||||||
|
signing input from the raw on-wire bytes (per RFC 7515 §3.1, NOT
|
||||||
|
re-base64-encoded segments) and verifies against every cert in the
|
||||||
|
trust anchor pool. Supports RS256 and ES256 (both fixed-width
|
||||||
|
r||s and ASN.1-DER form). Explicitly rejects `alg=none` and
|
||||||
|
HMAC algs.
|
||||||
|
3. **Version dispatch** — extracts the `version` claim from the
|
||||||
|
payload prelude. v1 (current Connector format, no `version` key)
|
||||||
|
routes to `unmarshalChallengeV1`. Future v2 plugs in a sibling
|
||||||
|
parser without touching the validator.
|
||||||
|
4. **Time bounds** — `now+tolerance ≥ iat AND now-tolerance < exp`.
|
||||||
|
The `±tolerance` window is configurable per profile via
|
||||||
|
`INTUNE_CLOCK_SKEW_TOLERANCE` (default 60s, covers modest clock
|
||||||
|
drift between the Connector host and certctl). Configurable cap on
|
||||||
|
top via `INTUNE_CHALLENGE_VALIDITY` (defense-in-depth against a
|
||||||
|
Connector that mints long-validity challenges). The validator
|
||||||
|
refuses `tolerance ≥ ChallengeValidity` at startup-validation time
|
||||||
|
to keep the cap meaningful.
|
||||||
|
5. **Audience pin** — `claim.aud == INTUNE_AUDIENCE` (skipped when
|
||||||
|
`INTUNE_AUDIENCE` is empty for proxy/load-balancer scenarios).
|
||||||
|
6. **CSR binding** — `claim.DeviceMatchesCSR(csr)` checks
|
||||||
|
set-equality between the claim's `device_name` / `san_dns` /
|
||||||
|
`san_rfc822` / `san_upn` and the CSR's CN + SANs. Set-equality
|
||||||
|
means the CSR carries EXACTLY the claim's values, no extras and
|
||||||
|
no missing.
|
||||||
|
7. **Replay** — `intune.ReplayCache.CheckAndInsert` rejects
|
||||||
|
duplicates within the configured TTL. Sized for 100k entries
|
||||||
|
(covers a ~25 RPS Intune fleet's steady-state).
|
||||||
|
8. **Per-device rate limit** — sliding window log keyed by
|
||||||
|
`(claim.Subject, claim.Issuer)`. Catches a compromised Connector
|
||||||
|
issuing many DIFFERENT valid challenges for the same device. Default
|
||||||
|
3 enrollments per 24h covers legitimate first-cert + recovery +
|
||||||
|
post-wipe.
|
||||||
|
9. **Optional compliance check** — V3-Pro plug-in seam (nil-default
|
||||||
|
no-op). When set, the gate calls Microsoft Graph's compliance API
|
||||||
|
and short-circuits non-compliant devices with FAILURE+BadRequest.
|
||||||
|
|
||||||
|
A request that passes all nine gates flows to
|
||||||
|
`processEnrollment`, which builds the issuance request, calls the
|
||||||
|
configured issuer connector, and emits a CertRep PKIMessage with the
|
||||||
|
issued cert encrypted to the device's transient signing cert per RFC
|
||||||
|
8894 §3.3.2.
|
||||||
|
|
||||||
|
## Migration from NDES + EJBCA (or NDES + ADCS)
|
||||||
|
|
||||||
|
The migration plan below is conservative — install certctl alongside
|
||||||
|
your existing NDES so you can flip Intune profiles fleet-by-fleet
|
||||||
|
without a flag day. Validated against a fresh `docker compose up`
|
||||||
|
stack; the docker-compose.test.yml stack does not currently bake
|
||||||
|
Intune in (Phase 10.2 ships a hermetic in-process e2e test instead),
|
||||||
|
so the production validation step is a manual run-book item.
|
||||||
|
|
||||||
|
1. **Install certctl alongside existing NDES.** Stand up the certctl
|
||||||
|
server on a separate host (or as a Kubernetes deployment) reachable
|
||||||
|
from the Connector host. Use the existing operator-run-book in
|
||||||
|
`docs/tls.md` for the TLS bootstrap.
|
||||||
|
2. **Configure a per-profile SCEP endpoint.** Pick a path id (e.g.
|
||||||
|
`corp` — referenced as `<NAME>` below; the value gets uppercased
|
||||||
|
for the env-var key and lowercased for the URL path) and set:
|
||||||
|
|
||||||
|
```
|
||||||
|
CERTCTL_SCEP_ENABLED=true
|
||||||
|
CERTCTL_SCEP_PROFILES=corp
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID=iss-local # or your existing issuer
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD=<random> # Intune still requires this
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH=/etc/certctl/ra-corp.pem
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH=/etc/certctl/ra-corp.key
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint will be served at `https://certctl.example.com/scep/corp`
|
||||||
|
— the URL path uses the lowercased name and the env-var keys use
|
||||||
|
the uppercased form. Concrete env-var name mappings are listed in
|
||||||
|
[`features.md`](features.md).
|
||||||
|
3. **Extract the Intune Connector's signing cert.** On the Connector
|
||||||
|
host (Windows), the Connector's installation creates a self-signed
|
||||||
|
cert in the local machine's `Personal` cert store with subject
|
||||||
|
`CN=Microsoft Intune Certificate Connector` (path documented by
|
||||||
|
Microsoft — see Microsoft Learn link in the
|
||||||
|
[Microsoft support statement](#microsoft-support-statement) below).
|
||||||
|
Export the public cert (no private key) as a base64 `.cer` file.
|
||||||
|
4. **Configure the trust anchor.** Copy the `.cer` to the certctl host
|
||||||
|
(or mount via your secret manager) and set:
|
||||||
|
|
||||||
|
```
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH=/etc/certctl/intune-corp.pem
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE=https://certctl.example.com/scep/corp
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CHALLENGE_VALIDITY=60m
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE=60s # ±tolerance on iat/exp; raise on poorly-NTP-synced fleets, lower to enforce strict time
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H=3
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart certctl. The startup preflight refuses to boot if the
|
||||||
|
trust anchor file is missing, unparseable, or contains an expired
|
||||||
|
cert — failure is loud at boot rather than silent at request time.
|
||||||
|
5. **Configure the issuer connector.** If you're keeping EJBCA,
|
||||||
|
point `CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID` at your EJBCA issuer
|
||||||
|
profile (see `docs/connectors.md`). For a clean cut-over to the
|
||||||
|
built-in local CA, follow `docs/tls.md` to bootstrap a sub-CA cert.
|
||||||
|
6. **Migrate one Intune SCEP profile to certctl.** In the Intune
|
||||||
|
admin center, edit the SCEP profile for a small canary device
|
||||||
|
group and update the SCEP server URL to
|
||||||
|
`https://certctl.example.com/scep/corp`. Push the profile and
|
||||||
|
wait for the canary devices to rotate (24-48h).
|
||||||
|
7. **Verify enrollment.** Open the certctl admin GUI's
|
||||||
|
[SCEP Intune Monitoring tab](#operational-monitoring) and watch
|
||||||
|
the `success` counter tick on the `corp` profile card. The
|
||||||
|
`recent failures` table surfaces any rejected enrollments with
|
||||||
|
the exact reason (e.g. `signature_invalid`, `claim_mismatch`).
|
||||||
|
8. **Roll out the rest of the fleet.** Once the canary is clean,
|
||||||
|
migrate the remaining Intune SCEP profiles in batches.
|
||||||
|
9. **Decommission NDES.** After all fleets are migrated and a few
|
||||||
|
renewal cycles have completed cleanly, take down the NDES role
|
||||||
|
and the IIS site. The existing certs continue to chain to your
|
||||||
|
issuer; only the enrollment path changes.
|
||||||
|
|
||||||
|
## Intune SCEP profile fields → certctl behavior
|
||||||
|
|
||||||
|
The Intune admin center's SCEP profile editor exposes a fixed set of
|
||||||
|
fields. The mapping below is what each field controls relative to
|
||||||
|
certctl's behavior.
|
||||||
|
|
||||||
|
| Intune profile field | certctl behavior |
|
||||||
|
|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| Certificate type | Treated as device or user; surfaces in the claim's `subject` field (device GUID vs. user UPN). certctl doesn't gate on type; the issuer's certificate profile decides. |
|
||||||
|
| Subject name format | Drives the CSR's CN. The Intune Connector sets `claim.device_name` from this value; certctl's CSR-binding gate enforces equality. |
|
||||||
|
| Subject alternative name | Drives the CSR's SAN list. Intune supports DNS / RFC 822 / UPN; certctl's claim binding checks set-equality per dimension. Mismatches surface as `ErrClaimSANDNSMismatch` / `_SANRFC822Mismatch` / `_SANUPNMismatch`. |
|
||||||
|
| Certificate validity period | Honored by the issuer connector. certctl caps via the per-profile `CertificateProfile.MaxTTLSeconds`; the smaller of the two wins. |
|
||||||
|
| Key storage provider | Device-side concern (the Connector negotiates with the device's TPM / Software KSP). certctl never sees the device's private key — it only signs the CSR. |
|
||||||
|
| Key usage / Extended key usage | Honored by the issuer connector via the bound `CertificateProfile.AllowedEKUs`. CSRs requesting an EKU outside the allowed set are rejected by the crypto-policy gate (`ValidateCSRAgainstProfile`). |
|
||||||
|
| Hash algorithm | The CSR's signature hash (SHA-256 typical). The SCEP `GetCACaps` advertises SHA-256 + SHA-512; the device picks. |
|
||||||
|
| SCEP server URL | The endpoint URL the Connector posts to. Set to `https://certctl.example.com/scep/<profile-name>`. |
|
||||||
|
|
||||||
|
## Trust anchor extraction
|
||||||
|
|
||||||
|
The Intune Certificate Connector self-signs an installation cert at
|
||||||
|
install time. To configure certctl, extract this cert (PUBLIC ONLY,
|
||||||
|
no private key) as PEM:
|
||||||
|
|
||||||
|
1. On the Connector host (Windows), open `certlm.msc` (Local Machine
|
||||||
|
Certificate Manager).
|
||||||
|
2. Navigate to `Personal` → `Certificates`. Find the cert with
|
||||||
|
subject `CN=Microsoft Intune Certificate Connector`.
|
||||||
|
3. Right-click → All Tasks → Export. Choose **No, do not export
|
||||||
|
the private key**. Format: **Base-64 encoded X.509 (.CER)**.
|
||||||
|
4. Copy the resulting `.cer` file to the certctl host. Rename to
|
||||||
|
`.pem` (the bytes are identical; certctl's PEM loader accepts
|
||||||
|
either extension).
|
||||||
|
5. Set `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH` to
|
||||||
|
the file path.
|
||||||
|
6. If you have multiple Connectors in HA, repeat steps 1-3 on each
|
||||||
|
and concatenate the PEM blocks into one bundle file.
|
||||||
|
|
||||||
|
When the operator rotates the Connector signing cert (typically once
|
||||||
|
every few years per Microsoft's Connector lifecycle), repeat the
|
||||||
|
extraction, overwrite the on-disk file, then send `SIGHUP` to the
|
||||||
|
certctl process. The trust holder swaps atomically; bad files (parse
|
||||||
|
error, expired cert) keep the OLD pool in place so a half-rotation
|
||||||
|
doesn't take Intune enrollment down.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
The dispatcher emits a typed metric label per failure mode plus a
|
||||||
|
matching audit-log entry. The table below maps the label to the most
|
||||||
|
common root cause and the operator action.
|
||||||
|
|
||||||
|
| Counter label | Symptom | Root cause + fix |
|
||||||
|
|----------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `signature_invalid` | Every enrollment from a specific profile failing | Trust anchor mismatch — the Connector's signing cert was rotated and certctl wasn't reloaded. Re-extract the cert ([trust anchor extraction](#trust-anchor-extraction)), overwrite the file, send `SIGHUP`. |
|
||||||
|
| `claim_mismatch` | Some enrollments from one Intune SCEP profile failing | The Intune SCEP profile's SAN config doesn't match what the device CSR actually has. Compare the `recent failures` table's claim row to the device's CSR; usually a SAN format mismatch (e.g. claim wants UPN, CSR has DNS). |
|
||||||
|
| `expired` | All enrollments failing on a date boundary | Either clock skew between the Connector host and certctl (NTP both ends) OR the Connector's signing cert is past `NotAfter`. The certctl preflight catches an expired trust anchor at boot; check the Monitoring tab's expiry countdown. |
|
||||||
|
| `not_yet_valid` | All enrollments failing | Reverse clock skew (certctl's clock is BEHIND the Connector's). Sync via NTP. |
|
||||||
|
| `wrong_audience` | All enrollments from a profile failing | `INTUNE_AUDIENCE` doesn't match the URL the Connector is configured to call. Either fix `INTUNE_AUDIENCE` to match the operator URL, or unset it (defense-in-depth then disabled — the claim's exp + sig still gate). |
|
||||||
|
| `replay` | Sporadic per-device failures, mostly during retries | The device retried the SAME challenge after the first one failed. The replay cache TTL is `INTUNE_CHALLENGE_VALIDITY` (default 60m). Either widen the device's retry window (Intune-side) or shorten validity. |
|
||||||
|
| `rate_limited` | A specific device hitting `429`-equivalent failures | The device exceeded `INTUNE_PER_DEVICE_RATE_LIMIT_24H` (default 3). If legitimate (post-wipe + recovery + first-cert all in 24h), bump the cap. If suspicious, this is the limiter doing its job — investigate the device. |
|
||||||
|
| `unknown_version` | Sudden onset of failures across the entire fleet | Microsoft shipped a new Connector version with a `version` claim certctl doesn't understand. Open an issue on the certctl repo with the failing claim payload (anonymized); the parser dispatcher accepts new versions in ~30 LoC. |
|
||||||
|
| `malformed` | Sporadic, low-volume | Malformed challenge bytes — almost always a network proxy mangling the request body, or the Connector logging itself out mid-handshake. Capture a packet trace; the Connector should re-emit on the next device retry. |
|
||||||
|
| `compliance_failed` | V3-Pro only | The pluggable compliance check returned non-compliant. The audit-log details carries the reason string from Microsoft Graph. V2 deployments never see this counter tick. |
|
||||||
|
|
||||||
|
## Operational monitoring (SCEP Administration → Intune Monitoring tab)
|
||||||
|
|
||||||
|
The admin GUI surface for SCEP lives at `/scep` and is structured as
|
||||||
|
three tabs: **Profiles** (default landing — every configured SCEP
|
||||||
|
profile, lean cards with always-present fields), **Intune Monitoring**
|
||||||
|
(the Intune-specific deep-dive described below), and **Recent Activity**
|
||||||
|
(full SCEP audit log filter). Operators monitoring an Intune deployment
|
||||||
|
spend most of their time on the Intune Monitoring tab, deep-linkable via
|
||||||
|
`/scep?tab=intune` or the legacy alias `/scep/intune`. The Profiles tab
|
||||||
|
gives the at-a-glance per-profile health (RA cert expiry, mTLS status,
|
||||||
|
Intune enabled/disabled badge, challenge-password-set indicator) and a
|
||||||
|
"View Intune details →" link from each Intune-enabled card that switches
|
||||||
|
into this tab filtered to that profile.
|
||||||
|
|
||||||
|
The Intune Monitoring tab shows:
|
||||||
|
|
||||||
|
- **Per-profile cards** — one card per SCEP profile, with the trust
|
||||||
|
anchor expiry countdown badge:
|
||||||
|
- `green` ≥ 30 days remaining
|
||||||
|
- `amber` 7-30 days remaining (rotate soon)
|
||||||
|
- `red` < 7 days remaining
|
||||||
|
- `EXPIRED` past `NotAfter`
|
||||||
|
- **Live counters** — the per-status enrollment counts polled every
|
||||||
|
30s. The order in the grid puts `success` first (vanity) and
|
||||||
|
failure modes after.
|
||||||
|
- **Recent failures table** — the last 50 audit-log events with
|
||||||
|
action `scep_pkcsreq_intune` or `scep_renewalreq_intune`, sorted
|
||||||
|
by timestamp descending. Polled every 60s.
|
||||||
|
- **Trust anchor reload button** — confirms via modal then issues
|
||||||
|
`POST /api/v1/admin/scep/intune/reload-trust` (the SIGHUP-equivalent).
|
||||||
|
Bad reloads keep the OLD pool in place; the modal stays open with
|
||||||
|
the underlying error so the operator can correct the file and retry.
|
||||||
|
|
||||||
|
Three admin endpoints back the page:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/scep/profiles` — per-profile snapshot for the
|
||||||
|
Profiles tab; surfaces RA cert subject + NotAfter + days-to-expiry,
|
||||||
|
mTLS sibling-route status + bundle path, challenge-password-set flag,
|
||||||
|
and an optional `intune` sub-block for Intune-enabled profiles.
|
||||||
|
- `GET /api/v1/admin/scep/intune/stats` — Intune-specific deep-dive
|
||||||
|
for the Intune Monitoring tab; per-status counters + trust anchor
|
||||||
|
pool details. Backward-compat shape preserved from Phase 9.
|
||||||
|
- `POST /api/v1/admin/scep/intune/reload-trust` — SIGHUP-equivalent
|
||||||
|
trust anchor reload, body `{"path_id": "<pathID>"}`.
|
||||||
|
|
||||||
|
All three are M-008 admin-gated. Non-admin Bearer callers get HTTP 403
|
||||||
|
+ a clear message; the GUI hides the page entirely for non-admin users
|
||||||
|
(UX hint; server-side enforcement is independent).
|
||||||
|
|
||||||
|
### Recommended alert thresholds
|
||||||
|
|
||||||
|
The counters are exposed in the GUI as snapshots; if you wrap them
|
||||||
|
in a Prometheus exporter (V3-Pro plug-in seam — V2 doesn't ship a
|
||||||
|
`/metrics` surface today), reasonable starting thresholds:
|
||||||
|
|
||||||
|
- `signature_invalid` rate > 0 for > 5 minutes → page on-call. The
|
||||||
|
trust anchor is stale; the operator missed a SIGHUP after a
|
||||||
|
Connector rotation.
|
||||||
|
- `claim_mismatch` rate > 0 sustained > 1 hour → notify (not page).
|
||||||
|
An Intune SCEP profile is misconfigured; an admin needs to fix
|
||||||
|
the SAN definition or the operator's CertificateProfile.
|
||||||
|
- `replay` rate climbing → notify. Either an aggressive retry policy
|
||||||
|
on the device side OR active replay attempts. Cross-reference
|
||||||
|
source IPs in the audit log.
|
||||||
|
- `rate_limited` for a single device > 1 per hour → notify. Either
|
||||||
|
legitimate enrollment storm (post-wipe scenarios) or a compromised
|
||||||
|
Connector signing key.
|
||||||
|
- Trust anchor `days_to_expiry` < 30 on any profile → notify; rotate
|
||||||
|
the Connector's signing cert before the cliff.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
This bundle is V2-free. The following capabilities are deferred to
|
||||||
|
V3-Pro:
|
||||||
|
|
||||||
|
- **Native Microsoft Graph integration.** certctl validates the
|
||||||
|
Connector's signed challenge but doesn't call Microsoft's API
|
||||||
|
directly — the Connector already did that. V3-Pro could ship a
|
||||||
|
Graph client that pulls device-compliance state in addition to
|
||||||
|
the challenge claim.
|
||||||
|
- **Conditional Access compliance gating.** The dispatcher exposes a
|
||||||
|
nil-default `ComplianceCheck` hook. V3-Pro plugs in a Microsoft
|
||||||
|
Graph compliance lookup before issuance; non-compliant devices
|
||||||
|
fail with a typed `compliance_failed` failInfo.
|
||||||
|
- **Per-tenant trust anchors.** V2 has one trust anchor pool per
|
||||||
|
SCEP profile; V3-Pro could support per-AAD-tenant anchor scoping
|
||||||
|
for MSPs running shared certctl deployments across customers.
|
||||||
|
- **OCSP stapling at SCEP-response time.** The CertRep doesn't carry
|
||||||
|
a stapled OCSP response today; certificate validators look up OCSP
|
||||||
|
via the `id-pkix-ocsp` extension on the issued cert. V3-Pro could
|
||||||
|
staple inline.
|
||||||
|
- **Auto-discovery of the Connector signing cert.** V2 requires the
|
||||||
|
operator to extract the cert manually and configure the path.
|
||||||
|
V3-Pro could pull from a Microsoft-published endpoint (with the
|
||||||
|
appropriate trust constraints).
|
||||||
|
|
||||||
|
These deferrals are deliberate, not oversights. The V2 surface
|
||||||
|
covers every operationally-required path for a single-tenant
|
||||||
|
enterprise replacing NDES; V3-Pro adds the multi-tenant + native-API
|
||||||
|
features procurement teams sometimes ask for.
|
||||||
|
|
||||||
|
## Microsoft support statement
|
||||||
|
|
||||||
|
Microsoft documents the Intune Certificate Connector as
|
||||||
|
**RFC-8894-compliant** and supports its use against any RFC 8894
|
||||||
|
SCEP server. The relevant Microsoft Learn pages:
|
||||||
|
|
||||||
|
- [Intune Certificate Connector overview](https://learn.microsoft.com/en-us/mem/intune/protect/certificate-connector-overview) —
|
||||||
|
documents the Connector's architecture and explicitly notes it
|
||||||
|
speaks RFC-8894-compliant SCEP.
|
||||||
|
- [Use SCEP certificate profiles in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/certificates-scep-configure) —
|
||||||
|
the operator-facing setup guide, with the SCEP server URL field
|
||||||
|
the migration playbook above edits.
|
||||||
|
- [Validate setup of Intune Certificate Connector](https://learn.microsoft.com/en-us/mem/intune/protect/certificate-connector-install) —
|
||||||
|
the install-validation checklist; useful when troubleshooting
|
||||||
|
Connector-side failures vs. certctl-side failures.
|
||||||
|
|
||||||
|
certctl's role per Microsoft's framing: a third-party SCEP server
|
||||||
|
that the Connector posts to. Microsoft supports this topology; only
|
||||||
|
certctl's own RFC 8894 implementation is in scope for certctl
|
||||||
|
support. The end-to-end Connector → certctl → issuer flow is
|
||||||
|
exercised in `internal/api/handler/scep_intune_e2e_test.go` and
|
||||||
|
the golden-file fixtures in `internal/scep/intune/testdata/`.
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [`legacy-est-scep.md`](legacy-est-scep.md) — the per-profile SCEP
|
||||||
|
setup guide + RFC 8894 reference + mTLS sibling route. Read this
|
||||||
|
first if you're not already running certctl SCEP for non-Intune
|
||||||
|
fleets.
|
||||||
|
- [`architecture.md`](architecture.md) — overall control-plane
|
||||||
|
architecture; Security Model section calls out the Intune trust
|
||||||
|
anchor as a sensitive operator-configured surface.
|
||||||
|
- [`features.md`](features.md) — every `CERTCTL_*` env var,
|
||||||
|
including the per-profile `CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_*`
|
||||||
|
family.
|
||||||
|
- [`tls.md`](tls.md) — TLS bootstrap for the certctl control plane;
|
||||||
|
prerequisite for any production deploy.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
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, extended in the
|
||||||
|
// Phase 9 follow-up (cowork/scep-gui-restructure-prompt.md) with
|
||||||
|
// Profiles for the per-profile SCEP Administration tab.
|
||||||
|
type AdminSCEPIntuneService interface {
|
||||||
|
// Stats returns one snapshot per configured SCEP profile (Intune-
|
||||||
|
// enabled or not) in the Phase 9.1 flat shape. Backward-compat for
|
||||||
|
// the existing /admin/scep/intune/stats endpoint.
|
||||||
|
Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error)
|
||||||
|
|
||||||
|
// Profiles returns one snapshot per configured SCEP profile in the
|
||||||
|
// new shape (always-present per-profile fields + optional Intune
|
||||||
|
// sub-block). Backs the new /admin/scep/profiles endpoint.
|
||||||
|
Profiles(ctx context.Context, now time.Time) ([]service.SCEPProfileStatsSnapshot, error)
|
||||||
|
|
||||||
|
// ReloadTrust triggers the SIGHUP-equivalent Reload on the named
|
||||||
|
// 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 SCEP observability
|
||||||
|
// endpoints for the GUI SCEP Administration page.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
//
|
||||||
|
// GET /api/v1/admin/scep/profiles — Phase 9 follow-up
|
||||||
|
// GET /api/v1/admin/scep/intune/stats — Phase 9.2
|
||||||
|
// POST /api/v1/admin/scep/intune/reload-trust — Phase 9.2 (JSON body: {"path_id": "corp"})
|
||||||
|
//
|
||||||
|
// All three 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), the
|
||||||
|
// profiles endpoint additionally reveals RA cert expiries + mTLS bundle
|
||||||
|
// paths, 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profiles handles GET /api/v1/admin/scep/profiles.
|
||||||
|
//
|
||||||
|
// Phase 9 follow-up endpoint backing the SCEP Administration page's
|
||||||
|
// Profiles tab. Returns one snapshot per configured SCEP profile in
|
||||||
|
// the SCEPProfileStatsSnapshot shape (always-present per-profile
|
||||||
|
// fields + optional Intune sub-block).
|
||||||
|
//
|
||||||
|
// Same M-008 admin gate as Stats. Profiles where Intune is disabled
|
||||||
|
// appear with Intune=null in the response.
|
||||||
|
func (h AdminSCEPIntuneHandler) Profiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !middleware.IsAdmin(r.Context()) {
|
||||||
|
Error(w, http.StatusForbidden, "Admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
rows, err := h.svc.Profiles(r.Context(), now)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "Failed to read SCEP profiles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
// Avoid serialising as `null` — the GUI expects an array.
|
||||||
|
rows = []service.SCEPProfileStatsSnapshot{}
|
||||||
|
}
|
||||||
|
_ = JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"profiles": rows,
|
||||||
|
"profile_count": len(rows),
|
||||||
|
"generated_at": now.UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats handles GET /api/v1/admin/scep/intune/stats.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profiles implements AdminSCEPIntuneService for the new
|
||||||
|
// /admin/scep/profiles endpoint. Walks the same per-profile SCEPService
|
||||||
|
// map but emits the SCEPProfileStatsSnapshot shape (always-present
|
||||||
|
// fields + optional Intune sub-block).
|
||||||
|
func (s *AdminSCEPIntuneServiceImpl) Profiles(_ context.Context, now time.Time) ([]service.SCEPProfileStatsSnapshot, error) {
|
||||||
|
out := make([]service.SCEPProfileStatsSnapshot, 0, len(s.services))
|
||||||
|
for _, svc := range s.services {
|
||||||
|
out = append(out, svc.ProfileStats(now))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadTrust implements AdminSCEPIntuneService.
|
||||||
|
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,495 @@
|
|||||||
|
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
|
||||||
|
profilesCalled bool
|
||||||
|
reloadCalled bool
|
||||||
|
rows []service.IntuneStatsSnapshot
|
||||||
|
profileRows []service.SCEPProfileStatsSnapshot
|
||||||
|
statsErr error
|
||||||
|
profilesErr 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) Profiles(_ context.Context, _ time.Time) ([]service.SCEPProfileStatsSnapshot, error) {
|
||||||
|
f.profilesCalled = true
|
||||||
|
return f.profileRows, f.profilesErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// M-008 admin-gate triplet for Profiles (GET) — Phase 9 follow-up endpoint.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdminSCEPProfiles_NonAdmin_Returns403(t *testing.T) {
|
||||||
|
svc := &fakeAdminSCEPIntuneService{}
|
||||||
|
h := NewAdminSCEPIntuneHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||||
|
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Profiles(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
msg, _ := resp["message"].(string)
|
||||||
|
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||||
|
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||||
|
}
|
||||||
|
if svc.profilesCalled {
|
||||||
|
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||||
|
svc := &fakeAdminSCEPIntuneService{}
|
||||||
|
h := NewAdminSCEPIntuneHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||||
|
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||||
|
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Profiles(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if svc.profilesCalled {
|
||||||
|
t.Error("service called despite admin=false gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||||
|
svc := &fakeAdminSCEPIntuneService{
|
||||||
|
profileRows: []service.SCEPProfileStatsSnapshot{
|
||||||
|
{
|
||||||
|
PathID: "corp",
|
||||||
|
IssuerID: "iss-corp",
|
||||||
|
ChallengePasswordSet: true,
|
||||||
|
MTLSEnabled: true,
|
||||||
|
Intune: &service.IntuneSection{
|
||||||
|
Audience: "https://certctl.example.com/scep/corp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PathID: "iot",
|
||||||
|
IssuerID: "iss-iot",
|
||||||
|
ChallengePasswordSet: true,
|
||||||
|
// Intune nil — disabled
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewAdminSCEPIntuneHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||||
|
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||||
|
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||||
|
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Profiles(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !svc.profilesCalled {
|
||||||
|
t.Fatal("service was not invoked for admin caller")
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if pc, ok := resp["profile_count"].(float64); !ok || pc != 2 {
|
||||||
|
t.Errorf("profile_count = %v, want 2", resp["profile_count"])
|
||||||
|
}
|
||||||
|
rows, ok := resp["profiles"].([]any)
|
||||||
|
if !ok || len(rows) != 2 {
|
||||||
|
t.Fatalf("profiles missing or wrong shape: %v", resp["profiles"])
|
||||||
|
}
|
||||||
|
// Find the Intune-enabled vs Intune-disabled row by path_id and
|
||||||
|
// assert the Intune sub-block is present/absent accordingly.
|
||||||
|
for _, raw := range rows {
|
||||||
|
row := raw.(map[string]any)
|
||||||
|
switch row["path_id"] {
|
||||||
|
case "corp":
|
||||||
|
if _, has := row["intune"]; !has {
|
||||||
|
t.Errorf("expected corp profile to carry an intune sub-block")
|
||||||
|
}
|
||||||
|
case "iot":
|
||||||
|
if _, has := row["intune"]; has {
|
||||||
|
t.Errorf("expected iot profile to OMIT the intune sub-block (Intune disabled)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSCEPProfiles_RejectsNonGetMethod(t *testing.T) {
|
||||||
|
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/profiles", nil)
|
||||||
|
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Profiles(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405 for POST, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSCEPProfiles_PropagatesServiceError(t *testing.T) {
|
||||||
|
svc := &fakeAdminSCEPIntuneService{profilesErr: errors.New("registry walk failed")}
|
||||||
|
h := NewAdminSCEPIntuneHandler(svc)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||||
|
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Profiles(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500 on service error, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSCEPProfilesServiceImpl_NilMapReturnsEmpty(t *testing.T) {
|
||||||
|
impl := NewAdminSCEPIntuneServiceImpl(nil)
|
||||||
|
rows, err := impl.Profiles(context.Background(), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nil-map Profiles: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("nil-map Profiles len=%d, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,9 @@ import (
|
|||||||
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
|
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
|
||||||
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
||||||
var AdminGatedHandlers = map[string]string{
|
var AdminGatedHandlers = map[string]string{
|
||||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||||
|
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
|
||||||
}
|
}
|
||||||
|
|
||||||
// InformationalIsAdminCallers is the documented allowlist of files that
|
// InformationalIsAdminCallers is the documented allowlist of files that
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ type NetworkScanService interface {
|
|||||||
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
||||||
DeleteTarget(ctx context.Context, id string) error
|
DeleteTarget(ctx context.Context, id string) error
|
||||||
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error)
|
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
|
// ProbeSCEP issues a capability + posture probe against a single
|
||||||
|
// SCEP server URL (GetCACaps + GetCACert) and returns the structured
|
||||||
|
// result. ListRecentSCEPProbes returns the most recent N probe rows
|
||||||
|
// from the persistence layer for the GUI's history table.
|
||||||
|
ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error)
|
||||||
|
ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkScanHandler handles HTTP requests for network scan targets.
|
// NetworkScanHandler handles HTTP requests for network scan targets.
|
||||||
@@ -177,3 +185,80 @@ func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
JSON(w, http.StatusAccepted, scan)
|
JSON(w, http.StatusAccepted, scan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scepProbeRequest is the POST body for /api/v1/network-scan/scep-probe.
|
||||||
|
// Only field is the target URL — capability-only probe so no other input
|
||||||
|
// is needed. Path-level form is preserved as raw body rather than query
|
||||||
|
// string because SCEP server URLs frequently contain meaningful query
|
||||||
|
// segments (?operation=PKIOperation, etc.) that would collide with our
|
||||||
|
// probe's operation parameter; passing in the body keeps the URL clean.
|
||||||
|
type scepProbeRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeSCEP handles POST /api/v1/network-scan/scep-probe.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Synchronous: the
|
||||||
|
// caller blocks until the probe completes (cap: 30s via the service's
|
||||||
|
// http.Client.Timeout). Returns the SCEPProbeResult; non-empty `error`
|
||||||
|
// field indicates the probe ran but couldn't complete one of its
|
||||||
|
// sub-steps (e.g. unreachable server, malformed response). HTTP 400 is
|
||||||
|
// returned when the request body is invalid; HTTP 422 when the URL
|
||||||
|
// passes JSON parse but fails the SSRF safety validation; HTTP 200 in
|
||||||
|
// every other case (the result body carries the success/failure state).
|
||||||
|
func (h NetworkScanHandler) ProbeSCEP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body scepProbeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.URL == "" {
|
||||||
|
Error(w, http.StatusBadRequest, "url is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.ProbeSCEP(r.Context(), body.URL)
|
||||||
|
if err != nil {
|
||||||
|
// SSRF rejection → 422 (input validation failure semantically
|
||||||
|
// distinct from a malformed body). Other probe errors fall
|
||||||
|
// through and the result body is still emitted with the error
|
||||||
|
// captured in result.Error.
|
||||||
|
if result == nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "SCEP probe failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Reachable=false + non-empty Error → return the result so the
|
||||||
|
// GUI can render the failure tone with the operator-actionable
|
||||||
|
// message. The HTTP 200 response carries the diagnostic body.
|
||||||
|
}
|
||||||
|
JSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSCEPProbes handles GET /api/v1/network-scan/scep-probes.
|
||||||
|
//
|
||||||
|
// Returns the most recent N probe rows for the GUI's history table.
|
||||||
|
// Default limit is 50; max via ?limit=N is clamped at 200 by the
|
||||||
|
// underlying repository. No filter parameters in V2 — the GUI does
|
||||||
|
// any per-target filtering client-side over the returned slice.
|
||||||
|
func (h NetworkScanHandler) ListSCEPProbes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.svc.ListRecentSCEPProbes(r.Context(), 50)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "Failed to list SCEP probe history: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []*domain.SCEPProbeResult{}
|
||||||
|
}
|
||||||
|
JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"probes": rows,
|
||||||
|
"probe_count": len(rows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
|||||||
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
|
||||||
|
// satisfaction stubs for the SCEP probe methods. The existing mock
|
||||||
|
// doesn't exercise the probe path; dedicated tests in
|
||||||
|
// scep_probe_handler_test.go (Phase 11.5.F) cover that surface with
|
||||||
|
// their own targeted mock.
|
||||||
|
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
|
||||||
|
return nil, fmt.Errorf("ProbeSCEP not implemented in mockNetworkScanService — use scepProbeMockService")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
return []*domain.SCEPProbeResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestListNetworkScanTargets(t *testing.T) {
|
func TestListNetworkScanTargets(t *testing.T) {
|
||||||
svc := &mockNetworkScanService{
|
svc := &mockNetworkScanService{
|
||||||
targets: []*domain.NetworkScanTarget{
|
targets: []*domain.NetworkScanTarget{
|
||||||
|
|||||||
@@ -285,6 +285,183 @@ func TestSCEPHandler_ChromeOSPKIMessage_AESVariants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_RAKeyMismatch — closure-bundle
|
||||||
|
// gap M-1 / acceptance D.1 (cowork/scep-bundle-gap-closure-prompt.md).
|
||||||
|
// Build a PKIMessage encrypted to a freshly-generated RA cert whose
|
||||||
|
// matching private key the server does NOT have. The handler MUST
|
||||||
|
// reject (RFC 8894 path can't decrypt → falls through; MVP path can't
|
||||||
|
// either because the EnvelopedData isn't a raw CSR). Assert no
|
||||||
|
// PKCSReqWithEnvelope was reached. Closes the documented threat that
|
||||||
|
// an attacker who swaps the RA cert in transit gets a polite error
|
||||||
|
// rather than information leak about the underlying issuer.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_RAKeyMismatch(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
|
||||||
|
// Build a PKIMessage targeting an UNRELATED RA cert (different key).
|
||||||
|
// The server's handler still has fix.raKey, so decryption MUST fail.
|
||||||
|
bogusRAKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey bogus RA: %v", err)
|
||||||
|
}
|
||||||
|
bogusRACert := selfSignedRSACert(t, bogusRAKey, "ra-bogus-not-on-server")
|
||||||
|
bogusFix := &chromeOSStackFixture{
|
||||||
|
raKey: bogusRAKey,
|
||||||
|
raCert: bogusRACert,
|
||||||
|
deviceKey: fix.deviceKey,
|
||||||
|
deviceCert: fix.deviceCert,
|
||||||
|
}
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, bogusFix, domain.SCEPMessageTypePKCSReq, "txn-ra-mismatch", "shared-secret-123", "ra-mismatch.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||||
|
|
||||||
|
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
// RFC 8894 path returns FAILURE+badMessageCheck CertRep (200), MVP
|
||||||
|
// fall-through returns 400. Either is acceptable — what we MUST
|
||||||
|
// see is "the issuer never received the CSR."
|
||||||
|
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
||||||
|
t.Errorf("POST PKIOperation (RA-key mismatch): got %d, want 400 (MVP fall-through) or 200 (CertRep+failInfo)", w.Code)
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope != nil {
|
||||||
|
t.Error("PKCSReqWithEnvelope was reached despite the RA-cert/key mismatch — decrypt-failure leaked through to the service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_3DESBackwardCompat — closure-bundle
|
||||||
|
// gap M-1 / acceptance D.2. RFC 8894 §3.5.2 names DES-EDE3-CBC
|
||||||
|
// (1.2.840.113549.3.7) as a "supported but discouraged" content-encryption
|
||||||
|
// algorithm for backward compat with older Cisco IOS / Apple legacy
|
||||||
|
// clients. Verify the parser accepts this OID + the handler reaches
|
||||||
|
// the service with a decoded CSR.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_3DESBackwardCompat(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
tdesKey := aesKeyForOID(pkcs7.OIDDESEDE3CBC) // 24 bytes (3DES K1||K2||K3)
|
||||||
|
|
||||||
|
csrDER := buildTestCSR(t, fix.deviceKey, "tdes.example.com", "shared-secret-123")
|
||||||
|
|
||||||
|
iv := make([]byte, des.BlockSize) // 8 bytes for 3DES
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand iv: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext := tripleDESCBCEncrypt(t, tdesKey, iv, csrDER)
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), tdesKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa encrypt 3des key: %v", err)
|
||||||
|
}
|
||||||
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, pkcs7.OIDDESEDE3CBC)
|
||||||
|
pkiMessage := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-3des", []byte("0123456789abcdef"), envelopedData)
|
||||||
|
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation (3DES legacy): got %d, want 200 (RFC 8894 §3.5.2 backward-compat) — body=%q", w.Code, body)
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope == nil {
|
||||||
|
t.Fatal("PKCSReqWithEnvelope was NOT reached — 3DES decrypt path didn't make it to the service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_RSACSR — closure-bundle gap M-1 /
|
||||||
|
// acceptance D.4. Pins the "RSA CSR" matrix corner explicitly so a
|
||||||
|
// future helper refactor that quietly drops the RSA path doesn't
|
||||||
|
// disappear from the test count without a counter dropping. The
|
||||||
|
// shared positive-flow assertions live in
|
||||||
|
// assertChromeOSPositiveCertRep so the matrix-pair {RSA, ECDSA} stays
|
||||||
|
// readable.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_RSACSR(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-rsa-csr", "shared-secret-123", "rsa-csr.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||||
|
assertChromeOSPositiveCertRep(t, fix, pkiMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_ECDSACSR — closure-bundle gap M-1
|
||||||
|
// / acceptance D.3. The CSR's keypair is ECDSA P-256; the device's
|
||||||
|
// transient signerInfo identity stays RSA (matches what real ChromeOS
|
||||||
|
// + Intune-managed devices commonly emit — device identity is a
|
||||||
|
// long-lived RSA key, the new cert can be ECDSA). Verifies the
|
||||||
|
// handler doesn't choke on the inner CSR's algorithm even when the
|
||||||
|
// outer SignerInfo is RSA-SHA256.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_ECDSACSR(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
|
||||||
|
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
csrDER := buildTestECDSACSR(t, csrKey, "ecdsa-csr.example.com", "shared-secret-123")
|
||||||
|
|
||||||
|
symKey := aesKeyForOID(pkcs7.OIDAES256CBC)
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand iv: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa encrypt symKey: %v", err)
|
||||||
|
}
|
||||||
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, pkcs7.OIDAES256CBC)
|
||||||
|
pkiMessage := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-ecdsa-csr", []byte("0123456789abcdef"), envelopedData)
|
||||||
|
assertChromeOSPositiveCertRep(t, fix, pkiMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertChromeOSPositiveCertRep is the shared positive-flow assertion
|
||||||
|
// helper for the {RSA, ECDSA} CSR matrix tests. Asserts HTTP 200 +
|
||||||
|
// content-type + the service-level mock saw the envelope.
|
||||||
|
func assertChromeOSPositiveCertRep(t *testing.T, fix *chromeOSStackFixture, pkiMessage []byte) {
|
||||||
|
t.Helper()
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope == nil {
|
||||||
|
t.Fatal("PKCSReqWithEnvelope was NOT reached — handler dispatched to MVP path or rejected the message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestECDSACSR mirrors buildTestCSR but for an ECDSA P-256
|
||||||
|
// signing key. Closure-bundle Phase D helper. The CSR carries the
|
||||||
|
// challengePassword attribute the same way the RSA helper does.
|
||||||
|
func buildTestECDSACSR(t *testing.T, key *ecdsa.PrivateKey, commonName, challengePassword string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
tmpl := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
ExtraExtensions: []pkix.Extension{},
|
||||||
|
Attributes: []pkix.AttributeTypeAndValueSET{
|
||||||
|
{
|
||||||
|
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||||
|
Value: [][]pkix.AttributeTypeAndValue{
|
||||||
|
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificateRequest (ECDSA): %v", err)
|
||||||
|
}
|
||||||
|
return der
|
||||||
|
}
|
||||||
|
|
||||||
|
// tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES — used by the
|
||||||
|
// 3DES backward-compat test. PKCS#7 padding to 8-byte blocks.
|
||||||
|
func tripleDESCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
block, err := des.NewTripleDESCipher(key) //nolint:gosec // RFC 8894 §3.5.2 legacy backward-compat test fixture
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("des.NewTripleDESCipher: %v", err)
|
||||||
|
}
|
||||||
|
bs := block.BlockSize()
|
||||||
|
padLen := bs - len(plaintext)%bs
|
||||||
|
padded := append([]byte{}, plaintext...)
|
||||||
|
for i := 0; i < padLen; i++ {
|
||||||
|
padded = append(padded, byte(padLen))
|
||||||
|
}
|
||||||
|
enc := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
out := make([]byte, len(padded))
|
||||||
|
enc.CryptBlocks(out, padded)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw
|
// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw
|
||||||
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for
|
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for
|
||||||
// backward compat with lightweight clients.
|
// backward compat with lightweight clients.
|
||||||
|
|||||||
@@ -0,0 +1,676 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"github.com/shankar0123/certctl/internal/scep/intune"
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end
|
||||||
|
// test for the Intune dispatcher running through the full handler →
|
||||||
|
// service → validator → CertRep wire path.
|
||||||
|
//
|
||||||
|
// What this test exercises (top to bottom):
|
||||||
|
//
|
||||||
|
// 1. Real SCEPService instance with SetIntuneIntegration wired to a
|
||||||
|
// real intune.TrustAnchorHolder (loaded from a temp PEM file).
|
||||||
|
// 2. Real intune.ReplayCache + intune.PerDeviceRateLimiter.
|
||||||
|
// 3. Real SCEPHandler with RA cert/key + service injected.
|
||||||
|
// 4. Real PKIMessage built via the existing chromeOS-shape builders
|
||||||
|
// (SignedData wrapping EnvelopedData wrapping a CSR carrying the
|
||||||
|
// Intune-shaped challengePassword attribute).
|
||||||
|
// 5. POST through HandleSCEP — handler runs tryParseRFC8894 →
|
||||||
|
// service.PKCSReqWithEnvelope → dispatchIntuneChallenge →
|
||||||
|
// ValidateChallenge → DeviceMatchesCSR → replay → rate-limit →
|
||||||
|
// processEnrollment → CertRep PKIMessage response.
|
||||||
|
// 6. Decode the CertRep response and assert pkiStatus=Success.
|
||||||
|
//
|
||||||
|
// What this test deliberately does NOT do:
|
||||||
|
//
|
||||||
|
// - Boot docker-compose.test.yml. The spec's deploy/test/ variant
|
||||||
|
// reserves that for a future enhancement that mounts a fixture
|
||||||
|
// trust anchor into the running container; this hermetic version
|
||||||
|
// runs in the default `go test ./...` sweep so every CI run
|
||||||
|
// exercises the full Intune chain.
|
||||||
|
// - Hit a real issuer connector. The IssuerConnector is a fixture
|
||||||
|
// mock (intuneE2EIssuerConnector below) that returns a deterministic
|
||||||
|
// issued cert so the test can assert its own CN/SANs without
|
||||||
|
// spinning up a CA.
|
||||||
|
|
||||||
|
// intuneE2EFixture wires up a real SCEPService with the Intune dispatcher
|
||||||
|
// enabled, a real handler, plus a forged Intune Connector signing
|
||||||
|
// keypair the test uses to mint valid challenges.
|
||||||
|
type intuneE2EFixture struct {
|
||||||
|
connectorKey *ecdsa.PrivateKey
|
||||||
|
connectorDir string // dir holding the trust-anchor PEM (for SIGHUP-reload tests)
|
||||||
|
trustPath string // PEM file the holder watches; rewriting + Reload simulates SIGHUP
|
||||||
|
trustHolder *intune.TrustAnchorHolder
|
||||||
|
raKey *rsa.PrivateKey
|
||||||
|
raCert *x509.Certificate
|
||||||
|
deviceKey *rsa.PrivateKey
|
||||||
|
deviceCert *x509.Certificate
|
||||||
|
issuer *intuneE2EIssuerConnector
|
||||||
|
auditRepo *intuneE2EAuditRepo
|
||||||
|
scepService *service.SCEPService
|
||||||
|
handler SCEPHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// intuneE2EIssuerConnector is a minimal IssuerConnector that returns a
|
||||||
|
// deterministic fake-issued cert. We don't need a real CA for this test
|
||||||
|
// — the goal is to verify the handler→service→dispatcher chain end to
|
||||||
|
// end, NOT to verify cert issuance (which is covered in the local
|
||||||
|
// issuer's own tests).
|
||||||
|
type intuneE2EIssuerConnector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
caPEM string
|
||||||
|
signKey *rsa.PrivateKey
|
||||||
|
caCert *x509.Certificate
|
||||||
|
issued []intuneE2EIssuance
|
||||||
|
}
|
||||||
|
|
||||||
|
type intuneE2EIssuance struct {
|
||||||
|
commonName string
|
||||||
|
sans []string
|
||||||
|
mustStaple bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) GetCACertPEM(_ context.Context) (string, error) {
|
||||||
|
return i.caPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) IssueCertificate(_ context.Context, commonName string, sans []string, _ string, _ []string, _ int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||||
|
i.mu.Lock()
|
||||||
|
defer i.mu.Unlock()
|
||||||
|
i.issued = append(i.issued, intuneE2EIssuance{commonName: commonName, sans: sans, mustStaple: mustStaple})
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(int64(len(i.issued)) + 1),
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
DNSNames: sans,
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Minute),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, i.caCert, &i.signKey.PublicKey, i.signKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
return &service.IssuanceResult{
|
||||||
|
CertPEM: string(certPEM),
|
||||||
|
ChainPEM: i.caPEM,
|
||||||
|
Serial: tmpl.SerialNumber.String(),
|
||||||
|
NotAfter: tmpl.NotAfter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||||
|
return i.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) RevokeCertificate(_ context.Context, _ string, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) GenerateCRL(_ context.Context, _ []service.CRLEntry) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) SignOCSPResponse(_ context.Context, _ service.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *intuneE2EIssuerConnector) GetRenewalInfo(_ context.Context, _ string) (*service.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// intuneE2EAuditRepo captures audit events so the test can assert the
|
||||||
|
// dispatcher emitted scep_pkcsreq_intune.
|
||||||
|
type intuneE2EAuditRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []domain.AuditEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *intuneE2EAuditRepo) Create(_ context.Context, e *domain.AuditEvent) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.events = append(r.events, *e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *intuneE2EAuditRepo) List(_ context.Context, _ *repository.AuditFilter) ([]*domain.AuditEvent, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *intuneE2EAuditRepo) actions() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]string, 0, len(r.events))
|
||||||
|
for _, e := range r.events {
|
||||||
|
out = append(out, e.Action)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// newIntuneE2EFixture wires up the full Intune-mode SCEP stack.
|
||||||
|
func newIntuneE2EFixture(t *testing.T) *intuneE2EFixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// 1. Forge a Connector signing keypair + self-signed cert. This is
|
||||||
|
// what an operator would extract from their installed Intune
|
||||||
|
// Certificate Connector and configure as INTUNE_CONNECTOR_CERT_PATH.
|
||||||
|
connectorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connector key: %v", err)
|
||||||
|
}
|
||||||
|
connectorCert := selfSignedECCertForIntuneE2E(t, connectorKey, "intune-connector-test")
|
||||||
|
|
||||||
|
// 2. Write the Connector cert to a temp PEM file so the
|
||||||
|
// TrustAnchorHolder loads it the same way it would in production.
|
||||||
|
dir := t.TempDir()
|
||||||
|
trustPath := filepath.Join(dir, "intune-trust.pem")
|
||||||
|
if err := os.WriteFile(trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: connectorCert.Raw}), 0o600); err != nil {
|
||||||
|
t.Fatalf("write trust anchor: %v", err)
|
||||||
|
}
|
||||||
|
trustHolder, err := intune.NewTrustAnchorHolder(trustPath, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTrustAnchorHolder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build a fixture issuer + RA pair (RA cert/key the SCEP handler
|
||||||
|
// uses to decrypt EnvelopedData). The RA cert and the issuer's
|
||||||
|
// fake CA are independent — RA is a SCEP-protocol artifact, the
|
||||||
|
// CA cert is what the issuer connector returns from GetCACertPEM.
|
||||||
|
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ra key: %v", err)
|
||||||
|
}
|
||||||
|
raCert := selfSignedRSACert(t, raKey, "ra-intune-e2e")
|
||||||
|
|
||||||
|
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ca key: %v", err)
|
||||||
|
}
|
||||||
|
caCert := selfSignedRSACert(t, caKey, "test-fixture-ca")
|
||||||
|
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})
|
||||||
|
|
||||||
|
issuer := &intuneE2EIssuerConnector{
|
||||||
|
caPEM: string(caPEM),
|
||||||
|
signKey: caKey,
|
||||||
|
caCert: caCert,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build a real SCEPService with intune integration wired in.
|
||||||
|
auditRepo := &intuneE2EAuditRepo{}
|
||||||
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||||
|
scepSvc := service.NewSCEPService("iss-test", issuer, auditSvc, logger, "static-fallback-secret")
|
||||||
|
scepSvc.SetPathID("test")
|
||||||
|
|
||||||
|
replayCache := intune.NewReplayCache(60*time.Minute, 100)
|
||||||
|
rateLimiter := intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100)
|
||||||
|
scepSvc.SetIntuneIntegration(
|
||||||
|
trustHolder,
|
||||||
|
"https://certctl.example.com/scep/test",
|
||||||
|
60*time.Minute,
|
||||||
|
0, // ClockSkewTolerance — strict (the e2e fixture uses time.Now() consistently so no drift to absorb)
|
||||||
|
replayCache,
|
||||||
|
rateLimiter,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Build a transient device cert/key. The device wraps its CSR in
|
||||||
|
// EnvelopedData and signs the SCEP signerInfo with this transient
|
||||||
|
// key (the same shape ChromeOS / Intune-managed devices use).
|
||||||
|
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("device key: %v", err)
|
||||||
|
}
|
||||||
|
deviceCert := selfSignedRSACert(t, deviceKey, "device-transient-intune")
|
||||||
|
|
||||||
|
// 6. Build the SCEP handler.
|
||||||
|
handler := NewSCEPHandler(scepSvc)
|
||||||
|
handler.SetRAPair(raCert, raKey)
|
||||||
|
|
||||||
|
return &intuneE2EFixture{
|
||||||
|
connectorKey: connectorKey,
|
||||||
|
connectorDir: dir,
|
||||||
|
trustPath: trustPath,
|
||||||
|
trustHolder: trustHolder,
|
||||||
|
raKey: raKey,
|
||||||
|
raCert: raCert,
|
||||||
|
deviceKey: deviceKey,
|
||||||
|
deviceCert: deviceCert,
|
||||||
|
issuer: issuer,
|
||||||
|
auditRepo: auditRepo,
|
||||||
|
scepService: scepSvc,
|
||||||
|
handler: handler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfSignedECCertForIntuneE2E mirrors the existing selfSignedRSACert
|
||||||
|
// helper for an ECDSA P-256 keypair. Used for the fixture Connector
|
||||||
|
// signing cert. Distinct name to avoid colliding with selfSignedRSACert
|
||||||
|
// in the same package.
|
||||||
|
func selfSignedECCertForIntuneE2E(t *testing.T, key *ecdsa.PrivateKey, cn string) *x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// signIntuneChallengeES256 builds a real Intune-shaped challenge that
|
||||||
|
// the Connector would emit. RFC 7515 §3.4 fixed-width r||s ES256 form
|
||||||
|
// because that's the canonical JOSE shape.
|
||||||
|
func signIntuneChallengeES256(t *testing.T, connectorKey *ecdsa.PrivateKey, payload map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
hdr, _ := json.Marshal(map[string]string{"alg": "ES256", "typ": "JWT"})
|
||||||
|
pl, _ := json.Marshal(payload)
|
||||||
|
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||||
|
base64.RawURLEncoding.EncodeToString(pl)
|
||||||
|
h := sha256.Sum256([]byte(signingInput))
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, connectorKey, h[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.Sign: %v", err)
|
||||||
|
}
|
||||||
|
rb, sb := r.Bytes(), s.Bytes()
|
||||||
|
sig := make([]byte, 64)
|
||||||
|
copy(sig[32-len(rb):], rb)
|
||||||
|
copy(sig[64-len(sb):], sb)
|
||||||
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validIntuneE2EClaim returns a claim payload that matches a CSR with
|
||||||
|
// CN=device-corp-001.example.com — the dispatcher's DeviceMatchesCSR
|
||||||
|
// uses set-equality semantics, so we only pin device_name (CN). The
|
||||||
|
// CSR builder helper buildTestCSR doesn't populate DNSNames so we
|
||||||
|
// deliberately leave san_dns out of the claim — adding it would trip
|
||||||
|
// ErrClaimSANDNSMismatch (claim says ['x'], CSR has no DNS SANs).
|
||||||
|
// The claim_mismatch sibling test exercises the SAN-dimension failure
|
||||||
|
// path via the claim_mismatch counter.
|
||||||
|
func validIntuneE2EClaim(now time.Time, nonce string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"iss": "intune-connector-installation-fixture",
|
||||||
|
"sub": "device-guid-corp-001",
|
||||||
|
"aud": "https://certctl.example.com/scep/test",
|
||||||
|
"iat": now.Add(-1 * time.Minute).Unix(),
|
||||||
|
"exp": now.Add(59 * time.Minute).Unix(),
|
||||||
|
"nonce": nonce,
|
||||||
|
"device_name": "device-corp-001.example.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_E2E walks the full Phase 10.2 spec scenario:
|
||||||
|
// boot the stack (in-process), forge a valid challenge, build a CSR
|
||||||
|
// matching the claim, POST through the handler, decode the CertRep
|
||||||
|
// response, assert success + audit log + counter increment.
|
||||||
|
func TestSCEPIntuneEnrollment_E2E(t *testing.T) {
|
||||||
|
fix := newIntuneE2EFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
intuneChallenge := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-nonce-001"))
|
||||||
|
if !strings.Contains(intuneChallenge, ".") || len(intuneChallenge) <= 200 {
|
||||||
|
t.Fatalf("forged challenge doesn't satisfy looksIntuneShaped: len=%d", len(intuneChallenge))
|
||||||
|
}
|
||||||
|
|
||||||
|
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-e2e-001", intuneChallenge, "device-corp-001.example.com")
|
||||||
|
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData(CertRep): %v", err)
|
||||||
|
}
|
||||||
|
if len(certRep.SignerInfos) != 1 {
|
||||||
|
t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos))
|
||||||
|
}
|
||||||
|
statusRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("CertRep missing pkiStatus auth-attr")
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, statusRV)
|
||||||
|
if statusStr != string(domain.SCEPStatusSuccess) {
|
||||||
|
t.Errorf("pkiStatus = %q, want %q (SUCCESS)", statusStr, domain.SCEPStatusSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fix.issuer.issued) != 1 {
|
||||||
|
t.Fatalf("issuer received %d issuances, want 1", len(fix.issuer.issued))
|
||||||
|
}
|
||||||
|
if fix.issuer.issued[0].commonName != "device-corp-001.example.com" {
|
||||||
|
t.Errorf("issued CN = %q, want device-corp-001.example.com", fix.issuer.issued[0].commonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundIntune := false
|
||||||
|
for _, a := range fix.auditRepo.actions() {
|
||||||
|
if a == "scep_pkcsreq_intune" {
|
||||||
|
foundIntune = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundIntune {
|
||||||
|
t.Errorf("expected an audit_event with action=scep_pkcsreq_intune; got actions=%v", fix.auditRepo.actions())
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := fix.scepService.IntuneStats(time.Now())
|
||||||
|
if got := stats.Counters["success"]; got != 1 {
|
||||||
|
t.Errorf("IntuneStats.counters[success] = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E builds a CSR whose
|
||||||
|
// CN does NOT match the claim's device_name. The dispatcher should
|
||||||
|
// reject with a CertRep FAILURE+BadRequest rather than issuing the
|
||||||
|
// cert. Per Phase 8 + the spec's claim-mismatch failInfo mapping
|
||||||
|
// (mapIntuneErrorToFailInfo).
|
||||||
|
func TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E(t *testing.T) {
|
||||||
|
fix := newIntuneE2EFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
intuneChallenge := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-mismatch-001"))
|
||||||
|
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-mismatch", intuneChallenge, "attacker-host.example.com")
|
||||||
|
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation (mismatch): got %d, want 200 (CertRep+failInfo wire shape, body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData(CertRep): %v", err)
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Fatalf("pkiStatus = %q, want %q (FAILURE) for claim-mismatched CSR", statusStr, domain.SCEPStatusFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
failRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("CertRep missing failInfo auth-attr on a FAILURE response")
|
||||||
|
}
|
||||||
|
failStr := decodeFirstSetMember(t, failRV)
|
||||||
|
if failStr != string(domain.SCEPFailBadRequest) {
|
||||||
|
t.Errorf("failInfo = %q, want %q (BadRequest) for claim mismatch", failStr, domain.SCEPFailBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fix.issuer.issued) != 0 {
|
||||||
|
t.Errorf("issuer should NOT have issued a cert for a claim-mismatched CSR; got %d issuances", len(fix.issuer.issued))
|
||||||
|
}
|
||||||
|
stats := fix.scepService.IntuneStats(time.Now())
|
||||||
|
if got := stats.Counters["claim_mismatch"]; got != 1 {
|
||||||
|
t.Errorf("IntuneStats.counters[claim_mismatch] = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_TamperedSignature_E2E flips a byte in the
|
||||||
|
// JWT signature segment of the Intune challenge before wrapping it in
|
||||||
|
// the PKIMessage. The dispatcher should reject with FAILURE+BadMessageCheck
|
||||||
|
// (mapIntuneErrorToFailInfo: signature errors → BadMessageCheck).
|
||||||
|
func TestSCEPIntuneEnrollment_TamperedSignature_E2E(t *testing.T) {
|
||||||
|
fix := newIntuneE2EFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
good := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-tamper-001"))
|
||||||
|
parts := strings.Split(good, ".")
|
||||||
|
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
sig[0] ^= 0xFF
|
||||||
|
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
tampered := strings.Join(parts, ".")
|
||||||
|
|
||||||
|
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-tamper", tampered, "device-corp-001.example.com")
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation (tampered): got %d, want 200 with FAILURE pkiStatus (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Errorf("pkiStatus = %q, want FAILURE for tampered Intune sig", statusStr)
|
||||||
|
}
|
||||||
|
failStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()])
|
||||||
|
if failStr != string(domain.SCEPFailBadMessageCheck) {
|
||||||
|
t.Errorf("failInfo = %q, want BadMessageCheck for tampered Intune sig", failStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIntuneE2EPKIMessage builds a real SCEP PKIMessage that wraps the
|
||||||
|
// given Intune-shaped challenge as challengePassword inside an
|
||||||
|
// EnvelopedData(KTRI(raCert), AES-256-CBC(CSR + challengePassword)).
|
||||||
|
// Mirrors buildChromeOSStylePKIMessage but lets the test override the
|
||||||
|
// challengePassword to an Intune-shaped JWT-like blob.
|
||||||
|
func buildIntuneE2EPKIMessage(t *testing.T, fix *intuneE2EFixture, transactionID, challengePassword, csrCN string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword)
|
||||||
|
|
||||||
|
symKey := aesKeyForOID(pkcs7.OIDAES256CBC)
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand iv: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||||
|
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa encrypt symKey: %v", err)
|
||||||
|
}
|
||||||
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey)))
|
||||||
|
signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, transactionID, []byte("0123456789abcdef"), envelopedData)
|
||||||
|
return signedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SCEP RFC 8894 + Intune master-prompt §13 line 1849 acceptance — the two
|
||||||
|
// remaining e2e named tests: _RateLimited_E2E + _TrustAnchorSIGHUPReload_E2E.
|
||||||
|
// Closed in the 2026-04-29 audit-closure bundle.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_RateLimited_E2E exercises the full
|
||||||
|
// handler→service→dispatcher chain past the per-device rate-limit cap.
|
||||||
|
// The fixture's default cap (3) is too high for a quick test; we
|
||||||
|
// re-inject a fresh limiter with cap=2 so the 3rd attempt for the same
|
||||||
|
// (Subject, Issuer) returns FAILURE+BadRequest with rate_limited
|
||||||
|
// counter ticked. Each PKIMessage carries a distinct nonce (replay
|
||||||
|
// cache otherwise rejects on duplicate-nonce well before the limiter
|
||||||
|
// fires), and a distinct transactionID so the audit-log shape is
|
||||||
|
// inspectable per attempt.
|
||||||
|
func TestSCEPIntuneEnrollment_RateLimited_E2E(t *testing.T) {
|
||||||
|
fix := newIntuneE2EFixture(t)
|
||||||
|
|
||||||
|
// Re-wire SetIntuneIntegration with a stricter cap so the test
|
||||||
|
// stays fast. Also a fresh replay cache so a previous attempt's
|
||||||
|
// state doesn't leak into this test if Go ever reorders test
|
||||||
|
// execution within the package.
|
||||||
|
tightLimiter := intune.NewPerDeviceRateLimiter(2, 24*time.Hour, 100)
|
||||||
|
freshReplay := intune.NewReplayCache(60*time.Minute, 100)
|
||||||
|
fix.scepService.SetIntuneIntegration(
|
||||||
|
fix.trustHolder,
|
||||||
|
"https://certctl.example.com/scep/test",
|
||||||
|
60*time.Minute,
|
||||||
|
0, // ClockSkewTolerance — strict (we mint claims at time.Now())
|
||||||
|
freshReplay,
|
||||||
|
tightLimiter,
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// First two attempts succeed (cap=2 means ≤2 issuances per 24h).
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
nonce := "e2e-rate-allow-" + string(rune('a'+i))
|
||||||
|
ch := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, nonce))
|
||||||
|
txn := "txn-rate-allow-" + string(rune('a'+i))
|
||||||
|
pkiMessage := buildIntuneE2EPKIMessage(t, fix, txn, ch, "device-corp-001.example.com")
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("attempt %d: HTTP %d (body=%q)", i+1, w.Code, body)
|
||||||
|
}
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d: ParseSignedData: %v", i+1, err)
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusSuccess) {
|
||||||
|
t.Fatalf("attempt %d: pkiStatus = %q, want SUCCESS (the allowed first %d/%d)", i+1, statusStr, i+1, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3rd attempt for the SAME (Subject, Issuer) MUST be rate-limited.
|
||||||
|
tripCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-rate-deny-c"))
|
||||||
|
tripMsg := buildIntuneE2EPKIMessage(t, fix, "txn-rate-deny-c", tripCh, "device-corp-001.example.com")
|
||||||
|
w, body := postPKIOperation(t, fix.handler, tripMsg)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rate-limited attempt: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep on every PKIOperation, including failures", w.Code, body)
|
||||||
|
}
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rate-limited attempt: ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Fatalf("rate-limited pkiStatus = %q, want FAILURE", statusStr)
|
||||||
|
}
|
||||||
|
failRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("rate-limited CertRep missing failInfo auth-attr")
|
||||||
|
}
|
||||||
|
failStr := decodeFirstSetMember(t, failRV)
|
||||||
|
if failStr != string(domain.SCEPFailBadRequest) {
|
||||||
|
t.Errorf("rate-limited failInfo = %q, want BadRequest (mapIntuneErrorToFailInfo: rate_limit → BadRequest)", failStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture's issuer should have seen exactly 2 issuances (the
|
||||||
|
// allowed pair) — the 3rd was blocked at the dispatcher gate.
|
||||||
|
if got, want := len(fix.issuer.issued), 2; got != want {
|
||||||
|
t.Errorf("issuer issuances = %d, want %d (rate-limited 3rd should not reach the issuer)", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log — at least one rate-limited entry. The dispatcher's
|
||||||
|
// audit action is "scep_pkcsreq_intune" for both successes and
|
||||||
|
// failures; we inspect the counter table for the rate_limited tick.
|
||||||
|
stats := fix.scepService.IntuneStats(time.Now())
|
||||||
|
if got := stats.Counters["rate_limited"]; got != 1 {
|
||||||
|
t.Errorf("IntuneStats.counters[rate_limited] = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := stats.Counters["success"]; got != 2 {
|
||||||
|
t.Errorf("IntuneStats.counters[success] = %d, want 2 (cap=2 allowed pair)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E proves the full
|
||||||
|
// SIGHUP-reload contract end-to-end: an enrollment that succeeds against
|
||||||
|
// the original trust anchor MUST fail after the operator rotates the
|
||||||
|
// on-disk file + reloads, when the device tries to enroll with the OLD
|
||||||
|
// connector key.
|
||||||
|
//
|
||||||
|
// Why we call holder.Reload() directly instead of os.Process.Signal(SIGHUP):
|
||||||
|
// signal delivery in tests is flaky (signals to the test process can
|
||||||
|
// race with t.Parallel(), and signal.Notify is global). The SIGHUP
|
||||||
|
// goroutine's only job is to call Reload, so calling Reload directly is
|
||||||
|
// the equivalent contract — and stable in tests. Phase B frozen
|
||||||
|
// decision #3 in cowork/scep-bundle-gap-closure-prompt.md.
|
||||||
|
func TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E(t *testing.T) {
|
||||||
|
fix := newIntuneE2EFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Step 1: a valid enrollment against the original trust anchor.
|
||||||
|
originalCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-sighup-pre"))
|
||||||
|
originalMsg := buildIntuneE2EPKIMessage(t, fix, "txn-sighup-pre", originalCh, "device-corp-001.example.com")
|
||||||
|
w, body := postPKIOperation(t, fix.handler, originalMsg)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("pre-rotation enrollment: HTTP %d (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pre-rotation ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusSuccess) {
|
||||||
|
t.Fatalf("pre-rotation pkiStatus = %q, want SUCCESS", statusStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: operator rotates the trust anchor — write a fresh signing
|
||||||
|
// cert from a NEW key into the same path. Holder.Reload() then
|
||||||
|
// swaps the in-memory pool to the new bundle. The OLD key
|
||||||
|
// (fix.connectorKey) is now disowned.
|
||||||
|
rotatedKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotated key: %v", err)
|
||||||
|
}
|
||||||
|
rotatedCert := selfSignedECCertForIntuneE2E(t, rotatedKey, "intune-connector-rotated")
|
||||||
|
if err := os.WriteFile(fix.trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rotatedCert.Raw}), 0o600); err != nil {
|
||||||
|
t.Fatalf("rewrite trust anchor file: %v", err)
|
||||||
|
}
|
||||||
|
if err := fix.trustHolder.Reload(); err != nil {
|
||||||
|
t.Fatalf("trustHolder.Reload (post-rotation): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: a device that signs with the OLD connector key MUST be
|
||||||
|
// rejected — the holder no longer recognizes the signature.
|
||||||
|
staleCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-sighup-stale"))
|
||||||
|
staleMsg := buildIntuneE2EPKIMessage(t, fix, "txn-sighup-stale", staleCh, "device-corp-001.example.com")
|
||||||
|
w, body = postPKIOperation(t, fix.handler, staleMsg)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("stale-key enrollment: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep+failInfo wire shape", w.Code, body)
|
||||||
|
}
|
||||||
|
certRep, err = pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stale-key ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
statusStr = decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Fatalf("stale-key pkiStatus = %q, want FAILURE after trust-anchor rotation", statusStr)
|
||||||
|
}
|
||||||
|
failStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()])
|
||||||
|
if failStr != string(domain.SCEPFailBadMessageCheck) {
|
||||||
|
t.Errorf("stale-key failInfo = %q, want BadMessageCheck (mapIntuneErrorToFailInfo: sig errors → BadMessageCheck)", failStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := fix.scepService.IntuneStats(time.Now())
|
||||||
|
if got := stats.Counters["signature_invalid"]; got != 1 {
|
||||||
|
t.Errorf("IntuneStats.counters[signature_invalid] = %d, want 1 (post-rotation stale-key attempt)", got)
|
||||||
|
}
|
||||||
|
if got := stats.Counters["success"]; got != 1 {
|
||||||
|
t.Errorf("IntuneStats.counters[success] = %d, want 1 (only the pre-rotation attempt)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
|
"github.com/shankar0123/certctl/internal/scep/intune"
|
||||||
|
"github.com/shankar0123/certctl/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance —
|
||||||
|
// "Per-profile dispatch test must prove per-profile counters in
|
||||||
|
// metrics." Closed in the 2026-04-29 audit-closure bundle (Phase E).
|
||||||
|
//
|
||||||
|
// Why this test exists separately from the existing router-level
|
||||||
|
// /scep/<pathID> dispatch test (TestRouter_RegisterSCEPHandlers_
|
||||||
|
// MultipleProfilesNoCrossBleed): that test proves the route table
|
||||||
|
// doesn't bleed; this one proves the in-memory observability state
|
||||||
|
// (intuneCounterTab) is per-SCEPService, not shared. The bug class
|
||||||
|
// it guards against is a future cmd/server/main.go refactor that
|
||||||
|
// constructs a single shared *intuneCounterTab and injects it into
|
||||||
|
// every per-profile service — that would compile cleanly, pass the
|
||||||
|
// existing route-table test, and silently inflate one profile's
|
||||||
|
// counters with another's traffic.
|
||||||
|
|
||||||
|
// TestSCEPHandler_PerProfileIntuneCountersIsolated wires two real
|
||||||
|
// SCEPService instances, each with its OWN trust anchor + audience.
|
||||||
|
// A success on profile "corp" MUST NOT tick "iot"'s success counter,
|
||||||
|
// and vice versa for the failure path. The test constructs the
|
||||||
|
// fixtures hermetically (no shared state between the two profiles
|
||||||
|
// except the test's t.TempDir + selfSignedRSACert helpers).
|
||||||
|
func TestSCEPHandler_PerProfileIntuneCountersIsolated(t *testing.T) {
|
||||||
|
corpFix := buildPerProfileIntuneFixture(t, "corp", "https://certctl.example.com/scep/corp")
|
||||||
|
iotFix := buildPerProfileIntuneFixture(t, "iot", "https://certctl.example.com/scep/iot")
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// --- Drive a SUCCESS through CORP ---
|
||||||
|
corpChallenge := signIntuneChallengeES256(t, corpFix.connectorKey, map[string]any{
|
||||||
|
"iss": "intune-connector-corp-fixture",
|
||||||
|
"sub": "device-guid-corp-001",
|
||||||
|
"aud": "https://certctl.example.com/scep/corp",
|
||||||
|
"iat": now.Add(-1 * time.Minute).Unix(),
|
||||||
|
"exp": now.Add(59 * time.Minute).Unix(),
|
||||||
|
"nonce": "iso-corp-nonce-001",
|
||||||
|
"device_name": "device-corp-001.example.com",
|
||||||
|
})
|
||||||
|
corpMsg := buildIntuneE2EPKIMessage(t, corpFix, "txn-iso-corp", corpChallenge, "device-corp-001.example.com")
|
||||||
|
w, body := postPKIOperation(t, corpFix.handler, corpMsg)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("corp success: HTTP %d (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drive an EXPIRED challenge through IOT ---
|
||||||
|
iotChallenge := signIntuneChallengeES256(t, iotFix.connectorKey, map[string]any{
|
||||||
|
"iss": "intune-connector-iot-fixture",
|
||||||
|
"sub": "device-guid-iot-001",
|
||||||
|
"aud": "https://certctl.example.com/scep/iot",
|
||||||
|
"iat": now.Add(-2 * time.Hour).Unix(),
|
||||||
|
"exp": now.Add(-1 * time.Hour).Unix(), // expired
|
||||||
|
"nonce": "iso-iot-nonce-001",
|
||||||
|
"device_name": "device-iot-001.example.com",
|
||||||
|
})
|
||||||
|
iotMsg := buildIntuneE2EPKIMessage(t, iotFix, "txn-iso-iot", iotChallenge, "device-iot-001.example.com")
|
||||||
|
w, body = postPKIOperation(t, iotFix.handler, iotMsg)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("iot expired: HTTP %d — RFC 8894 §3.3 mandates a CertRep on every PKIOperation including failures; body=%q", w.Code, body)
|
||||||
|
}
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iot expired: ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
|
||||||
|
if statusStr != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Errorf("iot expired pkiStatus = %q, want FAILURE", statusStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Assert per-service counter isolation ---
|
||||||
|
corpStats := corpFix.scepService.IntuneStats(time.Now())
|
||||||
|
iotStats := iotFix.scepService.IntuneStats(time.Now())
|
||||||
|
|
||||||
|
if got, want := corpStats.PathID, "corp"; got != want {
|
||||||
|
t.Errorf("corp PathID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := iotStats.PathID, "iot"; got != want {
|
||||||
|
t.Errorf("iot PathID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORP should have exactly one success and zero of every other label.
|
||||||
|
if got := corpStats.Counters["success"]; got != 1 {
|
||||||
|
t.Errorf("corp.Counters[success] = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := corpStats.Counters["expired"]; got != 0 {
|
||||||
|
t.Errorf("corp.Counters[expired] = %d, want 0 (iot's expired traffic must NOT bleed into corp)", got)
|
||||||
|
}
|
||||||
|
// IOT should have exactly one expired and zero successes.
|
||||||
|
if got := iotStats.Counters["expired"]; got != 1 {
|
||||||
|
t.Errorf("iot.Counters[expired] = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := iotStats.Counters["success"]; got != 0 {
|
||||||
|
t.Errorf("iot.Counters[success] = %d, want 0 (corp's success traffic must NOT bleed into iot)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the issuer-side state — corp's mock issuer saw the issuance,
|
||||||
|
// iot's did not. This pins that the per-profile dispatch reaches
|
||||||
|
// the per-profile issuer connector too (not just the counter tab).
|
||||||
|
if got, want := len(corpFix.issuer.issued), 1; got != want {
|
||||||
|
t.Errorf("corp issuances = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(iotFix.issuer.issued), 0; got != want {
|
||||||
|
t.Errorf("iot issuances = %d, want %d (iot's expired challenge must NOT have produced issuance)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPerProfileIntuneFixture builds an Intune-enabled SCEPService for
|
||||||
|
// the given pathID + audience, with its own freshly-generated trust
|
||||||
|
// anchor + RA pair + issuer mock. Mirrors newIntuneE2EFixture but
|
||||||
|
// parameterized so the per-profile-isolation test can stand up two
|
||||||
|
// independent stacks side-by-side.
|
||||||
|
func buildPerProfileIntuneFixture(t *testing.T, pathID, audience string) *intuneE2EFixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
connectorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connector key (%s): %v", pathID, err)
|
||||||
|
}
|
||||||
|
connectorCert := selfSignedECCertForIntuneE2E(t, connectorKey, "intune-connector-"+pathID)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
trustPath := filepath.Join(dir, "intune-trust-"+pathID+".pem")
|
||||||
|
if err := os.WriteFile(trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: connectorCert.Raw}), 0o600); err != nil {
|
||||||
|
t.Fatalf("write trust anchor (%s): %v", pathID, err)
|
||||||
|
}
|
||||||
|
trustHolder, err := intune.NewTrustAnchorHolder(trustPath, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTrustAnchorHolder (%s): %v", pathID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ra key (%s): %v", pathID, err)
|
||||||
|
}
|
||||||
|
raCert := selfSignedRSACert(t, raKey, "ra-iso-"+pathID)
|
||||||
|
|
||||||
|
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ca key (%s): %v", pathID, err)
|
||||||
|
}
|
||||||
|
caCert := selfSignedRSACert(t, caKey, "test-fixture-ca-"+pathID)
|
||||||
|
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})
|
||||||
|
|
||||||
|
issuer := &intuneE2EIssuerConnector{
|
||||||
|
caPEM: string(caPEM),
|
||||||
|
signKey: caKey,
|
||||||
|
caCert: caCert,
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRepo := &intuneE2EAuditRepo{}
|
||||||
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||||
|
scepSvc := service.NewSCEPService("iss-"+pathID, issuer, auditSvc, logger, "static-fallback-"+pathID)
|
||||||
|
scepSvc.SetPathID(pathID)
|
||||||
|
scepSvc.SetIntuneIntegration(
|
||||||
|
trustHolder,
|
||||||
|
audience,
|
||||||
|
60*time.Minute,
|
||||||
|
0, // ClockSkewTolerance — strict
|
||||||
|
intune.NewReplayCache(60*time.Minute, 100),
|
||||||
|
intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("device key (%s): %v", pathID, err)
|
||||||
|
}
|
||||||
|
deviceCert := selfSignedRSACert(t, deviceKey, "device-iso-"+pathID)
|
||||||
|
|
||||||
|
handler := NewSCEPHandler(scepSvc)
|
||||||
|
handler.SetRAPair(raCert, raKey)
|
||||||
|
|
||||||
|
return &intuneE2EFixture{
|
||||||
|
connectorKey: connectorKey,
|
||||||
|
connectorDir: dir,
|
||||||
|
trustPath: trustPath,
|
||||||
|
trustHolder: trustHolder,
|
||||||
|
raKey: raKey,
|
||||||
|
raCert: raCert,
|
||||||
|
deviceKey: deviceKey,
|
||||||
|
deviceCert: deviceCert,
|
||||||
|
issuer: issuer,
|
||||||
|
auditRepo: auditRepo,
|
||||||
|
scepService: scepSvc,
|
||||||
|
handler: handler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import for httptest (only needed if a future test in
|
||||||
|
// this file constructs requests directly — kept here to avoid a
|
||||||
|
// goimports-driven churn the next time the file gains a test).
|
||||||
|
var _ = httptest.NewRecorder
|
||||||
@@ -127,6 +127,14 @@ type HandlerRegistry struct {
|
|||||||
// Responder Phase 5 — admin-gated ops surface for the
|
// Responder Phase 5 — admin-gated ops surface for the
|
||||||
// scheduler-driven CRL pre-generation pipeline.
|
// scheduler-driven CRL pre-generation pipeline.
|
||||||
AdminCRLCache handler.AdminCRLCacheHandler
|
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.
|
// RegisterHandlers sets up all API routes with their handlers.
|
||||||
@@ -296,6 +304,14 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
||||||
// the handler (M-003 pattern); non-admin callers get 403.
|
// the handler (M-003 pattern); non-admin callers get 403.
|
||||||
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md). All three endpoints are
|
||||||
|
// admin-gated at the handler layer; the M-008 regression scanner pins
|
||||||
|
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
|
||||||
|
// enforces the per-handler test triplet.
|
||||||
|
r.Register("GET /api/v1/admin/scep/profiles", http.HandlerFunc(reg.AdminSCEPIntune.Profiles))
|
||||||
|
r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats))
|
||||||
|
r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust))
|
||||||
|
|
||||||
// Notifications routes: /api/v1/notifications
|
// Notifications routes: /api/v1/notifications
|
||||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
|
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
|
||||||
@@ -333,6 +349,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
|
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
|
||||||
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
|
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
|
||||||
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
|
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
|
// Bearer-auth gated by the standard middleware chain; not admin-
|
||||||
|
// only because the probe is read-only against operator-supplied
|
||||||
|
// URLs and reuses the existing SafeHTTPDialContext SSRF defense.
|
||||||
|
r.Register("POST /api/v1/network-scan/scep-probe", http.HandlerFunc(reg.NetworkScan.ProbeSCEP))
|
||||||
|
r.Register("GET /api/v1/network-scan/scep-probes", http.HandlerFunc(reg.NetworkScan.ListSCEPProbes))
|
||||||
|
|
||||||
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
|
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
|
||||||
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
|
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
|
||||||
|
|||||||
@@ -820,6 +820,77 @@ type SCEPProfileConfig struct {
|
|||||||
// `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists,
|
// `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists,
|
||||||
// parses as PEM, contains ≥1 cert, none expired.
|
// parses as PEM, contains ≥1 cert, none expired.
|
||||||
MTLSClientCATrustBundlePath string
|
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
|
||||||
|
|
||||||
|
// ClockSkewTolerance widens the iat/exp validation window by
|
||||||
|
// ±|tolerance| to absorb modest clock drift between the Microsoft
|
||||||
|
// Intune Certificate Connector and the certctl host. Default 60s
|
||||||
|
// per master prompt §15 ("known hazards"). Operators on tightly
|
||||||
|
// time-synced fleets can set this to zero to enforce strict
|
||||||
|
// iat/exp checks; operators on loosely synced fleets (e.g. field
|
||||||
|
// devices with no NTP) may raise to 5m. Validate() refuses any
|
||||||
|
// tolerance ≥ ChallengeValidity (which would make the per-profile
|
||||||
|
// validity cap meaningless). Source env var:
|
||||||
|
// CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
|
||||||
|
ClockSkewTolerance time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkScanConfig controls the server-side active TLS scanner.
|
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||||
@@ -1448,6 +1519,15 @@ func loadSCEPProfilesFromEnv() []SCEPProfileConfig {
|
|||||||
// SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route.
|
// SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route.
|
||||||
MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false),
|
MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false),
|
||||||
MTLSClientCATrustBundlePath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""),
|
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),
|
||||||
|
ClockSkewTolerance: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CLOCK_SKEW_TOLERANCE", 60*time.Second),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -1706,6 +1786,38 @@ func (c *Config) Validate() error {
|
|||||||
if p.MTLSEnabled && p.MTLSClientCATrustBundlePath == "" {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
// Master prompt §15 hazard closure: clock-skew tolerance must
|
||||||
|
// be ≥0 AND strictly less than ChallengeValidity. A negative
|
||||||
|
// value is operator typo; a value ≥ ChallengeValidity makes
|
||||||
|
// the iat/exp checks vacuously pass (a Connector challenge
|
||||||
|
// minted at NotBefore-tolerance still validates), defeating
|
||||||
|
// the per-profile validity cap. Reject at startup so the
|
||||||
|
// operator's first grep narrows it down fast.
|
||||||
|
if p.Intune.ClockSkewTolerance < 0 {
|
||||||
|
return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_CLOCK_SKEW_TOLERANCE=%s — refuse to start: must be ≥0 (zero disables the grace window, positive values widen it)", i, p.PathID, p.Intune.ClockSkewTolerance)
|
||||||
|
}
|
||||||
|
if p.Intune.ChallengeValidity > 0 && p.Intune.ClockSkewTolerance >= p.Intune.ChallengeValidity {
|
||||||
|
return fmt.Errorf("SCEP profile %d (PathID=%q) has INTUNE_CLOCK_SKEW_TOLERANCE=%s ≥ INTUNE_CHALLENGE_VALIDITY=%s — refuse to start: tolerance ≥ validity makes the per-profile validity cap vacuous", i, p.PathID, p.Intune.ClockSkewTolerance, p.Intune.ChallengeValidity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import "time"
|
|||||||
|
|
||||||
// NetworkScanTarget defines a network range to scan for TLS certificates.
|
// NetworkScanTarget defines a network range to scan for TLS certificates.
|
||||||
type NetworkScanTarget struct {
|
type NetworkScanTarget struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CIDRs []string `json:"cidrs"`
|
CIDRs []string `json:"cidrs"`
|
||||||
Ports []int64 `json:"ports"`
|
Ports []int64 `json:"ports"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ScanIntervalHours int `json:"scan_interval_hours"`
|
ScanIntervalHours int `json:"scan_interval_hours"`
|
||||||
TimeoutMs int `json:"timeout_ms"`
|
TimeoutMs int `json:"timeout_ms"`
|
||||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||||
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
||||||
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkScanResult holds the outcome of scanning a single endpoint.
|
// NetworkScanResult holds the outcome of scanning a single endpoint.
|
||||||
@@ -25,3 +25,43 @@ type NetworkScanResult struct {
|
|||||||
Error string
|
Error string
|
||||||
LatencyMs int
|
LatencyMs int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEPProbeResult is the per-target output of an SCEP probe — a
|
||||||
|
// capability/posture snapshot of an SCEP server (RFC 8894 §3.5.1
|
||||||
|
// GetCACaps + §3.5.1 GetCACert). Used for pre-migration assessment
|
||||||
|
// (operators about to switch from EJBCA / NDES to certctl run the
|
||||||
|
// scanner against their existing SCEP server first) and compliance
|
||||||
|
// posture audits.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
//
|
||||||
|
// The probe deliberately does NOT POST a CSR — that would consume slot
|
||||||
|
// allocations on the target server and create audit noise. Reachability
|
||||||
|
// + capability + CA-cert metadata is the value this returns.
|
||||||
|
//
|
||||||
|
// Persistence: instances are stored in scep_probe_results (migration
|
||||||
|
// 000021) so the operator's GUI can show recent probe history.
|
||||||
|
type SCEPProbeResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
AdvertisedCaps []string `json:"advertised_caps"` // GetCACaps response, parsed
|
||||||
|
SupportsRFC8894 bool `json:"supports_rfc8894"` // GetCACaps contains "SCEPStandard"
|
||||||
|
SupportsAES bool `json:"supports_aes"` // contains "AES"
|
||||||
|
SupportsPOSTOperation bool `json:"supports_post_operation"` // contains "POSTPKIOperation"
|
||||||
|
SupportsRenewal bool `json:"supports_renewal"` // contains "Renewal"
|
||||||
|
SupportsSHA256 bool `json:"supports_sha256"` // contains "SHA-256"
|
||||||
|
SupportsSHA512 bool `json:"supports_sha512"` // contains "SHA-512"
|
||||||
|
CACertSubject string `json:"ca_cert_subject,omitempty"` // GetCACert leaf cert subject DN
|
||||||
|
CACertIssuer string `json:"ca_cert_issuer,omitempty"` // leaf cert issuer DN
|
||||||
|
CACertNotBefore time.Time `json:"ca_cert_not_before,omitempty"`
|
||||||
|
CACertNotAfter time.Time `json:"ca_cert_not_after,omitempty"`
|
||||||
|
CACertExpired bool `json:"ca_cert_expired"`
|
||||||
|
CACertDaysToExpiry int `json:"ca_cert_days_to_expiry"`
|
||||||
|
CACertAlgorithm string `json:"ca_cert_algorithm,omitempty"` // "RSA-2048", "ECDSA-P256", etc.
|
||||||
|
CACertChainLength int `json:"ca_cert_chain_length"` // 1 = single cert, >1 = full chain returned
|
||||||
|
ProbedAt time.Time `json:"probed_at"`
|
||||||
|
ProbeDurationMs int64 `json:"probe_duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1516,6 +1516,18 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
|
||||||
|
// satisfaction stubs. The lifecycle integration tests don't exercise
|
||||||
|
// the SCEP probe path; targeted coverage lives in
|
||||||
|
// internal/service/scep_probe_test.go.
|
||||||
|
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mockVerificationService implements handler.VerificationService for integration tests.
|
// mockVerificationService implements handler.VerificationService for integration tests.
|
||||||
type mockVerificationService struct{}
|
type mockVerificationService struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -554,6 +554,22 @@ type NetworkScanRepository interface {
|
|||||||
UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error
|
UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEPProbeResultRepository persists per-run SCEP probe snapshots.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5. The probe is a
|
||||||
|
// pre-migration / compliance-posture tool — operators run it ad-hoc
|
||||||
|
// against arbitrary SCEP server URLs and the GUI shows recent history.
|
||||||
|
// No FK to network_scan_targets — probe targets are URLs, not necessarily
|
||||||
|
// network-scan-target rows.
|
||||||
|
type SCEPProbeResultRepository interface {
|
||||||
|
// Insert persists a single probe outcome.
|
||||||
|
Insert(ctx context.Context, result *domain.SCEPProbeResult) error
|
||||||
|
// ListRecent returns the most recent N probe results across any URL,
|
||||||
|
// ordered by probed_at descending. Used by the GUI's "recent probes"
|
||||||
|
// table on the Network Scan page.
|
||||||
|
ListRecent(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
// OwnerRepository defines operations for managing certificate owners.
|
// OwnerRepository defines operations for managing certificate owners.
|
||||||
type OwnerRepository interface {
|
type OwnerRepository interface {
|
||||||
// List returns all owners.
|
// List returns all owners.
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEPProbeResultRepository is the PostgreSQL-backed implementation of
|
||||||
|
// repository.SCEPProbeResultRepository.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Each row is one
|
||||||
|
// completed probe run; the table accumulates history (no in-place
|
||||||
|
// updates) so the GUI can show "recent probes" without losing the prior
|
||||||
|
// snapshot's CA cert metadata.
|
||||||
|
type SCEPProbeResultRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSCEPProbeResultRepository creates a new Postgres-backed repo.
|
||||||
|
func NewSCEPProbeResultRepository(db *sql.DB) *SCEPProbeResultRepository {
|
||||||
|
return &SCEPProbeResultRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert persists a single probe result.
|
||||||
|
func (r *SCEPProbeResultRepository) Insert(ctx context.Context, result *domain.SCEPProbeResult) error {
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("scep probe result: nil")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO scep_probe_results (
|
||||||
|
id, target_url, reachable,
|
||||||
|
advertised_caps, supports_rfc8894, supports_aes,
|
||||||
|
supports_post_operation, supports_renewal,
|
||||||
|
supports_sha256, supports_sha512,
|
||||||
|
ca_cert_subject, ca_cert_issuer,
|
||||||
|
ca_cert_not_before, ca_cert_not_after, ca_cert_expired,
|
||||||
|
ca_cert_algorithm, ca_cert_chain_length,
|
||||||
|
probed_at, probe_duration_ms, error
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6,
|
||||||
|
$7, $8,
|
||||||
|
$9, $10,
|
||||||
|
$11, $12,
|
||||||
|
$13, $14, $15,
|
||||||
|
$16, $17,
|
||||||
|
$18, $19, $20
|
||||||
|
)`,
|
||||||
|
result.ID, result.TargetURL, result.Reachable,
|
||||||
|
pq.Array(result.AdvertisedCaps), result.SupportsRFC8894, result.SupportsAES,
|
||||||
|
result.SupportsPOSTOperation, result.SupportsRenewal,
|
||||||
|
result.SupportsSHA256, result.SupportsSHA512,
|
||||||
|
nullString(result.CACertSubject), nullString(result.CACertIssuer),
|
||||||
|
nullTime(result.CACertNotBefore), nullTime(result.CACertNotAfter), result.CACertExpired,
|
||||||
|
nullString(result.CACertAlgorithm), result.CACertChainLength,
|
||||||
|
result.ProbedAt, result.ProbeDurationMs, nullString(result.Error),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert scep probe result: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecent returns the most recent N probe results across any URL,
|
||||||
|
// ordered by probed_at descending. limit is clamped to [1, 200] to bound
|
||||||
|
// the response size — the GUI defaults to 50.
|
||||||
|
func (r *SCEPProbeResultRepository) ListRecent(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, target_url, reachable,
|
||||||
|
advertised_caps, supports_rfc8894, supports_aes,
|
||||||
|
supports_post_operation, supports_renewal,
|
||||||
|
supports_sha256, supports_sha512,
|
||||||
|
ca_cert_subject, ca_cert_issuer,
|
||||||
|
ca_cert_not_before, ca_cert_not_after, ca_cert_expired,
|
||||||
|
ca_cert_algorithm, ca_cert_chain_length,
|
||||||
|
probed_at, probe_duration_ms, error,
|
||||||
|
created_at
|
||||||
|
FROM scep_probe_results
|
||||||
|
ORDER BY probed_at DESC
|
||||||
|
LIMIT $1`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list recent scep probe results: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*domain.SCEPProbeResult
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
row domain.SCEPProbeResult
|
||||||
|
subject sql.NullString
|
||||||
|
issuer sql.NullString
|
||||||
|
notBefore sql.NullTime
|
||||||
|
notAfter sql.NullTime
|
||||||
|
algorithm sql.NullString
|
||||||
|
errString sql.NullString
|
||||||
|
)
|
||||||
|
err := rows.Scan(
|
||||||
|
&row.ID, &row.TargetURL, &row.Reachable,
|
||||||
|
pq.Array(&row.AdvertisedCaps), &row.SupportsRFC8894, &row.SupportsAES,
|
||||||
|
&row.SupportsPOSTOperation, &row.SupportsRenewal,
|
||||||
|
&row.SupportsSHA256, &row.SupportsSHA512,
|
||||||
|
&subject, &issuer,
|
||||||
|
¬Before, ¬After, &row.CACertExpired,
|
||||||
|
&algorithm, &row.CACertChainLength,
|
||||||
|
&row.ProbedAt, &row.ProbeDurationMs, &errString,
|
||||||
|
&row.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan scep probe result row: %w", err)
|
||||||
|
}
|
||||||
|
if subject.Valid {
|
||||||
|
row.CACertSubject = subject.String
|
||||||
|
}
|
||||||
|
if issuer.Valid {
|
||||||
|
row.CACertIssuer = issuer.String
|
||||||
|
}
|
||||||
|
if notBefore.Valid {
|
||||||
|
row.CACertNotBefore = notBefore.Time
|
||||||
|
}
|
||||||
|
if notAfter.Valid {
|
||||||
|
row.CACertNotAfter = notAfter.Time
|
||||||
|
if !row.CACertExpired {
|
||||||
|
// Re-derive days_to_expiry on read so it reflects the
|
||||||
|
// query-time wall clock rather than the persisted
|
||||||
|
// snapshot's wall clock — operators care about how
|
||||||
|
// fresh "30d remaining" is.
|
||||||
|
hours := time.Until(notAfter.Time).Hours()
|
||||||
|
row.CACertDaysToExpiry = int(hours / 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if algorithm.Valid {
|
||||||
|
row.CACertAlgorithm = algorithm.String
|
||||||
|
}
|
||||||
|
if errString.Valid {
|
||||||
|
row.Error = errString.String
|
||||||
|
}
|
||||||
|
out = append(out, &row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate scep probe results: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullString returns sql.NullString — empty becomes NULL.
|
||||||
|
func nullString(s string) sql.NullString {
|
||||||
|
if s == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullTime returns sql.NullTime — zero time becomes NULL.
|
||||||
|
func nullTime(t time.Time) sql.NullTime {
|
||||||
|
if t.IsZero() {
|
||||||
|
return sql.NullTime{}
|
||||||
|
}
|
||||||
|
return sql.NullTime{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface check.
|
||||||
|
var _ repository.SCEPProbeResultRepository = (*SCEPProbeResultRepository)(nil)
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOptions parameterizes ValidateChallenge. Introduced in the
|
||||||
|
// 2026-04-29 SCEP RFC 8894 + Intune master-prompt §15 hazard closure
|
||||||
|
// to add a configurable clock-skew tolerance without continuing to
|
||||||
|
// pile positional arguments onto the validator. Future per-validation
|
||||||
|
// knobs (e.g. an explicit version allow-list, a custom sig-alg policy)
|
||||||
|
// land here without churning every call site.
|
||||||
|
//
|
||||||
|
// Field defaults via the zero value MUST preserve the strict pre-§15
|
||||||
|
// behavior — i.e. a caller that passes ValidateOptions{Trust: ..., Now: ...}
|
||||||
|
// with no other fields gets exactly the iat/exp/audience semantics that
|
||||||
|
// shipped before the tolerance was introduced. This is a load-bearing
|
||||||
|
// contract for the existing test suite and any out-of-tree caller that
|
||||||
|
// hasn't migrated to opt-in tolerance.
|
||||||
|
type ValidateOptions struct {
|
||||||
|
// Trust is the pool of operator-supplied Connector signing-cert public
|
||||||
|
// keys to verify the challenge signature against. Required (an empty
|
||||||
|
// pool returns ErrChallengeSignature with a "no trust anchors
|
||||||
|
// configured" message so the operator boot-time misconfig is
|
||||||
|
// distinguishable from an in-the-wild signature mismatch).
|
||||||
|
Trust []*x509.Certificate
|
||||||
|
|
||||||
|
// ExpectedAudience is the SCEP endpoint URL the challenge's "aud"
|
||||||
|
// claim is expected to match. Empty disables the audience check
|
||||||
|
// (proxy / load-balancer scenarios where the URL the Connector saw
|
||||||
|
// differs from the URL we see, plus test convenience).
|
||||||
|
ExpectedAudience string
|
||||||
|
|
||||||
|
// Now is the wall-clock time used for the iat/exp comparisons.
|
||||||
|
// Injected (rather than read from time.Now() inside the function) so
|
||||||
|
// tests are deterministic and the per-profile dispatcher can pin a
|
||||||
|
// single "request started at" timestamp across the validate + replay
|
||||||
|
// + rate-limit triplet.
|
||||||
|
Now time.Time
|
||||||
|
|
||||||
|
// ClockSkewTolerance widens the iat/exp window by ±|tolerance| to
|
||||||
|
// absorb modest clock drift between the Microsoft Intune Certificate
|
||||||
|
// Connector and the certctl host. Default zero preserves strict
|
||||||
|
// pre-§15 behaviour. Operators wire this from the per-profile env
|
||||||
|
// var CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE
|
||||||
|
// (default 60s — see internal/config/config.go).
|
||||||
|
//
|
||||||
|
// Asymmetric application: an iat in the future is accepted when
|
||||||
|
// `now + tolerance >= iat` (so a Connector clock 30s ahead of certctl
|
||||||
|
// passes with tolerance=60s). An exp in the past is accepted when
|
||||||
|
// `now - tolerance < exp` (so a Connector clock 30s behind certctl
|
||||||
|
// passes too). Negative tolerance is treated as zero (a defensive
|
||||||
|
// no-op rather than a footgun that tightens the window).
|
||||||
|
ClockSkewTolerance time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateChallenge runs the full Intune-challenge validation pipeline:
|
||||||
|
//
|
||||||
|
// 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+tolerance ≥ iat AND now-tolerance < exp
|
||||||
|
// (tolerance defaults to zero — strict — and widens via opts)
|
||||||
|
// 6. Audience: claim.Audience == opts.ExpectedAudience (when
|
||||||
|
// ExpectedAudience is non-empty; empty disables the check)
|
||||||
|
//
|
||||||
|
// Returns *ChallengeClaim on success, typed error on failure (caller can
|
||||||
|
// 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, opts ValidateOptions) (*ChallengeClaim, error) {
|
||||||
|
if len(opts.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, opts.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. Tolerance defaults to zero (strict) and is normalized
|
||||||
|
// to absolute value so a misconfigured negative value is a defensive
|
||||||
|
// no-op rather than a footgun that tightens the window.
|
||||||
|
tolerance := opts.ClockSkewTolerance
|
||||||
|
if tolerance < 0 {
|
||||||
|
tolerance = -tolerance
|
||||||
|
}
|
||||||
|
now := opts.Now
|
||||||
|
// iat check: a future iat is accepted when (now + tolerance) >= iat.
|
||||||
|
// Equivalent to: reject when (now + tolerance) < iat.
|
||||||
|
if !claim.IssuedAt.IsZero() && now.Add(tolerance).Before(claim.IssuedAt) {
|
||||||
|
return nil, fmt.Errorf("%w: iat=%s now=%s tolerance=%s", ErrChallengeNotYetValid,
|
||||||
|
claim.IssuedAt.Format(time.RFC3339), now.Format(time.RFC3339), tolerance)
|
||||||
|
}
|
||||||
|
// exp check: a past exp is accepted when (now - tolerance) < exp.
|
||||||
|
// Equivalent to: reject when (now - tolerance) >= exp.
|
||||||
|
if !claim.ExpiresAt.IsZero() && !now.Add(-tolerance).Before(claim.ExpiresAt) {
|
||||||
|
return nil, fmt.Errorf("%w: exp=%s now=%s tolerance=%s", ErrChallengeExpired,
|
||||||
|
claim.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), tolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audience binds the challenge to a specific SCEP endpoint URL. An
|
||||||
|
// 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 opts.ExpectedAudience != "" && claim.Audience != "" && claim.Audience != opts.ExpectedAudience {
|
||||||
|
return nil, fmt.Errorf("%w: claim=%q expected=%q", ErrChallengeWrongAudience,
|
||||||
|
claim.Audience, opts.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package intune
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 10.1.
|
||||||
|
//
|
||||||
|
// challenge_golden_test.go reads the three persistent fixtures under
|
||||||
|
// testdata/ and asserts ValidateChallenge returns the documented typed
|
||||||
|
// error per case:
|
||||||
|
//
|
||||||
|
// testdata/intune_trust_anchor.pem — golden trust cert
|
||||||
|
// testdata/intune_challenge_golden_success.txt — valid challenge
|
||||||
|
// testdata/intune_challenge_golden_expired.txt — exp in past
|
||||||
|
// testdata/intune_challenge_golden_tampered_sig.txt — payload OK, sig flipped
|
||||||
|
//
|
||||||
|
// The fixtures are reproducibly generated by running:
|
||||||
|
//
|
||||||
|
// go test -run='^TestRegenerateGoldenFixtures$' -update-golden ./internal/scep/intune/...
|
||||||
|
//
|
||||||
|
// The trust anchor cert + signing key come from a deterministic PRNG so
|
||||||
|
// the key.PEM diff stays clean across regenerations; only the ECDSA
|
||||||
|
// signature suffix bytes vary (Go's stdlib doesn't expose RFC 6979
|
||||||
|
// deterministic-k in a clean surface, so the signature embeds a real
|
||||||
|
// random nonce). ValidateChallenge re-verifies the signature on every
|
||||||
|
// read so a re-randomized signature still passes — what we pin in the
|
||||||
|
// golden tests is the FAILURE-DIMENSION semantics, not the byte-exact
|
||||||
|
// signature output.
|
||||||
|
|
||||||
|
// updateGolden is the test flag operators flip when regenerating the
|
||||||
|
// fixtures. Default false: regular `go test` runs the read-and-validate
|
||||||
|
// path only.
|
||||||
|
var updateGolden = flag.Bool("update-golden", false, "regenerate testdata/intune_*.txt + intune_trust_anchor.pem fixtures (deterministic except for ECDSA sig nonce)")
|
||||||
|
|
||||||
|
// TestRegenerateGoldenFixtures rebuilds testdata/ when -update-golden
|
||||||
|
// is passed. Skipped otherwise so a fresh `go test` doesn't churn the
|
||||||
|
// PEM file on every run.
|
||||||
|
func TestRegenerateGoldenFixtures(t *testing.T) {
|
||||||
|
if !*updateGolden {
|
||||||
|
t.Skip("regenerate fixtures only when -update-golden is passed")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(testdataDir(t), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir testdata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, cert := generateGoldenTrustAnchor(t)
|
||||||
|
|
||||||
|
// Trust anchor PEM.
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(testdataDir(t), "intune_trust_anchor.pem"),
|
||||||
|
pemEncodeForFixture(cert.Raw),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write trust anchor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success fixture.
|
||||||
|
successRaw := signGoldenChallenge(t, key, goldenChallengePayload())
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(testdataDir(t), "intune_challenge_golden_success.txt"),
|
||||||
|
[]byte(successRaw+"\n"),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write success fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired fixture — same signing key, payload with iat+exp in the past.
|
||||||
|
expiredRaw := signGoldenChallenge(t, key, goldenExpiredChallengePayload())
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(testdataDir(t), "intune_challenge_golden_expired.txt"),
|
||||||
|
[]byte(expiredRaw+"\n"),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write expired fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampered-sig fixture — start from a fresh success challenge then
|
||||||
|
// flip one byte of the signature. We deliberately re-sign here so
|
||||||
|
// the regenerated tampered file's payload lines up with whatever
|
||||||
|
// the success fixture happens to be in this regeneration round —
|
||||||
|
// otherwise the golden tests for "TamperedSig" might accidentally
|
||||||
|
// pass for "WrongAudience" or similar if the fixtures drifted apart.
|
||||||
|
freshForTamper := signGoldenChallenge(t, key, goldenChallengePayload())
|
||||||
|
tamperedRaw := flipLastSignatureByte(t, freshForTamper)
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(testdataDir(t), "intune_challenge_golden_tampered_sig.txt"),
|
||||||
|
[]byte(tamperedRaw+"\n"),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write tampered fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown-version fixture — same signing key + valid signature, but
|
||||||
|
// the payload carries a `version: "v999"` claim that the dispatcher
|
||||||
|
// does NOT have an unmarshaler for. ValidateChallenge MUST surface
|
||||||
|
// ErrChallengeUnknownVersion; the unknown-version fixture pins the
|
||||||
|
// dispatcher's defense against the inevitable Microsoft format
|
||||||
|
// change (master prompt §13 line 1848).
|
||||||
|
unknownVersionRaw := signGoldenChallengeAny(t, key, goldenUnknownVersionPayload())
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(testdataDir(t), "intune_challenge_golden_unknown_version.txt"),
|
||||||
|
[]byte(unknownVersionRaw+"\n"),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write unknown-version fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("regenerated 5 fixture files in %s", testdataDir(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenChallenge_Success — the documented happy-path: the success
|
||||||
|
// fixture validates against the trust anchor and produces a populated
|
||||||
|
// claim. Pinned at goldenChallengeNow so the iat/exp window check
|
||||||
|
// passes deterministically (no wall-clock dependency).
|
||||||
|
func TestGoldenChallenge_Success(t *testing.T) {
|
||||||
|
trust := loadGoldenTrustAnchor(t)
|
||||||
|
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
|
||||||
|
|
||||||
|
claim, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: goldenChallengeNow})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateChallenge success fixture: %v", err)
|
||||||
|
}
|
||||||
|
if claim.DeviceName != "fixture-device.example.com" {
|
||||||
|
t.Errorf("DeviceName = %q, want fixture-device.example.com", claim.DeviceName)
|
||||||
|
}
|
||||||
|
if claim.Subject != "device-guid-fixture-0001" {
|
||||||
|
t.Errorf("Subject = %q, want device-guid-fixture-0001", claim.Subject)
|
||||||
|
}
|
||||||
|
if len(claim.SANDNS) != 1 || claim.SANDNS[0] != "fixture-device.example.com" {
|
||||||
|
t.Errorf("SANDNS = %v, want [fixture-device.example.com]", claim.SANDNS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenChallenge_Expired — the expired fixture's iat + exp are
|
||||||
|
// both before goldenChallengeNow, so ValidateChallenge MUST surface
|
||||||
|
// ErrChallengeExpired (the validator's exp branch is the first
|
||||||
|
// time-bounds check that fires for past-exp inputs).
|
||||||
|
func TestGoldenChallenge_Expired(t *testing.T) {
|
||||||
|
trust := loadGoldenTrustAnchor(t)
|
||||||
|
raw := readGoldenFixture(t, "intune_challenge_golden_expired.txt")
|
||||||
|
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, Now: goldenChallengeNow})
|
||||||
|
if !errors.Is(err, ErrChallengeExpired) {
|
||||||
|
t.Fatalf("got %v, want errors.Is(ErrChallengeExpired)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenChallenge_TamperedSig — the tampered fixture's signature
|
||||||
|
// byte was flipped; ValidateChallenge MUST reject with ErrChallengeSignature
|
||||||
|
// regardless of whether the payload + audience check would otherwise pass.
|
||||||
|
func TestGoldenChallenge_TamperedSig(t *testing.T) {
|
||||||
|
trust := loadGoldenTrustAnchor(t)
|
||||||
|
raw := readGoldenFixture(t, "intune_challenge_golden_tampered_sig.txt")
|
||||||
|
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: goldenChallengeNow})
|
||||||
|
if !errors.Is(err, ErrChallengeSignature) {
|
||||||
|
t.Fatalf("got %v, want errors.Is(ErrChallengeSignature)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenChallenge_WrongAudienceReuse — defensive: feed the success
|
||||||
|
// fixture but with the wrong audience pinned — the audience-check leg
|
||||||
|
// of ValidateChallenge MUST fire even though the signature would
|
||||||
|
// otherwise verify. Pins the correct ordering of the check sequence so
|
||||||
|
// a future refactor doesn't accidentally short-circuit the audience
|
||||||
|
// check after a successful signature verify.
|
||||||
|
func TestGoldenChallenge_WrongAudienceReuse(t *testing.T) {
|
||||||
|
trust := loadGoldenTrustAnchor(t)
|
||||||
|
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
|
||||||
|
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://attacker.example.com/scep/wrong", Now: goldenChallengeNow})
|
||||||
|
if !errors.Is(err, ErrChallengeWrongAudience) {
|
||||||
|
t.Fatalf("got %v, want errors.Is(ErrChallengeWrongAudience)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenChallenge_RotatedTrustAnchorRejects — defensive: load the
|
||||||
|
// success fixture but verify against a freshly-generated different
|
||||||
|
// trust anchor (simulating an operator who rotated the Connector
|
||||||
|
// signing key without reloading certctl's trust). The validator MUST
|
||||||
|
// reject with ErrChallengeSignature.
|
||||||
|
func TestGoldenChallenge_RotatedTrustAnchorRejects(t *testing.T) {
|
||||||
|
// Generate a fresh trust anchor that bears no relationship to the
|
||||||
|
// fixture's signing key. Reuses the helper from challenge_test.go.
|
||||||
|
rotated := genTestECDSAConnector(t)
|
||||||
|
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
|
||||||
|
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{rotated.cert}, Now: goldenChallengeNow})
|
||||||
|
if !errors.Is(err, ErrChallengeSignature) {
|
||||||
|
t.Fatalf("got %v, want errors.Is(ErrChallengeSignature) when validated against a rotated trust anchor", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenChallenge_UnknownVersionRejected — master prompt §13 line
|
||||||
|
// 1848 named acceptance criterion. A challenge whose payload carries a
|
||||||
|
// `version: "v999"` claim (a value the dispatcher's
|
||||||
|
// versionUnmarshalers map deliberately does NOT contain) MUST surface
|
||||||
|
// ErrChallengeUnknownVersion regardless of whether the signature is
|
||||||
|
// otherwise valid. This is the dispatcher's defense against the
|
||||||
|
// inevitable Microsoft Connector format change — the day Microsoft
|
||||||
|
// ships v2 and certctl's parser doesn't yet have a v2 unmarshaler, every
|
||||||
|
// Intune enrollment lands here with a clear typed error rather than
|
||||||
|
// crashing the SCEP handler with a confusing unmarshal panic.
|
||||||
|
//
|
||||||
|
// Why this test uses a fresh trust anchor instead of the on-disk
|
||||||
|
// golden PEM: the on-disk PEM was generated with a Go-stdlib version
|
||||||
|
// that produces different ECDSA key bytes from the current
|
||||||
|
// generateGoldenTrustAnchor() call (the deterministic-PRNG +
|
||||||
|
// ecdsa.GenerateKey pair has shifted across Go releases — the on-disk
|
||||||
|
// public key bytes don't match what the current Go runtime regenerates
|
||||||
|
// from the same seed). Rather than bake a stale trust anchor into the
|
||||||
|
// regression, we generate a fresh ECDSA Connector keypair in-process
|
||||||
|
// + use BOTH for signing AND for the validator's trust pool. The
|
||||||
|
// regen target still emits a fixture file under testdata/ for the
|
||||||
|
// operator-readable artifact; the test itself stays decoupled from
|
||||||
|
// the on-disk PEM's drift.
|
||||||
|
func TestGoldenChallenge_UnknownVersionRejected(t *testing.T) {
|
||||||
|
conn := genTestECDSAConnector(t)
|
||||||
|
raw := signTestChallengeES256_FixedWidth(t, conn, struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
challengePayloadV1
|
||||||
|
}{
|
||||||
|
Version: "v999",
|
||||||
|
challengePayloadV1: goldenChallengePayload(),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{
|
||||||
|
Trust: []*x509.Certificate{conn.cert},
|
||||||
|
Now: goldenChallengeNow,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrChallengeUnknownVersion) {
|
||||||
|
t.Fatalf("got %v, want errors.Is(ErrChallengeUnknownVersion) for version=v999 claim", err)
|
||||||
|
}
|
||||||
|
// The error message MUST surface the specific version string so the
|
||||||
|
// operator's audit log narrows the diagnosis to "Microsoft shipped
|
||||||
|
// vN" rather than "something is wrong with the challenge."
|
||||||
|
if !strings.Contains(err.Error(), "v999") {
|
||||||
|
t.Errorf("error should contain the unknown version literal for operator audit log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: "https://wrong-host.example.com/scep", Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{rotatedTo.cert}, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: nil, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: p.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: p.Audience, Now: 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, ValidateOptions{Trust: bundle, ExpectedAudience: pl.Audience, Now: 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, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
|
||||||
|
if !errors.Is(vErr, ErrChallengeMalformed) {
|
||||||
|
t.Fatalf("got %v, want ErrChallengeMalformed", vErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Clock-skew tolerance — master prompt §15 hazard closure (2026-04-29).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestValidateChallenge_AcceptsClaimWithinSkewTolerance — a Connector
|
||||||
|
// clock 30 seconds ahead of certctl produces a challenge whose iat is
|
||||||
|
// 30s in the future. With the default 60s tolerance, ValidateChallenge
|
||||||
|
// MUST accept it (the half-window covers the drift).
|
||||||
|
func TestValidateChallenge_AcceptsClaimWithinSkewTolerance(t *testing.T) {
|
||||||
|
c := genTestRSAConnector(t)
|
||||||
|
now := time.Now()
|
||||||
|
pl := validV1Payload(now)
|
||||||
|
pl.IssuedAt = now.Add(30 * time.Second).Unix() // Connector clock ahead
|
||||||
|
pl.ExpiresAt = now.Add(60 * time.Minute).Unix()
|
||||||
|
raw := signTestChallengeRS256(t, c, pl)
|
||||||
|
|
||||||
|
if _, err := ValidateChallenge(raw, ValidateOptions{
|
||||||
|
Trust: []*x509.Certificate{c.cert},
|
||||||
|
ExpectedAudience: pl.Audience,
|
||||||
|
Now: now,
|
||||||
|
ClockSkewTolerance: 60 * time.Second,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("future iat within tolerance should be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateChallenge_RejectsClaimBeyondSkewTolerance — a Connector
|
||||||
|
// clock 90 seconds ahead of certctl exceeds the default 60s tolerance.
|
||||||
|
// ValidateChallenge MUST reject with ErrChallengeNotYetValid; the error
|
||||||
|
// message MUST include the configured tolerance so the operator's
|
||||||
|
// audit log makes the misconfiguration distinguishable.
|
||||||
|
func TestValidateChallenge_RejectsClaimBeyondSkewTolerance(t *testing.T) {
|
||||||
|
c := genTestRSAConnector(t)
|
||||||
|
now := time.Now()
|
||||||
|
pl := validV1Payload(now)
|
||||||
|
pl.IssuedAt = now.Add(90 * time.Second).Unix() // beyond tolerance
|
||||||
|
pl.ExpiresAt = now.Add(60 * time.Minute).Unix()
|
||||||
|
raw := signTestChallengeRS256(t, c, pl)
|
||||||
|
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{
|
||||||
|
Trust: []*x509.Certificate{c.cert},
|
||||||
|
ExpectedAudience: pl.Audience,
|
||||||
|
Now: now,
|
||||||
|
ClockSkewTolerance: 60 * time.Second,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrChallengeNotYetValid) {
|
||||||
|
t.Fatalf("got %v, want ErrChallengeNotYetValid", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "tolerance=") {
|
||||||
|
t.Errorf("error should report tolerance for operator audit log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateChallenge_AcceptsExpiredClaimWithinSkewTolerance — a
|
||||||
|
// Connector clock 30 seconds behind certctl produces a challenge whose
|
||||||
|
// exp is 30s in the past relative to certctl's now. With the default
|
||||||
|
// 60s tolerance, ValidateChallenge MUST accept it (the half-window
|
||||||
|
// covers the drift in the other direction).
|
||||||
|
func TestValidateChallenge_AcceptsExpiredClaimWithinSkewTolerance(t *testing.T) {
|
||||||
|
c := genTestRSAConnector(t)
|
||||||
|
now := time.Now()
|
||||||
|
pl := validV1Payload(now)
|
||||||
|
pl.IssuedAt = now.Add(-60 * time.Minute).Unix()
|
||||||
|
pl.ExpiresAt = now.Add(-30 * time.Second).Unix() // Connector clock behind
|
||||||
|
raw := signTestChallengeRS256(t, c, pl)
|
||||||
|
|
||||||
|
if _, err := ValidateChallenge(raw, ValidateOptions{
|
||||||
|
Trust: []*x509.Certificate{c.cert},
|
||||||
|
ExpectedAudience: pl.Audience,
|
||||||
|
Now: now,
|
||||||
|
ClockSkewTolerance: 60 * time.Second,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("past exp within tolerance should be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateChallenge_NegativeToleranceTreatedAsZero — defensive: a
|
||||||
|
// negative tolerance is operator typo; the validator MUST treat it as
|
||||||
|
// zero (strict iat/exp) rather than tightening the window or panicking.
|
||||||
|
func TestValidateChallenge_NegativeToleranceTreatedAsZero(t *testing.T) {
|
||||||
|
c := genTestRSAConnector(t)
|
||||||
|
now := time.Now()
|
||||||
|
pl := validV1Payload(now)
|
||||||
|
pl.IssuedAt = now.Add(30 * time.Second).Unix() // future iat
|
||||||
|
pl.ExpiresAt = now.Add(60 * time.Minute).Unix()
|
||||||
|
raw := signTestChallengeRS256(t, c, pl)
|
||||||
|
|
||||||
|
// Negative tolerance MUST behave like zero — the future iat (no
|
||||||
|
// matter how small) should be rejected. If negative tolerances were
|
||||||
|
// applied as written, |neg| would WIDEN the window symmetrically and
|
||||||
|
// accept the iat. Pin the defensive normalization here.
|
||||||
|
_, err := ValidateChallenge(raw, ValidateOptions{
|
||||||
|
Trust: []*x509.Certificate{c.cert},
|
||||||
|
ExpectedAudience: pl.Audience,
|
||||||
|
Now: now,
|
||||||
|
ClockSkewTolerance: -10 * time.Second,
|
||||||
|
})
|
||||||
|
// |-10s| = 10s; 30s future iat > 10s tolerance → rejected. If the
|
||||||
|
// negative-as-zero normalization fired instead, this would still be
|
||||||
|
// rejected (zero tolerance). Either way the contract holds: negative
|
||||||
|
// tolerance never widens the window beyond |tolerance|.
|
||||||
|
if !errors.Is(err, ErrChallengeNotYetValid) {
|
||||||
|
t.Fatalf("got %v, want ErrChallengeNotYetValid (negative tolerance must not widen the window)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// asn1 + math/big are imported to keep the test compile in case future
|
||||||
|
// helpers add ASN.1 wire shaping (e.g. malformed-DER ES256 fixture).
|
||||||
|
var (
|
||||||
|
_ = asn1.Marshal
|
||||||
|
_ = big.NewInt
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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, ValidateOptions{Trust: bundle, Now: time.Now()})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package intune
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 10.1 — golden-file fixture
|
||||||
|
// helpers. The fixtures live under internal/scep/intune/testdata/ and are
|
||||||
|
// (re)generated on demand by `go test -run=TestRegenerateGoldenFixtures
|
||||||
|
// -update-golden ./internal/scep/intune/...`. The default `go test` run
|
||||||
|
// just READS the fixtures and asserts ValidateChallenge produces the
|
||||||
|
// documented typed error per case.
|
||||||
|
//
|
||||||
|
// Why we generate-on-demand instead of hand-curating bytes:
|
||||||
|
//
|
||||||
|
// - Real Intune challenges leak device GUIDs + user UPNs that we can't
|
||||||
|
// publish in the test corpus (PII / tenant-identifying).
|
||||||
|
// - The RSA + ECDSA signatures over JSON payloads are sensitive to any
|
||||||
|
// marshaling order change (json.Marshal sorts map keys but not struct
|
||||||
|
// field order); a hand-pasted base64 blob would break on every Go
|
||||||
|
// stdlib bump.
|
||||||
|
// - The trust anchor cert + RA pair we generate at init time gives us
|
||||||
|
// a stable fixture cert deterministically (we use a fixed seed for
|
||||||
|
// the EC key + a pinned timestamp for NotBefore/NotAfter).
|
||||||
|
//
|
||||||
|
// Determinism: the fixture key + timestamp are pinned via a custom
|
||||||
|
// io.Reader-style PRNG seeded from a constant byte string. Re-running
|
||||||
|
// the regeneration target produces byte-identical PEM + challenge files.
|
||||||
|
|
||||||
|
// goldenFixtureSeed is the constant byte string the deterministic PRNG
|
||||||
|
// is seeded from. Changing it invalidates every fixture; only do so if
|
||||||
|
// the fixture format itself changes.
|
||||||
|
var goldenFixtureSeed = []byte("scep-intune-golden-fixtures-v1-do-not-change-without-regenerating")
|
||||||
|
|
||||||
|
// goldenFixtureNotBefore is the pinned NotBefore for the test trust
|
||||||
|
// anchor cert. Pinned to a calendar date in the past so the cert is
|
||||||
|
// always valid relative to test wall-clock; the matching NotAfter is
|
||||||
|
// goldenFixtureNotBefore + 30 years so the fixture stays valid for the
|
||||||
|
// project lifetime.
|
||||||
|
var goldenFixtureNotBefore = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
var goldenFixtureNotAfter = goldenFixtureNotBefore.AddDate(30, 0, 0)
|
||||||
|
|
||||||
|
// goldenFixtureChallengeIat is the pinned iat for the success golden
|
||||||
|
// challenge. The expiry test fixture sets exp BEFORE this so it's in
|
||||||
|
// the past relative to any wall-clock; the success test reads
|
||||||
|
// IssuedAt + ExpiresAt out of the fixture and validates against
|
||||||
|
// goldenChallengeNow (a fixed time chosen to fall inside the success
|
||||||
|
// window). All three fixtures share the same iat so a regeneration of
|
||||||
|
// one doesn't drift the others.
|
||||||
|
var goldenFixtureChallengeIat = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// goldenChallengeNow is the wall-clock the fixture tests pin so the
|
||||||
|
// success challenge falls inside its iat→exp window AND the expired
|
||||||
|
// challenge's exp falls before it. Picked one minute after iat so the
|
||||||
|
// success path has a comfortable window.
|
||||||
|
var goldenChallengeNow = goldenFixtureChallengeIat.Add(1 * time.Minute)
|
||||||
|
|
||||||
|
// testdataDir resolves the testdata/ directory adjacent to the package
|
||||||
|
// source. The Go tooling pins `internal/scep/intune/testdata` regardless
|
||||||
|
// of the working dir the test runs from.
|
||||||
|
func testdataDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return filepath.Join("testdata")
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenChallengePayload is the v1 wire shape we use for all three
|
||||||
|
// fixtures. They share the same device claim so the only difference
|
||||||
|
// between the three is the iat/exp window (success vs. expired) or the
|
||||||
|
// signature bytes (tampered).
|
||||||
|
func goldenChallengePayload() challengePayloadV1 {
|
||||||
|
return challengePayloadV1{
|
||||||
|
Issuer: "intune-connector-installation-guid-test-fixture",
|
||||||
|
Subject: "device-guid-fixture-0001",
|
||||||
|
Audience: "https://certctl.example.com/scep/test",
|
||||||
|
IssuedAt: goldenFixtureChallengeIat.Unix(),
|
||||||
|
ExpiresAt: goldenFixtureChallengeIat.Add(60 * time.Minute).Unix(),
|
||||||
|
Nonce: "fixture-nonce-success-001",
|
||||||
|
DeviceName: "fixture-device.example.com",
|
||||||
|
SANDNS: []string{"fixture-device.example.com"},
|
||||||
|
SANRFC822: []string{"fixture-user@example.com"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenExpiredChallengePayload is the same shape as the success payload
|
||||||
|
// but with iat + exp shifted into the past so the validator's time-bounds
|
||||||
|
// check fires.
|
||||||
|
func goldenExpiredChallengePayload() challengePayloadV1 {
|
||||||
|
p := goldenChallengePayload()
|
||||||
|
// Both iat and exp are 2 hours BEFORE goldenChallengeNow so the
|
||||||
|
// validator returns ErrChallengeExpired (now is past exp).
|
||||||
|
p.IssuedAt = goldenChallengeNow.Add(-2 * time.Hour).Unix()
|
||||||
|
p.ExpiresAt = goldenChallengeNow.Add(-1 * time.Hour).Unix()
|
||||||
|
p.Nonce = "fixture-nonce-expired-001"
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenUnknownVersionPayload wraps the success v1 payload in a
|
||||||
|
// version-bearing prelude where Version="v999" — a value the
|
||||||
|
// versionUnmarshalers map does NOT contain. ValidateChallenge MUST
|
||||||
|
// surface ErrChallengeUnknownVersion when given this payload.
|
||||||
|
//
|
||||||
|
// Master prompt §13 line 1848 (golden test acceptance) specifically
|
||||||
|
// names "unknown-version-rejected" alongside success / expired /
|
||||||
|
// tampered_sig as a required golden case; this helper materializes the
|
||||||
|
// fixture from the same deterministic seed as the others so the
|
||||||
|
// regenerated fixture file diff stays clean.
|
||||||
|
type goldenUnknownVersionWire struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
challengePayloadV1
|
||||||
|
}
|
||||||
|
|
||||||
|
func goldenUnknownVersionPayload() goldenUnknownVersionWire {
|
||||||
|
return goldenUnknownVersionWire{
|
||||||
|
Version: "v999",
|
||||||
|
challengePayloadV1: goldenChallengePayload(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateGoldenTrustAnchor returns a deterministic ECDSA P-256 cert +
|
||||||
|
// signing key for the golden fixtures. The same goldenFixtureSeed always
|
||||||
|
// produces the same key + cert bytes — important so the testdata files
|
||||||
|
// stay reproducible across regenerations.
|
||||||
|
//
|
||||||
|
// We use ECDSA over RSA because the marshaled SEC1 ECDSA key is shorter
|
||||||
|
// (so the PEM file is operator-readable) and because both ES256 and
|
||||||
|
// the equivalent RS256 paths through verifyChallengeSignature are
|
||||||
|
// already covered by the unit tests in challenge_test.go — the golden
|
||||||
|
// suite focuses on wire-format reproducibility, not algorithm coverage.
|
||||||
|
func generateGoldenTrustAnchor(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
prng := newDeterministicReader(goldenFixtureSeed)
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), prng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deterministic ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "intune-connector-fixture"},
|
||||||
|
NotBefore: goldenFixtureNotBefore,
|
||||||
|
NotAfter: goldenFixtureNotAfter,
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(prng, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deterministic CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return key, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// signGoldenChallenge builds the JWT-shape ES256 challenge for a payload
|
||||||
|
// using the golden trust anchor key. Uses crypto/rand for the signature
|
||||||
|
// (ECDSA signatures embed a random nonce; we can't deterministically
|
||||||
|
// reproduce the signature bytes without re-implementing RFC 6979's
|
||||||
|
// deterministic-k variant, which Go's stdlib doesn't expose in a clean
|
||||||
|
// surface). The payload + header bytes are deterministic; only the
|
||||||
|
// signature suffix varies between regenerations. ValidateChallenge
|
||||||
|
// re-verifies the signature on every read, so the test still passes.
|
||||||
|
func signGoldenChallenge(t *testing.T, key *ecdsa.PrivateKey, payload challengePayloadV1) string {
|
||||||
|
t.Helper()
|
||||||
|
return signGoldenChallengeAny(t, key, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signGoldenChallengeAny mirrors signGoldenChallenge for any
|
||||||
|
// JSON-marshalable payload type. The goldenUnknownVersionWire fixture
|
||||||
|
// embeds the v1 payload inside a version-bearing prelude, so the typed
|
||||||
|
// helper above can't reach it without a cast — this any-typed sibling
|
||||||
|
// keeps the typed entrypoint stable while letting the regen target +
|
||||||
|
// the unknown-version-rejected golden test pass an embedded struct.
|
||||||
|
func signGoldenChallengeAny(t *testing.T, key *ecdsa.PrivateKey, payload any) string {
|
||||||
|
t.Helper()
|
||||||
|
hdr, _ := json.Marshal(jwtHeader{Alg: "ES256", Typ: "JWT"})
|
||||||
|
pl, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||||
|
base64.RawURLEncoding.EncodeToString(pl)
|
||||||
|
h := sha256.Sum256([]byte(signingInput))
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.Sign: %v", err)
|
||||||
|
}
|
||||||
|
rb, sb := r.Bytes(), s.Bytes()
|
||||||
|
sig := make([]byte, 64)
|
||||||
|
copy(sig[32-len(rb):], rb)
|
||||||
|
copy(sig[64-len(sb):], sb)
|
||||||
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readGoldenFixture reads a fixture file relative to testdata/. Uses
|
||||||
|
// strings.TrimSpace so a trailing newline (from operator-friendly editor
|
||||||
|
// saves of the .txt files) doesn't break ValidateChallenge.
|
||||||
|
func readGoldenFixture(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(testdataDir(t), name)
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadGoldenTrustAnchor reads the testdata/ trust anchor PEM and parses
|
||||||
|
// it. Mirror of LoadTrustAnchor but bypasses the wall-clock expiry
|
||||||
|
// check (the golden fixtures use a 30-year lifetime so any reasonable
|
||||||
|
// test wall-clock falls inside the valid window).
|
||||||
|
func loadGoldenTrustAnchor(t *testing.T) []*x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(filepath.Join(testdataDir(t), "intune_trust_anchor.pem"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read trust anchor: %v", err)
|
||||||
|
}
|
||||||
|
var out []*x509.Certificate
|
||||||
|
rest := body
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse trust anchor cert: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, cert)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("trust anchor file contained no CERTIFICATE blocks")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pemEncodeForFixture returns a PEM-encoded CERTIFICATE block for the
|
||||||
|
// given DER bytes — used by the regeneration target.
|
||||||
|
func pemEncodeForFixture(der []byte) []byte {
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flipLastSignatureByte takes a JWT-compact-serialized challenge and
|
||||||
|
// returns the same wire bytes with one byte flipped in the signature
|
||||||
|
// segment. Used to build the tampered-sig fixture without re-signing
|
||||||
|
// (tampering is a destructive transform; signing inputs stay byte-
|
||||||
|
// identical so any future tooling re-checking the payload bytes against
|
||||||
|
// the success fixture sees the same content).
|
||||||
|
func flipLastSignatureByte(t *testing.T, raw string) string {
|
||||||
|
t.Helper()
|
||||||
|
parts := strings.Split(raw, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("flipLastSignatureByte: expected 3 segments, got %d", len(parts))
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flipLastSignatureByte: base64 decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(sig) == 0 {
|
||||||
|
t.Fatalf("flipLastSignatureByte: empty signature")
|
||||||
|
}
|
||||||
|
sig[len(sig)-1] ^= 0xFF
|
||||||
|
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
return strings.Join(parts, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-symbol warnings for helpers reserved for the
|
||||||
|
// regenerate-golden target (kept here so the test file diff stays
|
||||||
|
// minimal when an operator runs the regenerate flow).
|
||||||
|
var _ = pemEncodeForFixture
|
||||||
|
var _ = signGoldenChallenge
|
||||||
|
var _ = signGoldenChallengeAny
|
||||||
|
var _ = generateGoldenTrustAnchor
|
||||||
|
|
||||||
|
// deterministicReader is a sha256-based PRNG seeded from a constant
|
||||||
|
// byte slice. Used so the trust anchor cert + key bytes stay identical
|
||||||
|
// across regenerations — important for the testdata diff to stay clean.
|
||||||
|
//
|
||||||
|
// Concurrency: not safe; the regenerate-golden target uses one instance
|
||||||
|
// per call so no contention.
|
||||||
|
type deterministicReader struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state []byte
|
||||||
|
cursor int
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeterministicReader(seed []byte) *deterministicReader {
|
||||||
|
return &deterministicReader{state: append([]byte(nil), seed...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fills p with sha256-derived pseudo-random bytes. The first
|
||||||
|
// sha256 block is sha256(seed); subsequent blocks are sha256(prev+counter).
|
||||||
|
func (d *deterministicReader) Read(p []byte) (int, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
for n := 0; n < len(p); {
|
||||||
|
if d.cursor >= len(d.buf) {
|
||||||
|
h := sha256.Sum256(append(d.state, byteCounter(len(p)+n)...))
|
||||||
|
d.buf = h[:]
|
||||||
|
d.cursor = 0
|
||||||
|
d.state = d.buf
|
||||||
|
}
|
||||||
|
c := copy(p[n:], d.buf[d.cursor:])
|
||||||
|
n += c
|
||||||
|
d.cursor += c
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func byteCounter(i int) []byte {
|
||||||
|
out := make([]byte, 8)
|
||||||
|
for k := 0; k < 8; k++ {
|
||||||
|
out[k] = byte(i >> (8 * k))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rsa unused import shim — Go's compile guard fires on unused imports
|
||||||
|
// even when reserved for the regenerate-golden target. This var binds a
|
||||||
|
// rsa-package symbol so the import survives even when the fixture key
|
||||||
|
// type changes.
|
||||||
|
var _ = rsa.PublicKey{}
|
||||||
|
var _ = crypto.SHA256
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjE2NjAsImV4cCI6MTc2NzI2NTI2MCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLWV4cGlyZWQtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Kbu7e38_ENiEfcPKRXueu3XGnod557cE2vqX_B4pjnCsnoyZi0we7U_5ZeP3WhlB_fFmMmduEfYAbiSFylmuQw
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.2lzOwwFYjZzTkGDtK7sMv20XL-eIa8eX9jgcwtVff7ffcBXo4izw45mOMga3Vdan0JTdEkQykLzvisA1iju3Lg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Npt7MAPBOln73QxsjzUHjpRB8dXLLPSFA8461pHAaLikkzlkaQlrwKwjDK0x4PBgsI2M84QoFj_RUyD-nABUMQ
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBSTCB76ADAgECAgEBMAoGCCqGSM49BAMCMCMxITAfBgNVBAMTGGludHVuZS1j
|
||||||
|
b25uZWN0b3ItZml4dHVyZTAgFw0yNTAxMDEwMDAwMDBaGA8yMDU1MDEwMTAwMDAw
|
||||||
|
MFowIzEhMB8GA1UEAxMYaW50dW5lLWNvbm5lY3Rvci1maXh0dXJlMFkwEwYHKoZI
|
||||||
|
zj0CAQYIKoZIzj0DAQcDQgAENtxi3HwutH7U37ycdniZK8t84keB7GDz0C6wjY15
|
||||||
|
IG8PtH8ob8yAMqjJujcC3c/k2KelFAb+xKT6BTKuJOXruaMSMBAwDgYDVR0PAQH/
|
||||||
|
BAQDAgeAMAoGCCqGSM49BAMCA0kAMEYCIQDWprfO49J8Zm52u4Su4HiXxCufrnvQ
|
||||||
|
sNjHNpGil502DgIhANe/OstPGojs/4TBM4+n5+3ROGdSnnLhhqWcUiqC5HEw
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,6 +30,15 @@ type NetworkScanService struct {
|
|||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
concurrency int
|
concurrency int
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe
|
||||||
|
// state. Optional: nil-safe so deploys that don't enable the probe
|
||||||
|
// surface (no scep_probe_results table populated) still work.
|
||||||
|
scepProbeRepo repository.SCEPProbeResultRepository
|
||||||
|
scepHTTPClient *http.Client // built from SafeHTTPDialContext for SSRF defense
|
||||||
|
scepValidateURL func(string) error // defaults to validation.ValidateSafeURL; tests inject permissive
|
||||||
|
scepIDFn func() string
|
||||||
|
nowFn func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNetworkScanService creates a new network scan service.
|
// NewNetworkScanService creates a new network scan service.
|
||||||
@@ -44,9 +54,20 @@ func NewNetworkScanService(
|
|||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
concurrency: 50,
|
concurrency: 50,
|
||||||
|
nowFn: time.Now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSCEPProbeRepo wires the SCEP probe persistence repository onto the
|
||||||
|
// service. Called from cmd/server/main.go at startup. Nil-safe — calling
|
||||||
|
// ProbeSCEP without a repo just skips the persist step (the probe still
|
||||||
|
// runs and returns its result synchronously).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
func (s *NetworkScanService) SetSCEPProbeRepo(repo repository.SCEPProbeResultRepository) {
|
||||||
|
s.scepProbeRepo = repo
|
||||||
|
}
|
||||||
|
|
||||||
// ListTargets returns all network scan targets.
|
// ListTargets returns all network scan targets.
|
||||||
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
||||||
return s.networkScanRepo.List(ctx)
|
return s.networkScanRepo.List(ctx)
|
||||||
|
|||||||
@@ -5,17 +5,32 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
"github.com/shankar0123/certctl/internal/scep/intune"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEPService implements the SCEP (RFC 8894) enrollment protocol.
|
// SCEPService implements the SCEP (RFC 8894) enrollment protocol.
|
||||||
// It delegates certificate operations to an existing IssuerConnector and records
|
// It delegates certificate operations to an existing IssuerConnector and records
|
||||||
// enrollment events in the audit trail.
|
// 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 {
|
type SCEPService struct {
|
||||||
issuer IssuerConnector
|
issuer IssuerConnector
|
||||||
issuerID string
|
issuerID string
|
||||||
@@ -24,6 +39,659 @@ type SCEPService struct {
|
|||||||
profileID string // optional: constrain enrollments to a specific profile
|
profileID string // optional: constrain enrollments to a specific profile
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
challengePassword string // shared secret for enrollment authentication
|
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
|
||||||
|
intuneClockSkew time.Duration // ±tolerance applied to iat/exp; default 60s wired from config
|
||||||
|
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
|
||||||
|
|
||||||
|
// Per-profile metadata surfaced by the new /admin/scep/profiles
|
||||||
|
// endpoint. SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md). All fields are nil/zero
|
||||||
|
// when the operator runs without Intune AND without mTLS — we still
|
||||||
|
// surface the always-present challenge-password-set + RA cert
|
||||||
|
// expiry on the Profiles tab for those.
|
||||||
|
raCertSubject string
|
||||||
|
raCertNotBefore time.Time
|
||||||
|
raCertNotAfter time.Time
|
||||||
|
mtlsEnabled bool
|
||||||
|
mtlsTrustBundlePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// intuneCounterTab is the in-memory equivalent of the
|
||||||
|
// `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"`
|
||||||
|
ClockSkewTolerance time.Duration `json:"clock_skew_tolerance_ns,omitempty"`
|
||||||
|
RateLimitDisabled bool `json:"rate_limit_disabled"`
|
||||||
|
ReplayCacheSize int `json:"replay_cache_size"`
|
||||||
|
Counters map[string]uint64 `json:"counters"`
|
||||||
|
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
|
||||||
|
out.ClockSkewTolerance = s.intuneClockSkew
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRACert records the RA cert metadata the admin Profiles endpoint
|
||||||
|
// surfaces (subject + NotBefore + NotAfter for the expiry countdown).
|
||||||
|
// Called from cmd/server/main.go right after loadSCEPRAPair returns the
|
||||||
|
// leaf cert. Nil-safe — passing nil leaves the fields zero-valued so
|
||||||
|
// the snapshot's RACertSubject is empty (the GUI then renders
|
||||||
|
// "RA cert not loaded").
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up.
|
||||||
|
func (s *SCEPService) SetRACert(cert *x509.Certificate) {
|
||||||
|
if cert == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.raCertSubject = cert.Subject.CommonName
|
||||||
|
s.raCertNotBefore = cert.NotBefore
|
||||||
|
s.raCertNotAfter = cert.NotAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMTLSConfig records this profile's mTLS sibling-route status for
|
||||||
|
// the admin Profiles endpoint. The trust bundle PATH is surfaced (not
|
||||||
|
// the bundle contents) so operators can correlate against their own
|
||||||
|
// secret manager / file system audit. Called from cmd/server/main.go
|
||||||
|
// in the per-profile loop, parallel to SetIntuneIntegration.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up.
|
||||||
|
func (s *SCEPService) SetMTLSConfig(enabled bool, bundlePath string) {
|
||||||
|
s.mtlsEnabled = enabled
|
||||||
|
s.mtlsTrustBundlePath = bundlePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEPProfileStatsSnapshot is the per-profile observability shape the
|
||||||
|
// new /admin/scep/profiles endpoint emits. Surfaces every always-
|
||||||
|
// present per-profile field PLUS an optional Intune sub-block.
|
||||||
|
// Profiles that don't have Intune enabled get Intune=nil (the GUI
|
||||||
|
// renders the lean per-profile card without the Intune deep-dive
|
||||||
|
// button).
|
||||||
|
//
|
||||||
|
// Distinct from IntuneStatsSnapshot (which the existing
|
||||||
|
// /admin/scep/intune/stats endpoint emits) so the existing endpoint's
|
||||||
|
// JSON shape stays byte-stable for external consumers — backward
|
||||||
|
// compatibility for the Phase 9 admin contract.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md).
|
||||||
|
type SCEPProfileStatsSnapshot struct {
|
||||||
|
// Always-present per-profile fields.
|
||||||
|
PathID string `json:"path_id"`
|
||||||
|
IssuerID string `json:"issuer_id"`
|
||||||
|
ChallengePasswordSet bool `json:"challenge_password_set"`
|
||||||
|
RACertSubject string `json:"ra_cert_subject,omitempty"`
|
||||||
|
RACertNotBefore time.Time `json:"ra_cert_not_before,omitempty"`
|
||||||
|
RACertNotAfter time.Time `json:"ra_cert_not_after,omitempty"`
|
||||||
|
RACertDaysToExpiry int `json:"ra_cert_days_to_expiry"`
|
||||||
|
RACertExpired bool `json:"ra_cert_expired"`
|
||||||
|
MTLSEnabled bool `json:"mtls_enabled"`
|
||||||
|
MTLSTrustBundlePath string `json:"mtls_trust_bundle_path,omitempty"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
|
||||||
|
// Optional Intune sub-block; nil when this profile has Intune
|
||||||
|
// disabled. Mirrors the IntuneStatsSnapshot fields minus the
|
||||||
|
// always-present per-profile ones (which now live on the parent).
|
||||||
|
Intune *IntuneSection `json:"intune,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntuneSection is the Intune-specific data a per-profile snapshot
|
||||||
|
// carries when INTUNE_ENABLED=true. Same fields as IntuneStatsSnapshot
|
||||||
|
// minus the always-present per-profile ones (PathID, IssuerID,
|
||||||
|
// GeneratedAt) which live on SCEPProfileStatsSnapshot.
|
||||||
|
type IntuneSection struct {
|
||||||
|
TrustAnchorPath string `json:"trust_anchor_path,omitempty"`
|
||||||
|
TrustAnchors []IntuneTrustAnchorInfo `json:"trust_anchors,omitempty"`
|
||||||
|
Audience string `json:"audience,omitempty"`
|
||||||
|
ChallengeValidity time.Duration `json:"challenge_validity_ns,omitempty"`
|
||||||
|
ClockSkewTolerance time.Duration `json:"clock_skew_tolerance_ns,omitempty"`
|
||||||
|
RateLimitDisabled bool `json:"rate_limit_disabled"`
|
||||||
|
ReplayCacheSize int `json:"replay_cache_size"`
|
||||||
|
Counters map[string]uint64 `json:"counters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileStats returns the per-profile observability snapshot in the
|
||||||
|
// new shape (always-present fields + optional Intune sub-block).
|
||||||
|
// Safe for concurrent callers; reads only; uses the same atomic
|
||||||
|
// counter snapshots as IntuneStats.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up.
|
||||||
|
func (s *SCEPService) ProfileStats(now time.Time) SCEPProfileStatsSnapshot {
|
||||||
|
out := SCEPProfileStatsSnapshot{
|
||||||
|
PathID: s.pathID,
|
||||||
|
IssuerID: s.issuerID,
|
||||||
|
ChallengePasswordSet: s.challengePassword != "",
|
||||||
|
RACertSubject: s.raCertSubject,
|
||||||
|
RACertNotBefore: s.raCertNotBefore,
|
||||||
|
RACertNotAfter: s.raCertNotAfter,
|
||||||
|
MTLSEnabled: s.mtlsEnabled,
|
||||||
|
MTLSTrustBundlePath: s.mtlsTrustBundlePath,
|
||||||
|
GeneratedAt: now.UTC(),
|
||||||
|
}
|
||||||
|
if !s.raCertNotAfter.IsZero() {
|
||||||
|
out.RACertExpired = now.After(s.raCertNotAfter)
|
||||||
|
if !out.RACertExpired {
|
||||||
|
out.RACertDaysToExpiry = int(s.raCertNotAfter.Sub(now).Hours() / 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !s.intuneEnabled {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
intuneSection := IntuneSection{
|
||||||
|
Audience: s.intuneAudience,
|
||||||
|
ChallengeValidity: s.intuneValidity,
|
||||||
|
ClockSkewTolerance: s.intuneClockSkew,
|
||||||
|
Counters: s.intuneCounters.snapshot(),
|
||||||
|
}
|
||||||
|
if s.intuneRateLimiter != nil {
|
||||||
|
intuneSection.RateLimitDisabled = s.intuneRateLimiter.Disabled()
|
||||||
|
}
|
||||||
|
if s.intuneReplayCache != nil {
|
||||||
|
intuneSection.ReplayCacheSize = s.intuneReplayCache.Len()
|
||||||
|
}
|
||||||
|
if s.intuneTrust != nil {
|
||||||
|
intuneSection.TrustAnchorPath = s.intuneTrust.Path()
|
||||||
|
certs := s.intuneTrust.Get()
|
||||||
|
intuneSection.TrustAnchors = make([]IntuneTrustAnchorInfo, 0, len(certs))
|
||||||
|
for _, c := range certs {
|
||||||
|
info := IntuneTrustAnchorInfo{
|
||||||
|
Subject: c.Subject.CommonName,
|
||||||
|
NotBefore: c.NotBefore,
|
||||||
|
NotAfter: c.NotAfter,
|
||||||
|
Expired: now.After(c.NotAfter),
|
||||||
|
}
|
||||||
|
if !info.Expired {
|
||||||
|
info.DaysToExpiry = int(c.NotAfter.Sub(now).Hours() / 24)
|
||||||
|
}
|
||||||
|
intuneSection.TrustAnchors = append(intuneSection.TrustAnchors, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Intune = &intuneSection
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrSCEPProfileIntuneDisabled is returned by ReloadIntuneTrust when
|
||||||
|
// 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,
|
||||||
|
clockSkew time.Duration,
|
||||||
|
replayCache *intune.ReplayCache,
|
||||||
|
rateLimiter *intune.PerDeviceRateLimiter,
|
||||||
|
) {
|
||||||
|
s.intuneEnabled = true
|
||||||
|
s.intuneTrust = trust
|
||||||
|
s.intuneAudience = audience
|
||||||
|
s.intuneValidity = validity
|
||||||
|
s.intuneClockSkew = clockSkew
|
||||||
|
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, intune.ValidateOptions{
|
||||||
|
Trust: trust,
|
||||||
|
ExpectedAudience: s.intuneAudience,
|
||||||
|
Now: now,
|
||||||
|
ClockSkewTolerance: s.intuneClockSkew,
|
||||||
|
})
|
||||||
|
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.
|
// NewSCEPService creates a new SCEPService for the given issuer connector.
|
||||||
@@ -86,6 +754,19 @@ func (s *SCEPService) GetCACert(ctx context.Context) (string, error) {
|
|||||||
// non-empty branch now uses crypto/subtle.ConstantTimeCompare to avoid leaking
|
// non-empty branch now uses crypto/subtle.ConstantTimeCompare to avoid leaking
|
||||||
// the shared secret through a response-time side channel.
|
// 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) {
|
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
|
// Defense-in-depth: refuse any enrollment when no shared secret is
|
||||||
// configured. The server-level pre-flight check in cmd/server/main.go
|
// configured. The server-level pre-flight check in cmd/server/main.go
|
||||||
// normally prevents the service from being constructed in this state, but
|
// normally prevents the service from being constructed in this state, but
|
||||||
@@ -258,6 +939,29 @@ func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, ch
|
|||||||
RecipientNonce: envelope.SenderNonce,
|
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
|
// Defense-in-depth: refuse any enrollment when no shared secret is
|
||||||
// configured. Mirrors PKCSReq's gate. Returning nil signals 'let the
|
// configured. Mirrors PKCSReq's gate. Returning nil signals 'let the
|
||||||
// caller translate to HTTP 403' — the existing PKCSReq path returns
|
// caller translate to HTTP 403' — the existing PKCSReq path returns
|
||||||
@@ -287,6 +991,41 @@ func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, ch
|
|||||||
return resp
|
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
|
// mapServiceErrorToFailInfo translates a service-layer error into the
|
||||||
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. The mapping mirrors
|
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. The mapping mirrors
|
||||||
// the table in PKCSReqWithEnvelope's docblock; defaults to BadRequest
|
// the table in PKCSReqWithEnvelope's docblock; defaults to BadRequest
|
||||||
@@ -345,6 +1084,38 @@ func (s *SCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string,
|
|||||||
RecipientNonce: envelope.SenderNonce,
|
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
|
// Same challenge-password gate as PKCSReqWithEnvelope. Defense in depth
|
||||||
// even though the RenewalReq path additionally verifies the signing
|
// even though the RenewalReq path additionally verifies the signing
|
||||||
// cert chain — a stolen/leaked challenge password combined with a
|
// cert chain — a stolen/leaked challenge password combined with a
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
// 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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace) keeps these tests deterministic
|
||||||
|
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,
|
||||||
|
0, // ClockSkewTolerance — strict (no grace)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
|
"github.com/shankar0123/certctl/internal/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
|
//
|
||||||
|
// Probes an SCEP server URL for capability + posture metadata
|
||||||
|
// (RFC 8894 §3.5.1 GetCACaps + GetCACert). Used for pre-migration
|
||||||
|
// assessment + compliance posture audits. Deliberately does NOT POST a
|
||||||
|
// CSR — capability-only.
|
||||||
|
//
|
||||||
|
// SSRF defense: the HTTP client uses validation.SafeHTTPDialContext so
|
||||||
|
// dial-time DNS resolution is checked against the reserved-IP filter
|
||||||
|
// (defends against DNS rebinding); the URL is also validated up-front
|
||||||
|
// via validation.ValidateSafeURL for an early diagnostic.
|
||||||
|
//
|
||||||
|
// The probe accumulates persistent history in scep_probe_results
|
||||||
|
// (migration 000021) when SetSCEPProbeRepo wired a repo at startup;
|
||||||
|
// otherwise the probe runs and returns its result without persisting.
|
||||||
|
|
||||||
|
// scepProbeTimeout caps a single probe at 30s. The probe issues at
|
||||||
|
// most 2-3 GETs against the target, each with default Go HTTP-client
|
||||||
|
// behavior (single connection, no retries) — 30s is generous for
|
||||||
|
// reachable servers and bounds the wait for unreachable / hung ones.
|
||||||
|
const scepProbeTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// scepProbeUserAgent identifies certctl in the target server's logs so
|
||||||
|
// operators running the probe see a clear source attribution.
|
||||||
|
const scepProbeUserAgent = "certctl-network-scan/scep-probe"
|
||||||
|
|
||||||
|
// ProbeSCEP probes the given URL as an SCEP server and returns a
|
||||||
|
// structured posture snapshot. The result is also persisted via
|
||||||
|
// SetSCEPProbeRepo (when configured) so the GUI can render recent
|
||||||
|
// probe history.
|
||||||
|
//
|
||||||
|
// Validation order:
|
||||||
|
//
|
||||||
|
// 1. validation.ValidateSafeURL — catches obvious SSRF targets
|
||||||
|
// (loopback / link-local / cloud-metadata literals) before any
|
||||||
|
// network call. Cheap early diagnostic.
|
||||||
|
// 2. The HTTP transport's DialContext (SafeHTTPDialContext) re-
|
||||||
|
// resolves the target host at dial time and re-checks reserved
|
||||||
|
// IPs. Defends against DNS-rebinding (the URL passes step 1 but
|
||||||
|
// resolves to a reserved IP at dial time).
|
||||||
|
// 3. The probe issues GET ?operation=GetCACaps and GET ?operation=GetCACert.
|
||||||
|
// GetCACert can return either a single DER cert OR a PKCS#7
|
||||||
|
// SignedData certs-only envelope (RFC 8894 §3.5.1). The probe
|
||||||
|
// handles both.
|
||||||
|
func (s *NetworkScanService) ProbeSCEP(ctx context.Context, rawURL string) (*domain.SCEPProbeResult, error) {
|
||||||
|
id := s.scepProbeID()
|
||||||
|
now := s.nowFnOrDefault()
|
||||||
|
started := now()
|
||||||
|
result := &domain.SCEPProbeResult{
|
||||||
|
ID: id,
|
||||||
|
TargetURL: rawURL,
|
||||||
|
ProbedAt: started,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: cheap up-front URL validation (SSRF early diagnostic).
|
||||||
|
// Defaults to validation.ValidateSafeURL; tests inject a permissive
|
||||||
|
// validator via service-level field so they can hit httptest
|
||||||
|
// loopback servers (which the production validator correctly
|
||||||
|
// rejects). Mirrors the webhook notifier's `newForTest` pattern.
|
||||||
|
validateURL := s.scepValidateURL
|
||||||
|
if validateURL == nil {
|
||||||
|
validateURL = validation.ValidateSafeURL
|
||||||
|
}
|
||||||
|
if err := validateURL(rawURL); err != nil {
|
||||||
|
result.Reachable = false
|
||||||
|
result.Error = "url validation: " + err.Error()
|
||||||
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
||||||
|
s.persistProbeResult(ctx, result)
|
||||||
|
return result, fmt.Errorf("scep probe: validate url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the base URL — strip any trailing query string so we
|
||||||
|
// can append ?operation=... unambiguously.
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
result.Reachable = false
|
||||||
|
result.Error = "url parse: " + err.Error()
|
||||||
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
||||||
|
s.persistProbeResult(ctx, result)
|
||||||
|
return result, fmt.Errorf("scep probe: parse url: %w", err)
|
||||||
|
}
|
||||||
|
parsed.RawQuery = ""
|
||||||
|
baseURL := parsed.String()
|
||||||
|
|
||||||
|
client := s.scepProbeClient()
|
||||||
|
|
||||||
|
// Step 2: GetCACaps — newline-separated capability list.
|
||||||
|
caps, capsErr := s.scepGetCACaps(ctx, client, baseURL)
|
||||||
|
if capsErr != nil {
|
||||||
|
result.Reachable = false
|
||||||
|
result.Error = "GetCACaps: " + capsErr.Error()
|
||||||
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
||||||
|
s.persistProbeResult(ctx, result)
|
||||||
|
return result, capsErr
|
||||||
|
}
|
||||||
|
result.Reachable = true
|
||||||
|
result.AdvertisedCaps = caps
|
||||||
|
for _, c := range caps {
|
||||||
|
switch strings.TrimSpace(c) {
|
||||||
|
case "SCEPStandard":
|
||||||
|
result.SupportsRFC8894 = true
|
||||||
|
case "AES":
|
||||||
|
result.SupportsAES = true
|
||||||
|
case "POSTPKIOperation":
|
||||||
|
result.SupportsPOSTOperation = true
|
||||||
|
case "Renewal":
|
||||||
|
result.SupportsRenewal = true
|
||||||
|
case "SHA-256":
|
||||||
|
result.SupportsSHA256 = true
|
||||||
|
case "SHA-512":
|
||||||
|
result.SupportsSHA512 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: GetCACert — DER cert OR PKCS#7 SignedData certs-only envelope.
|
||||||
|
certs, certErr := s.scepGetCACert(ctx, client, baseURL)
|
||||||
|
if certErr != nil {
|
||||||
|
// Non-fatal: server reached + caps parsed, but CA cert fetch
|
||||||
|
// failed. Operator gets caps + the error explaining the CA
|
||||||
|
// cert state.
|
||||||
|
result.Error = "GetCACert: " + certErr.Error()
|
||||||
|
} else if len(certs) > 0 {
|
||||||
|
result.CACertChainLength = len(certs)
|
||||||
|
leaf := certs[0]
|
||||||
|
result.CACertSubject = leaf.Subject.String()
|
||||||
|
result.CACertIssuer = leaf.Issuer.String()
|
||||||
|
result.CACertNotBefore = leaf.NotBefore
|
||||||
|
result.CACertNotAfter = leaf.NotAfter
|
||||||
|
nowVal := now()
|
||||||
|
result.CACertExpired = nowVal.After(leaf.NotAfter)
|
||||||
|
if !result.CACertExpired {
|
||||||
|
result.CACertDaysToExpiry = int(leaf.NotAfter.Sub(nowVal).Hours() / 24)
|
||||||
|
}
|
||||||
|
result.CACertAlgorithm = describeCertAlgorithm(leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
||||||
|
s.persistProbeResult(ctx, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scepGetCACaps fetches GET ?operation=GetCACaps and parses the
|
||||||
|
// newline-separated capability list. Lines are trimmed of CRLF; empty
|
||||||
|
// lines are skipped. Per RFC 8894 §3.5.2 the response Content-Type is
|
||||||
|
// text/plain with one capability per line.
|
||||||
|
func (s *NetworkScanService) scepGetCACaps(ctx context.Context, client *http.Client, baseURL string) ([]string, error) {
|
||||||
|
url := baseURL + "?operation=GetCACaps"
|
||||||
|
body, err := s.scepHTTPGet(ctx, client, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scepGetCACert fetches GET ?operation=GetCACert and parses the
|
||||||
|
// returned cert(s). RFC 8894 §3.5.1: the response is either:
|
||||||
|
//
|
||||||
|
// - A single DER-encoded X.509 cert (Content-Type
|
||||||
|
// application/x-x509-ca-cert) when the CA has a single cert.
|
||||||
|
// - A PKCS#7 SignedData certs-only envelope (Content-Type
|
||||||
|
// application/x-x509-ca-ra-cert) when the CA returns multiple
|
||||||
|
// certs (CA + RA, or CA chain).
|
||||||
|
//
|
||||||
|
// We attempt the PKCS#7 parse first, fall back to single-cert DER
|
||||||
|
// parse if that fails. Returns the cert chain in order (CA leaf first).
|
||||||
|
func (s *NetworkScanService) scepGetCACert(ctx context.Context, client *http.Client, baseURL string) ([]*x509.Certificate, error) {
|
||||||
|
url := baseURL + "?operation=GetCACert"
|
||||||
|
body, err := s.scepHTTPGet(ctx, client, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PKCS#7 SignedData first — the multi-cert form. ParseSignedData
|
||||||
|
// already decodes each embedded cert into *x509.Certificate, so we
|
||||||
|
// just take the slice as-is.
|
||||||
|
if signed, p7Err := pkcs7.ParseSignedData(body); p7Err == nil && len(signed.Certificates) > 0 {
|
||||||
|
return signed.Certificates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to single DER cert (or a PEM-wrapped cert from a
|
||||||
|
// non-conforming server — try both).
|
||||||
|
if c, err := x509.ParseCertificate(body); err == nil {
|
||||||
|
return []*x509.Certificate{c}, nil
|
||||||
|
}
|
||||||
|
if block, _ := pem.Decode(body); block != nil {
|
||||||
|
if c, err := x509.ParseCertificate(block.Bytes); err == nil {
|
||||||
|
return []*x509.Certificate{c}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("could not parse GetCACert response as DER, PEM, or PKCS#7 SignedData")
|
||||||
|
}
|
||||||
|
|
||||||
|
// scepHTTPGet issues a single GET with the probe's user agent + the
|
||||||
|
// SSRF-defended HTTP client. Reads the body up to 1MB to defend against
|
||||||
|
// a huge-response DoS from a misbehaving target.
|
||||||
|
func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", scepProbeUserAgent)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http get: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB cap
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scepProbeClient returns the lazily-built SSRF-defended HTTP client.
|
||||||
|
// Built once per service lifetime; the transport reuses connections.
|
||||||
|
func (s *NetworkScanService) scepProbeClient() *http.Client {
|
||||||
|
if s.scepHTTPClient != nil {
|
||||||
|
return s.scepHTTPClient
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: validation.SafeHTTPDialContext(scepProbeTimeout),
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
}
|
||||||
|
s.scepHTTPClient = &http.Client{
|
||||||
|
Timeout: scepProbeTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
return s.scepHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// scepProbeID returns a fresh ID for a probe row. Defaults to
|
||||||
|
// "spr-<uuid>"; tests can inject a deterministic generator via
|
||||||
|
// (NetworkScanService).scepIDFn.
|
||||||
|
func (s *NetworkScanService) scepProbeID() string {
|
||||||
|
if s.scepIDFn != nil {
|
||||||
|
return s.scepIDFn()
|
||||||
|
}
|
||||||
|
return "spr-" + uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// nowFnOrDefault returns the configured clock (for test injection) or
|
||||||
|
// time.Now if unset. Used so the probe's two NotAfter comparisons
|
||||||
|
// (CACertExpired + ProbedAt) share a single observation point.
|
||||||
|
func (s *NetworkScanService) nowFnOrDefault() func() time.Time {
|
||||||
|
if s.nowFn != nil {
|
||||||
|
return s.nowFn
|
||||||
|
}
|
||||||
|
return time.Now
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistProbeResult writes the probe outcome to scep_probe_results
|
||||||
|
// when a repo was wired. Failure to persist is logged but doesn't
|
||||||
|
// fail the caller — the probe's primary contract is "run + return"
|
||||||
|
// not "run + persist". Operators get the result regardless.
|
||||||
|
func (s *NetworkScanService) persistProbeResult(ctx context.Context, result *domain.SCEPProbeResult) {
|
||||||
|
if s.scepProbeRepo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.scepProbeRepo.Insert(ctx, result); err != nil && s.logger != nil {
|
||||||
|
s.logger.Warn("scep probe result persist failed (probe still returned to caller)",
|
||||||
|
"target_url", result.TargetURL,
|
||||||
|
"id", result.ID,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecentSCEPProbes returns the most recent N probe rows. Thin
|
||||||
|
// wrapper around the repository so the handler depends on the service
|
||||||
|
// surface, not the repo directly. Returns empty slice (not nil) when
|
||||||
|
// no repo is wired so JSON marshaling stays clean.
|
||||||
|
func (s *NetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
if s.scepProbeRepo == nil {
|
||||||
|
return []*domain.SCEPProbeResult{}, nil
|
||||||
|
}
|
||||||
|
return s.scepProbeRepo.ListRecent(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeCertAlgorithm returns a short, operator-friendly description
|
||||||
|
// of the cert's public key algorithm + size. Examples:
|
||||||
|
// - "RSA-2048" / "RSA-3072" / "RSA-4096"
|
||||||
|
// - "ECDSA-P256" / "ECDSA-P384" / "ECDSA-P521"
|
||||||
|
// - "Ed25519"
|
||||||
|
// - "" for unrecognized algorithms.
|
||||||
|
func describeCertAlgorithm(c *x509.Certificate) string {
|
||||||
|
switch pub := c.PublicKey.(type) {
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
return fmt.Sprintf("RSA-%d", pub.N.BitLen())
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
// Curve is embedded in ecdsa.PublicKey; check the interface
|
||||||
|
// itself for nil before calling Params() via promotion (QF1008
|
||||||
|
// — staticcheck wants the promoted-method form, not the
|
||||||
|
// chained selector). Still need the nil check because
|
||||||
|
// calling Params() on a nil embedded interface would panic.
|
||||||
|
if pub.Curve != nil {
|
||||||
|
if params := pub.Params(); params != nil {
|
||||||
|
return "ECDSA-" + params.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "ECDSA"
|
||||||
|
}
|
||||||
|
switch c.PublicKeyAlgorithm {
|
||||||
|
case x509.Ed25519:
|
||||||
|
return "Ed25519"
|
||||||
|
case x509.DSA:
|
||||||
|
return "DSA"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master prompt §13 line 1859 acceptance —
|
||||||
|
// coverage uplift on the SCEP probe persistence + clamp paths. Closed
|
||||||
|
// in the 2026-04-29 audit-closure bundle (Phase H).
|
||||||
|
//
|
||||||
|
// Targets the lowest-coverage hot spots in
|
||||||
|
// internal/service/scep_probe.go (per the audit) without bloating the
|
||||||
|
// suite:
|
||||||
|
//
|
||||||
|
// 1. persistProbeResult is nil-safe + nil-repo-safe.
|
||||||
|
// 2. persistProbeResult swallows repo errors (probe stays a "best-
|
||||||
|
// effort persist") + still surfaces them through the logger.
|
||||||
|
// 3. ListRecentSCEPProbes returns an empty slice (NOT nil) when no
|
||||||
|
// repo is wired so JSON marshaling stays clean.
|
||||||
|
// 4. describeCertAlgorithm covers RSA/ECDSA/Ed25519/unknown branches
|
||||||
|
// including the QF1008 nil-curve defensive branch added in
|
||||||
|
// commit 9fcea95.
|
||||||
|
|
||||||
|
// stubSCEPProbeRepo is a controllable repository.SCEPProbeResultRepository
|
||||||
|
// used by the persist + list tests. Returns the configured insertErr +
|
||||||
|
// listResults from each Insert/ListRecent call; bumps insertCalls so the
|
||||||
|
// test can assert which probes reached the persist path.
|
||||||
|
type stubSCEPProbeRepo struct {
|
||||||
|
insertCalls int
|
||||||
|
insertErr error
|
||||||
|
listResults []*domain.SCEPProbeResult
|
||||||
|
listLimit int
|
||||||
|
listErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubSCEPProbeRepo) Insert(_ context.Context, _ *domain.SCEPProbeResult) error {
|
||||||
|
r.insertCalls++
|
||||||
|
return r.insertErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubSCEPProbeRepo) ListRecent(_ context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
r.listLimit = limit
|
||||||
|
return r.listResults, r.listErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPersistProbeResult_NoRepoIsNoOp verifies persistProbeResult is
|
||||||
|
// safe to call before SetSCEPProbeRepo wires a repo (the production
|
||||||
|
// startup order is: build service → wire repo). Without this, a probe
|
||||||
|
// that runs during the boot window would nil-deref.
|
||||||
|
func TestPersistProbeResult_NoRepoIsNoOp(t *testing.T) {
|
||||||
|
s := newScepProbeServiceForTest(t)
|
||||||
|
// Should not panic even though scepProbeRepo is nil.
|
||||||
|
s.persistProbeResult(context.Background(), &domain.SCEPProbeResult{
|
||||||
|
ID: "probe-no-repo",
|
||||||
|
TargetURL: "https://example.com/scep",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPersistProbeResult_RepoErrorDoesNotFailCaller pins the
|
||||||
|
// "best-effort persist" contract documented on persistProbeResult: a
|
||||||
|
// repo write failure MUST NOT bubble back to the probe caller (the
|
||||||
|
// probe's primary contract is "run + return," not "run + persist").
|
||||||
|
// The repo's insertCalls counter MUST still be bumped so an operator
|
||||||
|
// can prove the persist code path was reached even when it failed.
|
||||||
|
func TestPersistProbeResult_RepoErrorDoesNotFailCaller(t *testing.T) {
|
||||||
|
repo := &stubSCEPProbeRepo{insertErr: errors.New("simulated db down")}
|
||||||
|
s := newScepProbeServiceForTest(t)
|
||||||
|
s.SetSCEPProbeRepo(repo)
|
||||||
|
|
||||||
|
s.persistProbeResult(context.Background(), &domain.SCEPProbeResult{
|
||||||
|
ID: "probe-err",
|
||||||
|
TargetURL: "https://example.com/scep",
|
||||||
|
})
|
||||||
|
if repo.insertCalls != 1 {
|
||||||
|
t.Errorf("Insert calls = %d, want 1", repo.insertCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A logger-less service MUST also survive a repo error — the warn-
|
||||||
|
// log branch guards on `s.logger != nil`. Walk the same code path
|
||||||
|
// with a logger-nil service to exercise that defensive guard.
|
||||||
|
sNoLog := &NetworkScanService{nowFn: time.Now}
|
||||||
|
sNoLog.SetSCEPProbeRepo(repo)
|
||||||
|
sNoLog.persistProbeResult(context.Background(), &domain.SCEPProbeResult{
|
||||||
|
ID: "probe-err-nologger",
|
||||||
|
TargetURL: "https://example.com/scep",
|
||||||
|
})
|
||||||
|
if repo.insertCalls != 2 {
|
||||||
|
t.Errorf("Insert calls (after nologger run) = %d, want 2", repo.insertCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListRecentSCEPProbes_NilRepoReturnsEmptySlice pins the
|
||||||
|
// "JSON-clean empty" contract documented on ListRecentSCEPProbes —
|
||||||
|
// the absence of a repo MUST surface as an empty slice (not nil) so
|
||||||
|
// the GUI's JSON consumer doesn't render `null` instead of `[]`.
|
||||||
|
// Critical for the React Network Scan page that .map()s over the
|
||||||
|
// result and would crash on null.
|
||||||
|
func TestListRecentSCEPProbes_NilRepoReturnsEmptySlice(t *testing.T) {
|
||||||
|
s := newScepProbeServiceForTest(t)
|
||||||
|
got, err := s.ListRecentSCEPProbes(context.Background(), 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRecentSCEPProbes (nil repo): %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("ListRecentSCEPProbes (nil repo) returned nil, want empty slice for JSON cleanliness")
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("ListRecentSCEPProbes (nil repo) = %d items, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListRecentSCEPProbes_DelegatesToRepo verifies the wired-repo
|
||||||
|
// path: the limit value flows through to the repository unmodified
|
||||||
|
// (the [1, 200] clamp lives at the handler layer, not the service —
|
||||||
|
// this test pins the service is a thin pass-through).
|
||||||
|
func TestListRecentSCEPProbes_DelegatesToRepo(t *testing.T) {
|
||||||
|
repo := &stubSCEPProbeRepo{
|
||||||
|
listResults: []*domain.SCEPProbeResult{
|
||||||
|
{ID: "probe-1", TargetURL: "https://a.example.com/scep"},
|
||||||
|
{ID: "probe-2", TargetURL: "https://b.example.com/scep"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := newScepProbeServiceForTest(t)
|
||||||
|
s.SetSCEPProbeRepo(repo)
|
||||||
|
|
||||||
|
got, err := s.ListRecentSCEPProbes(context.Background(), 17)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRecentSCEPProbes: %v", err)
|
||||||
|
}
|
||||||
|
if repo.listLimit != 17 {
|
||||||
|
t.Errorf("repo.ListRecent received limit=%d, want 17", repo.listLimit)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Errorf("ListRecentSCEPProbes returned %d items, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDescribeCertAlgorithm covers every documented branch of the
|
||||||
|
// describe helper — including the QF1008 nil-curve defensive guard
|
||||||
|
// added in commit 9fcea95. Walking each branch keeps the staticcheck
|
||||||
|
// fix exercised in CI so a future "simplify" never reverts the nil
|
||||||
|
// check + crashes on a malformed cert.
|
||||||
|
func TestDescribeCertAlgorithm(t *testing.T) {
|
||||||
|
rsaCert, _ := fixtureRSACertForDescribeTest(t)
|
||||||
|
if got, want := describeCertAlgorithm(rsaCert), "RSA-2048"; got != want {
|
||||||
|
t.Errorf("RSA describe = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
ecCert, _ := fixtureCACert(t, "ec-describe", time.Now().Add(-1*time.Hour), time.Now().Add(24*time.Hour))
|
||||||
|
if got, want := describeCertAlgorithm(ecCert), "ECDSA-P-256"; got != want {
|
||||||
|
t.Errorf("ECDSA describe = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive branch: an ECDSA public key with a nil Curve. The
|
||||||
|
// QF1008 fix keeps the explicit nil check so this case returns
|
||||||
|
// "ECDSA" without panicking.
|
||||||
|
bogusEC := &x509.Certificate{
|
||||||
|
PublicKey: &ecdsa.PublicKey{Curve: nil},
|
||||||
|
PublicKeyAlgorithm: x509.ECDSA,
|
||||||
|
}
|
||||||
|
if got, want := describeCertAlgorithm(bogusEC), "ECDSA"; got != want {
|
||||||
|
t.Errorf("nil-curve ECDSA describe = %q, want %q (QF1008 defensive branch)", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Algorithm-only fall-through (no key type match) → Ed25519/DSA.
|
||||||
|
ed := &x509.Certificate{PublicKeyAlgorithm: x509.Ed25519}
|
||||||
|
if got, want := describeCertAlgorithm(ed), "Ed25519"; got != want {
|
||||||
|
t.Errorf("Ed25519 describe = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
dsa := &x509.Certificate{PublicKeyAlgorithm: x509.DSA}
|
||||||
|
if got, want := describeCertAlgorithm(dsa), "DSA"; got != want {
|
||||||
|
t.Errorf("DSA describe = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrecognized → empty string (the GUI then renders "—").
|
||||||
|
unknown := &x509.Certificate{}
|
||||||
|
if got := describeCertAlgorithm(unknown); got != "" {
|
||||||
|
t.Errorf("unknown describe = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixtureRSACertForDescribeTest is a tiny helper exclusive to the
|
||||||
|
// describe-algo coverage test. The package's other RSA cert helpers
|
||||||
|
// live behind type-specialized fixtures; we want a generic 2048-bit
|
||||||
|
// RSA cert + nothing else.
|
||||||
|
func fixtureRSACertForDescribeTest(t *testing.T) (*x509.Certificate, *rsa.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "rsa-describe"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return parsed, key
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5.4 — five named backend
|
||||||
|
// tests for the SCEP probe per the master prompt's exit criteria:
|
||||||
|
//
|
||||||
|
// TestProbeSCEP_AdvertisesAllCaps
|
||||||
|
// TestProbeSCEP_MissingSCEPStandard
|
||||||
|
// TestProbeSCEP_GetCACertExpired
|
||||||
|
// TestProbeSCEP_Unreachable
|
||||||
|
// TestProbeSCEP_RejectsReservedIP
|
||||||
|
//
|
||||||
|
// Plus PrintsCACertAlgorithm + IDOverride for coverage of the algorithm
|
||||||
|
// helper + deterministic ID injection. Run-once tests; no fuzz.
|
||||||
|
|
||||||
|
// silentScepLogger drops all probe logs so test output stays clean.
|
||||||
|
func silentScepLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newScepProbeServiceForTest wires a NetworkScanService in a way that
|
||||||
|
// only exposes what the SCEP probe path needs — the TLS-scan side stays
|
||||||
|
// unconfigured (nil deps) which is fine because none of the probe tests
|
||||||
|
// touch ScanAllTargets / TriggerScan.
|
||||||
|
func newScepProbeServiceForTest(t *testing.T) *NetworkScanService {
|
||||||
|
t.Helper()
|
||||||
|
svc := NewNetworkScanService(nil, nil, nil, silentScepLogger())
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixtureCACert returns a fresh self-signed cert + DER bytes the test
|
||||||
|
// httptest server can return for GetCACert. notAfter lets tests pin the
|
||||||
|
// cert into the past so the expired-cert assertions fire.
|
||||||
|
func fixtureCACert(t *testing.T, cn string, notBefore, notAfter time.Time) (*x509.Certificate, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
Issuer: pkix.Name{CommonName: cn + "-issuer"},
|
||||||
|
NotBefore: notBefore,
|
||||||
|
NotAfter: notAfter,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("x509.CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
parsed, _ := x509.ParseCertificate(der)
|
||||||
|
return parsed, der
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSCEPHandler returns an http.Handler that mimics an RFC 8894 SCEP
|
||||||
|
// server. Caller sets caps + an optional CA cert. GetCACert returns DER
|
||||||
|
// bytes (single cert form); GetCACaps returns the newline-separated
|
||||||
|
// list. Counts hits per operation for assertions.
|
||||||
|
type fakeSCEPHandler struct {
|
||||||
|
caps string
|
||||||
|
caCertDER []byte
|
||||||
|
getCAHits atomic.Int32
|
||||||
|
getCertHits atomic.Int32
|
||||||
|
emitFakeError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeSCEPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
op := r.URL.Query().Get("operation")
|
||||||
|
switch op {
|
||||||
|
case "GetCACaps":
|
||||||
|
h.getCAHits.Add(1)
|
||||||
|
if h.emitFakeError {
|
||||||
|
http.Error(w, "fake server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, _ = w.Write([]byte(h.caps))
|
||||||
|
case "GetCACert":
|
||||||
|
h.getCertHits.Add(1)
|
||||||
|
if len(h.caCertDER) == 0 {
|
||||||
|
http.Error(w, "no ca cert", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||||
|
_, _ = w.Write(h.caCertDER)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installPermissiveClientForTest swaps the production SSRF-defended
|
||||||
|
// HTTP client + URL validator for permissive test versions. The
|
||||||
|
// production stack rejects loopback / link-local / cloud-metadata IPs
|
||||||
|
// for SSRF defense; the httptest servers tests spin up bind to
|
||||||
|
// 127.0.0.1 by default, so tests need to bypass both layers. Mirrors
|
||||||
|
// the webhook notifier's `newForTest` pattern.
|
||||||
|
func installPermissiveClientForTest(svc *NetworkScanService) {
|
||||||
|
svc.scepHTTPClient = &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
svc.scepValidateURL = func(string) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeSCEP_AdvertisesAllCaps exercises the happy path where the
|
||||||
|
// fake server advertises the full RFC 8894 + AES + POST + Renewal +
|
||||||
|
// SHA-256 + SHA-512 set. Probe must parse all the flags + extract CA
|
||||||
|
// cert metadata + return reachable=true with no error.
|
||||||
|
func TestProbeSCEP_AdvertisesAllCaps(t *testing.T) {
|
||||||
|
cert, der := fixtureCACert(t, "fixture-ca", time.Now().Add(-1*time.Hour), time.Now().Add(365*24*time.Hour))
|
||||||
|
fake := &fakeSCEPHandler{
|
||||||
|
caps: "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n",
|
||||||
|
caCertDER: der,
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(fake)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := newScepProbeServiceForTest(t)
|
||||||
|
installPermissiveClientForTest(svc)
|
||||||
|
|
||||||
|
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProbeSCEP: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Reachable {
|
||||||
|
t.Fatalf("Reachable = false, want true")
|
||||||
|
}
|
||||||
|
if !res.SupportsRFC8894 || !res.SupportsAES || !res.SupportsPOSTOperation || !res.SupportsRenewal {
|
||||||
|
t.Errorf("expected all caps, got %+v", res)
|
||||||
|
}
|
||||||
|
if !res.SupportsSHA256 || !res.SupportsSHA512 {
|
||||||
|
t.Errorf("SHA cap flags missing")
|
||||||
|
}
|
||||||
|
if res.CACertSubject == "" || res.CACertSubject != cert.Subject.String() {
|
||||||
|
t.Errorf("CACertSubject = %q, want %q", res.CACertSubject, cert.Subject.String())
|
||||||
|
}
|
||||||
|
if res.CACertExpired {
|
||||||
|
t.Errorf("CACertExpired = true, want false (cert is valid for 365 days)")
|
||||||
|
}
|
||||||
|
if res.CACertChainLength != 1 {
|
||||||
|
t.Errorf("CACertChainLength = %d, want 1", res.CACertChainLength)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(res.CACertAlgorithm, "ECDSA") {
|
||||||
|
t.Errorf("CACertAlgorithm = %q, want ECDSA-*", res.CACertAlgorithm)
|
||||||
|
}
|
||||||
|
if res.Error != "" {
|
||||||
|
t.Errorf("Error = %q, want empty", res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeSCEP_MissingSCEPStandard probes a server that omits the
|
||||||
|
// "SCEPStandard" capability — modelling a pre-RFC-8894 server. Probe
|
||||||
|
// must succeed but flag SupportsRFC8894=false.
|
||||||
|
func TestProbeSCEP_MissingSCEPStandard(t *testing.T) {
|
||||||
|
_, der := fixtureCACert(t, "old-ca", time.Now().Add(-1*time.Hour), time.Now().Add(180*24*time.Hour))
|
||||||
|
fake := &fakeSCEPHandler{
|
||||||
|
caps: "POSTPKIOperation\nSHA-1\nDES3\n", // legacy server
|
||||||
|
caCertDER: der,
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(fake)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := newScepProbeServiceForTest(t)
|
||||||
|
installPermissiveClientForTest(svc)
|
||||||
|
|
||||||
|
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProbeSCEP: %v", err)
|
||||||
|
}
|
||||||
|
if res.SupportsRFC8894 {
|
||||||
|
t.Errorf("SupportsRFC8894 = true, want false (legacy server)")
|
||||||
|
}
|
||||||
|
if !res.SupportsPOSTOperation {
|
||||||
|
t.Errorf("SupportsPOSTOperation = false (server advertises POSTPKIOperation)")
|
||||||
|
}
|
||||||
|
if res.SupportsAES {
|
||||||
|
t.Errorf("SupportsAES = true (server doesn't advertise AES)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeSCEP_GetCACertExpired probes a server whose CA cert NotAfter
|
||||||
|
// is in the past. Probe must mark CACertExpired=true.
|
||||||
|
func TestProbeSCEP_GetCACertExpired(t *testing.T) {
|
||||||
|
_, der := fixtureCACert(t, "expired-ca",
|
||||||
|
time.Now().Add(-2*365*24*time.Hour),
|
||||||
|
time.Now().Add(-30*24*time.Hour),
|
||||||
|
)
|
||||||
|
fake := &fakeSCEPHandler{
|
||||||
|
caps: "SCEPStandard\n",
|
||||||
|
caCertDER: der,
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(fake)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := newScepProbeServiceForTest(t)
|
||||||
|
installPermissiveClientForTest(svc)
|
||||||
|
|
||||||
|
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProbeSCEP: %v", err)
|
||||||
|
}
|
||||||
|
if !res.CACertExpired {
|
||||||
|
t.Errorf("CACertExpired = false, want true (cert expired 30d ago)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeSCEP_Unreachable points the probe at a URL that doesn't
|
||||||
|
// respond. Probe must return reachable=false + a non-empty Error.
|
||||||
|
func TestProbeSCEP_Unreachable(t *testing.T) {
|
||||||
|
svc := newScepProbeServiceForTest(t)
|
||||||
|
installPermissiveClientForTest(svc)
|
||||||
|
|
||||||
|
// Use a port nothing's listening on. A short connect timeout via
|
||||||
|
// the install client means we don't wait long.
|
||||||
|
svc.scepHTTPClient = &http.Client{Timeout: 500 * time.Millisecond}
|
||||||
|
|
||||||
|
res, err := svc.ProbeSCEP(context.Background(), "http://127.0.0.1:1/scep")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected an error, got result: %+v", res)
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("expected non-nil result with error populated, got nil")
|
||||||
|
}
|
||||||
|
if res.Reachable {
|
||||||
|
t.Errorf("Reachable = true, want false")
|
||||||
|
}
|
||||||
|
if res.Error == "" {
|
||||||
|
t.Errorf("Error = empty, want a connection-failure message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeSCEP_RejectsReservedIP confirms the SSRF up-front check
|
||||||
|
// fires for literal reserved IPs. Run with the production HTTP client
|
||||||
|
// (the one wired by SafeHTTPDialContext) — the URL validation step
|
||||||
|
// rejects before any HTTP call.
|
||||||
|
func TestProbeSCEP_RejectsReservedIP(t *testing.T) {
|
||||||
|
svc := newScepProbeServiceForTest(t)
|
||||||
|
// Do NOT install the permissive client; we want the production
|
||||||
|
// SSRF path to fire on the first call.
|
||||||
|
|
||||||
|
res, err := svc.ProbeSCEP(context.Background(), "http://169.254.169.254/scep") // EC2 metadata
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected SSRF rejection, got result: %+v", res)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errSSRFRejection) && !strings.Contains(err.Error(), "url validation") {
|
||||||
|
// Either pattern is acceptable — the underlying validator
|
||||||
|
// wraps its error string differently across versions; what
|
||||||
|
// matters is that the Error string mentions the validation
|
||||||
|
// failure and the result has Reachable=false.
|
||||||
|
t.Logf("err: %v (acceptable as long as Reachable=false + Error captured)", err)
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatalf("expected non-nil result with error populated, got nil")
|
||||||
|
}
|
||||||
|
if res.Reachable {
|
||||||
|
t.Errorf("Reachable = true, want false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Error, "url validation") {
|
||||||
|
t.Errorf("Error = %q, want it to mention url validation", res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errSSRFRejection is a sentinel for the test's optional errors.Is
|
||||||
|
// match. The probe wraps validation errors in a generic fmt.Errorf so
|
||||||
|
// the underlying ValidateSafeURL error can vary; the test focuses on
|
||||||
|
// the visible behavior (Reachable=false + Error captured).
|
||||||
|
var errSSRFRejection = errors.New("url validation rejection")
|
||||||
|
|
||||||
|
// TestProbeSCEP_PEMWrappedCert exercises the fallback parse path: some
|
||||||
|
// servers return PEM-wrapped DER instead of raw DER for GetCACert.
|
||||||
|
// Probe should still parse the cert successfully.
|
||||||
|
func TestProbeSCEP_PEMWrappedCert(t *testing.T) {
|
||||||
|
cert, der := fixtureCACert(t, "pem-ca", time.Now().Add(-1*time.Hour), time.Now().Add(30*24*time.Hour))
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
fake := &fakeSCEPHandler{
|
||||||
|
caps: "SCEPStandard\nAES\n",
|
||||||
|
caCertDER: pemBytes, // server returned PEM, not DER
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(fake)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := newScepProbeServiceForTest(t)
|
||||||
|
installPermissiveClientForTest(svc)
|
||||||
|
|
||||||
|
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProbeSCEP: %v", err)
|
||||||
|
}
|
||||||
|
if res.CACertSubject != cert.Subject.String() {
|
||||||
|
t.Errorf("CACertSubject = %q, want %q (PEM fallback parse)", res.CACertSubject, cert.Subject.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Down migration for 000021_scep_probe_results.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_scep_probe_results_target_url;
|
||||||
|
DROP INDEX IF EXISTS idx_scep_probe_results_probed_at;
|
||||||
|
DROP TABLE IF EXISTS scep_probe_results;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Migration 000021: SCEP probe results (Phase 11.5 of the SCEP RFC 8894
|
||||||
|
-- + Intune master bundle).
|
||||||
|
--
|
||||||
|
-- The control plane's network scanner can probe an SCEP server URL
|
||||||
|
-- (RFC 8894 §3.5.1 GetCACaps + GetCACert) and persist a structured
|
||||||
|
-- posture snapshot per run. Operators use this for:
|
||||||
|
-- 1. Pre-migration assessment — point the probe at an existing
|
||||||
|
-- EJBCA / NDES SCEP server to see what capabilities it advertises
|
||||||
|
-- (RFC 8894 / AES / POST / Renewal / SHA-256 / SHA-512) and what
|
||||||
|
-- the CA cert looks like (subject, issuer, expiry, algorithm).
|
||||||
|
-- 2. Compliance posture audits — periodic probes against the
|
||||||
|
-- operator's own SCEP servers to flag drift.
|
||||||
|
--
|
||||||
|
-- The probe deliberately does NOT POST a CSR — capability-only.
|
||||||
|
-- Standalone CLI for this same probe is explicitly out of scope for
|
||||||
|
-- this bundle; the GUI surface inside certctl is the only consumer
|
||||||
|
-- of this table at this time.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scep_probe_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
reachable BOOLEAN NOT NULL,
|
||||||
|
advertised_caps TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
supports_rfc8894 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_aes BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_post_operation BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_renewal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_sha256 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_sha512 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ca_cert_subject TEXT,
|
||||||
|
ca_cert_issuer TEXT,
|
||||||
|
ca_cert_not_before TIMESTAMPTZ,
|
||||||
|
ca_cert_not_after TIMESTAMPTZ,
|
||||||
|
ca_cert_expired BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ca_cert_algorithm TEXT,
|
||||||
|
ca_cert_chain_length INTEGER NOT NULL DEFAULT 0,
|
||||||
|
probed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
probe_duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The two query patterns the GUI uses:
|
||||||
|
-- - "show me the most recent N probes across any URL" → probed_at DESC
|
||||||
|
-- - "show me the probe history for this URL" → target_url + probed_at DESC
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scep_probe_results_probed_at
|
||||||
|
ON scep_probe_results(probed_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scep_probe_results_target_url
|
||||||
|
ON scep_probe_results(target_url, probed_at DESC);
|
||||||
+38
-1
@@ -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, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } from './types';
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -296,6 +296,43 @@ export const fetchCRL = (issuerId: string) => {
|
|||||||
export const getAdminCRLCache = () =>
|
export const getAdminCRLCache = () =>
|
||||||
fetchJSON<CRLCacheResponse>(`${BASE}/admin/crl/cache`);
|
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 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP admin
|
||||||
|
// surface backing the Profiles tab on the SCEP Administration page.
|
||||||
|
// M-008 admin-gated; same gating semantics as the existing
|
||||||
|
// getAdminSCEPIntuneStats helper.
|
||||||
|
export const getAdminSCEPProfiles = () =>
|
||||||
|
fetchJSON<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
|
||||||
|
// (capability + posture). Synchronous — the caller blocks until the
|
||||||
|
// probe completes (cap: 30s server-side). Persists to the history
|
||||||
|
// table that listSCEPProbes reads from.
|
||||||
|
export const probeSCEPServer = (url: string) =>
|
||||||
|
fetchJSON<SCEPProbeResult>(`${BASE}/network-scan/scep-probe`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listSCEPProbes = () =>
|
||||||
|
fetchJSON<SCEPProbesResponse>(`${BASE}/network-scan/scep-probes`);
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
export const getAgents = (params: Record<string, string> = {}) => {
|
export const getAgents = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
@@ -626,3 +626,141 @@ export interface CRLCacheResponse {
|
|||||||
row_count: number;
|
row_count: number;
|
||||||
generated_at: string;
|
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;
|
||||||
|
// Master prompt §15 hazard closure (2026-04-29): per-profile
|
||||||
|
// ±tolerance on iat/exp checks. Default 60s wired from
|
||||||
|
// CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
|
||||||
|
clock_skew_tolerance_ns?: number;
|
||||||
|
rate_limit_disabled: boolean;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP admin
|
||||||
|
// snapshot. Backs the new /api/v1/admin/scep/profiles endpoint and
|
||||||
|
// the Profiles tab on the SCEP Administration page.
|
||||||
|
//
|
||||||
|
// Distinct from IntuneStatsSnapshot (which mirrors the existing
|
||||||
|
// /admin/scep/intune/stats endpoint) so the existing endpoint's JSON
|
||||||
|
// shape stays byte-stable for external consumers — backward-compat
|
||||||
|
// for the Phase 9 admin contract. The Profiles endpoint nests Intune
|
||||||
|
// data under a single optional `intune` field; the legacy Intune
|
||||||
|
// endpoint keeps the flat shape.
|
||||||
|
export interface IntuneSection {
|
||||||
|
trust_anchor_path?: string;
|
||||||
|
trust_anchors?: IntuneTrustAnchorInfo[];
|
||||||
|
audience?: string;
|
||||||
|
challenge_validity_ns?: number;
|
||||||
|
// Master prompt §15 hazard closure (2026-04-29): per-profile
|
||||||
|
// ±tolerance on iat/exp checks. Default 60s.
|
||||||
|
clock_skew_tolerance_ns?: number;
|
||||||
|
rate_limit_disabled: boolean;
|
||||||
|
replay_cache_size: number;
|
||||||
|
counters: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SCEPProfileStatsSnapshot {
|
||||||
|
path_id: string;
|
||||||
|
issuer_id: string;
|
||||||
|
challenge_password_set: boolean;
|
||||||
|
ra_cert_subject?: string;
|
||||||
|
ra_cert_not_before?: string;
|
||||||
|
ra_cert_not_after?: string;
|
||||||
|
ra_cert_days_to_expiry: number;
|
||||||
|
ra_cert_expired: boolean;
|
||||||
|
mtls_enabled: boolean;
|
||||||
|
mtls_trust_bundle_path?: string;
|
||||||
|
generated_at: string;
|
||||||
|
// nil/undefined when Intune is disabled on this profile.
|
||||||
|
intune?: IntuneSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SCEPProfilesResponse {
|
||||||
|
profiles: SCEPProfileStatsSnapshot[];
|
||||||
|
profile_count: number;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
|
//
|
||||||
|
// Backs the SCEP Probe section on the Network Scan page. The probe
|
||||||
|
// issues GetCACaps + GetCACert against an operator-supplied SCEP
|
||||||
|
// server URL and returns capability + posture metadata. Used for
|
||||||
|
// pre-migration assessment + compliance posture audits. Persisted
|
||||||
|
// to scep_probe_results (migration 000021) so the GUI can render
|
||||||
|
// recent probe history.
|
||||||
|
export interface SCEPProbeResult {
|
||||||
|
id: string;
|
||||||
|
target_url: string;
|
||||||
|
reachable: boolean;
|
||||||
|
advertised_caps: string[];
|
||||||
|
supports_rfc8894: boolean;
|
||||||
|
supports_aes: boolean;
|
||||||
|
supports_post_operation: boolean;
|
||||||
|
supports_renewal: boolean;
|
||||||
|
supports_sha256: boolean;
|
||||||
|
supports_sha512: boolean;
|
||||||
|
ca_cert_subject?: string;
|
||||||
|
ca_cert_issuer?: string;
|
||||||
|
ca_cert_not_before?: string;
|
||||||
|
ca_cert_not_after?: string;
|
||||||
|
ca_cert_expired: boolean;
|
||||||
|
ca_cert_days_to_expiry: number;
|
||||||
|
ca_cert_algorithm?: string;
|
||||||
|
ca_cert_chain_length: number;
|
||||||
|
probed_at: string;
|
||||||
|
probe_duration_ms: number;
|
||||||
|
error?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SCEPProbesResponse {
|
||||||
|
probes: SCEPProbeResult[];
|
||||||
|
probe_count: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const nav = [
|
|||||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||||
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||||
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||||
|
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
||||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import ObservabilityPage from './pages/ObservabilityPage';
|
|||||||
import JobDetailPage from './pages/JobDetailPage';
|
import JobDetailPage from './pages/JobDetailPage';
|
||||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||||
import TargetDetailPage from './pages/TargetDetailPage';
|
import TargetDetailPage from './pages/TargetDetailPage';
|
||||||
|
import SCEPAdminPage from './pages/SCEPAdminPage';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -79,6 +80,17 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="health-monitor" element={<HealthMonitorPage />} />
|
<Route path="health-monitor" element={<HealthMonitorPage />} />
|
||||||
<Route path="digest" element={<DigestPage />} />
|
<Route path="digest" element={<DigestPage />} />
|
||||||
<Route path="observability" element={<ObservabilityPage />} />
|
<Route path="observability" element={<ObservabilityPage />} />
|
||||||
|
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
|
||||||
|
+ Phase 9 follow-up (rebrand): per-profile SCEP
|
||||||
|
Administration page with Profiles / Intune Monitoring /
|
||||||
|
Recent Activity tabs. Route is unconditional; the page
|
||||||
|
itself renders an "Admin access required" banner for
|
||||||
|
non-admin callers and skips the underlying API calls so
|
||||||
|
the server never sees a 403-prone request. */}
|
||||||
|
<Route path="scep" element={<SCEPAdminPage />} />
|
||||||
|
{/* Backward-compat alias for external bookmarks the Phase 9
|
||||||
|
release advertised. Lands on the Intune Monitoring tab. */}
|
||||||
|
<Route path="scep/intune" element={<SCEPAdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -17,6 +17,9 @@ vi.mock('../api/client', () => ({
|
|||||||
updateNetworkScanTarget: vi.fn(),
|
updateNetworkScanTarget: vi.fn(),
|
||||||
deleteNetworkScanTarget: vi.fn(),
|
deleteNetworkScanTarget: vi.fn(),
|
||||||
triggerNetworkScan: vi.fn(),
|
triggerNetworkScan: vi.fn(),
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe.
|
||||||
|
probeSCEPServer: vi.fn(),
|
||||||
|
listSCEPProbes: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import NetworkScanPage from './NetworkScanPage';
|
import NetworkScanPage from './NetworkScanPage';
|
||||||
@@ -52,6 +55,10 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
cleanup();
|
cleanup();
|
||||||
delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__;
|
delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__;
|
||||||
|
// SCEP probe section runs in parallel with the scan-targets table;
|
||||||
|
// stub its history endpoint to an empty list so the existing tests
|
||||||
|
// don't accidentally exercise the probe path.
|
||||||
|
vi.mocked(client.listSCEPProbes).mockResolvedValue({ probes: [], probe_count: 0 } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the page header when getNetworkScanTargets resolves', async () => {
|
it('renders the page header when getNetworkScanTargets resolves', async () => {
|
||||||
@@ -82,3 +89,109 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SCEP Probe section — Phase 11.5 of the master bundle.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const happyProbeResult = {
|
||||||
|
id: 'spr-test-1',
|
||||||
|
target_url: 'https://scep.example.com/scep',
|
||||||
|
reachable: true,
|
||||||
|
advertised_caps: ['POSTPKIOperation', 'SHA-256', 'SHA-512', 'AES', 'SCEPStandard', 'Renewal'],
|
||||||
|
supports_rfc8894: true,
|
||||||
|
supports_aes: true,
|
||||||
|
supports_post_operation: true,
|
||||||
|
supports_renewal: true,
|
||||||
|
supports_sha256: true,
|
||||||
|
supports_sha512: true,
|
||||||
|
ca_cert_subject: 'CN=test-ca',
|
||||||
|
ca_cert_issuer: 'CN=test-ca',
|
||||||
|
ca_cert_not_before: '2026-01-01T00:00:00Z',
|
||||||
|
ca_cert_not_after: '2027-01-01T00:00:00Z',
|
||||||
|
ca_cert_expired: false,
|
||||||
|
ca_cert_days_to_expiry: 250,
|
||||||
|
ca_cert_algorithm: 'ECDSA-P-256',
|
||||||
|
ca_cert_chain_length: 1,
|
||||||
|
probed_at: '2026-04-29T16:00:00Z',
|
||||||
|
probe_duration_ms: 245,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NetworkScanPage — SCEP probe section (Phase 11.5)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
vi.mocked(client.getNetworkScanTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
|
vi.mocked(client.listSCEPProbes).mockResolvedValue({ probes: [], probe_count: 0 } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the SCEP probe section header + form', async () => {
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
expect(await screen.findByTestId('scep-probe-section')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-url-input')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-submit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty URL with an inline error and never calls the probe endpoint', async () => {
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
fireEvent.click(await screen.findByTestId('scep-probe-submit'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('scep-probe-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(client.probeSCEPServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a probe and renders capability badges + CA cert details on success', async () => {
|
||||||
|
vi.mocked(client.probeSCEPServer).mockResolvedValue(happyProbeResult as never);
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('scep-probe-url-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'https://scep.example.com/scep' } });
|
||||||
|
fireEvent.click(screen.getByTestId('scep-probe-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(client.probeSCEPServer).toHaveBeenCalledWith('https://scep.example.com/scep');
|
||||||
|
});
|
||||||
|
const panel = await screen.findByTestId('scep-probe-result-panel');
|
||||||
|
expect(panel).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-cap-badges')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-cap-rfc-8894').textContent).toContain('✓');
|
||||||
|
expect(screen.getByTestId('scep-probe-cap-aes').textContent).toContain('✓');
|
||||||
|
// Subject + days-remaining are rendered inside the panel; assert
|
||||||
|
// their substrings rather than using getByText (which matches a
|
||||||
|
// single text node and can miss content split across nested
|
||||||
|
// elements like dt/dd pairs).
|
||||||
|
expect(panel.textContent ?? '').toContain('CN=test-ca');
|
||||||
|
expect(panel.textContent ?? '').toContain('250d remaining');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces probe-level errors in the inline panel', async () => {
|
||||||
|
vi.mocked(client.probeSCEPServer).mockRejectedValue(new Error('network unreachable'));
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByTestId('scep-probe-url-input'), { target: { value: 'https://broken.example.com/scep' } });
|
||||||
|
fireEvent.click(screen.getByTestId('scep-probe-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('scep-probe-error')).toHaveTextContent(/network unreachable/);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('scep-probe-result-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the recent-probes history table with a row per probe', async () => {
|
||||||
|
vi.mocked(client.listSCEPProbes).mockResolvedValue({
|
||||||
|
probes: [
|
||||||
|
happyProbeResult,
|
||||||
|
{ ...happyProbeResult, id: 'spr-test-2', target_url: 'https://other.example.com/scep', supports_rfc8894: false },
|
||||||
|
],
|
||||||
|
probe_count: 2,
|
||||||
|
} as never);
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
|
||||||
|
const table = await screen.findByTestId('scep-probe-history-table');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
expect(rows[0].textContent).toContain('scep.example.com');
|
||||||
|
expect(rows[1].textContent).toContain('other.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import {
|
|||||||
updateNetworkScanTarget,
|
updateNetworkScanTarget,
|
||||||
deleteNetworkScanTarget,
|
deleteNetworkScanTarget,
|
||||||
triggerNetworkScan,
|
triggerNetworkScan,
|
||||||
|
probeSCEPServer,
|
||||||
|
listSCEPProbes,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { NetworkScanTarget } from '../api/types';
|
import type { NetworkScanTarget, SCEPProbeResult } from '../api/types';
|
||||||
|
|
||||||
function CreateScanTargetModal({ onClose, onCreate }: {
|
function CreateScanTargetModal({ onClose, onCreate }: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -258,6 +260,7 @@ export default function NetworkScanPage() {
|
|||||||
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
|
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SCEPProbeSection />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
@@ -269,3 +272,220 @@ export default function NetworkScanPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SCEP Probe section — Phase 11.5 of the master bundle.
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Operator-facing panel that runs an ad-hoc SCEP probe against a single
|
||||||
|
// URL. Used for pre-migration assessment (probe an existing EJBCA / NDES
|
||||||
|
// SCEP server before switching to certctl) and compliance posture audits
|
||||||
|
// (probe your own SCEP server periodically). Capability-only — does NOT
|
||||||
|
// POST a CSR. SSRF-defended at the backend via SafeHTTPDialContext.
|
||||||
|
//
|
||||||
|
// History table polls every 60s via TanStack Query.
|
||||||
|
|
||||||
|
function SCEPProbeSection() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [latestResult, setLatestResult] = useState<SCEPProbeResult | null>(null);
|
||||||
|
const [probeError, setProbeError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: ['scep-probes'],
|
||||||
|
queryFn: listSCEPProbes,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const probeMutation = useTrackedMutation<SCEPProbeResult, Error, string>({
|
||||||
|
mutationFn: (target: string) => probeSCEPServer(target),
|
||||||
|
invalidates: [['scep-probes']],
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setLatestResult(result);
|
||||||
|
setProbeError(undefined);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setLatestResult(null);
|
||||||
|
setProbeError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleProbe = () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setProbeError('Enter a SCEP server URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProbeError(undefined);
|
||||||
|
probeMutation.mutate(url.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="px-6 py-4 mt-2 border-t border-surface-border" data-testid="scep-probe-section">
|
||||||
|
<header className="mb-3">
|
||||||
|
<h2 className="text-base font-semibold text-ink">SCEP server probe</h2>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
Probe a SCEP server URL for capability + posture (RFC 8894 GetCACaps + GetCACert).
|
||||||
|
Use before migrating from EJBCA / NDES to verify what the existing server advertises.
|
||||||
|
Capability-only: does NOT POST a CSR. Reserved IP ranges are rejected.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bg-surface border border-surface-border rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://scep.example.com/scep"
|
||||||
|
className="flex-1 border border-surface-border rounded px-3 py-2 text-sm font-mono"
|
||||||
|
data-testid="scep-probe-url-input"
|
||||||
|
disabled={probeMutation.isPending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleProbe();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleProbe}
|
||||||
|
disabled={probeMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm text-white bg-brand-600 hover:bg-brand-700 rounded disabled:opacity-50"
|
||||||
|
data-testid="scep-probe-submit"
|
||||||
|
>
|
||||||
|
{probeMutation.isPending ? 'Probing…' : 'Probe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{probeError && (
|
||||||
|
<div className="mt-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="scep-probe-error">
|
||||||
|
{probeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResult && <SCEPProbeResultPanel result={latestResult} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SCEPProbeHistoryTable
|
||||||
|
probes={historyQuery.data?.probes ?? []}
|
||||||
|
isLoading={historyQuery.isLoading}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
|
||||||
|
const tone = result.error
|
||||||
|
? 'bg-red-50 border-red-300 text-red-800'
|
||||||
|
: result.reachable
|
||||||
|
? 'bg-emerald-50 border-emerald-300 text-emerald-900'
|
||||||
|
: 'bg-amber-50 border-amber-300 text-amber-900';
|
||||||
|
return (
|
||||||
|
<div className={`mt-3 rounded border p-3 text-xs ${tone}`} data-testid="scep-probe-result-panel">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<strong className="text-sm">{result.target_url}</strong>
|
||||||
|
<span>{formatDateTime(result.probed_at)} · {result.probe_duration_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
{result.error && (
|
||||||
|
<p className="font-mono text-[11px] mb-2">Error: {result.error}</p>
|
||||||
|
)}
|
||||||
|
{result.reachable && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2" data-testid="scep-probe-cap-badges">
|
||||||
|
<CapBadge label="RFC 8894" supported={result.supports_rfc8894} />
|
||||||
|
<CapBadge label="AES" supported={result.supports_aes} />
|
||||||
|
<CapBadge label="POST" supported={result.supports_post_operation} />
|
||||||
|
<CapBadge label="Renewal" supported={result.supports_renewal} />
|
||||||
|
<CapBadge label="SHA-256" supported={result.supports_sha256} />
|
||||||
|
<CapBadge label="SHA-512" supported={result.supports_sha512} />
|
||||||
|
</div>
|
||||||
|
{result.ca_cert_subject && (
|
||||||
|
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
||||||
|
<dt className="font-semibold">CA cert subject:</dt>
|
||||||
|
<dd className="font-mono text-[11px]">{result.ca_cert_subject}</dd>
|
||||||
|
<dt className="font-semibold">Issuer:</dt>
|
||||||
|
<dd className="font-mono text-[11px]">{result.ca_cert_issuer}</dd>
|
||||||
|
<dt className="font-semibold">Algorithm:</dt>
|
||||||
|
<dd>{result.ca_cert_algorithm || '(unknown)'}</dd>
|
||||||
|
<dt className="font-semibold">Chain length:</dt>
|
||||||
|
<dd>{result.ca_cert_chain_length}</dd>
|
||||||
|
<dt className="font-semibold">Expires:</dt>
|
||||||
|
<dd>
|
||||||
|
{result.ca_cert_not_after ? formatDateTime(result.ca_cert_not_after) : '(unknown)'}
|
||||||
|
{' '}
|
||||||
|
{result.ca_cert_expired ? (
|
||||||
|
<span className="text-red-600 font-semibold">(EXPIRED)</span>
|
||||||
|
) : (
|
||||||
|
<span>({result.ca_cert_days_to_expiry}d remaining)</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
{result.advertised_caps && result.advertised_caps.length > 0 && (
|
||||||
|
<p className="mt-2 text-[11px]">
|
||||||
|
Raw caps: <code>{result.advertised_caps.join(', ')}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapBadge({ label, supported }: { label: string; supported: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[11px] uppercase px-2 py-0.5 rounded border ${
|
||||||
|
supported ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-gray-100 text-gray-600 border-gray-300'
|
||||||
|
}`}
|
||||||
|
data-testid={`scep-probe-cap-${label.toLowerCase().replace(/\W/g, '-')}`}
|
||||||
|
>
|
||||||
|
{label} {supported ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SCEPProbeHistoryTable({ probes, isLoading }: { probes: SCEPProbeResult[]; isLoading: boolean }) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-xs text-ink-muted">Loading probe history…</p>;
|
||||||
|
}
|
||||||
|
if (probes.length === 0) {
|
||||||
|
return <p className="text-xs text-ink-muted">No SCEP probes yet — probe a URL above to start.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-3" data-testid="scep-probe-history-table">
|
||||||
|
<h3 className="text-xs font-semibold text-ink uppercase tracking-wide mb-2">Recent SCEP probes</h3>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-ink-muted uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-1 pr-2">When</th>
|
||||||
|
<th className="text-left py-1 pr-2">Target</th>
|
||||||
|
<th className="text-left py-1 pr-2">Reachable</th>
|
||||||
|
<th className="text-left py-1 pr-2">RFC 8894</th>
|
||||||
|
<th className="text-left py-1 pr-2">CA expiry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{probes.map((p) => (
|
||||||
|
<tr key={p.id} className="border-t border-surface-border">
|
||||||
|
<td className="py-1 pr-2 font-mono">{formatDateTime(p.probed_at)}</td>
|
||||||
|
<td className="py-1 pr-2 font-mono break-all">{p.target_url}</td>
|
||||||
|
<td className="py-1 pr-2">
|
||||||
|
{p.reachable ? (
|
||||||
|
<span className="text-emerald-700">Yes</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-700">No</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-2">{p.supports_rfc8894 ? '✓' : '✗'}</td>
|
||||||
|
<td className="py-1 pr-2">
|
||||||
|
{p.ca_cert_expired ? (
|
||||||
|
<span className="text-red-700 font-semibold">EXPIRED</span>
|
||||||
|
) : p.ca_cert_subject ? (
|
||||||
|
`${p.ca_cert_days_to_expiry}d`
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
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, Routes, Route } from 'react-router-dom';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md): Vitest coverage for the
|
||||||
|
// rebranded SCEP Administration page. Pins:
|
||||||
|
// 1. Admin gate — non-admin sees the gated banner; admin requests are
|
||||||
|
// never issued.
|
||||||
|
// 2. Tab navigation — Profiles is the default; clicking each tab
|
||||||
|
// switches surface; ?tab=intune deep-links land on Intune; the
|
||||||
|
// legacy /scep/intune route alias also lands on Intune.
|
||||||
|
// 3. Profiles tab — per-profile lean cards; status badges reflect
|
||||||
|
// Intune + mTLS + challenge-password-set; RA cert expiry badge
|
||||||
|
// tone bands (good ≥30d / warn 7-30d / bad <7d / EXPIRED);
|
||||||
|
// "View Intune details →" link only renders for Intune-enabled
|
||||||
|
// profiles AND switches to the Intune tab on click.
|
||||||
|
// 4. Intune tab — counters render with the existing Phase 9 deep-dive
|
||||||
|
// shape; reload modal opens / Confirm calls mutation / Cancel
|
||||||
|
// skips mutation / Error keeps modal open + surfaces message.
|
||||||
|
// 5. Recent Activity tab — merges all four SCEP audit actions across
|
||||||
|
// four parallel useQuery calls; filter chips narrow to the
|
||||||
|
// requested subset.
|
||||||
|
// 6. Error path — surfaces ErrorState on the active tab.
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
getAdminSCEPProfiles: vi.fn(),
|
||||||
|
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 renderWithRoute(initialPath: string, ui: ReactNode) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/scep" element={ui} />
|
||||||
|
<Route path="/scep/intune" element={ui} />
|
||||||
|
</Routes>
|
||||||
|
</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 corpProfileSummary = {
|
||||||
|
path_id: 'corp',
|
||||||
|
issuer_id: 'iss-corp',
|
||||||
|
challenge_password_set: true,
|
||||||
|
ra_cert_subject: 'ra-corp',
|
||||||
|
ra_cert_not_before: '2026-01-01T00:00:00Z',
|
||||||
|
ra_cert_not_after: '2027-01-01T00:00:00Z',
|
||||||
|
ra_cert_days_to_expiry: 250,
|
||||||
|
ra_cert_expired: false,
|
||||||
|
mtls_enabled: true,
|
||||||
|
mtls_trust_bundle_path: '/etc/certctl/mtls-corp.pem',
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
intune: {
|
||||||
|
trust_anchor_path: '/etc/certctl/intune-corp.pem',
|
||||||
|
trust_anchors: [
|
||||||
|
{ subject: 'intune-conn', not_before: '2026-01-01T00:00:00Z', not_after: '2027-01-01T00:00:00Z', days_to_expiry: 250, expired: false },
|
||||||
|
],
|
||||||
|
audience: 'https://certctl.example.com/scep/corp',
|
||||||
|
challenge_validity_ns: 3_600_000_000_000,
|
||||||
|
rate_limit_disabled: false,
|
||||||
|
replay_cache_size: 12,
|
||||||
|
counters: { success: 42 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const iotProfileSummary = {
|
||||||
|
path_id: 'iot',
|
||||||
|
issuer_id: 'iss-iot',
|
||||||
|
challenge_password_set: true,
|
||||||
|
ra_cert_subject: 'ra-iot',
|
||||||
|
ra_cert_not_before: '2026-01-01T00:00:00Z',
|
||||||
|
ra_cert_not_after: '2026-05-15T00:00:00Z',
|
||||||
|
ra_cert_days_to_expiry: 16,
|
||||||
|
ra_cert_expired: false,
|
||||||
|
mtls_enabled: false,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
// Intune disabled — no intune field
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiredProfileSummary = {
|
||||||
|
path_id: 'legacy',
|
||||||
|
issuer_id: 'iss-old',
|
||||||
|
challenge_password_set: true,
|
||||||
|
ra_cert_subject: 'ra-old',
|
||||||
|
ra_cert_not_before: '2024-01-01T00:00:00Z',
|
||||||
|
ra_cert_not_after: '2025-01-01T00:00:00Z',
|
||||||
|
ra_cert_days_to_expiry: 0,
|
||||||
|
ra_cert_expired: true,
|
||||||
|
mtls_enabled: false,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const corpIntuneStats = {
|
||||||
|
path_id: 'corp',
|
||||||
|
issuer_id: 'iss-corp',
|
||||||
|
enabled: true,
|
||||||
|
trust_anchor_path: '/etc/certctl/intune-corp.pem',
|
||||||
|
trust_anchors: [
|
||||||
|
{ subject: 'intune-conn', not_before: '2026-01-01T00:00:00Z', not_after: '2027-01-01T00:00:00Z', days_to_expiry: 250, expired: false },
|
||||||
|
],
|
||||||
|
audience: 'https://certctl.example.com/scep/corp',
|
||||||
|
challenge_validity_ns: 3_600_000_000_000,
|
||||||
|
rate_limit_disabled: false,
|
||||||
|
replay_cache_size: 12,
|
||||||
|
counters: { success: 42, signature_invalid: 1, claim_mismatch: 3, replay: 2 },
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin gate.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
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 });
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: /SCEP Administration/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(client.getAdminSCEPProfiles).not.toHaveBeenCalled();
|
||||||
|
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/Admin access required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets admin callers through and fetches the per-profile snapshot', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument();
|
||||||
|
expect(client.getAdminSCEPProfiles).toHaveBeenCalled();
|
||||||
|
// Default tab is Profiles → Intune stats endpoint NOT called yet
|
||||||
|
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tab navigation + deep links.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SCEPAdminPage — tab navigation', () => {
|
||||||
|
it('renders Profiles tab as default', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tab-profiles').getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to Intune tab on click and triggers the Intune stats fetch', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||||
|
profiles: [corpIntuneStats],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('profile-summary-corp');
|
||||||
|
fireEvent.click(screen.getByTestId('tab-intune'));
|
||||||
|
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
|
||||||
|
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('?tab=intune deep-link lands on Intune tab', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||||
|
profiles: [corpIntuneStats],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy /scep/intune route alias lands on Intune tab', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||||
|
profiles: [corpIntuneStats],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep/intune', <SCEPAdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to Activity tab and merges the four SCEP audit actions', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
|
||||||
|
const events: Record<string, unknown[]> = {
|
||||||
|
scep_pkcsreq: [{ id: 'a1', action: 'scep_pkcsreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }],
|
||||||
|
scep_renewalreq: [{ id: 'a2', action: 'scep_renewalreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:10:00Z' }],
|
||||||
|
scep_pkcsreq_intune: [{ id: 'a3', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:20:00Z' }],
|
||||||
|
scep_renewalreq_intune: [{ id: 'a4', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:30:00Z' }],
|
||||||
|
};
|
||||||
|
const action = params.action ?? '';
|
||||||
|
return Promise.resolve({
|
||||||
|
data: events[action] ?? [],
|
||||||
|
total: events[action]?.length ?? 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 200,
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('profile-summary-corp');
|
||||||
|
fireEvent.click(screen.getByTestId('tab-activity'));
|
||||||
|
await screen.findByTestId('activity-tab');
|
||||||
|
const table = await screen.findByTestId('activity-events-table');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(4);
|
||||||
|
// Sorted descending → renewal_intune (14:30) is first
|
||||||
|
expect(rows[0].textContent).toContain('scep_renewalreq_intune');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Profiles tab — lean cards.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SCEPAdminPage — Profiles tab cards', () => {
|
||||||
|
it('renders status badges for Intune + mTLS + challenge-password-set', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary, iotProfileSummary],
|
||||||
|
profile_count: 2,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('profile-summary-corp');
|
||||||
|
const corpBadges = screen.getByTestId('profile-badges-corp');
|
||||||
|
expect(corpBadges.textContent).toContain('Intune enabled');
|
||||||
|
expect(corpBadges.textContent).toContain('mTLS enabled');
|
||||||
|
expect(corpBadges.textContent).toContain('Challenge password set');
|
||||||
|
const iotBadges = screen.getByTestId('profile-badges-iot');
|
||||||
|
expect(iotBadges.textContent).toContain('Intune disabled');
|
||||||
|
expect(iotBadges.textContent).toContain('mTLS disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RA cert expiry badge tone reflects the days-to-expiry band', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary, iotProfileSummary, expiredProfileSummary],
|
||||||
|
profile_count: 3,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
expect(await screen.findByTestId('ra-expiry-badge-corp')).toHaveTextContent('250d');
|
||||||
|
expect(screen.getByTestId('ra-expiry-badge-iot')).toHaveTextContent(/16d remaining \(rotate soon\)/);
|
||||||
|
expect(screen.getByTestId('ra-expiry-badge-legacy')).toHaveTextContent(/EXPIRED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"View Intune details →" only renders for Intune-enabled profiles AND switches tabs', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary, iotProfileSummary],
|
||||||
|
profile_count: 2,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||||
|
profiles: [corpIntuneStats],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('profile-summary-corp');
|
||||||
|
expect(screen.getByTestId('view-intune-details-corp')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('view-intune-details-iot')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId('view-intune-details-corp'));
|
||||||
|
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty-state banner when no profiles are configured', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [],
|
||||||
|
profile_count: 0,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
expect(await screen.findByText(/No SCEP profiles are configured/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Intune tab — reload modal + counters.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SCEPAdminPage — Intune tab', () => {
|
||||||
|
function gotoIntune() {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||||
|
profiles: [corpIntuneStats],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders counters with the expected labels and tones', async () => {
|
||||||
|
gotoIntune();
|
||||||
|
expect(await screen.findByTestId('counter-corp-success')).toHaveTextContent('42');
|
||||||
|
expect(screen.getByTestId('counter-corp-signature_invalid')).toHaveTextContent('1');
|
||||||
|
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the reload modal and calls the mutation on Confirm', async () => {
|
||||||
|
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockResolvedValue({
|
||||||
|
reloaded: true,
|
||||||
|
path_id: 'corp',
|
||||||
|
reloaded_at: '2026-04-29T15:01:00Z',
|
||||||
|
} as never);
|
||||||
|
gotoIntune();
|
||||||
|
expect(await screen.findByTestId('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 when reload fails', async () => {
|
||||||
|
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired'));
|
||||||
|
gotoIntune();
|
||||||
|
expect(await screen.findByTestId('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();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cancel closes the modal without calling the reload mutation', async () => {
|
||||||
|
gotoIntune();
|
||||||
|
expect(await screen.findByTestId('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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Recent Activity tab — filter chips.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SCEPAdminPage — Activity tab filter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||||
|
profiles: [corpProfileSummary],
|
||||||
|
profile_count: 1,
|
||||||
|
generated_at: '2026-04-29T15:00:00Z',
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
|
||||||
|
const lookup: Record<string, unknown[]> = {
|
||||||
|
scep_pkcsreq: [{ id: 'p1', action: 'scep_pkcsreq', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }],
|
||||||
|
scep_renewalreq: [{ id: 'p2', action: 'scep_renewalreq', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:01:00Z' }],
|
||||||
|
scep_pkcsreq_intune: [{ id: 'p3', action: 'scep_pkcsreq_intune', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:02:00Z' }],
|
||||||
|
scep_renewalreq_intune: [{ id: 'p4', action: 'scep_renewalreq_intune', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:03:00Z' }],
|
||||||
|
};
|
||||||
|
return Promise.resolve({
|
||||||
|
data: lookup[params.action ?? ''] ?? [],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 200,
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filter=all shows all four actions', async () => {
|
||||||
|
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('activity-tab');
|
||||||
|
const table = await screen.findByTestId('activity-events-table');
|
||||||
|
expect(table.querySelectorAll('tbody tr').length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filter=intune narrows to just the two _intune actions', async () => {
|
||||||
|
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('activity-tab');
|
||||||
|
fireEvent.click(screen.getByTestId('activity-filter-intune'));
|
||||||
|
const table = await screen.findByTestId('activity-events-table');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
for (const r of rows) {
|
||||||
|
expect(r.textContent).toMatch(/_intune/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filter=renewal narrows to just the two renewal actions', async () => {
|
||||||
|
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('activity-tab');
|
||||||
|
fireEvent.click(screen.getByTestId('activity-filter-renewal'));
|
||||||
|
const table = await screen.findByTestId('activity-events-table');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
for (const r of rows) {
|
||||||
|
expect(r.textContent).toContain('scep_renewalreq');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filter=static narrows to just the two non-Intune actions', async () => {
|
||||||
|
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
|
||||||
|
await screen.findByTestId('activity-tab');
|
||||||
|
fireEvent.click(screen.getByTestId('activity-filter-static'));
|
||||||
|
const table = await screen.findByTestId('activity-events-table');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
for (const r of rows) {
|
||||||
|
expect(r.textContent).not.toMatch(/_intune/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Error path.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SCEPAdminPage — error surfacing', () => {
|
||||||
|
it('surfaces ErrorState on the active tab when its query fails', async () => {
|
||||||
|
vi.mocked(client.getAdminSCEPProfiles).mockRejectedValue(new Error('boom-profiles'));
|
||||||
|
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getAdminSCEPIntuneStats,
|
||||||
|
getAdminSCEPProfiles,
|
||||||
|
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,
|
||||||
|
SCEPProfileStatsSnapshot,
|
||||||
|
} from '../api/types';
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||||
|
// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP
|
||||||
|
// administration page with three tabs.
|
||||||
|
//
|
||||||
|
// Profiles (default) — every configured SCEP profile, lean card per
|
||||||
|
// profile with always-present fields (RA cert
|
||||||
|
// expiry, mTLS sibling-route status,
|
||||||
|
// challenge-password-set indicator). Cards on
|
||||||
|
// Intune-enabled profiles get a "View Intune
|
||||||
|
// details →" link that deep-links to the
|
||||||
|
// Intune tab filtered to that profile.
|
||||||
|
// Intune Monitoring — the existing Phase 9.4 deep-dive. Per-profile
|
||||||
|
// counters (success / signature_invalid /
|
||||||
|
// claim_mismatch / expired / wrong_audience /
|
||||||
|
// replay / rate_limited / malformed /
|
||||||
|
// compliance_failed / not_yet_valid /
|
||||||
|
// unknown_version), trust anchor expiry
|
||||||
|
// countdown, recent failures table, reload-
|
||||||
|
// trust button + confirmation modal. Polled
|
||||||
|
// every 30s via TanStack Query.
|
||||||
|
// Recent Activity — full SCEP audit log filter covering all four
|
||||||
|
// action codes (scep_pkcsreq, scep_renewalreq,
|
||||||
|
// scep_pkcsreq_intune, scep_renewalreq_intune).
|
||||||
|
// Merged + sorted descending by timestamp.
|
||||||
|
// Filter chips for All / Initial / Renewal /
|
||||||
|
// Intune / Static. Polled every 60s.
|
||||||
|
//
|
||||||
|
// Admin-gated: the page itself renders an "Admin access required" banner
|
||||||
|
// 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabId = 'profiles' | 'intune' | 'activity';
|
||||||
|
type ActivityFilter = 'all' | 'initial' | 'renewal' | 'intune' | 'static';
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<TabId, string> = {
|
||||||
|
profiles: 'Profiles',
|
||||||
|
intune: 'Intune Monitoring',
|
||||||
|
activity: 'Recent Activity',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCEP_AUDIT_ACTIONS = [
|
||||||
|
'scep_pkcsreq',
|
||||||
|
'scep_renewalreq',
|
||||||
|
'scep_pkcsreq_intune',
|
||||||
|
'scep_renewalreq_intune',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tone + badge helpers (shared across tabs).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } {
|
||||||
|
if (expired) return { text: 'EXPIRED', tone: 'bad' };
|
||||||
|
if (days === null) return { text: 'Not loaded', tone: 'warn' };
|
||||||
|
if (days < 7) return { text: `${days}d remaining`, tone: 'bad' };
|
||||||
|
if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' };
|
||||||
|
return { text: `${days}d remaining`, tone: 'good' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(tone: 'good' | 'warn' | 'bad'): string {
|
||||||
|
if (tone === 'good') return 'bg-emerald-100 text-emerald-800';
|
||||||
|
if (tone === 'warn') return 'bg-amber-100 text-amber-800';
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillClass(active: boolean): string {
|
||||||
|
return active
|
||||||
|
? 'bg-brand-100 text-brand-800 border-brand-300'
|
||||||
|
: 'bg-surface-alt text-ink-muted border-surface-border';
|
||||||
|
}
|
||||||
|
|
||||||
|
// soonestExpiryDays returns the smallest days_to_expiry across the
|
||||||
|
// profile's Intune 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;
|
||||||
|
if (a.days_to_expiry < min) min = a.days_to_expiry;
|
||||||
|
}
|
||||||
|
return min === Number.POSITIVE_INFINITY ? null : min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Profiles tab — per-profile lean card with always-present fields.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ProfilesTabProps {
|
||||||
|
profiles: SCEPProfileStatsSnapshot[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onViewIntuneDetails: (pathID: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfilesTab({ profiles, isLoading, onViewIntuneDetails }: ProfilesTabProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-sm text-ink-muted px-1 py-6">Loading profiles…</p>;
|
||||||
|
}
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
No SCEP profiles are configured. Set <code>CERTCTL_SCEP_ENABLED=true</code> and either the
|
||||||
|
legacy single-profile env vars or <code>CERTCTL_SCEP_PROFILES=...</code> with the indexed
|
||||||
|
per-profile family to register at least one endpoint.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{profiles.map(p => (
|
||||||
|
<ProfileSummaryCard
|
||||||
|
key={p.path_id || '(root)'}
|
||||||
|
profile={p}
|
||||||
|
onViewIntuneDetails={onViewIntuneDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileSummaryCardProps {
|
||||||
|
profile: SCEPProfileStatsSnapshot;
|
||||||
|
onViewIntuneDetails: (pathID: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileSummaryCard({ profile, onViewIntuneDetails }: ProfileSummaryCardProps) {
|
||||||
|
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||||
|
const intuneEnabled = !!profile.intune;
|
||||||
|
const raBadge = expiryBadge(
|
||||||
|
profile.ra_cert_subject ? profile.ra_cert_days_to_expiry : null,
|
||||||
|
profile.ra_cert_expired,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
|
||||||
|
data-testid={`profile-summary-${profile.path_id}`}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
|
||||||
|
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(raBadge.tone)}`}
|
||||||
|
data-testid={`ra-expiry-badge-${profile.path_id}`}
|
||||||
|
>
|
||||||
|
RA cert: {raBadge.text}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3" data-testid={`profile-badges-${profile.path_id}`}>
|
||||||
|
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.challenge_password_set)}`}>
|
||||||
|
Challenge password{profile.challenge_password_set ? ' set' : ' MISSING'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
|
||||||
|
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(intuneEnabled)}`}>
|
||||||
|
Intune {intuneEnabled ? 'enabled' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-ink-muted">
|
||||||
|
<div>
|
||||||
|
<dt className="font-semibold text-ink">RA cert subject</dt>
|
||||||
|
<dd className="font-mono text-[11px]">{profile.ra_cert_subject || '(not loaded)'}</dd>
|
||||||
|
</div>
|
||||||
|
{profile.ra_cert_not_after && (
|
||||||
|
<div>
|
||||||
|
<dt className="font-semibold text-ink">RA cert expires</dt>
|
||||||
|
<dd>{formatDateTime(profile.ra_cert_not_after)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.mtls_enabled && profile.mtls_trust_bundle_path && (
|
||||||
|
<div>
|
||||||
|
<dt className="font-semibold text-ink">mTLS trust bundle</dt>
|
||||||
|
<dd className="font-mono text-[11px]">{profile.mtls_trust_bundle_path}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{intuneEnabled && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-surface-border flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onViewIntuneDetails(profile.path_id)}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||||
|
data-testid={`view-intune-details-${profile.path_id}`}
|
||||||
|
>
|
||||||
|
View Intune details →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Intune Monitoring tab — the existing Phase 9.4 deep-dive surface.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ConfirmReloadModalProps {
|
||||||
|
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 IntuneTabProps {
|
||||||
|
profiles: IntuneStatsSnapshot[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onRequestReload: (profile: IntuneStatsSnapshot) => void;
|
||||||
|
highlightPathID: string | null;
|
||||||
|
events: AuditEvent[];
|
||||||
|
eventsLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntuneTab({ profiles, isLoading, onRequestReload, highlightPathID, events, eventsLoading }: IntuneTabProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-sm text-ink-muted px-1 py-6">Loading Intune monitoring data…</p>;
|
||||||
|
}
|
||||||
|
const intuneProfiles = profiles.filter(p => p.enabled);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{intuneProfiles.length === 0 && (
|
||||||
|
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
|
||||||
|
No SCEP profile has Intune enabled. Set
|
||||||
|
<code className="mx-1">CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true</code>
|
||||||
|
plus the matching trust-anchor path env var, then restart the server.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{intuneProfiles.map(p => (
|
||||||
|
<IntuneProfileCard
|
||||||
|
key={p.path_id || '(root)'}
|
||||||
|
profile={p}
|
||||||
|
onRequestReload={onRequestReload}
|
||||||
|
highlighted={highlightPathID === p.path_id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<section className="bg-surface border border-surface-border rounded-lg mt-6">
|
||||||
|
<div className="px-4 py-3 border-b border-surface-border">
|
||||||
|
<h3 className="text-sm font-semibold text-ink">
|
||||||
|
Recent Intune-dispatched enrollments (last 50)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
Filtered to <code>action=scep_pkcsreq_intune</code> + <code>action=scep_renewalreq_intune</code>.
|
||||||
|
Refreshes every 60s.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{eventsLoading ? (
|
||||||
|
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log…</p>
|
||||||
|
) : (
|
||||||
|
<RecentEventsTable events={events.slice(0, 50)} testID="intune-failures-table" emptyMessage="No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled." />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntuneProfileCardProps {
|
||||||
|
profile: IntuneStatsSnapshot;
|
||||||
|
onRequestReload: (profile: IntuneStatsSnapshot) => void;
|
||||||
|
highlighted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntuneProfileCard({ profile, onRequestReload, highlighted }: IntuneProfileCardProps) {
|
||||||
|
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||||
|
const days = soonestExpiryDays(profile.trust_anchors);
|
||||||
|
const badge = expiryBadge(days, days !== null && days < 0);
|
||||||
|
const cardClass = highlighted
|
||||||
|
? 'bg-surface border-2 border-brand-400 rounded-lg p-5 mb-4 shadow-sm'
|
||||||
|
: 'bg-surface border border-surface-border rounded-lg p-5 mb-4';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cardClass} 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 ${badgeClass(badge.tone)}`}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Recent Activity tab — full SCEP audit log filter.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ActivityTabProps {
|
||||||
|
events: AuditEvent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
filter: ActivityFilter;
|
||||||
|
setFilter: (f: ActivityFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityFilterMatches(filter: ActivityFilter, action: string): boolean {
|
||||||
|
switch (filter) {
|
||||||
|
case 'all':
|
||||||
|
return true;
|
||||||
|
case 'initial':
|
||||||
|
return action === 'scep_pkcsreq' || action === 'scep_pkcsreq_intune';
|
||||||
|
case 'renewal':
|
||||||
|
return action === 'scep_renewalreq' || action === 'scep_renewalreq_intune';
|
||||||
|
case 'intune':
|
||||||
|
return action === 'scep_pkcsreq_intune' || action === 'scep_renewalreq_intune';
|
||||||
|
case 'static':
|
||||||
|
return action === 'scep_pkcsreq' || action === 'scep_renewalreq';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) {
|
||||||
|
const filtered = events.filter(e => activityFilterMatches(filter, e.action));
|
||||||
|
return (
|
||||||
|
<section className="bg-surface border border-surface-border rounded-lg" data-testid="activity-tab">
|
||||||
|
<div className="px-4 py-3 border-b border-surface-border">
|
||||||
|
<h3 className="text-sm font-semibold text-ink">SCEP enrollment audit log (last 100)</h3>
|
||||||
|
<p className="text-xs text-ink-muted mb-3">
|
||||||
|
Merged across <code>scep_pkcsreq</code> + <code>scep_renewalreq</code> +
|
||||||
|
<code> scep_pkcsreq_intune</code> + <code>scep_renewalreq_intune</code>. Refreshes every 60s.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2" data-testid="activity-filter-chips">
|
||||||
|
{(['all', 'initial', 'renewal', 'intune', 'static'] as const).map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
filter === f
|
||||||
|
? 'bg-brand-100 text-brand-800 border-brand-300'
|
||||||
|
: 'bg-surface text-ink-muted border-surface-border hover:bg-surface-alt'
|
||||||
|
}`}
|
||||||
|
data-testid={`activity-filter-${f}`}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log…</p>
|
||||||
|
) : (
|
||||||
|
<RecentEventsTable
|
||||||
|
events={filtered.slice(0, 100)}
|
||||||
|
testID="activity-events-table"
|
||||||
|
emptyMessage={
|
||||||
|
events.length === 0
|
||||||
|
? 'No SCEP enrollment events recorded yet.'
|
||||||
|
: 'No events match the current filter — try a different chip.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Shared events table.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface RecentEventsTableProps {
|
||||||
|
events: AuditEvent[];
|
||||||
|
testID: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentEventsTable({ events, testID, emptyMessage }: RecentEventsTableProps) {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return <p className="text-sm text-ink-muted px-4 py-6">{emptyMessage}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<table className="w-full text-sm" data-testid={testID}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Top-level page.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function pickTabFromQuery(value: string | null): TabId {
|
||||||
|
if (value === 'intune' || value === 'activity') return value;
|
||||||
|
return 'profiles';
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickInitialTab honors three signals (precedence high → low):
|
||||||
|
// 1. ?tab=intune|activity in the query string (deep link)
|
||||||
|
// 2. Pathname ending in /scep/intune (legacy route alias from
|
||||||
|
// Phase 9.4; preserved so external bookmarks land on Intune)
|
||||||
|
// 3. Default to 'profiles'
|
||||||
|
function pickInitialTab(searchParams: URLSearchParams, pathname: string): TabId {
|
||||||
|
const fromQuery = searchParams.get('tab');
|
||||||
|
if (fromQuery === 'intune' || fromQuery === 'activity') return fromQuery;
|
||||||
|
if (pathname.endsWith('/scep/intune')) return 'intune';
|
||||||
|
return 'profiles';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SCEPAdminPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const adminAccess = !auth.authRequired || auth.admin;
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>(() => pickInitialTab(searchParams, location.pathname));
|
||||||
|
const [highlightPathID, setHighlightPathID] = useState<string | null>(searchParams.get('profile'));
|
||||||
|
const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null);
|
||||||
|
const [reloadError, setReloadError] = useState<string | undefined>(undefined);
|
||||||
|
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
|
||||||
|
|
||||||
|
// Keep URL in sync with tab + highlighted profile so deep links survive
|
||||||
|
// page reloads + browser back/forward.
|
||||||
|
useEffect(() => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (activeTab === 'profiles') {
|
||||||
|
next.delete('tab');
|
||||||
|
} else {
|
||||||
|
next.set('tab', activeTab);
|
||||||
|
}
|
||||||
|
if (highlightPathID && activeTab === 'intune') {
|
||||||
|
next.set('profile', highlightPathID);
|
||||||
|
} else {
|
||||||
|
next.delete('profile');
|
||||||
|
}
|
||||||
|
if (next.toString() !== searchParams.toString()) {
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}
|
||||||
|
}, [activeTab, highlightPathID, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
// Always-present per-profile data (Profiles tab).
|
||||||
|
const profilesQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'scep', 'profiles'],
|
||||||
|
queryFn: getAdminSCEPProfiles,
|
||||||
|
enabled: adminAccess,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intune deep-dive data (Intune tab).
|
||||||
|
const intuneStatsQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'scep', 'intune', 'stats'],
|
||||||
|
queryFn: getAdminSCEPIntuneStats,
|
||||||
|
enabled: adminAccess && activeTab === 'intune',
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log queries — four parallel queries (one per SCEP action) so
|
||||||
|
// both the Intune tab's recent-failures table and the Activity tab's
|
||||||
|
// full SCEP audit feed can pull from the same React Query cache.
|
||||||
|
const auditQueries = SCEP_AUDIT_ACTIONS.map(action =>
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['audit', { action }],
|
||||||
|
queryFn: () => getAuditEvents({ action }),
|
||||||
|
enabled: adminAccess && (activeTab === 'intune' || activeTab === 'activity'),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const allAuditEvents: AuditEvent[] = useMemo(() => {
|
||||||
|
const merged: AuditEvent[] = [];
|
||||||
|
for (const q of auditQueries) {
|
||||||
|
if (q.data?.data) merged.push(...q.data.data);
|
||||||
|
}
|
||||||
|
return merged.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [auditQueries.map(q => q.dataUpdatedAt).join('|')]);
|
||||||
|
const auditLoading = auditQueries.some(q => q.isLoading);
|
||||||
|
const intuneOnlyEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
allAuditEvents.filter(
|
||||||
|
e => e.action === 'scep_pkcsreq_intune' || e.action === 'scep_renewalreq_intune',
|
||||||
|
),
|
||||||
|
[allAuditEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloadMutation = useTrackedMutation<
|
||||||
|
Awaited<ReturnType<typeof reloadAdminSCEPIntuneTrust>>,
|
||||||
|
Error,
|
||||||
|
string
|
||||||
|
>({
|
||||||
|
mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID),
|
||||||
|
invalidates: [
|
||||||
|
['admin', 'scep', 'intune', 'stats'],
|
||||||
|
['admin', 'scep', 'profiles'],
|
||||||
|
],
|
||||||
|
onSuccess: () => {
|
||||||
|
setReloadTarget(null);
|
||||||
|
setReloadError(undefined);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setReloadError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auth.authRequired && !auth.admin) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="SCEP Administration" subtitle="Admin-only observability surface" />
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorState
|
||||||
|
error={new Error('Admin access required: this page exposes per-profile RA cert expiries, mTLS bundle paths, Intune trust anchor expiries, and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = profilesQuery.data?.profiles ?? [];
|
||||||
|
const intuneProfiles = intuneStatsQuery.data?.profiles ?? [];
|
||||||
|
|
||||||
|
const handleViewIntuneDetails = (pathID: string) => {
|
||||||
|
setHighlightPathID(pathID);
|
||||||
|
setActiveTab('intune');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="SCEP Administration"
|
||||||
|
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · per-profile observability + Intune monitoring + recent activity`}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void profilesQuery.refetch();
|
||||||
|
if (activeTab === 'intune') void intuneStatsQuery.refetch();
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||||
|
data-testid="refresh-stats-button"
|
||||||
|
>
|
||||||
|
Refresh now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="border-b border-surface-border bg-surface px-6">
|
||||||
|
<nav className="flex gap-1 -mb-px" data-testid="scep-admin-tabs">
|
||||||
|
{(['profiles', 'intune', 'activity'] as TabId[]).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
className={`px-4 py-2.5 text-sm border-b-2 transition-colors ${
|
||||||
|
activeTab === t
|
||||||
|
? 'border-brand-500 text-brand-700 font-semibold'
|
||||||
|
: 'border-transparent text-ink-muted hover:text-ink hover:border-surface-border'
|
||||||
|
}`}
|
||||||
|
data-testid={`tab-${t}`}
|
||||||
|
aria-pressed={activeTab === t}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[t]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto">
|
||||||
|
{profilesQuery.error && activeTab === 'profiles' && (
|
||||||
|
<ErrorState error={profilesQuery.error as Error} onRetry={() => profilesQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{intuneStatsQuery.error && activeTab === 'intune' && (
|
||||||
|
<ErrorState error={intuneStatsQuery.error as Error} onRetry={() => intuneStatsQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'profiles' && !profilesQuery.error && (
|
||||||
|
<ProfilesTab
|
||||||
|
profiles={profiles}
|
||||||
|
isLoading={profilesQuery.isLoading}
|
||||||
|
onViewIntuneDetails={handleViewIntuneDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'intune' && !intuneStatsQuery.error && (
|
||||||
|
<IntuneTab
|
||||||
|
profiles={intuneProfiles}
|
||||||
|
isLoading={intuneStatsQuery.isLoading}
|
||||||
|
onRequestReload={profile => {
|
||||||
|
setReloadError(undefined);
|
||||||
|
setReloadTarget(profile);
|
||||||
|
}}
|
||||||
|
highlightPathID={highlightPathID}
|
||||||
|
events={intuneOnlyEvents}
|
||||||
|
eventsLoading={auditLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<ActivityTab
|
||||||
|
events={allAuditEvents}
|
||||||
|
isLoading={auditLoading}
|
||||||
|
filter={activityFilter}
|
||||||
|
setFilter={setActivityFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reloadTarget && (
|
||||||
|
<ConfirmReloadModal
|
||||||
|
profile={reloadTarget}
|
||||||
|
onCancel={() => {
|
||||||
|
setReloadTarget(null);
|
||||||
|
setReloadError(undefined);
|
||||||
|
}}
|
||||||
|
onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)}
|
||||||
|
pending={reloadMutation.isPending}
|
||||||
|
errorMessage={reloadError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user