/** * Per-device fingerprint computation. * * The fingerprint is the SDK's answer to "is this still the same * device that started the test?" -- a stable hash over a handful * of low-entropy browser+device properties that change when the * candidate switches laptops, switches browsers, or plugs in a * different camera. Mid-session changes are interesting (and * suspicious); changes between two attempts on the same machine * are not. * * What goes in (all read synchronously, all safe to read in * boot before the worker is up): * - userAgent + platform browser + OS family * - language region hint * - screen geometry primary monitor signature * - devicePixelRatio retina vs non-retina * - hardwareConcurrency logical CPU count * - deviceMemory RAM bucket (Chromium-only) * - timezone region/IANA zone * * Camera groupId is *not* part of the initial fingerprint * because mediaDevices.enumerateDevices needs an * already-granted permission. The runtime SDK boots before any * permission flow; including groupId there would make the * fingerprint change the moment the camera is picked. Camera * identity belongs in the canonical embedding (phase 8), not * here. * * What goes out: a stable 16-hex-char id + the raw record. The * id is what gets stamped on events + headers; the record is * what the server's PreflightAttempt row stores so a proctor * can diff two attempts side by side. */ /** * Raw fingerprint inputs. Stored on `PreflightAttempt.fingerprint` * (the JSONB column) for forensic diffing. Mirrors what the * legacy session.fingerprint event already emits -- we will * dedupe with that event in a follow-up. */ export interface DeviceFingerprint { userAgent: string | null; platform: string | null; language: string | null; timezone: string | null; screen: { width: number; height: number; availWidth: number; availHeight: number; colorDepth: number; } | null; devicePixelRatio: number | null; hardwareConcurrency: number | null; deviceMemory: number | null; } export interface FingerprintResult { /** * Stable 16-char hex id derived from the canonical-serialised * fingerprint inputs. Same inputs -> same id every time. Same * across reloads on the same machine + browser; different * across browsers, devices, or major OS upgrades. */ fingerprintId: string; fingerprint: DeviceFingerprint; } /** * Compute the fingerprint synchronously. Safe to call in * environments without a `navigator` (SSR/tests/workers) -- it * just returns an "unknown" record with a stable sentinel id so * the rest of the pipeline never has to special-case the null * case. */ export function computeDeviceFingerprint(): FingerprintResult { const fingerprint = readFingerprint(); const fingerprintId = hashFingerprint(fingerprint); return { fingerprintId, fingerprint }; } const TAB_LOCK_TOKEN_KEY = "proctorkit.tab-lock-token"; function randomToken(): string { try { const c = (globalThis as { crypto?: Crypto }).crypto; if (c && typeof c.randomUUID === "function") return c.randomUUID(); } catch { /* fall through */ } // Non-crypto fallback (old/locked-down runtimes). Uniqueness, not secrecy, // is all the lock token needs. return `t-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`; } /** * Per-tab lock token for the single-active-session lock. Stored in * `sessionStorage`, so it **survives a refresh** (same tab keeps resuming) but * is **unique per tab** (a second tab gets its own → the server can tell them * apart and block the duplicate). Falls back to a fresh per-call token when * `sessionStorage` is unavailable (SSR, private mode, blocked storage) — the * lock then degrades to "no cross-refresh identity" rather than breaking. */ export function getTabLockToken(): string { let storage: Storage | null = null; try { storage = typeof sessionStorage === "undefined" ? null : sessionStorage; } catch { storage = null; } if (!storage) return randomToken(); try { const existing = storage.getItem(TAB_LOCK_TOKEN_KEY); if (existing) return existing; const fresh = randomToken(); storage.setItem(TAB_LOCK_TOKEN_KEY, fresh); return fresh; } catch { return randomToken(); } } function readFingerprint(): DeviceFingerprint { const safe = (read: () => T): T | null => { try { return read(); } catch { return null; } }; const nav: Navigator | undefined = typeof navigator === "undefined" ? undefined : navigator; const win: Window | undefined = typeof window === "undefined" ? undefined : window; const screen = win?.screen ?? null; return { userAgent: nav ? safe(() => nav.userAgent) : null, platform: nav ? safe(() => nav.platform) : null, language: nav ? safe(() => nav.language) : null, timezone: safe(() => Intl.DateTimeFormat().resolvedOptions().timeZone), screen: screen ? { width: screen.width, height: screen.height, availWidth: screen.availWidth, availHeight: screen.availHeight, colorDepth: screen.colorDepth, } : null, devicePixelRatio: win ? win.devicePixelRatio : null, hardwareConcurrency: nav ? safe(() => nav.hardwareConcurrency) : null, // deviceMemory is Chromium-only; bucketed in 0.25 increments. deviceMemory: nav ? safe( () => (nav as Navigator & { deviceMemory?: number }).deviceMemory ?? null, ) : null, }; } /** * Stable 16-char hex digest over the fingerprint. We use FNV-1a * (not a crypto hash) because: * * - Crypto.subtle is async, which would force the entire * fingerprint pipeline async, which would force the * constructor async, which the public API doesn't allow. * - The fingerprint is not a secret. It identifies the * device, but a server-side actor with knowledge of the * fields could compute the same id either way. Collision * resistance is the only property we care about, and * FNV-1a's distribution is fine for the input space. * * 16 hex chars (= 64 bits) gives a collision probability < 1e-9 * for any realistic per-session population, which is well below * the noise floor of the device-change detector. */ /** * Snap hardwareConcurrency to the nearest power of 2. * * Real machines have core counts at 2 / 4 / 8 / 10 / 12 / 16 / 24 / 32 * etc. Brave's fingerprinting protection (and some Chrome DevTools * emulation modes) jitter `navigator.hardwareConcurrency` by ±1-2 * per page load to defeat cross-session identity hashing. Snapping * to the nearest power of 2 absorbs that jitter without losing the * ability to distinguish "4-core laptop" from "16-core workstation" * — the FingerprintScoringService still reads the RAW value from * the un-rounded payload when scoring device changes. * * Returns 0 for null/undefined/non-finite so the hash stays stable. */ function quantiseCores(n: number | null | undefined): number { if (n === null || n === undefined || !Number.isFinite(n) || n <= 0) return 0; // Nearest power of 2 by log scaling. 10 → 8 (closer to 2^3 than 2^4), // 12 → 16 (closer to 2^4), 24 → 16 (closer to 2^4 than 2^5). return 2 ** Math.round(Math.log2(n)); } /** * Snap deviceMemory to the nearest spec-allowed bucket. The * Device Memory API spec only returns values from a fixed set: * 0.25, 0.5, 1, 2, 4, 8. Brave (and some Chrome extensions) can * jitter the returned value across bucket boundaries on each * page load. Clamping to the closest spec bucket keeps the hash * stable while the scoring service still reads raw precision * from the payload. */ function quantiseMemory(n: number | null | undefined): number { if (n === null || n === undefined || !Number.isFinite(n) || n <= 0) return 0; const buckets = [0.25, 0.5, 1, 2, 4, 8]; let best = buckets[0]!; let bestDelta = Math.abs(n - best); for (const b of buckets) { const d = Math.abs(n - b); if (d < bestDelta) { best = b; bestDelta = d; } } return best; } export function hashFingerprint(fp: DeviceFingerprint): string { // Canonical serialisation: keys in a fixed order so two // equivalent fingerprints with different object iteration // orders hash to the same value. JSON.stringify with a // replacer doesn't guarantee key order across runtimes, so we // serialise by hand. // // hardwareConcurrency + deviceMemory are quantised (NOT the // raw values) because Brave's fingerprinting protection // jitters them per page load — the raw values still go in the // payload for the scoring service, but the hash uses snapped // values so refreshes on the same physical device produce a // stable id. See quantiseCores + quantiseMemory above. const parts: string[] = [ `ua:${fp.userAgent ?? ""}`, `pl:${fp.platform ?? ""}`, `la:${fp.language ?? ""}`, `tz:${fp.timezone ?? ""}`, `sc:${ fp.screen ? `${fp.screen.width}x${fp.screen.height}@${fp.screen.colorDepth}aw${fp.screen.availWidth}ah${fp.screen.availHeight}` : "" }`, `dpr:${fp.devicePixelRatio ?? ""}`, `cores:${quantiseCores(fp.hardwareConcurrency)}`, `mem:${quantiseMemory(fp.deviceMemory)}`, ]; return fnv1a64(parts.join("|")); } /** * 64-bit FNV-1a as two 32-bit halves combined into a 16-hex * string. Pure 32-bit math throughout (no BigInt) to keep the * hot path allocation-free. */ function fnv1a64(input: string): string { // 64-bit prime = 1099511628211 = 2^40 + 2^8 + 0xb3 // We track the hash as two 32-bit halves: hi, lo. After each // byte we multiply by the prime using long-multiplication. let hi = 0xcbf2_9ce4; let lo = 0x8422_2325; for (let i = 0; i < input.length; i++) { const c = input.charCodeAt(i) & 0xff; lo = (lo ^ c) >>> 0; // 64-bit multiply (hi, lo) * (0x100, 0x0000_01b3): // prime = 0x100 << 32 | 0x000001b3 // result_lo = lo * 0x1b3 (low 32 of that) // result_hi = hi * 0x1b3 + lo * 0x100 + (lo*0x1b3 carry) const lo_lo = mul32(lo, 0x0000_01b3); const lo_hi = mul32hi(lo, 0x0000_01b3); const hi_lo = mul32(hi, 0x0000_01b3); const cross = mul32(lo, 0x0000_0100); lo = lo_lo >>> 0; hi = (hi_lo + cross + lo_hi) >>> 0; } return toHex32(hi) + toHex32(lo); } // 32x32 -> low 32 bits of the product, using two 16x16 multiplies // to stay within float53 precision. function mul32(a: number, b: number): number { const aHi = (a >>> 16) & 0xffff; const aLo = a & 0xffff; const bHi = (b >>> 16) & 0xffff; const bLo = b & 0xffff; return (((aHi * bLo + aLo * bHi) << 16) + aLo * bLo) >>> 0; } // 32x32 -> high 32 bits of the product. Used so the FNV multiply // can carry between the two halves of the hash. function mul32hi(a: number, b: number): number { const aHi = (a >>> 16) & 0xffff; const aLo = a & 0xffff; const bHi = (b >>> 16) & 0xffff; const bLo = b & 0xffff; const lowProd = aLo * bLo; const midProd = aHi * bLo + aLo * bHi; const highProd = aHi * bHi; const carry = Math.floor((lowProd + ((midProd & 0xffff) << 16)) / 0x1_0000_0000); return (highProd + (midProd >>> 16) + carry) >>> 0; } function toHex32(n: number): string { return (n >>> 0).toString(16).padStart(8, "0"); }