docs: device identity block binding, session-scoped blocks, progress lookup, validator budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-01 23:37:43 +02:00
parent bffb76625c
commit 73c0fbdd7e
3 changed files with 139 additions and 0 deletions
@@ -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:<guid>` — 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)
+1
View File
@@ -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.
+4
View File
@@ -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).