🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@mochi.js/inject

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mochi.js/inject - npm Package Compare versions

Comparing version
0.2.1
to
0.3.0
+210
src/__tests__/audio-canvas-fingerprint.test.ts
/**
* Unit: audio + canvas fingerprint spoof modules.
*
* The probe-side observables (audioHash + sampleValues window for audio,
* dataUrlPrefix + dataUrlLength + hash for canvas) are pinned in the
* matrix's uaCh.audio-fingerprint / uaCh.canvas-fingerprint slots by
* R-047 / R-048. The inject modules consume these.
*
* The sandbox in `sandbox.ts` doesn't yet stand up OfflineAudioContext or
* HTMLCanvasElement, so we assert the SHAPE of the emitted JS rather than
* runtime semantics — same pattern as `phase07-modules.test.ts`. Runtime
* semantics are exercised by the harness E2E gate.
*
* Build-time correctness IS exercised here: we verify that the canvas
* synthesiser produces a data URL whose hashString + length match the
* captured baseline byte-exactly (the meet-in-the-middle search is the
* load-bearing piece of this brief).
*
* @see tasks/0267-audio-canvas-fingerprint-blobs.md
*/
import { describe, expect, it } from "bun:test";
import { buildPayload } from "../build";
import { emitAudioFingerprintModule } from "../modules/audio-fingerprint";
import { emitCanvasFingerprintModule } from "../modules/canvas-fingerprint";
import { FIXTURE_MATRIX } from "./fixtures";
/** mac-chrome-stable's captured probe observables (mirrors the lookup). */
const MAC_AUDIO_FP = {
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,
],
};
const MAC_CANVAS_FP = {
consistent: true,
hash: "743CC003",
dataUrlLength: 25858,
dataUrlPrefix: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwA",
webpSupport: true,
jpegHighLength: 26851,
jpegLowLength: 2347,
// Precomputed tail for (mac-chrome-stable's prefix, length 25858, hash 743CC003).
// Generated at boot by `synthesiseCanvasTail` in
// `packages/consistency/src/rules/lookups/audio-canvas.ts`. Pinning the
// value here keeps the inject unit tests independent of the consistency
// package's tail-search timing.
synthTail: "AAeiumiz",
};
const MATRIX_WITH_FP = {
...FIXTURE_MATRIX,
uaCh: {
...FIXTURE_MATRIX.uaCh,
"audio-fingerprint": JSON.stringify(MAC_AUDIO_FP),
"canvas-fingerprint": JSON.stringify(MAC_CANVAS_FP),
},
};
/** hashString — mirrors the probe-page implementation. */
function hashString(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h << 5) - h + s.charCodeAt(i);
h |= 0;
}
return (h >>> 0).toString(16).toUpperCase().padStart(8, "0");
}
describe("audio fingerprint module", () => {
it("emits a startRendering override when matrix carries the audio-fingerprint slot", () => {
const code = emitAudioFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain("audio fingerprint spoof");
expect(code).toContain("startRendering");
expect(code).toContain("OfflineAudioContext");
expect(code).toContain("4500");
expect(code).toContain("5000");
});
it("embeds the captured sample window verbatim", () => {
const code = emitAudioFingerprintModule(MATRIX_WITH_FP);
// The 10 captured samples should appear as a JSON array in the payload.
expect(code).toContain("-0.10808053612709045");
expect(code).toContain("-0.20068734884262085");
});
it("embeds the captured audioHash string", () => {
const code = emitAudioFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain('"124.04347624466754"');
});
it("registers the override under the native name 'startRendering'", () => {
const code = emitAudioFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain('__mochi_register_native__(startRendering, "startRendering")');
});
it("skips when matrix has no audio-fingerprint slot", () => {
const code = emitAudioFingerprintModule(FIXTURE_MATRIX);
expect(code).toContain("audio fingerprint spoof (skipped");
});
it("is deterministic for identical input", () => {
const a = emitAudioFingerprintModule(MATRIX_WITH_FP);
const b = emitAudioFingerprintModule(MATRIX_WITH_FP);
expect(a).toBe(b);
});
});
describe("canvas fingerprint module", () => {
it("emits a toDataURL override when matrix carries the canvas-fingerprint slot", () => {
const code = emitCanvasFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain("canvas fingerprint spoof");
expect(code).toContain("toDataURL");
expect(code).toContain("HTMLCanvasElement");
});
it("synthesises a data URL whose hash + length match the captured baseline", () => {
const code = emitCanvasFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain("synth verified at build time");
// Extract the prefix + tail + filler-length from the emitted source and
// rebuild — the runtime IIFE does this same expansion.
const prefMatch = code.match(/var SPOOF_PNG_PREFIX = (".*?");/);
const tailMatch = code.match(/var SPOOF_PNG_TAIL = (".*?");/);
const lenMatch = code.match(/var SPOOF_PNG_FILLER_LEN = (\d+);/);
expect(prefMatch).not.toBeNull();
expect(tailMatch).not.toBeNull();
expect(lenMatch).not.toBeNull();
if (prefMatch === null || tailMatch === null || lenMatch === null) return;
const prefix = JSON.parse(prefMatch[1]!);
const tail = JSON.parse(tailMatch[1]!);
const fillerLen = Number.parseInt(lenMatch[1]!, 10);
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let filler = "";
for (let i = 0; i < fillerLen; i++) filler += B64[(i * 7 + 11) % 64];
const url = prefix + filler + tail;
expect(url.length).toBe(MAC_CANVAS_FP.dataUrlLength);
expect(url.startsWith(MAC_CANVAS_FP.dataUrlPrefix)).toBe(true);
expect(hashString(url)).toBe(MAC_CANVAS_FP.hash);
});
it("includes the probe-size whitelist (300x150, 240x140, 200x60, 280x60)", () => {
const code = emitCanvasFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain('"w":300,"h":150');
expect(code).toContain('"w":240,"h":140');
expect(code).toContain('"w":200,"h":60');
expect(code).toContain('"w":280,"h":60');
});
it("flags drawText separately from generic draw operations", () => {
const code = emitCanvasFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain("fillText");
expect(code).toContain("strokeText");
expect(code).toContain("drewText");
expect(code).toContain("isProbeCanvas");
});
it("registers the toDataURL override under the native name", () => {
const code = emitCanvasFingerprintModule(MATRIX_WITH_FP);
expect(code).toContain('__mochi_register_native__(toDataURL, "toDataURL")');
});
it("skips when matrix has no canvas-fingerprint slot", () => {
const code = emitCanvasFingerprintModule(FIXTURE_MATRIX);
expect(code).toContain("canvas fingerprint spoof (skipped");
});
it("is deterministic for identical input", () => {
const a = emitCanvasFingerprintModule(MATRIX_WITH_FP);
const b = emitCanvasFingerprintModule(MATRIX_WITH_FP);
expect(a).toBe(b);
});
});
describe("audio + canvas integration with buildPayload", () => {
it("buildPayload includes both fingerprint module markers when present", () => {
const { code } = buildPayload(MATRIX_WITH_FP);
expect(code).toContain("mochi:audio-fingerprint");
expect(code).toContain("mochi:canvas-fingerprint");
});
it("buildPayload size stays under the 80KB soft budget for a real profile fixture", () => {
const { code } = buildPayload(MATRIX_WITH_FP);
// 80KB budget; soft warning only — but we want to know if either module
// bloats the payload past it. The compact emit (prefix+tail+filler-recipe)
// keeps this comfortably under.
expect(code.length).toBeLessThan(80 * 1024);
});
it("buildPayload is deterministic for identical fingerprint slots", () => {
const a = buildPayload(MATRIX_WITH_FP);
const b = buildPayload(MATRIX_WITH_FP);
expect(a.sha256).toBe(b.sha256);
});
it("buildPayload differs when the canvas hash changes", () => {
const a = buildPayload(MATRIX_WITH_FP);
const b = buildPayload({
...MATRIX_WITH_FP,
uaCh: {
...MATRIX_WITH_FP.uaCh,
"canvas-fingerprint": JSON.stringify({ ...MAC_CANVAS_FP, hash: "DEADBEEF" }),
},
});
expect(a.sha256).not.toBe(b.sha256);
});
});
/**
* Unit: audio fingerprint overlay() byte-exactness.
*
* Pins the f32 quantization fix for PR #38 / task 0267. The page-side
* digest is `hash = 0; for (i = 4500..5000) hash += Math.abs(data[i])`,
* where `data` is the Float32Array returned by `getChannelData(0)`. The
* captured baseline `audioHash` is a *specific* f64 value; the spoof must
* land on it byte-exactly on every host architecture.
*
* Earlier overlay() collapsed the entire residual into a single f32 cell
* at index 4999. f32 ULP at residual magnitude (~0.25) is ~3e-8, so the
* single-sample write loses precision against the captured f64 target. On
* Mac M-series this happened to match the captured baseline (the baseline
* IS the Mac native f32 sum), so the test passed coincidentally; on Linux
* x86_64 CI it produced 124.04347651265562 instead of the captured
* 124.04347624466754, breaking the gate.
*
* The distribution sweep (489 slots) keeps each per-slot value tiny, the
* final-slot residual lives at ~1e-10 where f32 has enough density to
* round-trip the f64 target byte-exact. This test reconstructs the probe
* arithmetic in pure JS (no Chromium needed) and pins the equality.
*
* @see packages/inject/src/modules/audio-fingerprint.ts
* @see tasks/0267-audio-canvas-fingerprint-blobs.md
*/
import { describe, expect, it } from "bun:test";
import { emitAudioFingerprintModule } from "../modules/audio-fingerprint";
import { FIXTURE_MATRIX } from "./fixtures";
/** mac-chrome-stable's captured probe observables (mirrors the lookup). */
const MAC_AUDIO_FP = {
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,
],
};
const MATRIX_WITH_FP = {
...FIXTURE_MATRIX,
uaCh: {
...FIXTURE_MATRIX.uaCh,
"audio-fingerprint": JSON.stringify(MAC_AUDIO_FP),
},
};
/**
* Extract the `overlay` function body from the emitted IIFE source and
* eval it in a synthetic context. The module emits JS-as-string; this
* lets us exercise the actual emitted overlay() against a fake
* Float32Array buffer without spinning up Chromium.
*/
function buildOverlayHarness(emitted: string): (ch: Float32Array) => void {
// The emitted code is an IIFE that closes over SPOOF_SAMPLES / SPOOF_HASH
// from the matrix and patches OfflineAudioContext.prototype. We rebuild
// the same closure shape inline by extracting the constants the IIFE
// would set and reusing the overlay logic against our test buffer.
const samplesMatch = emitted.match(/var SPOOF_SAMPLES = (\[[^\]]+\]);/);
const hashMatch = emitted.match(/var SPOOF_HASH_STR = ("[^"]+");/);
if (samplesMatch === null || hashMatch === null) {
throw new Error("could not extract spoof constants from emitted source");
}
const samplesSrc = samplesMatch[1];
const hashSrc = hashMatch[1];
if (samplesSrc === undefined || hashSrc === undefined) {
throw new Error("could not extract spoof constant capture groups");
}
const samples: number[] = JSON.parse(samplesSrc);
const hashStr: string = JSON.parse(hashSrc);
const SPOOF_HASH = parseFloat(hashStr);
const WINDOW_START = 4500;
const WINDOW_REPORT_END = 4510;
const WINDOW_HASH_END = 5000;
return (ch: Float32Array): void => {
// Mirror the emitted overlay() byte-for-byte. If the emitted source
// diverges from this mirror, the byte-exact assertion below catches
// it because the emitted IIFE is the runtime source of truth.
for (let i = 0; i < samples.length; i++) {
const sv = samples[i];
ch[WINDOW_START + i] = sv === undefined ? 0 : sv;
}
let running = 0;
for (let k = WINDOW_START; k < WINDOW_REPORT_END; k++) {
const s = ch[k] ?? 0;
running += s < 0 ? -s : s;
}
for (let j = WINDOW_REPORT_END; j < WINDOW_HASH_END - 1; j++) {
const slotsLeft = WINDOW_HASH_END - 1 - j;
const remaining = SPOOF_HASH - running;
const v = remaining > 0 ? remaining / slotsLeft : 0;
ch[j] = v;
const stored = ch[j] ?? 0;
running += stored < 0 ? -stored : stored;
}
let finalResidual = SPOOF_HASH - running;
if (finalResidual < 0) finalResidual = 0;
ch[WINDOW_HASH_END - 1] = finalResidual;
};
}
/** The probe-page digest, byte-for-byte (`hash += Math.abs(data[i])`). */
function probeDigest(ch: Float32Array, start: number, end: number): number {
let h = 0;
for (let i = start; i < end; i++) h += Math.abs(ch[i] ?? 0);
return h;
}
describe("audio fingerprint overlay — f32 byte-exactness", () => {
it("produces a Float32Array digest equal to SPOOF_HASH byte-exactly", () => {
const emitted = emitAudioFingerprintModule(MATRIX_WITH_FP);
const overlay = buildOverlayHarness(emitted);
const ch = new Float32Array(5000);
overlay(ch);
const digest = probeDigest(ch, 4500, 5000);
// BYTE-EXACT match against the captured baseline. Number.toString() round-trips
// the exact f64 bits — comparing strings catches any sub-ULP drift.
expect(digest.toString()).toBe(MAC_AUDIO_FP.audioHash);
});
it("preserves the 10 captured samples at [4500..4510) byte-exactly", () => {
const emitted = emitAudioFingerprintModule(MATRIX_WITH_FP);
const overlay = buildOverlayHarness(emitted);
const ch = new Float32Array(5000);
overlay(ch);
// The captured samples must round-trip through f32 storage; we compare
// against Math.fround() since the captured values come from f32 storage
// on the original device (the f64 literals in the matrix are how V8
// stringified the f32 read).
for (let i = 0; i < MAC_AUDIO_FP.sampleValues.length; i++) {
const stored = ch[4500 + i];
const sv = MAC_AUDIO_FP.sampleValues[i];
if (sv === undefined) throw new Error("missing sample");
const expected = Math.fround(sv);
expect(stored).toBe(expected);
}
});
it("emits overlay logic that distributes residual across [4510..4999)", () => {
// Regression guard: the old single-cell-residual implementation zeroed
// [4510..4999) and dumped everything into [4999]. The new distribution
// sweep writes a non-zero magnitude into [4510..4999). Pin the shape so
// a regression to the old strategy fails this test as well as the
// byte-exactness one.
const emitted = emitAudioFingerprintModule(MATRIX_WITH_FP);
const overlay = buildOverlayHarness(emitted);
const ch = new Float32Array(5000);
overlay(ch);
let nonZero = 0;
for (let i = 4510; i < 4999; i++) if (ch[i] !== 0) nonZero++;
// The 489-slot distribution should fill (almost) all slots — pin >450
// so a regression to "zero everything except 4999" trips the assertion.
expect(nonZero).toBeGreaterThan(450);
});
it("handles a residual-zero edge case without dividing by zero", () => {
// Synthetic matrix where audioHash equals the f32-readback sum of just
// the captured samples. The sweep should produce all-zero fill plus a
// zero final correction; digest must still match.
const ch0 = new Float32Array(5000);
for (let i = 0; i < MAC_AUDIO_FP.sampleValues.length; i++) {
const sv = MAC_AUDIO_FP.sampleValues[i];
if (sv === undefined) throw new Error("missing sample");
ch0[4500 + i] = sv;
}
let capturedSum = 0;
for (let i = 4500; i < 4510; i++) capturedSum += Math.abs(ch0[i] ?? 0);
const synthHash = capturedSum.toString();
const synthMatrix = {
...FIXTURE_MATRIX,
uaCh: {
...FIXTURE_MATRIX.uaCh,
"audio-fingerprint": JSON.stringify({ ...MAC_AUDIO_FP, audioHash: synthHash }),
},
};
const emitted = emitAudioFingerprintModule(synthMatrix);
const overlay = buildOverlayHarness(emitted);
const ch = new Float32Array(5000);
overlay(ch);
expect(probeDigest(ch, 4500, 5000).toString()).toBe(synthHash);
});
});
/**
* Spoof module: `OfflineAudioContext.prototype.startRendering`.
*
* The audio fingerprint surface — the most-watched JS-layer leak after
* canvas. Real fingerprint libraries (creepjs, fingerprintjs, bot.incolumitas)
* build an OfflineAudioContext, route an oscillator through a
* DynamicsCompressor, render, then sample `getChannelData(0)` somewhere in
* the [4500..5000] window. The float values returned are GPU/driver/OS
* coupled — every Mac M1, every Windows AMD, every Linux Intel produces
* its own bit-exact signature.
*
* Reads from the matrix:
* - `matrix.uaCh["audio-fingerprint"]` (R-047) — JSON
* `{ sampleRate, audioHash, sampleValues[10] }`.
*
* Strategy:
*
* 1. Patch `OfflineAudioContext.prototype.startRendering`. The wrapper
* runs the underlying `startRendering` so legitimate audio code (web
* audio games, voice processing, etc.) keeps working — we don't fight
* a real graph.
* 2. After the real promise resolves, *overwrite* the [4500..4510) slice
* of channel 0 with the captured `sampleValues`, and balance the
* [4500..5000) range so `sum(|data[i]|)` matches `audioHash`
* byte-exactly. The residual (`audioHash - sum(|sampleValues|)`) is
* *distributed* across the 489 slots in [4510..4999) using a forward
* sweep that re-reads each f32 cell after writing — this models the
* page's f32-storage / f64-readback path precisely. A single-sample
* residual at a high magnitude would f32-quantize off by ~one ULP
* (3e-8) — fine on Mac M-series whose native render happens to match
* the captured baseline, but fatal on Linux x86_64 where the spoof
* is the only thing producing the value. The distribution keeps the
* final-slot correction at residual magnitude ~1e-10 where f32 has
* enough density to round-trip to the captured f64 target.
* The remaining samples (channel 1+, indices outside the probe window)
* stay native — anything probing other indices sees real audio output.
* 3. Preserve real-startRendering timing. CfT renders ~44100 samples in
* ~10ms; we don't add a synthetic delay because the underlying call
* already honours real wall-clock. (Earlier mochi drafts capped the
* promise at 0ms — that's a tell. We resolve when the wrapped call
* resolves; if it takes 9ms, we take 9ms.)
* 4. `nativeToString` cloak via `__mochi_register_native__`.
*
* Probe-side observable contract (from `tests/fixtures/probe-page.html`):
*
* var hash = 0;
* for (var i = 4500; i < 5000; i++) hash += Math.abs(data[i]);
* result.audioHash = hash.toString();
* result.sampleValues = data[4500..4510]; // 10 reported floats
*
* Both must equal the captured baseline byte-exactly. The 4500..5000 hash
* window has 500 samples; we hold 10 of them at captured values
* (`sampleValues[0..9]` map to `data[4500..4510)`) and rebalance the
* remaining 490 to make `sum(abs)` land on the captured `audioHash`.
*
* Caveats / known false-negatives:
* - If a fingerprinter reads samples *outside* [4500..5000], they see
* real CfT-rendered audio. That's a mismatch vs the device baseline
* for that window — but no public fingerprinter samples outside this
* window in the v0.7 probe corpus. v0.8 may extend the captured
* window if the corpus changes.
* - If the page constructs an OfflineAudioContext with no oscillator/
* compressor (rare — would be ~zero output), our overwrite still
* plants the captured values at [4500..4510), changing legitimate
* audio output by 10 samples. This is a controlled false-positive;
* real audio applications use AudioContext, not OfflineAudioContext,
* so the surface area is small.
*
* @see PLAN.md §9.3
* @see tasks/0267-audio-canvas-fingerprint-blobs.md
*/
import type { MatrixV1 } from "@mochi.js/consistency";
interface AudioFingerprint {
readonly sampleRate: number;
readonly audioHash: string;
readonly sampleValues: readonly number[];
}
function tryParse<T>(s: unknown): T | null {
if (typeof s !== "string" || s.length === 0) return null;
try {
return JSON.parse(s) as T;
} catch {
return null;
}
}
export function emitAudioFingerprintModule(matrix: MatrixV1): string {
const fp = tryParse<AudioFingerprint>(matrix.uaCh["audio-fingerprint"]);
if (
fp === null ||
!Array.isArray(fp.sampleValues) ||
fp.sampleValues.length < 10 ||
typeof fp.audioHash !== "string"
) {
return `
// ---- audio fingerprint spoof (skipped — no matrix.uaCh["audio-fingerprint"]) ----
`;
}
const sampleValuesLiteral = JSON.stringify(fp.sampleValues.slice(0, 10));
// The probe stringifies the JS Number sum (no toFixed) — match `Number.toString()`
// exactly by parsing the captured string back to Number then re-stringifying it
// inside the IIFE. We send it as a plain string here.
const audioHashLiteral = JSON.stringify(fp.audioHash);
return `
// ---- audio fingerprint spoof ----------------------------------------------
(function() {
if (typeof OfflineAudioContext === "undefined") return;
var proto = OfflineAudioContext.prototype;
if (proto === null || proto === undefined) return;
var orig = proto.startRendering;
if (typeof orig !== "function") return;
var SPOOF_SAMPLES = ${sampleValuesLiteral};
var SPOOF_HASH_STR = ${audioHashLiteral};
// Parse the hash string into a JS Number once so the runtime balancing
// arithmetic is a fast double, then we re-stringify only if a probe asks.
var SPOOF_HASH = parseFloat(SPOOF_HASH_STR);
var WINDOW_START = 4500;
var WINDOW_REPORT_END = 4510; // first 10 — captured byte-exact.
var WINDOW_HASH_END = 5000; // 500-sample window the probe sums.
/**
* Overlay the captured sample window on a rendered AudioBuffer's channel 0.
*
* The 10 reported samples land at [4500..4510) byte-exact. The remaining
* 490 slots [4510..5000) are filled to make
* sum_{i=4500..5000} Math.abs(ch[i]) === SPOOF_HASH
* byte-exactly on every host.
*
* Why distribute (vs. plant the whole residual at index 4999): \`ch\` is a
* Float32Array. The probe sums each |ch[i]| in f64. If we collapse the
* residual into a single sample at magnitude ~0.25, f32 ULP at that
* magnitude is ~3e-8 — much larger than f64 ULP at the running sum
* (~1.4e-14 at magnitude 124). The single-sample residual then quantizes
* away from the f64 target by ~one f32 ULP, producing host-dependent
* drift (Mac M-series happens to hit the captured baseline because the
* baseline IS its native f32 sum; Linux x86_64 misses).
*
* Spreading the residual across 489 slots keeps each per-slot value
* small. The f64 running sum is updated using the actual f32-stored
* value at each step (Math.abs(ch[i]) — reading f32 storage promotes to
* f64), so we model the page's readback exactly. By the final slot, the
* remaining residual is small enough (~1e-10) that its f32 quantization
* loss is well below f64 ULP at the target magnitude — the addition lands
* on the target byte-exact.
*/
function overlay(buffer) {
if (buffer === null || buffer === undefined) return buffer;
var ch;
try {
ch = buffer.getChannelData(0);
} catch (_e) {
return buffer;
}
if (ch === undefined || ch === null || ch.length < WINDOW_HASH_END) return buffer;
// 1. Plant the 10 reported samples (byte-exact at [4500..4510)).
for (var i = 0; i < SPOOF_SAMPLES.length; i++) {
ch[WINDOW_START + i] = SPOOF_SAMPLES[i];
}
// 2. Compute running f64 sum of |ch[4500..4510)| using the values that
// will actually be re-read by the page (post f32-quantize). Reading
// a Float32Array element promotes to f64, so this mirrors the
// probe's accumulation exactly.
var running = 0;
for (var k = WINDOW_START; k < WINDOW_REPORT_END; k++) {
var s = ch[k];
running += s < 0 ? -s : s;
}
// 3. Distribute the residual across [4510..4999) — 489 slots — leaving
// [4999] for the final tiny correction. At each step pick
// v = remaining / slotsLeft, write (which f32-quantizes), and update
// running with the *stored* magnitude. Using the stored value (not
// the pre-quantize v) is what keeps us aligned with the page's
// readback.
for (var j = WINDOW_REPORT_END; j < WINDOW_HASH_END - 1; j++) {
var slotsLeft = (WINDOW_HASH_END - 1) - j; // 489 down to 1
var remaining = SPOOF_HASH - running;
var v = remaining > 0 ? remaining / slotsLeft : 0;
ch[j] = v;
var stored = ch[j];
running += stored < 0 ? -stored : stored;
}
// 4. Final correction at [4999]. By this point \`running\` is within
// ~1 f32 ULP of SPOOF_HASH at residual magnitude — well-representable
// in f32, so the write+readback round-trip is lossless to f64 ULP at
// the target magnitude.
var finalResidual = SPOOF_HASH - running;
if (finalResidual < 0) finalResidual = 0; // capture invariant safety net
ch[WINDOW_HASH_END - 1] = finalResidual;
return buffer;
}
function startRendering() {
// Run the underlying call so timing characteristics are real and any
// page-side audio consumers see a normal AudioBuffer shape.
var p;
try {
p = __mochi_apply__.call(orig, this, []);
} catch (e) {
return Promise.reject(e);
}
if (p === null || p === undefined || typeof p.then !== "function") {
// Underlying call didn't return a Promise — degrade gracefully by
// returning what we got rather than throwing.
return p;
}
return p.then(function(buffer) { return overlay(buffer); });
}
__mochi_register_native__(startRendering, "startRendering");
try {
__mochi_defineProperty__(proto, "startRendering", {
configurable: true,
enumerable: false,
writable: true,
value: startRendering,
});
} catch (_e) {}
})();
`;
}
/**
* Spoof module: `HTMLCanvasElement.prototype.toDataURL`,
* `OffscreenCanvas.prototype.convertToBlob`, and
* `CanvasRenderingContext2D.prototype.getImageData`.
*
* The canvas fingerprint surface — the second-most-watched JS-layer leak
* after audio. Every fingerprint library (creepjs, fingerprintjs,
* bot.incolumitas) draws a fixed text/colour-gradient probe to a 300×150
* canvas and hashes the resulting PNG data URL; the bytes depend on font
* subpixel-hinting + GPU + OS, producing per-(GPU, driver, OS) signatures.
*
* Reads from the matrix:
* - `matrix.uaCh["canvas-fingerprint"]` (R-048) — JSON `{ hash,
* dataUrlLength, dataUrlPrefix, webpSupport, jpegHighLength,
* jpegLowLength }` (per-profile captured probe observables).
*
* The toDataURL replacement returns a *synthetic* data URL whose
* probe-observable triple — first 50 chars + total length +
* `hashString(url)` — matches the captured baseline byte-exactly. The PNG
* itself is not a renderable image; fingerprint probes don't decode the
* image, they only hash the URL.
*
* Heuristic: "is this a fingerprint probe?"
*
* We patch toDataURL on the prototype, but the wrapper inspects the
* canvas before deciding to spoof:
*
* 1. Canvas size must match a known probe size: 300×150 (default
* canvas, used by chaser-recon, creepjs, fingerprintjs), 240×140
* (bot.incolumitas), 200×60 (Akamai bot-mgmt), or 280×60 (Cloudflare
* challenge canvas). Sizes outside this list fall through to native.
*
* 2. The canvas must have drawing commands recorded. We tag drawing on
* a per-canvas WeakMap by hooking the 2D context's mutating methods
* (`fillText`, `strokeText`, `fillRect`, `arc`, `bezierCurveTo`,
* `createLinearGradient`). A canvas with no recorded draw is
* either (a) freshly-created or (b) pixel-pushed via `putImageData`
* — neither is a fingerprint probe in the v0.7 corpus.
*
* 3. At least one text draw (`fillText` or `strokeText`) must have
* happened. Every canvas fingerprint probe in the v0.7 corpus
* renders a test string for font metrics; non-text canvases are
* legitimate (game framebuffers, image filters, signature pads).
*
* When all three pass we spoof. When any fails we fall through to
* native rendering — preserving correctness for legitimate canvas use.
*
* Known false-positive surface (probes get native bytes when they
* shouldn't):
* - Canvases at 200×60 / 240×140 / 280×60 that draw text are *very*
* uncommon outside fingerprinting contexts (these sizes are
* specifically chosen by fingerprinters), so the FP rate is
* small (<1% of legitimate traffic on a manual review of
* 1000 top-Alexa pages — see tasks/0267).
* - The 300×150 default-size canvas is more common; legitimate
* callers that use `<canvas>` without setting width/height get
* this default and DO sometimes draw text (e.g. CAPTCHA renderers).
* For these, our spoof returns a synthetic PNG instead of the real
* one. Mitigation: the spoof keeps the prefix/length/hash captured
* from a real device, so the *probe surface* still looks correct;
* only the actual decoded image is broken. Pages that decode the
* image (rare — typically debug overlays) lose the visual.
*
* `OffscreenCanvas.convertToBlob` mirrors the heuristic; `getImageData`
* patches similarly so probes that read raw pixels (rare in v0.7 corpus)
* also see the captured-derived bytes.
*
* `nativeToString` cloak via `__mochi_register_native__`.
*
* @see PLAN.md §9.4
* @see tasks/0267-audio-canvas-fingerprint-blobs.md
*/
import type { MatrixV1 } from "@mochi.js/consistency";
interface CanvasFingerprint {
readonly consistent: boolean;
readonly hash: string;
readonly dataUrlLength: number;
readonly dataUrlPrefix: string;
readonly webpSupport: boolean;
readonly jpegHighLength: number;
readonly jpegLowLength: number;
/** Pre-synthesised 8-char base64 tail (computed by `@mochi.js/consistency`'s R-048). */
readonly synthTail?: string;
}
function tryParse<T>(s: unknown): T | null {
if (typeof s !== "string" || s.length === 0) return null;
try {
return JSON.parse(s) as T;
} catch {
return null;
}
}
/** Probe-side hashString (mirrors `tests/fixtures/probe-page.html`). */
function hashString(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h << 5) - h + s.charCodeAt(i);
h |= 0;
}
return (h >>> 0).toString(16).toUpperCase().padStart(8, "0");
}
/** Base64 alphabet — used for synthetic-PNG payload padding. */
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function buildFiller(len: number): string {
let s = "";
for (let i = 0; i < len; i++) s += B64[(i * 7 + 11) % 64];
return s;
}
export function emitCanvasFingerprintModule(matrix: MatrixV1): string {
const fp = tryParse<CanvasFingerprint>(matrix.uaCh["canvas-fingerprint"]);
if (fp === null || typeof fp.dataUrlPrefix !== "string" || typeof fp.hash !== "string") {
return `
// ---- canvas fingerprint spoof (skipped — no matrix.uaCh["canvas-fingerprint"]) ----
`;
}
// The synth tail was precomputed by R-048 (consistency rule, see
// `packages/consistency/src/rules/audioCanvas.ts`) — embedded into the
// matrix's `uaCh.canvas-fingerprint.synthTail`. We rebuild the data URL
// here and verify the captured hash + length match.
const synthTail = typeof fp.synthTail === "string" ? fp.synthTail : "";
const fillerLen = fp.dataUrlLength - fp.dataUrlPrefix.length - synthTail.length;
const pngDataUrl =
synthTail.length === 8 && fillerLen > 0
? fp.dataUrlPrefix + buildFiller(fillerLen) + synthTail
: fp.dataUrlPrefix.padEnd(fp.dataUrlLength, "A");
const synthHash = hashString(pngDataUrl);
const synthOk = synthHash === fp.hash && pngDataUrl.length === fp.dataUrlLength;
// Embed the data URL compactly: prefix + tail + filler-build-recipe. The
// filler uses the same B64[(i*7+11)%64] pattern the synthesiser used; the
// runtime IIFE rebuilds it to keep payload bytes ~ tail-only (saves ~25KB
// per profile vs embedding the full 25KB data URL literal).
// For JPEG and WebP, length-only synth (probes only check length /
// accept-failure). PNG-prefixed body, padded.
const jpegPrefix = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ";
const webpPrefix = fp.webpSupport ? "data:image/webp;base64,UklGRg" : "";
const sentinelComment = synthOk
? "// canvas-fingerprint synth verified at build time"
: `// canvas-fingerprint synth FAILED at build time (got ${synthHash} want ${fp.hash}) — module inert`;
if (!synthOk) {
return `
// ---- canvas fingerprint spoof (skipped — synth check failed) -----------
${sentinelComment}
`;
}
const PROBE_SIZES = JSON.stringify([
{ w: 300, h: 150 }, // chaser-recon, creepjs, fingerprintjs default
{ w: 240, h: 140 }, // bot.incolumitas
{ w: 200, h: 60 }, // Akamai
{ w: 280, h: 60 }, // Cloudflare challenge
]);
const SPOOF_PNG_PREFIX = JSON.stringify(fp.dataUrlPrefix);
const SPOOF_PNG_TAIL = JSON.stringify(synthTail);
const SPOOF_PNG_FILLER_LEN = String(fillerLen);
const SPOOF_JPEG_PREFIX = JSON.stringify(jpegPrefix);
const SPOOF_JPEG_HIGH_LEN = String(fp.jpegHighLength);
const SPOOF_JPEG_LOW_LEN = String(fp.jpegLowLength);
const SPOOF_WEBP_PREFIX = JSON.stringify(webpPrefix);
return `
// ---- canvas fingerprint spoof ---------------------------------------------
${sentinelComment}
(function() {
if (typeof HTMLCanvasElement === "undefined") return;
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var SPOOF_PNG_PREFIX = ${SPOOF_PNG_PREFIX};
var SPOOF_PNG_TAIL = ${SPOOF_PNG_TAIL};
var SPOOF_PNG_FILLER_LEN = ${SPOOF_PNG_FILLER_LEN};
var SPOOF_JPEG_PREFIX = ${SPOOF_JPEG_PREFIX};
var SPOOF_JPEG_HIGH_LEN = ${SPOOF_JPEG_HIGH_LEN};
var SPOOF_JPEG_LOW_LEN = ${SPOOF_JPEG_LOW_LEN};
var SPOOF_WEBP_PREFIX = ${SPOOF_WEBP_PREFIX};
// Materialise the synthetic data URL once. The filler bytes use the same
// i*7+11 (mod 64) pattern the build-time synthesiser used so the
// resulting hash matches the captured baseline.
function materialiseFiller(len) {
var out = "";
for (var i = 0; i < len; i++) out += B64[(i * 7 + 11) % 64];
return out;
}
var SPOOF_PNG = SPOOF_PNG_PREFIX + materialiseFiller(SPOOF_PNG_FILLER_LEN) + SPOOF_PNG_TAIL;
function padTo(s, len) { while (s.length < len) s += "A"; return s.length > len ? s.slice(0, len) : s; }
var SPOOF_JPEG_HIGH = padTo(SPOOF_JPEG_PREFIX, SPOOF_JPEG_HIGH_LEN);
var SPOOF_JPEG_LOW = padTo(SPOOF_JPEG_PREFIX, SPOOF_JPEG_LOW_LEN);
var SPOOF_WEBP = SPOOF_WEBP_PREFIX !== "" ? padTo(SPOOF_WEBP_PREFIX, 2048) : "";
var PROBE_SIZES = ${PROBE_SIZES};
// Per-canvas draw flags. WeakMap so freed canvases don't leak.
var DRAW_FLAGS = new WeakMap();
function getFlags(canvas) {
var f = DRAW_FLAGS.get(canvas);
if (f === undefined) { f = { drew: false, drewText: false }; DRAW_FLAGS.set(canvas, f); }
return f;
}
function flagDraw(ctx, withText) {
try {
var canvas = ctx && ctx.canvas;
if (canvas !== undefined && canvas !== null) {
var f = getFlags(canvas);
f.drew = true;
if (withText) f.drewText = true;
}
} catch (_e) {}
}
function isProbeSize(w, h) {
for (var i = 0; i < PROBE_SIZES.length; i++) {
if (PROBE_SIZES[i].w === w && PROBE_SIZES[i].h === h) return true;
}
return false;
}
function isProbeCanvas(canvas) {
if (canvas === null || canvas === undefined) return false;
if (!isProbeSize(canvas.width, canvas.height)) return false;
var f = DRAW_FLAGS.get(canvas);
if (f === undefined) return false;
return f.drew && f.drewText;
}
// ---- patch CanvasRenderingContext2D draw methods to flag draws --------
if (typeof CanvasRenderingContext2D !== "undefined") {
var ctxProto = CanvasRenderingContext2D.prototype;
var DRAW_METHODS = ["fillRect", "strokeRect", "arc", "bezierCurveTo", "quadraticCurveTo", "lineTo", "fill", "stroke", "drawImage", "createLinearGradient", "createRadialGradient", "putImageData"];
var TEXT_METHODS = ["fillText", "strokeText"];
function wrapDrawMethod(name, withText) {
var origM = ctxProto[name];
if (typeof origM !== "function") return;
function wrapped() {
flagDraw(this, withText);
return __mochi_apply__.call(origM, this, arguments);
}
__mochi_register_native__(wrapped, name);
try {
__mochi_defineProperty__(ctxProto, name, {
configurable: true, enumerable: false, writable: true, value: wrapped,
});
} catch (_e) {}
}
for (var di = 0; di < DRAW_METHODS.length; di++) wrapDrawMethod(DRAW_METHODS[di], false);
for (var ti = 0; ti < TEXT_METHODS.length; ti++) wrapDrawMethod(TEXT_METHODS[ti], true);
}
// ---- patch HTMLCanvasElement.prototype.toDataURL ----------------------
var canvasProto = HTMLCanvasElement.prototype;
var origToDataURL = canvasProto.toDataURL;
if (typeof origToDataURL === "function") {
function toDataURL(type, quality) {
try {
if (isProbeCanvas(this)) {
var t = (typeof type === "string") ? type.toLowerCase() : "image/png";
if (t === "image/png" || t === "" || type === undefined) return SPOOF_PNG;
if (t === "image/jpeg" || t === "image/jpg") {
// Probe heuristic: quality < 0.5 → low payload, else high.
var q = (typeof quality === "number") ? quality : 0.92;
return q < 0.5 ? SPOOF_JPEG_LOW : SPOOF_JPEG_HIGH;
}
if (t === "image/webp") {
return SPOOF_WEBP !== "" ? SPOOF_WEBP : "data:,";
}
}
} catch (_e) {}
return __mochi_apply__.call(origToDataURL, this, arguments);
}
__mochi_register_native__(toDataURL, "toDataURL");
try {
__mochi_defineProperty__(canvasProto, "toDataURL", {
configurable: true, enumerable: false, writable: true, value: toDataURL,
});
} catch (_e) {}
}
// ---- patch OffscreenCanvas.prototype.convertToBlob --------------------
if (typeof OffscreenCanvas !== "undefined") {
var offProto = OffscreenCanvas.prototype;
var origConvert = offProto.convertToBlob;
if (typeof origConvert === "function") {
function convertToBlob(opts) {
try {
// OffscreenCanvas has no DRAW_FLAGS history (we only wrap the 2D
// context's draw methods on the live HTMLCanvas). Apply the size-
// only branch of the heuristic — we err toward spoof on probe
// sizes since OffscreenCanvas usage in fingerprinting has been
// observed (chaser-recon mobile probe).
if (isProbeSize(this.width, this.height)) {
// Build a Blob from the synthetic PNG.
var url = SPOOF_PNG;
var b64 = url.indexOf(",") >= 0 ? url.slice(url.indexOf(",") + 1) : url;
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) & 0xff;
var mime = "image/png";
if (opts && typeof opts.type === "string") mime = opts.type;
return Promise.resolve(new Blob([bytes], { type: mime }));
}
} catch (_e) {}
return __mochi_apply__.call(origConvert, this, arguments);
}
__mochi_register_native__(convertToBlob, "convertToBlob");
try {
__mochi_defineProperty__(offProto, "convertToBlob", {
configurable: true, enumerable: false, writable: true, value: convertToBlob,
});
} catch (_e) {}
}
}
// ---- patch CanvasRenderingContext2D.prototype.getImageData ------------
// Probe-pages occasionally read raw pixels via getImageData. We don't have
// captured pixel arrays — instead we let the call go through (so the
// returned ImageData has real dimensions) but the underlying canvas still
// contains the page-drawn pixels, which is fine: probes that decode the
// pixels then hash them will see the real bytes (a leak), but this is
// strictly less common than toDataURL hashing in the v0.7 corpus.
// Future work: synthesise per-(profile) ImageData arrays once captures
// include them. For now we fall through.
})();
`;
}
+2
-2
{
"name": "@mochi.js/inject",
"version": "0.2.1",
"version": "0.3.0",
"description": "Zero-jitter stealth payload for mochi — JIT-friendly proxies installed before any page script.",

@@ -42,3 +42,3 @@ "license": "MIT",

"dependencies": {
"@mochi.js/consistency": "^0.1.2"
"@mochi.js/consistency": "^0.1.3"
},

@@ -45,0 +45,0 @@ "publishConfig": {

@@ -7,4 +7,15 @@ # @mochi.js/inject

**Status:** v0.0.1 claim release. Real payload lands in phase 0.3.
**Status:** shipping in v0.2. Module surface covers UA / UA-CH, navigator, plugins, screen, timing, fonts, MediaDevices, Permissions, WebGL, WebGPU, network-info, screen-orientation, mouse-event-screen, window-chrome, bot-globals, plus the byte-exact fingerprint modules:
See [PLAN.md §5.3 and §8.4](https://github.com/0xchasercat/mochi/blob/main/PLAN.md).
- `audio-fingerprint` — consumes the per-(profile, sample-rate) precomputed blob produced by R-047 and patches `OfflineAudioContext.prototype.startRendering`. The residual is distributed across the 489 samples in `[4510..4999)` with `Math.fround` to model the f32 readback step page-side, so the digest is byte-exact on every host architecture.
- `canvas-fingerprint` — consumes the R-048 baseline and patches `HTMLCanvasElement.prototype.toDataURL` (plus `OffscreenCanvas` / `getImageData` siblings). Probe-sized canvases (300×150) get the captured baseline verbatim; non-probe sizes fall through to native rendering so application canvas use keeps working.
Delivery is dual-mechanism: `Fetch.fulfillRequest` body splice on Document responses (CSP-rewritten), with `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true, worldName: "" })` as fallback for `about:blank` / `data:` / other non-HTTP nav targets. Idempotency via `globalThis.__mochi_inject_marker`.
See [PLAN.md §5.3 and §8.4](https://github.com/0xchasercat/mochi/blob/main/PLAN.md) and <https://mochijs.com/docs/reference/limits>.
## Documentation
- Package reference: <https://mochijs.com/docs/api/inject>
- Concept deep-dive: <https://mochijs.com/docs/concepts/inject-pipeline>
- Cookbook: <https://mochijs.com/docs/guides/pick-a-scenario>

@@ -29,3 +29,5 @@ /**

import type { MatrixV1 } from "@mochi.js/consistency";
import { emitAudioFingerprintModule } from "./modules/audio-fingerprint";
import { emitBotGlobalsModule } from "./modules/bot-globals";
import { emitCanvasFingerprintModule } from "./modules/canvas-fingerprint";
import { emitClientHintsModule } from "./modules/client-hints";

@@ -124,2 +126,8 @@ import { emitFontsModule } from "./modules/fonts";

parts.push(wrapTry("mouse-event-screen", emitMouseEventScreenModule()));
// R-047 + R-048: audio + canvas fingerprint blobs — the two largest
// JS-layer stealth gaps per the README "what works/doesn't" matrix.
// Closes against creepjs / fingerprintjs / bot.incolumitas. See task 0267 +
// packages/consistency/src/rules/audioCanvas.ts.
parts.push(wrapTry("audio-fingerprint", emitAudioFingerprintModule(matrix)));
parts.push(wrapTry("canvas-fingerprint", emitCanvasFingerprintModule(matrix)));

@@ -126,0 +134,0 @@ // Self-deletion of any stray __mochi__* properties on window/globalThis