From 73c0fbdd7e763deebceb5fab0cb95e08c8737854 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 1 Sep 2026 23:37:43 +0200 Subject: [PATCH] docs: device identity block binding, session-scoped blocks, progress lookup, validator budget Co-Authored-By: Claude Fable 5.1 --- docs/backend/device-identity-block-binding.md | 134 ++++++++++++++++++ docs/index.md | 1 + docs/log.md | 4 + 3 files changed, 139 insertions(+) create mode 100644 docs/backend/device-identity-block-binding.md diff --git a/docs/backend/device-identity-block-binding.md b/docs/backend/device-identity-block-binding.md new file mode 100644 index 00000000..090d560f --- /dev/null +++ b/docs/backend/device-identity-block-binding.md @@ -0,0 +1,134 @@ +--- +type: Concept +title: Device Identity Block Binding — Kill Switch Beyond the Serial Header +description: Why a device block keyed only by the caller-declared X-Device-SerialNumber could be dodged by omitting or forging the header (CWE-807), how a block is now mirrored onto the device's certificate identity as alias rows resolved at block time, how session-scoped watchdog blocks are finally decided once the session is known, why the progress-portal lookup dropped its newest-100 horizon, and the time budget that keeps the Graph device validators inside the agent's client timeout. +resource: src/Backend/AutopilotMonitor.Functions/Services/KillSwitchEvaluator.cs +tags: + - backend + - security + - kill-switch + - blocked-devices + - agent + - progress-portal + - graph +timestamp: 2026-09-01T00:00:00+02:00 +--- + +# Device Identity Block Binding + +## Threat + +`BlockedDevices` is keyed `(tenantId, serial)` and the kill switch read the serial verbatim from +`X-Device-SerialNumber`. A blocked device therefore had two exits: omit the header (the evaluator +skipped the device leg entirely) or send a foreign serial. In both cases the admin block, the +excessive-events watchdog and the **kill signal** never reached the agent. Mythos finding CWE-807 +("reliance on untrusted inputs in a security decision"). + +What the request does carry that the caller cannot choose: the client certificate. Its Subject CN +is the Intune device id (`SecurityValidator.TryGetIntuneDeviceIdFromCertSubject`), surfaced as +`SecurityValidationResult.IntuneDeviceId` at both kill-switch call sites. Live coverage (request +dimension `CertTenantBinding=Match`) is effectively every register/ingest/config request. + +## Design: alias rows, resolved at block time + +A rejected alternative was a standing device-index table (identity → serial claim written on every +request). It would have added a per-request write path, a cache and a retention sweep for an +event that happens ~30 times per 90 days, and the accompanying performance argument (replacing +the supersede partition scan) did not hold: RegisterSession runs at p50 ≈ 100 ms including that +scan. + +Instead the block itself is mirrored: + +| RowKey (PK = tenantId) | Role | Extra columns | +|---|---|---| +| `EscapeDataString(UPPER(serial))` | primary row (unchanged) | `AliasDeviceIds` — comma-separated lower-case GUIDs | +| `id:{intuneDeviceId}` | alias — same block fields (BlockedAt/UnblockAt/Action/Reason/BlockedSessionIds/BlockedByEmail/DurationHours) | `SerialNumber` = primary's canonical serial, `IsAlias = true` | + +* **Resolution.** `BlockedDeviceService.BlockDeviceAsync` asks `ISessionRepository.GetOwnerDeviceIdsForSerialAsync` + for the `OwnerDeviceId`s the serial has registered sessions under (Sessions partition query, + newest first, cap 5, fail-soft). Callers (admin `POST /api/devices/block`, watchdog) stay + serial-keyed — no API or UX change. +* **Enforcement.** `KillSwitchEvaluator.EvaluateAsync` runs serial leg → identity leg → version + leg. The identity leg only runs when the serial leg missed and a certificate identity exists; + the honest case pays no second lookup. An identity hit logs both serials (header vs. row) and + emits the existing throttled `KillSignalDelivered` ops event keyed on the row's serial. +* **Cache.** Same `BlockedDeviceService` cache, second key namespace `tenant|id:` — same + 30-s revalidation, same negative entries. The lazy tenant load seeds serial keys only (the + listing hides alias rows and its DTO is the admin/MCP wire shape — `AliasDeviceIds` never + leaves storage); an identity key is point-read on its first miss, exactly like a serial. +* **Lifecycle.** Unblock deletes aliases before the primary and returns their ids so caches drop + them; expiry cleanup sweeps aliases like any row; tenant offboarding wipes the partition. The + listings (admin UI, MCP `list_blocked_devices`) skip `IsAlias` rows — no duplicates, no wire + change; `MigrateLegacyRowKeyAsync` skips them too (it would otherwise re-key an alias onto the + serial key and delete it). +* **Keys cannot collide:** serial keys pass through `EscapeDataString` (`:` → `%3A`); alias keys + start literally with `id:`. + +Request dimension `DeviceIdentityBinding` (`NoIdentity | Match | IdentityBlocked`) on the +unsampled request row is the denominator: `NoIdentity` is the coverage gap (bootstrap-token +callers, non-GUID CNs), `IdentityBlocked` the finding actually firing. + +## Block scopes and channels + +`BlockedSessionIds` marks a session-scoped block (the watchdog's auto-block of a runaway +session): only those sessions are blocked and a new enrollment on the same device lifts it. +That branch was unreachable — the evaluator never passed a session id — so every auto-block +acted as a blanket 24-h device block. + +* **Telemetry channel.** The pre-body evaluation answers immediately for whole-device blocks and + kills. A session-scoped verdict is carried until the body's session id is known, then + re-evaluated with it: blocked session → blocked response; different session → auto-unblock + (primary + aliases) and the batch proceeds. The version leg runs again on purpose (the device + leg had short-circuited it). Additionally, when the Sessions row's registered serial differs + from the header, the row's serial is held against the block list too. +* **Config channel.** Has no session and stays a blanket report: the agent only logs + `DeviceBlocked` from config and acts on `DeviceKillSignal`, so the new session it registers + lifts a session-scoped block on the telemetry channel. + +## Progress-portal lookup + +`GET /api/progress/sessions/lookup` used to scan the newest 100 SessionsIndex rows client-side, so +a device whose enrollment was older than that horizon answered "Device Not Found" — indistinguishable +from a typo, and the member substring search could land on a newer device containing the fragment. +`ProgressPortalFunction.ResolveSessionAsync` now resolves exact serial, then exact device name, +through server-side `eq` filters on the tenant partition (`FindNewestSessionIdBySerialAsync` / +`…ByDeviceNameAsync`, stored form or upper-case, newest by inverted-tick RowKey), then point-reads +the session. Only members/GA fall through to the previous substring page; roleless callers stay +exact-or-nothing (the knowledge proof). Index rows written before the column was projected are not +matched by `eq` — immaterial for a current enrollment. + +## Graph validator budget + +The four Graph-backed device validators (Autopilot, corporate identifier, device association, +Cloud PC) used the unnamed `HttpClient` — 100-s default timeout — two attempts each, sequentially, +with an uncancellable token retry chain underneath. Observed: GetAgentConfig tails of 30–102 s +while the agent's own client gives up after 30 s (`BackendClientFactory`), so the backend finished +work for a request nobody was waiting on. `DeviceValidationBudget` gives the chain one 20-s token +(created in `SecurityValidator` around the validator block) and each attempt a linked 8-s token +that also bounds token acquisition; budget exhaustion is a transient result (never cached) → the +existing 503 + Retry-After 30, which the agent's 10/30/60-s retry handles. A second attempt is +skipped once the chain token is spent. Transient failures are rare (≈ 1–2 per day platform-wide), +so no stale-while-error reuse of an expired positive result was built — that would have been a +fail-open trade without a need. + +## Residuals + +* A device never seen with a certificate identity (only bootstrap-token sessions, or a CN that is + not a GUID) has no alias; the serial leg is all that applies — measurable as `NoIdentity`. +* An attacker who forges the serial from the very first request is a fresh, unknown device to the + platform; only a future "block by identity" admin action could target it. +* A re-enrolled device receives a new Intune device id; its earlier alias goes stale (harmless — a + cooperative agent announces its real serial, the primary row still matches). + +# Citations + +* `src/Backend/AutopilotMonitor.Functions/Services/KillSwitchEvaluator.cs` — three-leg evaluation, verdict with `BlockedSessionIds` / `IdentityBinding` +* `src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs` — cache namespaces, alias resolution, auto-unblock through the primary serial +* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs` — `IdentityRowKey`, alias write/delete, listing and migration skips +* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceLookup.cs` — owner-id and exact serial/device-name lookups +* `src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs` — pre-body / post-body evaluation, row-serial check +* `src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs` — `ResolveSessionAsync` +* `src/Backend/AutopilotMonitor.Functions/Security/DeviceValidationBudget.cs`, `SecurityValidator.cs` — chain and attempt budgets +* `src/Backend/AutopilotMonitor.Functions/Security/DeviceIdentityBinding.cs`, `Middleware/RequestTelemetryMiddleware.cs` — request dimension +* Tests: `KillSwitchEvaluatorTests`, `BlockedDeviceServiceTests`, `TableDeviceSecurityRepositoryKeyTests`, `ProgressPortalFunctionTests`, `AutopilotDeviceValidatorTests` +* Related: [session-owner-binding.md](session-owner-binding.md), [client-cert-tenant-binding.md](client-cert-tenant-binding.md) diff --git a/docs/index.md b/docs/index.md index fa574dc5..e050f1bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,6 +67,7 @@ https://docs.autopilotmonitor.com (separate repository). * [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events. * [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces. * [Session Owner Binding](backend/session-owner-binding.md) - Agent endpoints authenticate per tenant but write per caller-supplied session id; each Sessions row is now bound to the device identity that created it (cert thumbprint + Intune device id from the CN, or bootstrap code + serial), the rule matrix that keeps restart / WhiteGlove Part 2 / bootstrap→cert handoff / cert re-issue working, the shadow telemetry (`SessionOwnerBinding` request dimension, `AgentSessionOwnerBinding` warning, `SessionOwnerMismatch` ops event), and the enforcement contract where a 403 `session_owner_mismatch` makes the agent rotate its session id instead of counting an auth failure. +* [Device Identity Block Binding](backend/device-identity-block-binding.md) - The device kill switch was keyed only by the caller-declared serial header (CWE-807: omit or forge it and neither block nor kill lands); a block is now mirrored onto the device's certificate identity as `id:{intuneDeviceId}` alias rows resolved from its sessions at block time, the evaluator runs serial → identity → version with a `DeviceIdentityBinding` request dimension, session-scoped watchdog blocks are finally decided once the body's session id is known (new session lifts them), the progress-portal lookup resolves exact serial / device name through index queries instead of the newest-100 page, and the Graph device validators run under a 20 s chain / 8 s attempt budget instead of the 100 s default timeout. * [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else. * [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other. * [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else. diff --git a/docs/log.md b/docs/log.md index 0ade2ff5..ed027802 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-09-01 (9) + +* **New**: `backend/device-identity-block-binding.md` - closes the CWE-807 kill-switch bypass without a new table: a device block written by serial is mirrored onto the certificate identities (`OwnerDeviceId`) the device registered sessions under, as `id:{guid}` alias rows in `BlockedDevices` resolved at block time; `KillSwitchEvaluator` checks serial, then identity, then version, stamps `DeviceIdentityBinding` on the request row, and the honest path pays no second lookup. Same change repairs the session-scoped auto-unblock that was unreachable (the evaluator never received a session id; every watchdog block acted as a 24 h device block): ingest carries a session-scoped verdict past the body parse and re-evaluates with the session id, and holds the Sessions row's registered serial against the block list when the header differs. Progress portal: exact serial / device-name lookups are index queries over the tenant partition (`FindNewestSessionIdBySerialAsync`), the newest-100 substring page remains members/GA-only. Graph device validators: `DeviceValidationBudget` (20 s chain, 8 s per attempt incl. token acquisition, transient on exhaustion) replaces the 100 s default HttpClient timeout that produced 30-102 s GetAgentConfig tails for an agent that gives up after 30 s. The DeviceIndex-table plan was assessed and dropped (RegisterSession p50 ~100 ms with the supersede scan; no performance case). + ## 2026-09-01 (8) * **New**: `backend/demo-session-import.md` - the `import-session` skill (submodule) clones a session under a new id into the operator tenant gktatooine.net for live demos: export of the 23 cascade steps with EDM annotations, one replacement table over every key and string (tenant, session id, scrub tokens: UPNs -> Star-Wars pool, domains, device name, serial, thumbprints, MACs, public IPs), `EventId` re-keyed via uuid5, chunked PayloadJson re-chunked, `Owner*`/deletion/diagnostics columns dropped, SoftwareInventory counters bumped with ETag merge. Safety: insert-only under the new id (409 instead of overwrite), key invariant before the first write and as the rollback guard, zero-leak assertion, automatic rollback on any failed write plus a manual `rollback`, `apply --dry-run`. Provenance in the globaladmin lane; removal via the product's delete cascade. First import verified end to end (1954 rows, 3 min).