diff --git a/packages/client/src/clearance-behavior.test.ts b/packages/client/src/clearance-behavior.test.ts new file mode 100644 index 0000000..e5ede7c --- /dev/null +++ b/packages/client/src/clearance-behavior.test.ts @@ -0,0 +1,201 @@ +/** + * @jest-environment jsdom + * + * Locks the behavioral-clearance contract (app repo #328, PRD FR10): what may + * leave the browser, when a session is summarized at all, and that scripted + * input produces measurably different aggregates than a human hand. + */ + +import { BehavioralCollector } from './collectors/behavioral'; +import { + summarizeBehavior, + hasEnoughInteraction, + watchInteraction, + type BehaviorAggregate, +} from './clearance-behavior'; + +/** + * THE PUBLISHED COLLECTION CONTRACT. Adding a field here is a deliberate act + * that must also update the docs page and the ingest-side struct — it is not a + * detail to slip in, because everything on this list is data we told customers + * (and their visitors) we collect. + */ +const ALLOWED_FIELDS = [ + 'delta_var', + 'dir_changes', + 'duration_ms', + 'hold_ms', + 'keys', + 'micro_moves', + 'pointer_nonmouse', + 'samples', + 'scrolls', + 'straight_ratio', + 'touch_force_var', + 'touch_points', + 'tremor', + 'velocity_var', +]; + +/** A human-ish pointer path: arcs, jitter and uneven event timing. */ +function humanCollector(): BehavioralCollector { + const c = new BehavioralCollector(); + let t = 0; + let x = 100; + let y = 100; + for (let i = 0; i < 60; i++) { + // Uneven cadence (8–40ms), curved travel, sub-pixel-scale correction. + t += 8 + ((i * 7) % 33); + x += Math.round(6 * Math.cos(i / 4)) + (i % 3 === 0 ? 1 : -1); + y += Math.round(5 * Math.sin(i / 3)) + (i % 4 === 0 ? -1 : 1); + c.mousePositions.push({ x, y, t }); + if (i > 0) { + c.mouseVelocities.push({ v: 0.2 + (i % 5) * 0.11, t }); + c.eventDeltas.push(8 + ((i * 7) % 33)); + } + } + c.clickData = { x, y, button: 0, downTime: t, upTime: t + 88, holdDuration: 88 }; + return c; +} + +/** + * Scripted input: page.mouse.move(x, y, {steps: n}) — exact linear + * interpolation on a uniform beat. Paced at 40ms/step rather than a tight loop, + * so it clears the client-side window floor: this is the HARDER case, the one + * that actually reaches the server and has to be scored there. + */ +function scriptedCollector(): BehavioralCollector { + const c = new BehavioralCollector(); + for (let i = 0; i < 60; i++) { + c.mousePositions.push({ x: 100 + i * 5, y: 100 + i * 5, t: i * 40 }); // exactly collinear + if (i > 0) { + c.mouseVelocities.push({ v: 7.07, t: i * 40 }); // constant speed + c.eventDeltas.push(40); // isochronous + } + } + c.clickData = { x: 395, y: 395, button: 0, downTime: 2400, upTime: 2401, holdDuration: 1 }; + return c; +} + +describe('collection contract', () => { + it('emits only the published aggregate fields', () => { + const summary = summarizeBehavior(humanCollector()) as BehaviorAggregate; + expect(summary).not.toBeNull(); + expect(Object.keys(summary).sort()).toEqual(ALLOWED_FIELDS); + }); + + it('carries no coordinates, key identities or replayable event stream', () => { + const c = humanCollector(); + c.keyEvents.push({ type: 'keydown', keyLength: 1, t: 10 }); + const serialized = JSON.stringify(summarizeBehavior(c)); + + // The collector holds x/y positions and a per-event delta list; none of it + // may reach the wire. Guard on the shape, not on incidental values. + for (const banned of ['x', 'y', 'clickData', 'eventDeltas', 'mousePositions', 'key', 'target']) { + expect(JSON.parse(serialized)).not.toHaveProperty(banned); + } + // Nothing nested either — every value must be a scalar. + for (const v of Object.values(JSON.parse(serialized))) { + expect(['number', 'boolean']).toContain(typeof v); + } + }); + + it('reports keystrokes as a count only', () => { + const c = humanCollector(); + for (const key of ['p', 'a', 's', 's']) { + c.keyEvents.push({ type: 'keydown', keyLength: key.length, t: 1 }); + } + const summary = summarizeBehavior(c) as BehaviorAggregate; + expect(summary.keys).toBe(4); + expect(JSON.stringify(summary)).not.toContain('pass'); + }); +}); + +describe('when a session is summarized', () => { + it('declines sessions with too little interaction', () => { + expect(summarizeBehavior(new BehavioralCollector())).toBeNull(); + }); + + it('declines a keyboard-only session rather than scoring it badly', () => { + // The accessibility case: no pointer, no touch, plenty of typing. It must + // produce NO summary, so the mint stays clean instead of looking bot-like. + const c = new BehavioralCollector(); + for (let i = 0; i < 50; i++) c.keyEvents.push({ type: 'keydown', keyLength: 1, t: i * 90 }); + for (let i = 0; i < 10; i++) c.scrollEvents.push({ x: 0, y: i * 40, t: i * 120 }); + expect(hasEnoughInteraction(c)).toBe(false); + expect(summarizeBehavior(c)).toBeNull(); + }); + + it('declines a short burst that would report placeholder tremor', () => { + // Under 20 samples the collector returns a 0.5 tremor PLACEHOLDER, which + // the server would read as genuine human tremor. Never ship it. + const c = new BehavioralCollector(); + for (let i = 0; i < 15; i++) c.mousePositions.push({ x: i, y: i, t: i * 100 }); + expect(hasEnoughInteraction(c)).toBe(false); + expect(summarizeBehavior(c)).toBeNull(); + }); + + it('accepts a touch-only session (mobile has no mouse cadence to offer)', () => { + const c = new BehavioralCollector(); + for (let i = 0; i < 12; i++) { + c.touchEvents.push({ + x: 50 + i * 3, y: 200 - i * 7, t: i * 30, + force: 0.4 + (i % 3) * 0.05, radiusX: 12, radiusY: 14, + rotationAngle: 0, identifier: 1, touchCount: 1, + }); + } + const summary = summarizeBehavior(c) as BehaviorAggregate; + expect(summary).not.toBeNull(); + expect(summary.touch_points).toBe(12); + expect(summary.touch_force_var).toBeGreaterThan(0); + }); +}); + +describe('scripted vs human aggregates', () => { + it('separates them on the tells the server scores', () => { + const human = summarizeBehavior(humanCollector()) as BehaviorAggregate; + const scripted = summarizeBehavior(scriptedCollector()) as BehaviorAggregate; + + // Collinearity: interpolation draws a line, a hand does not. + expect(scripted.straight_ratio).toBeGreaterThan(0.9); + expect(human.straight_ratio).toBeLessThan(scripted.straight_ratio); + + // Physiological tremor is present in one and absent in the other. + expect(human.tremor).toBeGreaterThan(scripted.tremor); + + // Isochrony: a script's inter-event timing barely varies. + expect(human.delta_var).toBeGreaterThan(scripted.delta_var); + + // Constant velocity, and a click released in the same task it was pressed. + expect(scripted.velocity_var).toBeCloseTo(0, 5); + expect(human.velocity_var).toBeGreaterThan(0); + expect(scripted.hold_ms).toBeLessThan(15); + expect(human.hold_ms).toBeGreaterThan(30); + }); +}); + +describe('watchInteraction', () => { + const move = (x: number, y: number): void => { + document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y, bubbles: true })); + }; + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('never fires for a session that does not interact', () => { + const onReady = jest.fn(); + const stop = watchInteraction(onReady); + jest.advanceTimersByTime(60_000); + expect(onReady).not.toHaveBeenCalled(); + stop(); + }); + + it('stops listening once cancelled', () => { + const onReady = jest.fn(); + const stop = watchInteraction(onReady); + stop(); + for (let i = 0; i < 80; i++) move(i * 3, i * 2); + jest.advanceTimersByTime(60_000); + expect(onReady).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/clearance-behavior.ts b/packages/client/src/clearance-behavior.ts new file mode 100644 index 0000000..16f0a70 --- /dev/null +++ b/packages/client/src/clearance-behavior.ts @@ -0,0 +1,185 @@ +/** + * Behavioral human-likelihood for clearance minting (app repo #328, PRD FR10). + * + * A wd_clearance token normally says only "this is a real browser that hasn't + * tripped deception". This module lets a session also present POSITIVE evidence + * that a human is driving it, which the mint turns into a graded 'human-likely' + * token — the grade a route can require for its most sensitive paths. + * + * WHAT LEAVES THE BROWSER (this is the whole list): + * counts — how many pointer samples, scrolls, keystrokes, touches + * durations — the observation window, and how long a click was held + * variances — of pointer velocity, of inter-event timing, of touch force + * ratios — micro-tremor and collinearity of the pointer path, 0–1 + * + * WHAT NEVER LEAVES THE BROWSER: pointer coordinates, the keys pressed, form + * values, element or page content, URLs, screenshots, and any kind of replayable + * event stream. Nothing here can reconstruct what a person typed, read, clicked + * or looked at — the aggregates describe HOW the session moved, never WHAT it + * did. That is the whole difference between this and a session-replay vendor, + * and it is why the collection contract is published rather than buried. + * + * The scoring itself is deliberately NOT done here: the browser reports + * observations, the server decides what they earn. A client that fabricates + * flattering aggregates gains nothing it could not gain by lying about its + * fingerprint, and the mint caps any grade by the actor's threat score. + */ + +import { BehavioralCollector } from './collectors/behavioral'; + +/** The wire shape posted as `behavior` on a clearance mint. */ +export interface BehaviorAggregate { + samples: number; + duration_ms: number; + tremor: number; + straight_ratio: number; + velocity_var: number; + delta_var: number; + dir_changes: number; + micro_moves: number; + hold_ms: number; + touch_points: number; + touch_force_var: number; + pointer_nonmouse: boolean; + scrolls: number; + keys: number; +} + +/** + * Minimum pointer samples before a session is summarized at all. + * + * Above the collector's own 20-sample floor on purpose: below that, + * _detectMicroTremor returns a 0.5 placeholder rather than a measurement, and + * shipping a placeholder the server reads as real tremor would manufacture + * human evidence out of a short mouse twitch. + */ +const MIN_POINTER_SAMPLES = 24; + +/** Minimum touch points for the mobile modality (real digitizer data is dense). */ +const MIN_TOUCH_POINTS = 8; + +/** Minimum observation window; a burst inside a few milliseconds is not a session. */ +const MIN_WINDOW_MS = 750; + +/** + * How long to keep collecting after the thresholds are first met. More + * interaction means a better-founded summary, and the upgrade is not urgent — + * the session already holds a valid clean token. + */ +const SETTLE_MS = 1500; + +function num(v: unknown): number { + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +} + +/** The observed pointer window, measured from the samples rather than from + * page load — a visitor who reads for a minute and then moves the mouse has a + * two-second pointer window, not a sixty-second one. */ +function pointerWindowMs(collector: BehavioralCollector): number { + const p = collector.mousePositions; + if (!p || p.length < 2) return 0; + return Math.max(0, Math.round(p[p.length - 1].t - p[0].t)); +} + +/** Whether the session has enough interaction to be worth summarizing. */ +export function hasEnoughInteraction(collector: BehavioralCollector): boolean { + const pointerReady = + collector.mousePositions.length >= MIN_POINTER_SAMPLES && + pointerWindowMs(collector) >= MIN_WINDOW_MS; + const touchReady = collector.touchEvents.length >= MIN_TOUCH_POINTS; + return pointerReady || touchReady; +} + +/** + * Reduce a collector to the published aggregate set. Returns null when the + * session has too little interaction to describe — an unscored mint is a clean + * token, which is exactly what a keyboard-only or assistive-technology session + * should get rather than a low score it never earned. + */ +export function summarizeBehavior(collector: BehavioralCollector): BehaviorAggregate | null { + if (!hasEnoughInteraction(collector)) return null; + + const a = collector.analyze(); + const click = collector.clickData; + + // Every field below is a scalar count, duration, variance or ratio. If a + // future signal cannot be described that way, it does not belong on this wire. + return { + samples: num(a.totalPoints), + duration_ms: pointerWindowMs(collector), + tremor: num(a.microTremorScore), + straight_ratio: num(a.straightLineRatio), + velocity_var: num(a.velocityVariance), + delta_var: num(a.eventDeltaVariance), + dir_changes: num(a.directionChanges), + micro_moves: num(a.microMovements), + hold_ms: num(click?.holdDuration), + touch_points: num(a.touchTotalPoints), + touch_force_var: num(a.touchForceVariance), + pointer_nonmouse: a.pointerHasNonMouseType === true, + scrolls: num(a.scrollEvents), + keys: num(a.keyEvents), + }; +} + +/** + * Watch the session until it has enough interaction to summarize, then hand the + * aggregate over ONCE and stop listening. + * + * All listeners are passive (they never delay scrolling or input) and are + * removed as soon as the summary is delivered, so the steady-state cost of a + * cleared session is zero. A session that never interacts never fires, never + * posts, and keeps the clean token it already has. + * + * Returns a cancel function for callers that unmount. + */ +export function watchInteraction(onReady: (b: BehaviorAggregate) => void): () => void { + if (typeof document === 'undefined') return () => {}; + + const collector = new BehavioralCollector(); + let settleTimer: ReturnType | null = null; + let done = false; + + const handlers: Record = { + mousemove: (e) => collector.recordMouseMove(e as MouseEvent), + mousedown: (e) => collector.recordMouseDown(e as MouseEvent), + mouseup: (e) => collector.recordMouseUp(e as MouseEvent), + scroll: (e) => collector.recordScroll(e), + keydown: (e) => collector.recordKeyEvent(e as KeyboardEvent), + touchstart: (e) => collector.recordTouch(e as TouchEvent), + touchmove: (e) => collector.recordTouch(e as TouchEvent), + pointerdown: (e) => collector.recordPointer(e as PointerEvent), + }; + + const stop = (): void => { + if (done) return; + done = true; + if (settleTimer) clearTimeout(settleTimer); + for (const [event, handler] of Object.entries(handlers)) { + document.removeEventListener(event, handler); + } + }; + + const check = (): void => { + if (done || settleTimer) return; + if (!hasEnoughInteraction(collector)) return; + // Thresholds met: collect a little longer, then deliver and detach. + settleTimer = setTimeout(() => { + settleTimer = null; + const summary = summarizeBehavior(collector); + stop(); + if (summary) onReady(summary); + }, SETTLE_MS); + }; + + for (const [event, handler] of Object.entries(handlers)) { + const wrapped: EventListener = (e) => { + handler(e); + check(); + }; + handlers[event] = wrapped; + document.addEventListener(event, wrapped, { passive: true }); + } + + return stop; +} diff --git a/packages/client/src/clearance.ts b/packages/client/src/clearance.ts index fc3a73f..291034a 100644 --- a/packages/client/src/clearance.ts +++ b/packages/client/src/clearance.ts @@ -16,16 +16,29 @@ import { sha256 } from './sha256'; import { EnvironmentalCollector } from './collectors/environment'; +import { watchInteraction, type BehaviorAggregate } from './clearance-behavior'; export const CLEARANCE_COOKIE = 'wd_clearance'; /** - * Canonical device-fp algorithm version. The fp is the deny-list key, so this - * string is a CONTRACT: it MUST stay byte-identical to the edge challenge page - * (app repo: edge/clearance-worker challengePage()). If normal-mode minting and - * the challenge page computed different fps for the same browser, a decoy- - * triggered deny would catch one token but not the other — the lockout would - * leak. Bump the version prefix (never edit in place) to evolve the algorithm. + * Canonical device-fp algorithm version. This fp is the ENFORCEMENT identity — + * the clearance token binds to it and the deny-list keys on it — so the string + * is a CONTRACT. Three implementations must agree byte-for-byte: + * + * 1. this function + * 2. app repo `edge/clearance-worker/src/device-fp.ts` (the interstitial) + * 3. app repo `cdn/pro/bot-detection-pro.js` computeClearanceFP() + * + * If two of them computed different fps for the same browser, a decoy-triggered + * deny would catch one and not the other — the lockout would leak, silently. + * All three are pinned to the same golden vector (see clearance.test.ts here, + * and the device-fp tests in the app repo). + * + * Do NOT confuse this with the `device_fp` the scoring pipeline composes + * server-side: that is a correlation key that works without any token, this is + * what a deny actually locks out. They are different identity spaces. + * + * Bump the version prefix (never edit in place) to evolve the algorithm. */ export const FP_VERSION = 'wdfp1'; @@ -75,12 +88,15 @@ interface MintResponse { expires_in?: number; } -/** Call the public issuance endpoint. Returns null on any failure (fail open). */ +/** Call the public issuance endpoint. Returns null on any failure (fail open). + * `behavior`, when present, carries the session's interaction aggregates and + * can earn the token a graded 'human-likely' trust level (#328). */ async function mint( ingestUrl: string, siteKey: string, fp: string, scope: string, + behavior?: BehaviorAggregate, ): Promise<{ token: string; expiresIn: number } | null> { try { const res = await fetch(ingestUrl.replace(/\/$/, '') + '/api/v1/clearance', { @@ -93,6 +109,7 @@ async function mint( ua: navigator.userAgent, webdriver: (navigator as unknown as { webdriver?: boolean }).webdriver === true, headless: /HeadlessChrome/.test(navigator.userAgent), + ...(behavior ? { behavior } : {}), }), }); const out = (await res.json()) as MintResponse; @@ -137,6 +154,51 @@ export interface ClearanceOptions { ingestUrl?: string; /** Route-group scope; '' = tenant-wide (default), valid on every route. */ scope?: string; + /** + * Collect interaction aggregates and upgrade the token to a graded + * 'human-likely' trust level once the visitor actually interacts (#328). + * Default true. Set false to mint clean tokens only — routes that require a + * minimum trust level will then always challenge. + * + * See clearance-behavior.ts for exactly what is collected; it is aggregate + * counts and variances, never coordinates, keys or content. + */ + behavior?: boolean; +} + +/** One behavioral upgrade per page load, no matter how many times clearance is + * started or refreshed — the token refresh loop re-enters startClearance, and + * each entry must not attach another set of listeners. */ +let upgradeStarted = false; + +/** + * Watch for real interaction and, once there is enough of it, re-mint the + * session's token with the behavioral evidence attached. + * + * The device fingerprint is computed INSIDE the callback, not up front: it is + * the only expensive step (one canvas + one WebGL read), and a session that + * never interacts must never pay for it. A failed upgrade leaves the existing + * clean token untouched. + */ +function startBehavioralUpgrade(ingestUrl: string, siteKey: string, scope: string): void { + if (upgradeStarted) return; + upgradeStarted = true; + + watchInteraction((behavior) => { + void (async () => { + try { + const env = new EnvironmentalCollector(); + const fp = await computeDeviceFP({ + canvasHash: env._getCanvasHash(), + webglInfo: env._getWebGLInfo(), + }); + const minted = await mint(ingestUrl, siteKey, fp, scope, behavior); + if (minted) setClearanceCookie(minted.token, minted.expiresIn); + } catch { + /* fail open — the session keeps whatever token it already had */ + } + })(); + }); } /** @@ -172,4 +234,16 @@ export function startClearance(opts: ClearanceOptions): void { whenIdle(() => { void run(); }); + + // Independent of the mint above: a session that already holds a token can + // still upgrade it once the visitor interacts, and one that mints a fresh + // clean token upgrades that. Never blocks, never fires without interaction. + if (opts.behavior !== false) { + startBehavioralUpgrade(ingestUrl, opts.siteKey, scope); + } +} + +/** Reset the once-per-page-load upgrade guard. Test-only. */ +export function _resetBehavioralUpgradeForTests(): void { + upgradeStarted = false; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f2929fa..6eeda67 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -27,6 +27,11 @@ export { FormAnalyzer, getFormAnalyzer } from './collectors/form'; export { sha256 } from './sha256'; export { startClearance, computeDeviceFP, CLEARANCE_COOKIE, FP_VERSION, DEFAULT_INGEST_URL } from './clearance'; export type { ClearanceOptions, DeviceFPInputs } from './clearance'; +// Behavioral human-likelihood (#328). Exported so the collection contract is +// inspectable by anyone integrating — summarizeBehavior IS the published list +// of what we send. +export { summarizeBehavior, hasEnoughInteraction, watchInteraction } from './clearance-behavior'; +export type { BehaviorAggregate } from './clearance-behavior'; export type { CollectedSignals,