@mochi.js/inject
Advanced tools
| /** | ||
| * 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": { |
+13
-2
@@ -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> |
+8
-0
@@ -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 |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
179946
31.5%33
13.79%4189
27.25%21
110%15
15.38%Updated