mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 07:59:06 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 530593507b | |||
| 84fac19f98 | |||
| 506cff137d | |||
| 0be889ff1d | |||
| 5d080c86fd | |||
| e0d00717c7 |
@@ -108,6 +108,7 @@ gantt
|
||||
|----------|----------|----------|
|
||||
| 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. |
|
||||
| **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 ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
||||
|
||||
|
||||
@@ -732,6 +732,157 @@ paths:
|
||||
"500":
|
||||
$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]
|
||||
|
||||
+31
-6
@@ -356,6 +356,12 @@ func main() {
|
||||
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
|
||||
networkScanRepo := postgres.NewNetworkScanRepository(db)
|
||||
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")
|
||||
|
||||
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
|
||||
@@ -837,6 +843,12 @@ func main() {
|
||||
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
|
||||
scepService.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 != "" {
|
||||
scepService.SetProfileID(profile.ProfileID)
|
||||
}
|
||||
@@ -859,6 +871,11 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
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,
|
||||
@@ -868,7 +885,7 @@ func main() {
|
||||
// with INTUNE_ENABLED=false skip the entire block, so the cost on
|
||||
// non-Intune deploys is exactly one bool check per profile.
|
||||
if profile.Intune.Enabled {
|
||||
intuneHolder, err := preflightSCEPIntuneTrustAnchor(true, profile.Intune.ConnectorCertPath, profileLog)
|
||||
intuneHolder, err := preflightSCEPIntuneTrustAnchor(true, profile.PathID, profile.Intune.ConnectorCertPath, profileLog)
|
||||
if err != nil {
|
||||
profileLog.Error(
|
||||
"startup refused: SCEP profile INTUNE trust anchor preflight failed "+
|
||||
@@ -903,6 +920,7 @@ func main() {
|
||||
intuneHolder,
|
||||
profile.Intune.Audience,
|
||||
profile.Intune.ChallengeValidity,
|
||||
profile.Intune.ClockSkewTolerance,
|
||||
replayCache,
|
||||
rateLimiter,
|
||||
)
|
||||
@@ -910,6 +928,7 @@ func main() {
|
||||
"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,
|
||||
)
|
||||
}
|
||||
@@ -1445,18 +1464,24 @@ func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPo
|
||||
// On success returns the freshly-built *intune.TrustAnchorHolder ready to
|
||||
// inject into the per-profile SCEPService via SetIntuneIntegration. The
|
||||
// holder also installs the SIGHUP watcher (started by the caller).
|
||||
func preflightSCEPIntuneTrustAnchor(enabled bool, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) {
|
||||
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("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")
|
||||
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("INTUNE trust anchor load failed: %w (path=%s)", err, path)
|
||||
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE trust anchor load failed: %w (path=%s)", pathIDLabel, err, path)
|
||||
}
|
||||
return holder, nil
|
||||
}
|
||||
|
||||
@@ -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_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)
|
||||
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)
|
||||
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
|
||||
- ./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:
|
||||
certctl-test:
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
#### 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
|
||||
|
||||
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
||||
|
||||
+11
-4
@@ -498,10 +498,17 @@ otherwise.
|
||||
typically <50KB so the default cap is generous.
|
||||
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
|
||||
plane; there is no plaintext fallback.
|
||||
- **Forward reference:** for the deeper Intune integration writeup
|
||||
(architecture, migration playbook, troubleshooting,
|
||||
Microsoft-support-statement), see [`scep-intune.md`](scep-intune.md)
|
||||
(Phase 11 of the master bundle).
|
||||
- **For Microsoft Intune deployments, see [`scep-intune.md`](scep-intune.md)** —
|
||||
architecture, NDES-replacement migration playbook, Intune SCEP profile
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -16,14 +16,20 @@ import (
|
||||
// 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.
|
||||
// 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). Profiles where Intune is disabled appear with
|
||||
// Enabled=false so the GUI can show "off — opt in via env vars"
|
||||
// rather than 404ing per-profile.
|
||||
// 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
|
||||
@@ -39,18 +45,20 @@ type AdminSCEPIntuneService interface {
|
||||
// to any configured profile. The handler maps this to HTTP 404.
|
||||
var ErrAdminSCEPProfileNotFound = errors.New("admin scep intune: profile not found for the given path_id")
|
||||
|
||||
// AdminSCEPIntuneHandler serves the per-profile Intune observability
|
||||
// endpoints for the GUI Intune Monitoring tab.
|
||||
// AdminSCEPIntuneHandler serves the per-profile SCEP observability
|
||||
// endpoints for the GUI SCEP Administration page.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/v1/admin/scep/intune/stats
|
||||
// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"})
|
||||
// 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"})
|
||||
//
|
||||
// Both endpoints are admin-gated (M-008 pattern). Non-admin Bearer
|
||||
// All three endpoints are admin-gated (M-008 pattern). Non-admin Bearer
|
||||
// callers get 403 — the stats endpoint reveals the operator's profile
|
||||
// set + trust anchor expiries (sensitive operational metadata) and the
|
||||
// reload endpoint is a privileged action.
|
||||
// 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
|
||||
}
|
||||
@@ -68,6 +76,42 @@ 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 {
|
||||
@@ -177,6 +221,18 @@ func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([]
|
||||
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]
|
||||
|
||||
@@ -18,12 +18,15 @@ import (
|
||||
// Records call observations so the M-008 admin-gate triplet can pin
|
||||
// "service was never invoked" when the gate rejects the caller.
|
||||
type fakeAdminSCEPIntuneService struct {
|
||||
statsCalled bool
|
||||
reloadCalled bool
|
||||
rows []service.IntuneStatsSnapshot
|
||||
statsErr error
|
||||
reloadPathID string
|
||||
reloadErr error
|
||||
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) {
|
||||
@@ -31,6 +34,11 @@ func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]se
|
||||
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
|
||||
@@ -334,3 +342,154 @@ func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.
|
||||
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,9 +35,9 @@ import (
|
||||
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
|
||||
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
||||
var AdminGatedHandlers = map[string]string{
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2: stats endpoint reveals per-profile trust anchor expiries + reload-trust is a privileged action — admin-only",
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + 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
|
||||
|
||||
@@ -17,6 +17,14 @@ type NetworkScanService interface {
|
||||
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
||||
DeleteTarget(ctx context.Context, id string) 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.
|
||||
@@ -177,3 +185,80 @@ func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Re
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
svc := &mockNetworkScanService{
|
||||
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
|
||||
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for
|
||||
// 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
|
||||
@@ -304,10 +304,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
||||
// the handler (M-003 pattern); non-admin callers get 403.
|
||||
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.2. Both endpoints are
|
||||
// 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))
|
||||
|
||||
@@ -347,6 +349,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
|
||||
r.Register("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))
|
||||
// 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
|
||||
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
|
||||
|
||||
@@ -879,6 +879,18 @@ type SCEPIntuneProfileConfig struct {
|
||||
// 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.
|
||||
@@ -1514,6 +1526,7 @@ func loadSCEPProfilesFromEnv() []SCEPProfileConfig {
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1792,6 +1805,19 @@ func (c *Config) Validate() error {
|
||||
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.
|
||||
type NetworkScanTarget struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CIDRs []string `json:"cidrs"`
|
||||
Ports []int64 `json:"ports"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ScanIntervalHours int `json:"scan_interval_hours"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CIDRs []string `json:"cidrs"`
|
||||
Ports []int64 `json:"ports"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ScanIntervalHours int `json:"scan_interval_hours"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
||||
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
||||
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NetworkScanResult holds the outcome of scanning a single endpoint.
|
||||
@@ -25,3 +25,43 @@ type NetworkScanResult struct {
|
||||
Error string
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
type mockVerificationService struct{}
|
||||
|
||||
|
||||
@@ -554,6 +554,22 @@ type NetworkScanRepository interface {
|
||||
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.
|
||||
type OwnerRepository interface {
|
||||
// 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)
|
||||
@@ -166,6 +166,56 @@ func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
|
||||
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
|
||||
@@ -173,9 +223,10 @@ func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
|
||||
// trust-anchor cert's public key (try each until one verifies)
|
||||
// 3. Extract version claim via the lightweight versioned-prelude
|
||||
// 4. Dispatch to the per-version unmarshaler (v1 today)
|
||||
// 5. Time bounds: now ≥ iat AND now < exp (with stdlib RFC 3339 grace)
|
||||
// 6. Audience: claim.Audience == expectedAudience (when expectedAudience
|
||||
// is non-empty; empty disables the check, useful for tests)
|
||||
// 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).
|
||||
@@ -184,8 +235,8 @@ func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
|
||||
// claim's Nonce to a *ReplayCache.CheckAndInsert. We deliberately don't
|
||||
// own the cache here so the validator stays stateless + testable; the
|
||||
// handler glues parser + cache together.
|
||||
func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience string, now time.Time) (*ChallengeClaim, error) {
|
||||
if len(trust) == 0 {
|
||||
func ValidateChallenge(raw string, opts ValidateOptions) (*ChallengeClaim, error) {
|
||||
if len(opts.Trust) == 0 {
|
||||
return nil, fmt.Errorf("%w: no trust anchors configured", ErrChallengeSignature)
|
||||
}
|
||||
|
||||
@@ -212,7 +263,7 @@ func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience s
|
||||
return nil, fmt.Errorf("%w: header JSON: %v", ErrChallengeMalformed, err)
|
||||
}
|
||||
|
||||
if err := verifyChallengeSignature(hdr.Alg, signingInput, signature, trust); err != nil {
|
||||
if err := verifyChallengeSignature(hdr.Alg, signingInput, signature, opts.Trust); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -230,26 +281,34 @@ func ValidateChallenge(raw string, trust []*x509.Certificate, expectedAudience s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Time bounds. The Connector's signed iat/exp ARE authoritative;
|
||||
// we don't impose a separate validity cap here (the operator can
|
||||
// add one in the handler if defense-in-depth is wanted, e.g. via
|
||||
// SCEPProfileConfig.IntuneChallengeValidity in Phase 8).
|
||||
if !claim.IssuedAt.IsZero() && now.Before(claim.IssuedAt) {
|
||||
return nil, fmt.Errorf("%w: iat=%s now=%s", ErrChallengeNotYetValid,
|
||||
claim.IssuedAt.Format(time.RFC3339), now.Format(time.RFC3339))
|
||||
// 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
|
||||
}
|
||||
if !claim.ExpiresAt.IsZero() && !now.Before(claim.ExpiresAt) {
|
||||
return nil, fmt.Errorf("%w: exp=%s now=%s", ErrChallengeExpired,
|
||||
claim.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339))
|
||||
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
|
||||
// empty ExpectedAudience disables the check (test convenience + the
|
||||
// Phase 8 config allows operator opt-out for proxy / load-balancer
|
||||
// scenarios where the URL the Connector saw isn't the URL we see).
|
||||
if expectedAudience != "" && claim.Audience != "" && claim.Audience != expectedAudience {
|
||||
if opts.ExpectedAudience != "" && claim.Audience != "" && claim.Audience != opts.ExpectedAudience {
|
||||
return nil, fmt.Errorf("%w: claim=%q expected=%q", ErrChallengeWrongAudience,
|
||||
claim.Audience, expectedAudience)
|
||||
claim.Audience, opts.ExpectedAudience)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package intune
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 10.1.
|
||||
//
|
||||
// challenge_golden_test.go reads the three persistent fixtures under
|
||||
// testdata/ and asserts ValidateChallenge returns the documented typed
|
||||
// error per case:
|
||||
//
|
||||
// testdata/intune_trust_anchor.pem — golden trust cert
|
||||
// testdata/intune_challenge_golden_success.txt — valid challenge
|
||||
// testdata/intune_challenge_golden_expired.txt — exp in past
|
||||
// testdata/intune_challenge_golden_tampered_sig.txt — payload OK, sig flipped
|
||||
//
|
||||
// The fixtures are reproducibly generated by running:
|
||||
//
|
||||
// go test -run='^TestRegenerateGoldenFixtures$' -update-golden ./internal/scep/intune/...
|
||||
//
|
||||
// The trust anchor cert + signing key come from a deterministic PRNG so
|
||||
// the key.PEM diff stays clean across regenerations; only the ECDSA
|
||||
// signature suffix bytes vary (Go's stdlib doesn't expose RFC 6979
|
||||
// deterministic-k in a clean surface, so the signature embeds a real
|
||||
// random nonce). ValidateChallenge re-verifies the signature on every
|
||||
// read so a re-randomized signature still passes — what we pin in the
|
||||
// golden tests is the FAILURE-DIMENSION semantics, not the byte-exact
|
||||
// signature output.
|
||||
|
||||
// updateGolden is the test flag operators flip when regenerating the
|
||||
// fixtures. Default false: regular `go test` runs the read-and-validate
|
||||
// path only.
|
||||
var updateGolden = flag.Bool("update-golden", false, "regenerate testdata/intune_*.txt + intune_trust_anchor.pem fixtures (deterministic except for ECDSA sig nonce)")
|
||||
|
||||
// TestRegenerateGoldenFixtures rebuilds testdata/ when -update-golden
|
||||
// is passed. Skipped otherwise so a fresh `go test` doesn't churn the
|
||||
// PEM file on every run.
|
||||
func TestRegenerateGoldenFixtures(t *testing.T) {
|
||||
if !*updateGolden {
|
||||
t.Skip("regenerate fixtures only when -update-golden is passed")
|
||||
}
|
||||
if err := os.MkdirAll(testdataDir(t), 0o755); err != nil {
|
||||
t.Fatalf("mkdir testdata: %v", err)
|
||||
}
|
||||
|
||||
key, cert := generateGoldenTrustAnchor(t)
|
||||
|
||||
// Trust anchor PEM.
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(testdataDir(t), "intune_trust_anchor.pem"),
|
||||
pemEncodeForFixture(cert.Raw),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write trust anchor: %v", err)
|
||||
}
|
||||
|
||||
// Success fixture.
|
||||
successRaw := signGoldenChallenge(t, key, goldenChallengePayload())
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(testdataDir(t), "intune_challenge_golden_success.txt"),
|
||||
[]byte(successRaw+"\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write success fixture: %v", err)
|
||||
}
|
||||
|
||||
// Expired fixture — same signing key, payload with iat+exp in the past.
|
||||
expiredRaw := signGoldenChallenge(t, key, goldenExpiredChallengePayload())
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(testdataDir(t), "intune_challenge_golden_expired.txt"),
|
||||
[]byte(expiredRaw+"\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write expired fixture: %v", err)
|
||||
}
|
||||
|
||||
// Tampered-sig fixture — start from a fresh success challenge then
|
||||
// flip one byte of the signature. We deliberately re-sign here so
|
||||
// the regenerated tampered file's payload lines up with whatever
|
||||
// the success fixture happens to be in this regeneration round —
|
||||
// otherwise the golden tests for "TamperedSig" might accidentally
|
||||
// pass for "WrongAudience" or similar if the fixtures drifted apart.
|
||||
freshForTamper := signGoldenChallenge(t, key, goldenChallengePayload())
|
||||
tamperedRaw := flipLastSignatureByte(t, freshForTamper)
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(testdataDir(t), "intune_challenge_golden_tampered_sig.txt"),
|
||||
[]byte(tamperedRaw+"\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write tampered fixture: %v", err)
|
||||
}
|
||||
|
||||
// Unknown-version fixture — same signing key + valid signature, but
|
||||
// the payload carries a `version: "v999"` claim that the dispatcher
|
||||
// does NOT have an unmarshaler for. ValidateChallenge MUST surface
|
||||
// ErrChallengeUnknownVersion; the unknown-version fixture pins the
|
||||
// dispatcher's defense against the inevitable Microsoft format
|
||||
// change (master prompt §13 line 1848).
|
||||
unknownVersionRaw := signGoldenChallengeAny(t, key, goldenUnknownVersionPayload())
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(testdataDir(t), "intune_challenge_golden_unknown_version.txt"),
|
||||
[]byte(unknownVersionRaw+"\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write unknown-version fixture: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("regenerated 5 fixture files in %s", testdataDir(t))
|
||||
}
|
||||
|
||||
// TestGoldenChallenge_Success — the documented happy-path: the success
|
||||
// fixture validates against the trust anchor and produces a populated
|
||||
// claim. Pinned at goldenChallengeNow so the iat/exp window check
|
||||
// passes deterministically (no wall-clock dependency).
|
||||
func TestGoldenChallenge_Success(t *testing.T) {
|
||||
trust := loadGoldenTrustAnchor(t)
|
||||
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
|
||||
|
||||
claim, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: goldenChallengeNow})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChallenge success fixture: %v", err)
|
||||
}
|
||||
if claim.DeviceName != "fixture-device.example.com" {
|
||||
t.Errorf("DeviceName = %q, want fixture-device.example.com", claim.DeviceName)
|
||||
}
|
||||
if claim.Subject != "device-guid-fixture-0001" {
|
||||
t.Errorf("Subject = %q, want device-guid-fixture-0001", claim.Subject)
|
||||
}
|
||||
if len(claim.SANDNS) != 1 || claim.SANDNS[0] != "fixture-device.example.com" {
|
||||
t.Errorf("SANDNS = %v, want [fixture-device.example.com]", claim.SANDNS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenChallenge_Expired — the expired fixture's iat + exp are
|
||||
// both before goldenChallengeNow, so ValidateChallenge MUST surface
|
||||
// ErrChallengeExpired (the validator's exp branch is the first
|
||||
// time-bounds check that fires for past-exp inputs).
|
||||
func TestGoldenChallenge_Expired(t *testing.T) {
|
||||
trust := loadGoldenTrustAnchor(t)
|
||||
raw := readGoldenFixture(t, "intune_challenge_golden_expired.txt")
|
||||
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, Now: goldenChallengeNow})
|
||||
if !errors.Is(err, ErrChallengeExpired) {
|
||||
t.Fatalf("got %v, want errors.Is(ErrChallengeExpired)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenChallenge_TamperedSig — the tampered fixture's signature
|
||||
// byte was flipped; ValidateChallenge MUST reject with ErrChallengeSignature
|
||||
// regardless of whether the payload + audience check would otherwise pass.
|
||||
func TestGoldenChallenge_TamperedSig(t *testing.T) {
|
||||
trust := loadGoldenTrustAnchor(t)
|
||||
raw := readGoldenFixture(t, "intune_challenge_golden_tampered_sig.txt")
|
||||
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: goldenChallengeNow})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want errors.Is(ErrChallengeSignature)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenChallenge_WrongAudienceReuse — defensive: feed the success
|
||||
// fixture but with the wrong audience pinned — the audience-check leg
|
||||
// of ValidateChallenge MUST fire even though the signature would
|
||||
// otherwise verify. Pins the correct ordering of the check sequence so
|
||||
// a future refactor doesn't accidentally short-circuit the audience
|
||||
// check after a successful signature verify.
|
||||
func TestGoldenChallenge_WrongAudienceReuse(t *testing.T) {
|
||||
trust := loadGoldenTrustAnchor(t)
|
||||
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
|
||||
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: trust, ExpectedAudience: "https://attacker.example.com/scep/wrong", Now: goldenChallengeNow})
|
||||
if !errors.Is(err, ErrChallengeWrongAudience) {
|
||||
t.Fatalf("got %v, want errors.Is(ErrChallengeWrongAudience)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenChallenge_RotatedTrustAnchorRejects — defensive: load the
|
||||
// success fixture but verify against a freshly-generated different
|
||||
// trust anchor (simulating an operator who rotated the Connector
|
||||
// signing key without reloading certctl's trust). The validator MUST
|
||||
// reject with ErrChallengeSignature.
|
||||
func TestGoldenChallenge_RotatedTrustAnchorRejects(t *testing.T) {
|
||||
// Generate a fresh trust anchor that bears no relationship to the
|
||||
// fixture's signing key. Reuses the helper from challenge_test.go.
|
||||
rotated := genTestECDSAConnector(t)
|
||||
raw := readGoldenFixture(t, "intune_challenge_golden_success.txt")
|
||||
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{rotated.cert}, Now: goldenChallengeNow})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want errors.Is(ErrChallengeSignature) when validated against a rotated trust anchor", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenChallenge_UnknownVersionRejected — master prompt §13 line
|
||||
// 1848 named acceptance criterion. A challenge whose payload carries a
|
||||
// `version: "v999"` claim (a value the dispatcher's
|
||||
// versionUnmarshalers map deliberately does NOT contain) MUST surface
|
||||
// ErrChallengeUnknownVersion regardless of whether the signature is
|
||||
// otherwise valid. This is the dispatcher's defense against the
|
||||
// inevitable Microsoft Connector format change — the day Microsoft
|
||||
// ships v2 and certctl's parser doesn't yet have a v2 unmarshaler, every
|
||||
// Intune enrollment lands here with a clear typed error rather than
|
||||
// crashing the SCEP handler with a confusing unmarshal panic.
|
||||
//
|
||||
// Why this test uses a fresh trust anchor instead of the on-disk
|
||||
// golden PEM: the on-disk PEM was generated with a Go-stdlib version
|
||||
// that produces different ECDSA key bytes from the current
|
||||
// generateGoldenTrustAnchor() call (the deterministic-PRNG +
|
||||
// ecdsa.GenerateKey pair has shifted across Go releases — the on-disk
|
||||
// public key bytes don't match what the current Go runtime regenerates
|
||||
// from the same seed). Rather than bake a stale trust anchor into the
|
||||
// regression, we generate a fresh ECDSA Connector keypair in-process
|
||||
// + use BOTH for signing AND for the validator's trust pool. The
|
||||
// regen target still emits a fixture file under testdata/ for the
|
||||
// operator-readable artifact; the test itself stays decoupled from
|
||||
// the on-disk PEM's drift.
|
||||
func TestGoldenChallenge_UnknownVersionRejected(t *testing.T) {
|
||||
conn := genTestECDSAConnector(t)
|
||||
raw := signTestChallengeES256_FixedWidth(t, conn, struct {
|
||||
Version string `json:"version"`
|
||||
challengePayloadV1
|
||||
}{
|
||||
Version: "v999",
|
||||
challengePayloadV1: goldenChallengePayload(),
|
||||
})
|
||||
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{
|
||||
Trust: []*x509.Certificate{conn.cert},
|
||||
Now: goldenChallengeNow,
|
||||
})
|
||||
if !errors.Is(err, ErrChallengeUnknownVersion) {
|
||||
t.Fatalf("got %v, want errors.Is(ErrChallengeUnknownVersion) for version=v999 claim", err)
|
||||
}
|
||||
// The error message MUST surface the specific version string so the
|
||||
// operator's audit log narrows the diagnosis to "Microsoft shipped
|
||||
// vN" rather than "something is wrong with the challenge."
|
||||
if !strings.Contains(err.Error(), "v999") {
|
||||
t.Errorf("error should contain the unknown version literal for operator audit log: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func TestValidateChallenge_HappyPath_RS256(t *testing.T) {
|
||||
pl := validV1Payload(now)
|
||||
raw := signTestChallengeRS256(t, c, pl)
|
||||
|
||||
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
|
||||
got, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChallenge: %v", err)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestValidateChallenge_HappyPath_ES256_FixedWidth(t *testing.T) {
|
||||
pl := validV1Payload(now)
|
||||
raw := signTestChallengeES256_FixedWidth(t, c, pl)
|
||||
|
||||
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
|
||||
got, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChallenge: %v", err)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestValidateChallenge_HappyPath_ES256_DER(t *testing.T) {
|
||||
pl := validV1Payload(now)
|
||||
raw := signTestChallengeES256_DER(t, c, pl)
|
||||
|
||||
if _, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now); err != nil {
|
||||
if _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now}); err != nil {
|
||||
t.Fatalf("ValidateChallenge ES256 DER: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestValidateChallenge_Expired(t *testing.T) {
|
||||
pl.ExpiresAt = now.Add(-1 * time.Minute).Unix()
|
||||
raw := signTestChallengeRS256(t, c, pl)
|
||||
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if !errors.Is(err, ErrChallengeExpired) {
|
||||
t.Fatalf("got %v, want ErrChallengeExpired", err)
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func TestValidateChallenge_NotYetValid(t *testing.T) {
|
||||
pl.ExpiresAt = now.Add(65 * time.Minute).Unix()
|
||||
raw := signTestChallengeRS256(t, c, pl)
|
||||
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, pl.Audience, now)
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if !errors.Is(err, ErrChallengeNotYetValid) {
|
||||
t.Fatalf("got %v, want ErrChallengeNotYetValid", err)
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func TestValidateChallenge_WrongAudience(t *testing.T) {
|
||||
pl := validV1Payload(now)
|
||||
raw := signTestChallengeRS256(t, c, pl)
|
||||
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "https://wrong-host.example.com/scep", now)
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: "https://wrong-host.example.com/scep", Now: now})
|
||||
if !errors.Is(err, ErrChallengeWrongAudience) {
|
||||
t.Fatalf("got %v, want ErrChallengeWrongAudience", err)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ func TestValidateChallenge_EmptyExpectedAudienceDisablesCheck(t *testing.T) {
|
||||
pl := validV1Payload(now)
|
||||
raw := signTestChallengeRS256(t, c, pl)
|
||||
|
||||
if _, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", now); err != nil {
|
||||
if _, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: now}); err != nil {
|
||||
t.Fatalf("empty expected audience should disable the check: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func TestValidateChallenge_TamperedSignature(t *testing.T) {
|
||||
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
|
||||
tampered := strings.Join(parts, ".")
|
||||
|
||||
_, err := ValidateChallenge(tampered, []*x509.Certificate{c.cert}, pl.Audience, now)
|
||||
_, err := ValidateChallenge(tampered, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature", err)
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func TestValidateChallenge_TamperedPayload(t *testing.T) {
|
||||
parts[1] = base64.RawURLEncoding.EncodeToString(tamperedPayload)
|
||||
tampered := strings.Join(parts, ".")
|
||||
|
||||
_, err := ValidateChallenge(tampered, []*x509.Certificate{c.cert}, pl.Audience, now)
|
||||
_, err := ValidateChallenge(tampered, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature", err)
|
||||
}
|
||||
@@ -370,7 +370,7 @@ func TestValidateChallenge_RotatedTrustAnchor(t *testing.T) {
|
||||
pl := validV1Payload(now)
|
||||
raw := signTestChallengeRS256(t, signedBy, pl)
|
||||
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{rotatedTo.cert}, pl.Audience, now)
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{rotatedTo.cert}, ExpectedAudience: pl.Audience, Now: now})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature", err)
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func TestValidateChallenge_EmptyTrustBundle(t *testing.T) {
|
||||
now := time.Now()
|
||||
raw := signTestChallengeRS256(t, c, validV1Payload(now))
|
||||
|
||||
_, err := ValidateChallenge(raw, nil, "", now)
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: nil, Now: now})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature", err)
|
||||
}
|
||||
@@ -397,7 +397,7 @@ func TestValidateChallenge_AlgNoneRejected(t *testing.T) {
|
||||
base64.RawURLEncoding.EncodeToString([]byte("nope"))
|
||||
|
||||
c := genTestRSAConnector(t)
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature for alg=none", err)
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func TestValidateChallenge_UnsupportedAlg(t *testing.T) {
|
||||
base64.RawURLEncoding.EncodeToString([]byte("hmac-bytes"))
|
||||
|
||||
c := genTestRSAConnector(t)
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature for unsupported alg", err)
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func TestValidateChallenge_MissingAlgHeader(t *testing.T) {
|
||||
base64.RawURLEncoding.EncodeToString([]byte("xx"))
|
||||
|
||||
c := genTestRSAConnector(t)
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
|
||||
if !errors.Is(err, ErrChallengeSignature) {
|
||||
t.Fatalf("got %v, want ErrChallengeSignature for missing alg", err)
|
||||
}
|
||||
@@ -448,7 +448,7 @@ func TestValidateChallenge_VersionV1ExplicitOK(t *testing.T) {
|
||||
p := plWithVersion{Version: "v1", challengePayloadV1: validV1Payload(now)}
|
||||
raw := signTestChallengeRS256(t, c, p)
|
||||
|
||||
got, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, p.Audience, now)
|
||||
got, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: p.Audience, Now: now})
|
||||
if err != nil {
|
||||
t.Fatalf("explicit v1 should be accepted: %v", err)
|
||||
}
|
||||
@@ -467,7 +467,7 @@ func TestValidateChallenge_VersionUnknownRejected(t *testing.T) {
|
||||
p := plWithVersion{Version: "v999", challengePayloadV1: validV1Payload(now)}
|
||||
raw := signTestChallengeRS256(t, c, p)
|
||||
|
||||
_, err := ValidateChallenge(raw, []*x509.Certificate{c.cert}, p.Audience, now)
|
||||
_, err := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, ExpectedAudience: p.Audience, Now: now})
|
||||
if !errors.Is(err, ErrChallengeUnknownVersion) {
|
||||
t.Fatalf("got %v, want ErrChallengeUnknownVersion", err)
|
||||
}
|
||||
@@ -489,7 +489,7 @@ func TestValidateChallenge_MixedTrustBundle_IgnoresKeyTypeMismatches(t *testing.
|
||||
// mismatch), find RSA, verify, return success.
|
||||
raw := signTestChallengeRS256(t, rsaConn, pl)
|
||||
bundle := []*x509.Certificate{ecConn.cert, rsaConn.cert}
|
||||
if _, err := ValidateChallenge(raw, bundle, pl.Audience, now); err != nil {
|
||||
if _, err := ValidateChallenge(raw, ValidateOptions{Trust: bundle, ExpectedAudience: pl.Audience, Now: now}); err != nil {
|
||||
t.Fatalf("mixed-bundle validate: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -512,12 +512,118 @@ func TestValidateChallenge_NonJSONPayloadButValidSignature(t *testing.T) {
|
||||
}
|
||||
raw := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
|
||||
_, vErr := ValidateChallenge(raw, []*x509.Certificate{c.cert}, "", time.Now())
|
||||
_, vErr := ValidateChallenge(raw, ValidateOptions{Trust: []*x509.Certificate{c.cert}, Now: time.Now()})
|
||||
if !errors.Is(vErr, ErrChallengeMalformed) {
|
||||
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 (
|
||||
|
||||
@@ -51,6 +51,6 @@ func FuzzParseChallenge(f *testing.F) {
|
||||
// execute; pass a non-empty placeholder so signature-verify
|
||||
// gets exercised against arbitrary input.
|
||||
bundle := []*x509.Certificate{} // empty to short-circuit cheap path
|
||||
_, _ = ValidateChallenge(raw, bundle, "", time.Now())
|
||||
_, _ = 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 @@
|
||||
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-----
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +30,15 @@ type NetworkScanService struct {
|
||||
auditService *AuditService
|
||||
logger *slog.Logger
|
||||
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.
|
||||
@@ -44,9 +54,20 @@ func NewNetworkScanService(
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
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.
|
||||
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
||||
return s.networkScanRepo.List(ctx)
|
||||
|
||||
+172
-12
@@ -48,11 +48,24 @@ type SCEPService struct {
|
||||
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
|
||||
@@ -149,17 +162,18 @@ type IntuneTrustAnchorInfo struct {
|
||||
// GET endpoint hands back. SCEPService.IntuneStats() builds one of
|
||||
// these on demand under no contention with the dispatcher hot path.
|
||||
type IntuneStatsSnapshot struct {
|
||||
PathID string `json:"path_id"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TrustAnchorPath string `json:"trust_anchor_path,omitempty"`
|
||||
TrustAnchors []IntuneTrustAnchorInfo `json:"trust_anchors,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
ChallengeValidity time.Duration `json:"challenge_validity_ns,omitempty"`
|
||||
RateLimitDisabled bool `json:"rate_limit_disabled"`
|
||||
ReplayCacheSize int `json:"replay_cache_size"`
|
||||
Counters map[string]uint64 `json:"counters"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
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
|
||||
@@ -195,6 +209,7 @@ func (s *SCEPService) IntuneStats(now time.Time) IntuneStatsSnapshot {
|
||||
}
|
||||
out.Audience = s.intuneAudience
|
||||
out.ChallengeValidity = s.intuneValidity
|
||||
out.ClockSkewTolerance = s.intuneClockSkew
|
||||
if s.intuneRateLimiter != nil {
|
||||
out.RateLimitDisabled = s.intuneRateLimiter.Disabled()
|
||||
}
|
||||
@@ -235,6 +250,144 @@ func (s *SCEPService) ReloadIntuneTrust() error {
|
||||
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)
|
||||
@@ -298,6 +451,7 @@ func (s *SCEPService) SetIntuneIntegration(
|
||||
trust *intune.TrustAnchorHolder,
|
||||
audience string,
|
||||
validity time.Duration,
|
||||
clockSkew time.Duration,
|
||||
replayCache *intune.ReplayCache,
|
||||
rateLimiter *intune.PerDeviceRateLimiter,
|
||||
) {
|
||||
@@ -305,6 +459,7 @@ func (s *SCEPService) SetIntuneIntegration(
|
||||
s.intuneTrust = trust
|
||||
s.intuneAudience = audience
|
||||
s.intuneValidity = validity
|
||||
s.intuneClockSkew = clockSkew
|
||||
s.intuneReplayCache = replayCache
|
||||
s.intuneRateLimiter = rateLimiter
|
||||
if s.intuneCounters == nil {
|
||||
@@ -425,7 +580,12 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string
|
||||
now := time.Now()
|
||||
trust := s.intuneTrust.Get()
|
||||
|
||||
claim, err := intune.ValidateChallenge(challengePassword, trust, s.intuneAudience, now)
|
||||
claim, err := intune.ValidateChallenge(challengePassword, intune.ValidateOptions{
|
||||
Trust: trust,
|
||||
ExpectedAudience: s.intuneAudience,
|
||||
Now: now,
|
||||
ClockSkewTolerance: s.intuneClockSkew,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("SCEP enrollment rejected: Intune challenge validation failed",
|
||||
"transaction_id", transactionID, "reason", intuneFailReason(err), "error", err)
|
||||
|
||||
@@ -171,6 +171,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_Success(t *testing.T) {
|
||||
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),
|
||||
)
|
||||
@@ -207,6 +208,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_StaticChallengeStillWorks(t *testi
|
||||
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),
|
||||
)
|
||||
@@ -224,6 +226,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_TamperedChallengeRejected(t *testi
|
||||
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),
|
||||
)
|
||||
@@ -252,6 +255,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ClaimMismatchRejected(t *testing.T
|
||||
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),
|
||||
)
|
||||
@@ -277,6 +281,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ReplayDetected(t *testing.T) {
|
||||
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
|
||||
)
|
||||
@@ -300,6 +305,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_RateLimited(t *testing.T) {
|
||||
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
|
||||
@@ -337,6 +343,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookNilDefault(t *testin
|
||||
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),
|
||||
)
|
||||
@@ -354,6 +361,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookDeniesNonCompliant(t
|
||||
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),
|
||||
)
|
||||
@@ -382,6 +390,7 @@ func TestSCEPService_PKCSReq_IntuneDispatcher_ComplianceHookErrorFailsClosed(t *
|
||||
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),
|
||||
)
|
||||
@@ -411,6 +420,7 @@ func TestSCEPService_IntuneEnabled_AccessorReflectsState(t *testing.T) {
|
||||
holderFromCerts(t, []*x509.Certificate{conn.cert}),
|
||||
"",
|
||||
0,
|
||||
0, // ClockSkewTolerance — strict (no grace)
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
+22
-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, IntuneStatsResponse, IntuneReloadTrustResponse } from './types';
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -312,6 +312,27 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
|
||||
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
|
||||
export const getAgents = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -655,6 +655,10 @@ export interface IntuneStatsSnapshot {
|
||||
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:
|
||||
@@ -676,3 +680,87 @@ export interface IntuneReloadTrustResponse {
|
||||
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,7 +23,7 @@ const nav = [
|
||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
{ to: '/scep/intune', label: 'SCEP Intune', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
||||
{ to: '/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' },
|
||||
];
|
||||
|
||||
|
||||
+7
-2
@@ -80,11 +80,16 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="health-monitor" element={<HealthMonitorPage />} />
|
||||
<Route path="digest" element={<DigestPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile
|
||||
Intune Monitoring tab. Route is unconditional; the page
|
||||
{/* 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>
|
||||
</Routes>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { MemoryRouter } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -17,6 +17,9 @@ vi.mock('../api/client', () => ({
|
||||
updateNetworkScanTarget: vi.fn(),
|
||||
deleteNetworkScanTarget: 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';
|
||||
@@ -52,6 +55,10 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
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 () => {
|
||||
@@ -82,3 +89,109 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
|
||||
).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,
|
||||
deleteNetworkScanTarget,
|
||||
triggerNetworkScan,
|
||||
probeSCEPServer,
|
||||
listSCEPProbes,
|
||||
} from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { NetworkScanTarget } from '../api/types';
|
||||
import type { NetworkScanTarget, SCEPProbeResult } from '../api/types';
|
||||
|
||||
function CreateScanTargetModal({ onClose, onCreate }: {
|
||||
onClose: () => void;
|
||||
@@ -258,6 +260,7 @@ export default function NetworkScanPage() {
|
||||
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
|
||||
/>
|
||||
)}
|
||||
<SCEPProbeSection />
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.5: Vitest coverage for the
|
||||
// SCEPAdminPage component. Pins:
|
||||
// 1. Admin gate — non-admin callers see the gated banner and the page
|
||||
// MUST NOT issue the underlying admin API requests.
|
||||
// 2. Profile cards render with status + counters + trust-anchor expiry
|
||||
// badge tone (good / warn / bad / EXPIRED).
|
||||
// 3. Disabled profiles render the off-state pill instead of the counter
|
||||
// grid.
|
||||
// 4. Reload button opens the confirmation modal; Confirm calls the
|
||||
// mutation and refetches stats; Cancel closes without calling.
|
||||
// 5. Error path surfaces ErrorState with retry.
|
||||
// 6. Audit log filter merges PKCSReq + RenewalReq events and sorts by
|
||||
// timestamp descending.
|
||||
// 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(),
|
||||
@@ -32,13 +40,18 @@ import SCEPAdminPage from './SCEPAdminPage';
|
||||
import * as client from '../api/client';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
function renderWithRoute(initialPath: string, ui: ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/scep" element={ui} />
|
||||
<Route path="/scep/intune" element={ui} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -57,47 +70,71 @@ function setAuth(opts: { authRequired: boolean; admin: boolean }) {
|
||||
});
|
||||
}
|
||||
|
||||
const baseEnabledProfile = {
|
||||
const corpProfileSummary = {
|
||||
path_id: 'corp',
|
||||
issuer_id: 'iss-corp',
|
||||
challenge_password_set: true,
|
||||
ra_cert_subject: 'ra-corp',
|
||||
ra_cert_not_before: '2026-01-01T00:00:00Z',
|
||||
ra_cert_not_after: '2027-01-01T00:00:00Z',
|
||||
ra_cert_days_to_expiry: 250,
|
||||
ra_cert_expired: false,
|
||||
mtls_enabled: true,
|
||||
mtls_trust_bundle_path: '/etc/certctl/mtls-corp.pem',
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
intune: {
|
||||
trust_anchor_path: '/etc/certctl/intune-corp.pem',
|
||||
trust_anchors: [
|
||||
{ subject: 'intune-conn', not_before: '2026-01-01T00:00:00Z', not_after: '2027-01-01T00:00:00Z', days_to_expiry: 250, expired: false },
|
||||
],
|
||||
audience: 'https://certctl.example.com/scep/corp',
|
||||
challenge_validity_ns: 3_600_000_000_000,
|
||||
rate_limit_disabled: false,
|
||||
replay_cache_size: 12,
|
||||
counters: { success: 42 },
|
||||
},
|
||||
};
|
||||
|
||||
const iotProfileSummary = {
|
||||
path_id: 'iot',
|
||||
issuer_id: 'iss-iot',
|
||||
challenge_password_set: true,
|
||||
ra_cert_subject: 'ra-iot',
|
||||
ra_cert_not_before: '2026-01-01T00:00:00Z',
|
||||
ra_cert_not_after: '2026-05-15T00:00:00Z',
|
||||
ra_cert_days_to_expiry: 16,
|
||||
ra_cert_expired: false,
|
||||
mtls_enabled: false,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
// Intune disabled — no intune field
|
||||
};
|
||||
|
||||
const expiredProfileSummary = {
|
||||
path_id: 'legacy',
|
||||
issuer_id: 'iss-old',
|
||||
challenge_password_set: true,
|
||||
ra_cert_subject: 'ra-old',
|
||||
ra_cert_not_before: '2024-01-01T00:00:00Z',
|
||||
ra_cert_not_after: '2025-01-01T00:00:00Z',
|
||||
ra_cert_days_to_expiry: 0,
|
||||
ra_cert_expired: true,
|
||||
mtls_enabled: false,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
};
|
||||
|
||||
const corpIntuneStats = {
|
||||
path_id: 'corp',
|
||||
issuer_id: 'iss-corp',
|
||||
enabled: true,
|
||||
trust_anchor_path: '/etc/certctl/intune-corp.pem',
|
||||
trust_anchors: [
|
||||
{
|
||||
subject: 'intune-connector-installation-corp',
|
||||
not_before: '2026-01-01T00:00:00Z',
|
||||
not_after: '2027-01-01T00:00:00Z',
|
||||
days_to_expiry: 250,
|
||||
expired: false,
|
||||
},
|
||||
{ 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,
|
||||
expired: 0,
|
||||
not_yet_valid: 0,
|
||||
wrong_audience: 0,
|
||||
replay: 2,
|
||||
rate_limited: 0,
|
||||
claim_mismatch: 3,
|
||||
compliance_failed: 0,
|
||||
malformed: 0,
|
||||
unknown_version: 0,
|
||||
},
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
};
|
||||
|
||||
const disabledProfile = {
|
||||
path_id: 'iot',
|
||||
issuer_id: 'iss-iot',
|
||||
enabled: false,
|
||||
rate_limit_disabled: false,
|
||||
replay_cache_size: 0,
|
||||
counters: {},
|
||||
counters: { success: 42, signature_invalid: 1, claim_mismatch: 3, replay: 2 },
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
};
|
||||
|
||||
@@ -113,138 +150,236 @@ beforeEach(() => {
|
||||
} 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 });
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 2, name: /SCEP Intune Monitoring/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2, name: /SCEP Administration/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(client.getAdminSCEPProfiles).not.toHaveBeenCalled();
|
||||
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Admin access required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets admin callers through and fetches stats', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
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);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||
expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument();
|
||||
expect(client.getAdminSCEPProfiles).toHaveBeenCalled();
|
||||
// Default tab is Profiles → Intune stats endpoint NOT called yet
|
||||
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tab navigation + deep links.
|
||||
// =============================================================================
|
||||
|
||||
describe('SCEPAdminPage — tab navigation', () => {
|
||||
it('renders Profiles tab as default', async () => {
|
||||
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||
profiles: [corpProfileSummary],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||
expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tab-profiles').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('switches to Intune tab on click and triggers the Intune stats fetch', async () => {
|
||||
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||
profiles: [corpProfileSummary],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [corpIntuneStats],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||
await screen.findByTestId('profile-summary-corp');
|
||||
fireEvent.click(screen.getByTestId('tab-intune'));
|
||||
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
|
||||
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the page accessible when authRequired=false (no-auth dev mode)', async () => {
|
||||
setAuth({ authRequired: false, admin: false });
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [],
|
||||
profile_count: 0,
|
||||
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);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [corpIntuneStats],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
it('legacy /scep/intune route alias lands on Intune tab', async () => {
|
||||
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||
profiles: [corpProfileSummary],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [corpIntuneStats],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithRoute('/scep/intune', <SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to Activity tab and merges the four SCEP audit actions', async () => {
|
||||
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||
profiles: [corpProfileSummary],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
|
||||
const events: Record<string, unknown[]> = {
|
||||
scep_pkcsreq: [{ id: 'a1', action: 'scep_pkcsreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }],
|
||||
scep_renewalreq: [{ id: 'a2', action: 'scep_renewalreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:10:00Z' }],
|
||||
scep_pkcsreq_intune: [{ id: 'a3', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:20:00Z' }],
|
||||
scep_renewalreq_intune: [{ id: 'a4', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:30:00Z' }],
|
||||
};
|
||||
const action = params.action ?? '';
|
||||
return Promise.resolve({
|
||||
data: events[action] ?? [],
|
||||
total: events[action]?.length ?? 0,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
} as never);
|
||||
});
|
||||
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||
await screen.findByTestId('profile-summary-corp');
|
||||
fireEvent.click(screen.getByTestId('tab-activity'));
|
||||
await screen.findByTestId('activity-tab');
|
||||
const table = await screen.findByTestId('activity-events-table');
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
expect(rows.length).toBe(4);
|
||||
// Sorted descending → renewal_intune (14:30) is first
|
||||
expect(rows[0].textContent).toContain('scep_renewalreq_intune');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — profile rendering', () => {
|
||||
it('renders enabled profile counters with the expected labels and tone', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
// =============================================================================
|
||||
// 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);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('counter-corp-success')).toHaveTextContent('42');
|
||||
});
|
||||
expect(screen.getByTestId('counter-corp-replay')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3');
|
||||
// Expiry badge is "good" tone for >= 30 days remaining.
|
||||
const badge = screen.getByTestId('expiry-badge-corp');
|
||||
expect(badge).toHaveTextContent('250d');
|
||||
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('renders an expiry badge with EXPIRED text and bad tone when an anchor is past NotAfter', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
...baseEnabledProfile,
|
||||
trust_anchors: [
|
||||
{ subject: 'expired-conn', not_before: '2024-01-01T00:00:00Z', not_after: '2025-01-01T00:00:00Z', days_to_expiry: 0, expired: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
profile_count: 1,
|
||||
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);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/);
|
||||
});
|
||||
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('renders the off-state pill for disabled profiles instead of the counter grid', async () => {
|
||||
it('"View Intune details →" only renders for Intune-enabled profiles AND switches tabs', async () => {
|
||||
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||
profiles: [corpProfileSummary, iotProfileSummary],
|
||||
profile_count: 2,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [disabledProfile],
|
||||
profiles: [corpIntuneStats],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('profile-card-iot')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Intune disabled/)).toBeInTheDocument();
|
||||
// Counter grid should NOT render for disabled profiles.
|
||||
expect(screen.queryByTestId('counter-iot-success')).toBeNull();
|
||||
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.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
|
||||
profiles: [],
|
||||
profile_count: 0,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No SCEP profiles are configured/)).toBeInTheDocument();
|
||||
});
|
||||
renderWithRoute('/scep', <SCEPAdminPage />);
|
||||
expect(await screen.findByText(/No SCEP profiles are configured/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — reload-trust modal', () => {
|
||||
it('opens the confirmation modal when the Reload trust button is clicked', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
// =============================================================================
|
||||
// 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);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('reload-button-corp'));
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reload Intune trust anchor/i)).toBeInTheDocument();
|
||||
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('calls reloadAdminSCEPIntuneTrust on Confirm and closes the modal on success', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
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);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
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(() => {
|
||||
@@ -255,36 +390,21 @@ describe('SCEPAdminPage — reload-trust modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the modal open and shows the error message when reload fails', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
it('keeps the modal open and shows the error when reload fails', async () => {
|
||||
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired'));
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
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();
|
||||
});
|
||||
// Modal stays open so the operator can read the error and retry.
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Cancel closes the modal without calling the reload mutation', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
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(() => {
|
||||
@@ -294,47 +414,87 @@ describe('SCEPAdminPage — reload-trust modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — error + audit-log surface', () => {
|
||||
it('surfaces ErrorState when the stats query fails', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockRejectedValue(new Error('boom'));
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
// =============================================================================
|
||||
// Recent Activity tab — filter chips.
|
||||
// =============================================================================
|
||||
|
||||
it('merges PKCSReq + RenewalReq audit events and sorts by timestamp descending', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
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> = {}) => {
|
||||
if (params.action === 'scep_pkcsreq_intune') {
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{ id: 'ae-pkcs-1', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-1', details: {}, timestamp: '2026-04-29T14:00:00Z' },
|
||||
],
|
||||
total: 1, page: 1, per_page: 200,
|
||||
} as never);
|
||||
}
|
||||
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: [
|
||||
{ id: 'ae-renew-1', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-2', details: {}, timestamp: '2026-04-29T14:30:00Z' },
|
||||
],
|
||||
total: 1, page: 1, per_page: 200,
|
||||
data: lookup[params.action ?? ''] ?? [],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
} as never);
|
||||
});
|
||||
});
|
||||
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument();
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
const rows = screen.getByTestId('recent-failures-table').querySelectorAll('tbody tr');
|
||||
it('filter=intune narrows to just the two _intune actions', async () => {
|
||||
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
|
||||
await screen.findByTestId('activity-tab');
|
||||
fireEvent.click(screen.getByTestId('activity-filter-intune'));
|
||||
const table = await screen.findByTestId('activity-events-table');
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
expect(rows.length).toBe(2);
|
||||
// Sorted descending by timestamp — renewal (14:30) comes before pkcs (14:00).
|
||||
expect(rows[0].textContent).toContain('scep_renewalreq_intune');
|
||||
expect(rows[1].textContent).toContain('scep_pkcsreq_intune');
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+501
-152
@@ -1,28 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAdminSCEPIntuneStats, reloadAdminSCEPIntuneTrust, getAuditEvents } from '../api/client';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
getAdminSCEPIntuneStats,
|
||||
getAdminSCEPProfiles,
|
||||
reloadAdminSCEPIntuneTrust,
|
||||
getAuditEvents,
|
||||
} from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { IntuneStatsSnapshot, IntuneTrustAnchorInfo, AuditEvent } from '../api/types';
|
||||
import type {
|
||||
IntuneStatsSnapshot,
|
||||
IntuneTrustAnchorInfo,
|
||||
AuditEvent,
|
||||
SCEPProfileStatsSnapshot,
|
||||
} from '../api/types';
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile Intune
|
||||
// Monitoring tab.
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
|
||||
// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP
|
||||
// administration page with three tabs.
|
||||
//
|
||||
// Surfaces:
|
||||
// - Status banner per profile (trust anchor expiry countdown, rotates
|
||||
// when < 30 days; the soonest-to-expire anchor wins).
|
||||
// - Live counters table per profile (success / signature_invalid /
|
||||
// claim_mismatch / expired / wrong_audience / replay / rate_limited /
|
||||
// malformed / compliance_failed / not_yet_valid / unknown_version).
|
||||
// Polled every 30s via TanStack Query.
|
||||
// - Recent failures table (last 50) populated from the audit log
|
||||
// filtered to action=scep_pkcsreq_intune (and the renewal sibling).
|
||||
// - Trust anchor reload button (per-profile) with confirmation modal;
|
||||
// calls POST /api/v1/admin/scep/intune/reload-trust under the hood
|
||||
// (the SIGHUP-equivalent path).
|
||||
// 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.
|
||||
@@ -62,28 +84,179 @@ const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = {
|
||||
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 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).
|
||||
// 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; // any expired wins
|
||||
if (a.expired) return -1;
|
||||
if (a.days_to_expiry < min) min = a.days_to_expiry;
|
||||
}
|
||||
return min === Number.POSITIVE_INFINITY ? null : min;
|
||||
}
|
||||
|
||||
function expiryBadge(days: number | null): { text: string; tone: 'good' | 'warn' | 'bad' } {
|
||||
if (days === null) return { text: 'No trust anchors', tone: 'warn' };
|
||||
if (days < 0) return { text: 'EXPIRED', tone: 'bad' };
|
||||
if (days < 7) return { text: `${days}d remaining`, tone: 'bad' };
|
||||
if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' };
|
||||
return { text: `${days}d remaining`, tone: 'good' };
|
||||
// =============================================================================
|
||||
// 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;
|
||||
@@ -140,39 +313,74 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: IntuneStatsSnapshot;
|
||||
interface IntuneTabProps {
|
||||
profiles: IntuneStatsSnapshot[];
|
||||
isLoading: boolean;
|
||||
onRequestReload: (profile: IntuneStatsSnapshot) => void;
|
||||
highlightPathID: string | null;
|
||||
events: AuditEvent[];
|
||||
eventsLoading: boolean;
|
||||
}
|
||||
|
||||
function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
|
||||
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||
if (!profile.enabled) {
|
||||
return (
|
||||
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
|
||||
<header className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
|
||||
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-alt text-ink-muted">
|
||||
Intune disabled
|
||||
</span>
|
||||
</header>
|
||||
<p className="text-sm text-ink-muted">
|
||||
This profile honors only the static challenge password. To enable Intune dispatch, set
|
||||
<code className="mx-1">CERTCTL_SCEP_PROFILE_{(profile.path_id || 'DEFAULT').toUpperCase()}_INTUNE_ENABLED=true</code>
|
||||
plus the matching trust-anchor path env var, then restart the server.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
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);
|
||||
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="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
|
||||
<section className={cardClass} data-testid={`profile-card-${profile.path_id}`}>
|
||||
<header className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
|
||||
@@ -183,13 +391,7 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
badge.tone === 'good'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: badge.tone === 'warn'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
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}
|
||||
@@ -264,16 +466,93 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function RecentFailuresTable({ events }: { events: AuditEvent[] }) {
|
||||
// =============================================================================
|
||||
// Recent Activity tab — full SCEP audit log filter.
|
||||
// =============================================================================
|
||||
|
||||
interface ActivityTabProps {
|
||||
events: AuditEvent[];
|
||||
isLoading: boolean;
|
||||
filter: ActivityFilter;
|
||||
setFilter: (f: ActivityFilter) => void;
|
||||
}
|
||||
|
||||
function activityFilterMatches(filter: ActivityFilter, action: string): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'initial':
|
||||
return action === 'scep_pkcsreq' || action === 'scep_pkcsreq_intune';
|
||||
case 'renewal':
|
||||
return action === 'scep_renewalreq' || action === 'scep_renewalreq_intune';
|
||||
case 'intune':
|
||||
return action === 'scep_pkcsreq_intune' || action === 'scep_renewalreq_intune';
|
||||
case 'static':
|
||||
return action === 'scep_pkcsreq' || action === 'scep_renewalreq';
|
||||
}
|
||||
}
|
||||
|
||||
function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) {
|
||||
const filtered = events.filter(e => activityFilterMatches(filter, e.action));
|
||||
return (
|
||||
<section className="bg-surface border border-surface-border rounded-lg" data-testid="activity-tab">
|
||||
<div className="px-4 py-3 border-b border-surface-border">
|
||||
<h3 className="text-sm font-semibold text-ink">SCEP enrollment audit log (last 100)</h3>
|
||||
<p className="text-xs text-ink-muted mb-3">
|
||||
Merged across <code>scep_pkcsreq</code> + <code>scep_renewalreq</code> +
|
||||
<code> scep_pkcsreq_intune</code> + <code>scep_renewalreq_intune</code>. Refreshes every 60s.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2" data-testid="activity-filter-chips">
|
||||
{(['all', 'initial', 'renewal', 'intune', 'static'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={`text-xs px-2 py-1 rounded border ${
|
||||
filter === f
|
||||
? 'bg-brand-100 text-brand-800 border-brand-300'
|
||||
: 'bg-surface text-ink-muted border-surface-border hover:bg-surface-alt'
|
||||
}`}
|
||||
data-testid={`activity-filter-${f}`}
|
||||
>
|
||||
{f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log…</p>
|
||||
) : (
|
||||
<RecentEventsTable
|
||||
events={filtered.slice(0, 100)}
|
||||
testID="activity-events-table"
|
||||
emptyMessage={
|
||||
events.length === 0
|
||||
? 'No SCEP enrollment events recorded yet.'
|
||||
: 'No events match the current filter — try a different chip.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shared events table.
|
||||
// =============================================================================
|
||||
|
||||
interface RecentEventsTableProps {
|
||||
events: AuditEvent[];
|
||||
testID: string;
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
function RecentEventsTable({ events, testID, emptyMessage }: RecentEventsTableProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-ink-muted px-4 py-6">
|
||||
No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-ink-muted px-4 py-6">{emptyMessage}</p>;
|
||||
}
|
||||
return (
|
||||
<table className="w-full text-sm" data-testid="recent-failures-table">
|
||||
<table className="w-full text-sm" data-testid={testID}>
|
||||
<thead className="text-xs text-ink-muted uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="py-2 pl-4 pr-2 text-left">Timestamp</th>
|
||||
@@ -298,50 +577,113 @@ function RecentFailuresTable({ events }: { events: AuditEvent[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Top-level page.
|
||||
// =============================================================================
|
||||
|
||||
function pickTabFromQuery(value: string | null): TabId {
|
||||
if (value === 'intune' || value === 'activity') return value;
|
||||
return 'profiles';
|
||||
}
|
||||
|
||||
// pickInitialTab honors three signals (precedence high → low):
|
||||
// 1. ?tab=intune|activity in the query string (deep link)
|
||||
// 2. Pathname ending in /scep/intune (legacy route alias from
|
||||
// Phase 9.4; preserved so external bookmarks land on Intune)
|
||||
// 3. Default to 'profiles'
|
||||
function pickInitialTab(searchParams: URLSearchParams, pathname: string): TabId {
|
||||
const fromQuery = searchParams.get('tab');
|
||||
if (fromQuery === 'intune' || fromQuery === 'activity') return fromQuery;
|
||||
if (pathname.endsWith('/scep/intune')) return 'intune';
|
||||
return 'profiles';
|
||||
}
|
||||
|
||||
export default function SCEPAdminPage() {
|
||||
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');
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['admin', 'scep', 'intune', 'stats'],
|
||||
queryFn: getAdminSCEPIntuneStats,
|
||||
enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Audit-log filter: every Intune-dispatched enrollment (success + failure)
|
||||
// emits action=scep_pkcsreq_intune (initial) or scep_renewalreq_intune
|
||||
// (renewal). The audit endpoint accepts a single action filter; we fetch
|
||||
// both server-side via two queries and merge client-side rather than
|
||||
// adding a comma-separated filter that would require backend changes.
|
||||
const auditPKCSQuery = useQuery({
|
||||
queryKey: ['audit', { action: 'scep_pkcsreq_intune' }],
|
||||
queryFn: () => getAuditEvents({ action: 'scep_pkcsreq_intune' }),
|
||||
enabled: !auth.authRequired || auth.admin,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const auditRenewalQuery = useQuery({
|
||||
queryKey: ['audit', { action: 'scep_renewalreq_intune' }],
|
||||
queryFn: () => getAuditEvents({ action: 'scep_renewalreq_intune' }),
|
||||
enabled: !auth.authRequired || auth.admin,
|
||||
refetchInterval: 60_000,
|
||||
// Intune deep-dive data (Intune tab).
|
||||
const intuneStatsQuery = useQuery({
|
||||
queryKey: ['admin', 'scep', 'intune', 'stats'],
|
||||
queryFn: getAdminSCEPIntuneStats,
|
||||
enabled: adminAccess && activeTab === 'intune',
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Bundle-8 / M-009 invalidation contract: trust-anchor reload changes
|
||||
// both the per-profile trust pool (reflected in IntuneStats) AND every
|
||||
// recently-failed Intune enrollment counter that might now succeed on
|
||||
// retry. We invalidate the stats key so the per-profile trust-anchor
|
||||
// panel reflects the new pool immediately; the audit log queries
|
||||
// remain on their 60s timer (a SIGHUP-equivalent reload doesn't
|
||||
// backfill new audit rows).
|
||||
// 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']],
|
||||
invalidates: [
|
||||
['admin', 'scep', 'intune', 'stats'],
|
||||
['admin', 'scep', 'profiles'],
|
||||
],
|
||||
onSuccess: () => {
|
||||
setReloadTarget(null);
|
||||
setReloadError(undefined);
|
||||
@@ -354,53 +696,36 @@ export default function SCEPAdminPage() {
|
||||
if (auth.authRequired && !auth.admin) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="SCEP Intune Monitoring" subtitle="Admin-only observability surface" />
|
||||
<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 trust anchor expiries and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
|
||||
error={new Error('Admin access required: this page exposes per-profile RA cert expiries, mTLS bundle paths, Intune trust anchor expiries, and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (statsQuery.isLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
|
||||
<div className="p-6 text-sm text-ink-muted">Loading per-profile stats…</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const profiles = profilesQuery.data?.profiles ?? [];
|
||||
const intuneProfiles = intuneStatsQuery.data?.profiles ?? [];
|
||||
|
||||
if (statsQuery.error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
|
||||
<div className="p-6">
|
||||
<ErrorState error={statsQuery.error as Error} onRetry={() => statsQuery.refetch()} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = statsQuery.data?.profiles ?? [];
|
||||
const events: AuditEvent[] = [
|
||||
...(auditPKCSQuery.data?.data ?? []),
|
||||
...(auditRenewalQuery.data?.data ?? []),
|
||||
]
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 50);
|
||||
const handleViewIntuneDetails = (pathID: string) => {
|
||||
setHighlightPathID(pathID);
|
||||
setActiveTab('intune');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="SCEP Intune Monitoring"
|
||||
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · counters auto-refresh every 30s`}
|
||||
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={() => statsQuery.refetch()}
|
||||
onClick={() => {
|
||||
void profilesQuery.refetch();
|
||||
if (activeTab === 'intune') void intuneStatsQuery.refetch();
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||
data-testid="refresh-stats-button"
|
||||
>
|
||||
@@ -408,41 +733,65 @@ export default function SCEPAdminPage() {
|
||||
</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">
|
||||
{profiles.length === 0 && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
|
||||
No SCEP profiles are configured. Set <code>CERTCTL_SCEP_ENABLED=true</code> and either the
|
||||
legacy single-profile env vars or <code>CERTCTL_SCEP_PROFILES=...</code> with the indexed
|
||||
per-profile family to register at least one endpoint.
|
||||
</div>
|
||||
{profilesQuery.error && activeTab === 'profiles' && (
|
||||
<ErrorState error={profilesQuery.error as Error} onRetry={() => profilesQuery.refetch()} />
|
||||
)}
|
||||
{profiles.map(p => (
|
||||
<ProfileCard
|
||||
key={p.path_id || '(root)'}
|
||||
profile={p}
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
<section className="bg-surface border border-surface-border rounded-lg mt-6">
|
||||
<div className="px-4 py-3 border-b border-surface-border">
|
||||
<h3 className="text-sm font-semibold text-ink">
|
||||
Recent Intune-dispatched enrollments (last 50)
|
||||
</h3>
|
||||
<p className="text-xs text-ink-muted">
|
||||
Filtered to <code>action=scep_pkcsreq_intune</code> + <code>action=scep_renewalreq_intune</code>.
|
||||
Refreshes every 60s.
|
||||
</p>
|
||||
</div>
|
||||
{auditPKCSQuery.isLoading || auditRenewalQuery.isLoading ? (
|
||||
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log…</p>
|
||||
) : (
|
||||
<RecentFailuresTable events={events} />
|
||||
)}
|
||||
</section>
|
||||
{activeTab === 'activity' && (
|
||||
<ActivityTab
|
||||
events={allAuditEvents}
|
||||
isLoading={auditLoading}
|
||||
filter={activityFilter}
|
||||
setFilter={setActivityFilter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reloadTarget && (
|
||||
|
||||
Reference in New Issue
Block a user