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