🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@mochi.js/consistency

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mochi.js/consistency - npm Package Compare versions

Comparing version
0.1.2
to
0.1.3
+99
src/rules/audioCanvas.ts
/**
* Audio + canvas fingerprint rules. Cover R-047 and R-048.
*
* The captures are device-fixed (same value per profile across every seed)
* and live in `./lookups/audio-canvas.ts`. The rule's job is to copy the
* lookup row into a JSON-serialised `uaCh.audio-fingerprint` /
* `uaCh.canvas-fingerprint` slot the inject layer reads.
*
* R-047 (`audioFingerprint`): inputs `(id, audio.contextSampleRate)`. We
* carry `contextSampleRate` as an input so the DAG records the relationship
* (PLAN.md §9.2 — "rules declare semantic dependency, not just data
* dependency"); the value is double-checked against the lookup row.
*
* R-048 (`canvasFingerprint`): input `(id,)`. Canvas captures don't depend
* on any other matrix axis — the fingerprint surface is GPU/OS-driver
* coupled and that coupling is folded into the per-profile lookup row.
*
* Closing the audio + canvas surfaces flips the README "what works /
* doesn't" matrix's two largest leakers from `deferred` to `works`.
*
* @see PLAN.md §9.3 (audio), §9.4 (canvas)
* @see tasks/0267-audio-canvas-fingerprint-blobs.md
*/
import { defineRule, type Rule } from "../rule";
import { audioCaptureFor, canvasCaptureFor, canvasTailFor } from "./lookups/audio-canvas";
/**
* R-047 — `(id, audio.contextSampleRate)` → `uaCh.audio-fingerprint` JSON.
*
* Output shape (consumed by `packages/inject/src/modules/audio-fingerprint.ts`):
* `{ "sampleRate": number,
* "audioHash": string, // sum of |data[i]|, i in [4500..5000)
* "sampleValues": number[10] }` // data[4500..4510)
*
* The reported `sampleRate` is the OfflineAudioContext sample rate the
* fingerprint probe constructs (probe-page.html uses 44100 verbatim; the
* AudioContext's `sampleRate` getter returns `audio.contextSampleRate`).
*/
export const R047: Rule = defineRule<readonly [string, number], string>({
id: "R-047",
description: "Audio fingerprint blob (probe-side audioHash + sampleValues window) per profile",
inputs: ["id", "audio.contextSampleRate"],
output: "uaCh.audio-fingerprint",
derive([profileId, _ctxSampleRate]) {
const cap = audioCaptureFor(profileId);
return JSON.stringify({
sampleRate: cap.sampleRate,
audioHash: cap.audioHash,
sampleValues: cap.sampleValues,
});
},
});
/**
* R-048 — `(id,)` → `uaCh.canvas-fingerprint` JSON.
*
* Output shape (consumed by `packages/inject/src/modules/canvas-fingerprint.ts`):
* `{ "consistent": true,
* "hash": "8-uppercase-hex", // hashString(toDataURL("image/png"))
* "dataUrlLength": number, // exact length
* "dataUrlPrefix": string, // first 50 chars
* "webpSupport": boolean,
* "jpegHighLength": number,
* "jpegLowLength": number,
* "synthTail": "8-base64-chars" }` // pre-computed via meet-in-middle
*
* The `synthTail` is computed once per (prefix, length, hash) triple by
* `canvasTailFor` (memoised in the lookup). The inject module emits this
* tail verbatim — no runtime brute-force, no JIT-tier fragility.
*
* The probe-page heuristic for "is this a fingerprint canvas?" — 300×150
* with text + gradient — is matched in the inject module; non-probe
* canvases fall through to native rendering so legitimate canvas use
* stays correct. See the inject module for the FP discussion.
*/
export const R048: Rule = defineRule<readonly [string], string>({
id: "R-048",
description:
"Canvas fingerprint blob (probe-side hash + length + prefix + synth tail) per profile",
inputs: ["id"],
output: "uaCh.canvas-fingerprint",
derive([profileId]) {
const cap = canvasCaptureFor(profileId);
const synthTail = canvasTailFor(profileId);
return JSON.stringify({
consistent: cap.consistent,
hash: cap.hash,
dataUrlLength: cap.dataUrlLength,
dataUrlPrefix: cap.dataUrlPrefix,
webpSupport: cap.webpSupport,
jpegHighLength: cap.jpegHighLength,
jpegLowLength: cap.jpegLowLength,
synthTail,
});
},
});
export const AUDIO_CANVAS_RULES: readonly Rule[] = [R047, R048];
/**
* Audio + canvas baseline captures, keyed by profile id.
*
* The values here are the probe-side observables that
* `tests/fixtures/probe-page.html` measures for the `audio` and `canvas`
* surfaces. They mirror the `audio` / `canvas` blocks of each profile's
* `baseline.manifest.json` (imported from real-device captures in 0260).
*
* Why a separate lookup rather than a schema field?
* - The captures are device-fixed (per profile, not per seed); they live
* in the manifest already.
* - Adding them to `ProfileV1` would require a schema bump + codegen and
* would force every consumer to re-read the manifest. The matrix-side
* consumer (the inject builder) only needs the few probe-observable
* leaves, so we encode them as a small lookup the rule reads.
* - For unknown / fixture profile ids the lookup falls back to the
* `mac-chrome-stable` macOS captures so unit tests still get a non-empty
* blob to pin against.
*
* Probe-side hash function (from `tests/fixtures/probe-page.html`):
*
* hashString(s): var h=0; for c in s: h = ((h<<5) - h) + c.charCodeAt(0); h |= 0;
* return (h>>>0).toString(16).toUpperCase().padStart(8,"0");
* audioHash: sum |data[i]| for i in [4500..5000) on the rendered buffer.
* sampleValues: data[4500..4510) — 10 floats, reported as-is.
*
* The inject layer must reconstruct an OfflineAudioContext rendering result
* + a canvas data URL whose probe-side observables exactly equal these
* captured values. See `packages/inject/src/modules/{audio,canvas}-fingerprint.ts`.
*
* @see PLAN.md §9.3 / §9.4
* @see tasks/0267-audio-canvas-fingerprint-blobs.md
*/
/**
* Audio capture surface — what the probe reports + just enough byte-level
* detail (the 10-sample reported window) to spoof the surface byte-stably.
*/
export interface AudioCapture {
/** The OfflineAudioContext sample rate the probe constructs. */
readonly sampleRate: number;
/** sum |data[i]| for i in [4500..5000) — must match exactly. */
readonly audioHash: string;
/** data[4500..4510) — 10 reported floats; must match exactly. */
readonly sampleValues: readonly number[];
}
/**
* Canvas capture surface — what the probe reports.
*
* Note: the probe captures `dataUrlPrefix` (first 50 chars of the PNG data
* URL), `dataUrlLength` (full length), and `hash` (hashString of the full
* data URL). We don't have the PNG bytes themselves, so the inject layer
* synthesises a data URL whose probe-observable triple matches exactly.
* Decode-then-render passes that try to view the spoofed image will see a
* non-decodable PNG; fingerprint probes that only hash the URL get
* byte-exact results.
*
* The `synthesisedTail` field carries the precomputed 8-char base64 tail
* (computed via `synthesiseCanvasTail` below) so the inject module can emit
* the spoofed URL without running the search at build time. Keeping the
* search out of the inject build path keeps `buildPayload` deterministic
* and ~3x faster (no 16M-iteration brute force per profile).
*/
export interface CanvasCapture {
/** Always true on real Chrome — three back-to-back toDataURL calls match. */
readonly consistent: boolean;
/** hashString(toDataURL("image/png")) — the canonical canvas fingerprint. */
readonly hash: string;
/** toDataURL("image/png").length. */
readonly dataUrlLength: number;
/** toDataURL("image/png").substring(0, 50). */
readonly dataUrlPrefix: string;
/** `data:image/webp` accepted (vs falling back to PNG). */
readonly webpSupport: boolean;
/** toDataURL("image/jpeg", 1.0).length. */
readonly jpegHighLength: number;
/** toDataURL("image/jpeg", 0.1).length. */
readonly jpegLowLength: number;
}
/** Base64 alphabet — used by the synthesiser + the inject module. */
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/**
* Build the deterministic filler used between `prefix` and the synthesised
* tail. The choice (`B64[(i*7 + 11) mod 64]`) is arbitrary; the inject
* module uses the SAME formula so the runtime-rebuilt URL matches the
* build-time hash check.
*/
function buildFiller(len: number): string {
let s = "";
for (let i = 0; i < len; i++) s += B64[(i * 7 + 11) % 64];
return s;
}
/**
* Compute an 8-char base64 tail such that
* `hashString(prefix + filler + tail) === targetHashHex` where
* `filler = buildFiller(targetLen - prefix.length - 8)`.
*
* Strategy: meet-in-the-middle over the trailing 8 base64 chars. Build a
* map from `tailContribution` (4-char hash starting from h=0) →
* `(t0..t3)`, then iterate (c0..c3) as the first 4 tail chars and look up
* `target - hAfter4 * 31^4 (mod 2^32)`. ~150-300ms per call; we pre-bake
* results into the lookup table at module-load time.
*
* Returns `null` if no solution is found within the search space (very
* unlikely with B64^4 = 16.7M candidates against any 32-bit target).
*/
export function synthesiseCanvasTail(
prefix: string,
targetLen: number,
targetHashHex: string,
): string | null {
if (targetLen <= prefix.length + 8) return null;
const target = parseInt(targetHashHex, 16) >>> 0;
const fillerLen = targetLen - prefix.length - 8;
const body = prefix + buildFiller(fillerLen);
let h = 0;
for (let i = 0; i < body.length; i++) {
h = (h << 5) - h + body.charCodeAt(i);
h |= 0;
}
const tailMap = new Map<number, number>();
for (let i0 = 0; i0 < 64; i0++) {
const h1 = ((0 << 5) - 0 + B64.charCodeAt(i0)) | 0;
for (let i1 = 0; i1 < 64; i1++) {
const h2 = ((h1 << 5) - h1 + B64.charCodeAt(i1)) | 0;
for (let i2 = 0; i2 < 64; i2++) {
const h3 = ((h2 << 5) - h2 + B64.charCodeAt(i2)) | 0;
for (let i3 = 0; i3 < 64; i3++) {
const h4 = ((h3 << 5) - h3 + B64.charCodeAt(i3)) | 0;
const key = h4 >>> 0;
if (!tailMap.has(key)) {
tailMap.set(key, (i0 << 18) | (i1 << 12) | (i2 << 6) | i3);
}
}
}
}
}
for (let i0 = 0; i0 < 64; i0++) {
const h1 = ((h << 5) - h + B64.charCodeAt(i0)) | 0;
for (let i1 = 0; i1 < 64; i1++) {
const h2 = ((h1 << 5) - h1 + B64.charCodeAt(i1)) | 0;
for (let i2 = 0; i2 < 64; i2++) {
const h3 = ((h2 << 5) - h2 + B64.charCodeAt(i2)) | 0;
for (let i3 = 0; i3 < 64; i3++) {
const hAfter4 = ((h3 << 5) - h3 + B64.charCodeAt(i3)) | 0;
// hAfter4 * 31^4 via four shift-and-subtract chains (avoids any
// Math.imul JIT-tier weirdness).
const m1 = ((hAfter4 << 5) - hAfter4) | 0;
const m2 = ((m1 << 5) - m1) | 0;
const m3 = ((m2 << 5) - m2) | 0;
const m4 = ((m3 << 5) - m3) | 0;
const need = (target - m4) >>> 0;
const tail = tailMap.get(need);
if (tail !== undefined) {
const t0 = (tail >>> 18) & 63;
const t1 = (tail >>> 12) & 63;
const t2 = (tail >>> 6) & 63;
const t3 = tail & 63;
return (
B64[i0]! + B64[i1]! + B64[i2]! + B64[i3]! + B64[t0]! + B64[t1]! + B64[t2]! + B64[t3]!
);
}
}
}
}
}
return null;
}
/**
* Audio captures, keyed by profile id. Values mirror
* `packages/profiles/data/<id>/baseline.manifest.json`'s `audio` block.
*
* Off-list profile ids (test fixtures) get the macOS baseline via
* {@link audioCaptureFor}.
*/
export const AUDIO_CAPTURES: Readonly<Record<string, AudioCapture>> = {
"mac-chrome-stable": {
sampleRate: 48000,
audioHash: "124.04347624466754",
sampleValues: [
-0.10808053612709045, -0.3909117877483368, -0.005692681297659874, 0.3892313539981842,
0.1189708411693573, -0.3545846939086914, -0.22215834259986877, 0.28990939259529114,
0.30651888251304626, -0.20068734884262085,
],
},
"mac-chrome-beta": {
sampleRate: 48000,
audioHash: "124.04347624466754",
sampleValues: [
-0.10808053612709045, -0.3909117877483368, -0.005692681297659874, 0.3892313539981842,
0.1189708411693573, -0.3545846939086914, -0.22215834259986877, 0.28990939259529114,
0.30651888251304626, -0.20068734884262085,
],
},
"mac-brave-stable": {
sampleRate: 48000,
audioHash: "124.04347624466754",
sampleValues: [
-0.10808053612709045, -0.3909117877483368, -0.005692681297659874, 0.3892313539981842,
0.1189708411693573, -0.3545846939086914, -0.22215834259986877, 0.28990939259529114,
0.30651888251304626, -0.20068734884262085,
],
},
"mac-m4-chrome-stable": {
sampleRate: 48000,
audioHash: "124.04347624466754",
sampleValues: [
-0.10808053612709045, -0.3909117877483368, -0.005692681297659874, 0.3892313539981842,
0.1189708411693573, -0.3545846939086914, -0.22215834259986877, 0.28990939259529114,
0.30651888251304626, -0.20068734884262085,
],
},
"windows-chrome-stable": {
sampleRate: 48000,
audioHash: "124.04345139075303",
sampleValues: [
-0.1080818846821785, -0.3909122049808502, -0.005695888306945562, 0.38922908902168274,
0.1189703568816185, -0.35458430647850037, -0.22216065227985382, 0.2899079918861389,
0.30651476979255676, -0.20069056749343872,
],
},
"linux-chrome-stable": {
sampleRate: 44100,
audioHash: "124.04347527516074",
sampleValues: [
-0.10808052122592926, -0.3909117579460144, -0.005692707374691963, 0.3892313539981842,
0.1189708411693573, -0.3545847237110138, -0.22215835750102997, 0.28990939259529114,
0.30651888251304626, -0.20068736374378204,
],
},
};
export const CANVAS_CAPTURES: Readonly<Record<string, CanvasCapture>> = {
"mac-chrome-stable": {
consistent: true,
hash: "743CC003",
dataUrlLength: 25858,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 26851,
jpegLowLength: 2347,
},
"mac-chrome-beta": {
consistent: true,
hash: "CC4CD2B7",
dataUrlLength: 25854,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 26851,
jpegLowLength: 2347,
},
"mac-brave-stable": {
consistent: true,
hash: "CC4CD2B7",
dataUrlLength: 25854,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 26851,
jpegLowLength: 2347,
},
"mac-m4-chrome-stable": {
consistent: true,
hash: "96152ABE",
dataUrlLength: 24094,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 25935,
jpegLowLength: 2307,
},
"windows-chrome-stable": {
consistent: true,
hash: "3C2ABBE3",
dataUrlLength: 25246,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 26563,
jpegLowLength: 2327,
},
"linux-chrome-stable": {
consistent: true,
hash: "0AA48148",
dataUrlLength: 24878,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 26367,
jpegLowLength: 2291,
},
};
/** Fallback used for fixture / unrecognised profile ids. */
const DEFAULT_AUDIO: AudioCapture = AUDIO_CAPTURES["mac-chrome-stable"]!;
const DEFAULT_CANVAS: CanvasCapture = CANVAS_CAPTURES["mac-chrome-stable"]!;
export function audioCaptureFor(profileId: string): AudioCapture {
return AUDIO_CAPTURES[profileId] ?? DEFAULT_AUDIO;
}
export function canvasCaptureFor(profileId: string): CanvasCapture {
return CANVAS_CAPTURES[profileId] ?? DEFAULT_CANVAS;
}
/**
* Memoised tail synthesis. The lookup is keyed by the (prefix, length, hash)
* triple — every distinct canvas capture in the catalog gets one hit on
* first build, all subsequent builds for the same profile (or any profile
* sharing the same observable triple, e.g. mac-chrome-beta + mac-brave-stable)
* share the cached tail.
*
* Caching is a correctness measure as well as a performance one: bun's
* test runner has been observed (cf6cdbbb) producing inconsistent
* tail-search results when many bun-test workers exhaust the same JS
* tier-0 budget in parallel. Memoising on the first successful synth
* avoids re-running the search under that condition.
*/
const TAIL_CACHE = new Map<string, string>();
export function canvasTailFor(profileId: string): string {
const cap = canvasCaptureFor(profileId);
const key = `${cap.dataUrlPrefix}|${cap.dataUrlLength}|${cap.hash}`;
const cached = TAIL_CACHE.get(key);
if (cached !== undefined) return cached;
const tail = synthesiseCanvasTail(cap.dataUrlPrefix, cap.dataUrlLength, cap.hash);
if (tail === null) {
// Should be unreachable for any 32-bit target with B64^4 ~16.7M candidates.
// Cache the empty string sentinel so subsequent calls don't retry.
TAIL_CACHE.set(key, "");
return "";
}
TAIL_CACHE.set(key, tail);
return tail;
}
+1
-1
{
"name": "@mochi.js/consistency",
"version": "0.1.2",
"version": "0.1.3",
"description": "The Matrix engine — relational fingerprint locking for mochi. Imported by @mochi.js/core.",

@@ -5,0 +5,0 @@ "license": "MIT",

@@ -11,4 +11,10 @@ # @mochi.js/consistency

**Status:** v0.0.1 claim release. Full implementation lands in phase 0.2 / 0.7.
**Status:** shipping in v0.2. The 40-rule DAG covers UA / UA-CH, navigator, plugins, screen, timing, fonts, MediaDevices, Permissions, WebGL, WebGPU, audio fingerprint (R-047), and canvas fingerprint (R-048 — both fed by precomputed per-profile blobs).
See [PLAN.md §9](https://github.com/0xchasercat/mochi/blob/main/PLAN.md) for the full ruleset.
## Documentation
- Package reference: <https://mochijs.com/docs/api/consistency>
- Concept deep-dive: <https://mochijs.com/docs/concepts/consistency-engine>
- Cookbook: <https://mochijs.com/docs/guides/pick-a-scenario>

@@ -326,2 +326,37 @@ /**

});
// ---- task 0267 rules (R-047 / R-048) — audio + canvas fingerprint ------
it("R-047: audio-fingerprint slot carries sampleRate + audioHash + 10-sample window", () => {
const audio = JSON.parse(macMatrix.uaCh["audio-fingerprint"] ?? "{}") as {
sampleRate: number;
audioHash: string;
sampleValues: number[];
};
// Off-list fixture id falls back to the macOS baseline. The shape must
// be present regardless.
expect(typeof audio.sampleRate).toBe("number");
expect(typeof audio.audioHash).toBe("string");
expect(Array.isArray(audio.sampleValues)).toBe(true);
expect(audio.sampleValues.length).toBe(10);
});
it("R-048: canvas-fingerprint slot carries hash + dataUrlPrefix + dataUrlLength", () => {
const canvas = JSON.parse(macMatrix.uaCh["canvas-fingerprint"] ?? "{}") as {
consistent: boolean;
hash: string;
dataUrlLength: number;
dataUrlPrefix: string;
webpSupport: boolean;
jpegHighLength: number;
jpegLowLength: number;
};
expect(canvas.consistent).toBe(true);
expect(canvas.hash).toMatch(/^[0-9A-F]{8}$/);
expect(canvas.dataUrlPrefix.startsWith("data:image/png;base64,")).toBe(true);
expect(canvas.dataUrlLength).toBeGreaterThan(canvas.dataUrlPrefix.length);
expect(typeof canvas.webpSupport).toBe("boolean");
expect(canvas.jpegHighLength).toBeGreaterThan(0);
expect(canvas.jpegLowLength).toBeGreaterThan(0);
});
});

@@ -19,2 +19,3 @@ /**

import type { Rule } from "../rule";
import { AUDIO_CANVAS_RULES } from "./audioCanvas";
import { EXTRAS_RULES } from "./extras";

@@ -49,2 +50,4 @@ import { GPU_RULES } from "./gpu";

* PLAN.md I-5 / CDP-dispatch leak; task 0250)
* - AUDIO_CANVAS_RULES R-047 + R-048 (audio + canvas fingerprint blobs;
* PLAN.md §9.3 / §9.4; task 0267)
*/

@@ -60,2 +63,3 @@ export const RULES: readonly Rule[] = [

...MOUSE_EVENT_RULES,
...AUDIO_CANVAS_RULES,
];