From df7176849d614ddb215c548e5323b0a86b98612c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 24 Jul 2026 12:41:27 -0500 Subject: [PATCH 1/2] feat(client): behavioral human-likelihood for clearance mints (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK half of app#328 (PRD FR10). A session that a human actually interacts with can now present positive evidence of it and upgrade its wd_clearance token to the graded 'human-likely' trust level, which routes can require. Flow: mint clean at idle as before, then watch passively. Once there has been real interaction, summarize it once, re-mint with the aggregates attached, and stop listening. A session that never interacts sends nothing, and one upgrade happens per page load no matter how many times clearance is started or refreshed. What leaves the browser is counts, durations, variances and ratios — and nothing else. No pointer coordinates, no key identities, no form values, no DOM, no replayable event stream. The aggregates describe HOW a session moved, never WHAT it did, which is the whole difference between this and a session-replay product. A test asserts the exact field list, so the payload cannot grow without a deliberate change to the published contract. Reuses the existing BehavioralCollector rather than a parallel implementation; summarizeBehavior() maps it to the wire shape and drops everything else. Notes: - The 24-sample floor sits above the collector's own 20-sample threshold on purpose: below that, _detectMicroTremor returns a 0.5 PLACEHOLDER, and shipping it would manufacture human evidence out of a mouse twitch. - The window floor also refuses tight scripted bursts client-side, so the harder scripted case (one that paces itself) is what the server scores. - The device fingerprint — the only expensive step, one canvas + one WebGL read — is computed inside the upgrade callback, so a session that never interacts never pays for it. - Listeners are passive and removed on delivery: zero steady-state cost. - opts.behavior: false opts out entirely; tokens then stay at 'clean'. --- .../client/src/clearance-behavior.test.ts | 201 ++++++++++++++++++ packages/client/src/clearance-behavior.ts | 185 ++++++++++++++++ packages/client/src/clearance.ts | 64 +++++- packages/client/src/index.ts | 5 + 4 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/clearance-behavior.test.ts create mode 100644 packages/client/src/clearance-behavior.ts 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..e203054 100644 --- a/packages/client/src/clearance.ts +++ b/packages/client/src/clearance.ts @@ -16,6 +16,7 @@ import { sha256 } from './sha256'; import { EnvironmentalCollector } from './collectors/environment'; +import { watchInteraction, type BehaviorAggregate } from './clearance-behavior'; export const CLEARANCE_COOKIE = 'wd_clearance'; @@ -75,12 +76,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 +97,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 +142,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 +222,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, From b98ed06a0e889106d9b01a90977e608103942709 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Fri, 24 Jul 2026 13:03:44 -0500 Subject: [PATCH 2/2] docs(clearance): name all three canonical fingerprint implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wdfp1 fp is the enforcement identity (tokens bind to it, the deny-list keys on it) and now has three implementations that must agree byte-for-byte. Names them, and warns against confusing it with the pipeline's server-composed device_fp — a confusion that had made manual denies inert in the app repo. --- packages/client/src/clearance.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/client/src/clearance.ts b/packages/client/src/clearance.ts index e203054..291034a 100644 --- a/packages/client/src/clearance.ts +++ b/packages/client/src/clearance.ts @@ -21,12 +21,24 @@ 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';