/** * Runtime integrity checkpoint — core logic. * * Pure orchestration: it composes the injected {@link CheckpointProbes} and * compares them to a {@link CheckpointBaseline}, returning what drifted. It * has no browser dependencies of its own (those live in `probes.ts`), so it's * deterministically testable. It never throws for a browser that can't answer * a check — such a check simply contributes no drift. */ import { displayVerdict, permissionVerdict, presenceVerdict, trackVerdict, type PresenceFact, type TrackFact, } from "../verification-core/index.js"; import type { CheckpointBaseline, CheckpointChange, CheckpointDeviceKind, CheckpointOptions, CheckpointProbes, CheckpointResult, } from "./types.js"; const DEVICE_KIND_TO_ENUM: Record< CheckpointDeviceKind, "audioinput" | "videoinput" | "audiooutput" > = { microphone: "audioinput", camera: "videoinput", speaker: "audiooutput", }; /** * Run one checkpoint. Tier 1 (permission / device presence / monitor) always * runs; Tier 2 (capture liveness) runs unless `options.liveness === false`. */ export async function runCheckpoint( baseline: CheckpointBaseline, probes: CheckpointProbes, options: CheckpointOptions = {}, ): Promise { const changes: CheckpointChange[] = []; // ── Tier 1 · configuration drift ───────────────────────────────────────── // Permission regressions only, and only for permissions the session // depends on (see CheckpointBaseline.permissions): granted at baseline and // now denied. A permission not tracked in the baseline is never checked. // The pass/fail rule lives in verification-core (`permissionVerdict`); this // loop only gathers the fact and renders the drift change. for (const name of Object.keys(baseline.permissions) as Array<"microphone" | "camera">) { const required = baseline.permissions[name] === "granted"; const permission = await probes.queryPermission(name); if (permissionVerdict({ required, permission }) === "denied") { changes.push({ tier: 1, kind: "permission-lost", device: name, confidence: "hard" }); } } // Selected device gone: the persisted id is no longer enumerated. Two // graceful skips so a list we can't read never masquerades as "unplugged": // - listDeviceIds() null → enumeration unavailable entirely // - a kind's set is empty → that kind can't be enumerated // (ids censored without a media-input grant; Safari never lists // audiooutput). Unknown, not missing. // Only flag when the kind IS enumerable and the specific id is absent. const deviceIds = await probes.listDeviceIds(); if (deviceIds) { for (const kind of ["microphone", "camera", "speaker"] as const) { const selected = baseline.devices[kind]; if (!selected) continue; const enumerated = deviceIds[DEVICE_KIND_TO_ENUM[kind]]; // Normalise to the core's PresenceFact: an empty set means the kind // can't be enumerated (censored / unsupported) → cannot-tell, not // missing. presenceVerdict owns the pass/fail rule. const presence: PresenceFact = enumerated.size === 0 ? "cannot-tell" : enumerated.has(selected.id) ? "present" : "absent"; if (presenceVerdict({ presence }) === "absent") { changes.push({ tier: 1, kind: "device-unavailable", device: kind, ...(selected.label ? { label: selected.label } : {}), confidence: "hard", }); } } } // External monitor appeared after a single-display baseline (rule in core). if ( displayVerdict({ baseline: baseline.display, current: probes.probeDisplay() }) === "external-monitor" ) { changes.push({ tier: 1, kind: "external-monitor-connected", confidence: "hard" }); } // ── Tier 2 · capture liveness ──────────────────────────────────────────── if (options.liveness !== false) { const video = probes.getVideoTrack(); if (video) { // Only an ended video track is a hard failure; a live track falls // through to black/frozen frame sampling (adapter-specific, not a // core rule). Video `muted` isn't meaningful the way audio's is. if (trackVerdict({ track: videoTrackFact(video) }) === "ended") { changes.push({ tier: 2, kind: "camera-track-ended", device: "camera", confidence: "hard" }); } else { const verdict = await probes.sampleVideoFrame(video); if (verdict === "black") { changes.push({ tier: 2, kind: "camera-black", device: "camera", confidence: "hard" }); } else if (verdict === "frozen") { changes.push({ tier: 2, kind: "camera-frozen", device: "camera", confidence: "hard" }); } } } // Mic liveness is track-state only — loudness-independent, so a candidate // sitting silently in a quiet room never trips it (the rule lives in // verification-core's trackVerdict). `muted` is browser-owned: set when // the source stops delivering data (unplugged / OS mute / grabbed by // another app). No live audio track → no drift (unavailable). const audio = probes.getAudioTrack(); if (audio) { const track = trackVerdict({ track: audioTrackFact(audio) }); if (track === "ended") { changes.push({ tier: 2, kind: "mic-track-ended", device: "microphone", confidence: "hard" }); } else if (track === "muted") { changes.push({ tier: 2, kind: "mic-muted", device: "microphone", confidence: "hard" }); } } } return { ok: changes.length === 0, at: probes.now(), changes }; } /** * Normalise an audio track to the core's TrackFact. A live track that the * browser has flagged `muted` reads as `muted` (a hard mic failure); * otherwise the `readyState` maps straight across. */ function audioTrackFact(track: MediaStreamTrack): TrackFact { if (track.readyState === "ended") return "ended"; return track.muted ? "muted" : "live"; } /** * Video's TrackFact only distinguishes ended vs live: a `muted` video track is * not a meaningful failure the way a muted mic is, and a live track proceeds to * black/frozen frame sampling. So `muted` collapses to `live` here. */ function videoTrackFact(track: MediaStreamTrack): TrackFact { return track.readyState === "ended" ? "ended" : "live"; }