diff --git a/.gitignore b/.gitignore index bc75c3dd..67d247c5 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,9 @@ schem # pydeps-style dependency graph dumps from local analysis runs. deps.txt + +# Node modules vendored under decnet/canary/ for the obfuscator helper. +# The package.json is the source of truth; modules are reinstalled at +# build/deploy time. +node_modules/ +package-lock.json diff --git a/decnet/canary/_obfuscate_helper.js b/decnet/canary/_obfuscate_helper.js new file mode 100644 index 00000000..a1dbc067 --- /dev/null +++ b/decnet/canary/_obfuscate_helper.js @@ -0,0 +1,18 @@ +// Node helper invoked by decnet.canary.obfuscator. +// Reads {code, options} JSON from stdin, writes obfuscated JS to stdout. +// Kept dependency-light on purpose: only javascript-obfuscator. +const JsObf = require('javascript-obfuscator'); + +let raw = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + try { + const { code, options } = JSON.parse(raw); + const result = JsObf.obfuscate(code, options || {}); + process.stdout.write(result.getObfuscatedCode()); + } catch (e) { + process.stderr.write(String(e && e.stack || e)); + process.exit(2); + } +}); diff --git a/decnet/canary/fingerprint_payload.js b/decnet/canary/fingerprint_payload.js new file mode 100644 index 00000000..152c9cdf --- /dev/null +++ b/decnet/canary/fingerprint_payload.js @@ -0,0 +1,289 @@ +// Canary fingerprint payload — the JS that runs inside an opened HTML/SVG +// canary, harvests browser primitives, and beacons the result back to the +// canary worker. Ported from canary-self-test.html with the rendering UI +// stripped out. +// +// Two placeholders are substituted by the Python builder BEFORE +// javascript-obfuscator runs: +// +// {{BEACON_URL}} → full URL to /c/ (no trailing slash) +// {{MINT_UUID}} → per-mint UUID, baked into the string-array post-obf +// +// Beacon strategy (MVP): a bare GET pixel for "I was opened" reliability, +// then a fingerprint payload sent as a base64-URL query param on a second +// GET so the existing worker records the hit even before step-4 POST +// support lands. Both fail-open: any error short-circuits to next step. + +(async function () { + var BEACON_URL = "{{BEACON_URL}}"; + var MINT_UUID = "{{MINT_UUID}}"; + var fp = { mint: MINT_UUID }; + + function fire(url) { + try { + var img = new Image(); + img.src = url; + } catch (e) { /* swallow */ } + } + + // 1) bare-open beacon — fires regardless of whether the rest succeeds + fire(BEACON_URL + "?o=1"); + + function sha256(str) { + var buf = new TextEncoder().encode(str); + return crypto.subtle.digest("SHA-256", buf).then(function (h) { + return Array.from(new Uint8Array(h)) + .map(function (b) { return b.toString(16).padStart(2, "0"); }) + .join(""); + }); + } + + // navigator + try { + fp.nav = { + ua: navigator.userAgent, + pl: navigator.platform, + lg: navigator.language, + lgs: (navigator.languages || []).join(","), + ck: navigator.cookieEnabled, + dnt: navigator.doNotTrack, + hc: navigator.hardwareConcurrency, + dm: navigator.deviceMemory || null, + tp: navigator.maxTouchPoints, + wd: navigator.webdriver === true, + pdf: navigator.pdfViewerEnabled || null, + }; + } catch (e) { fp.nav = { err: String(e) }; } + + // screen + try { + fp.scr = { + w: screen.width, h: screen.height, + aw: screen.availWidth, ah: screen.availHeight, + cd: screen.colorDepth, pd: screen.pixelDepth, + dpr: window.devicePixelRatio, + iw: window.innerWidth, ih: window.innerHeight, + or: (screen.orientation && screen.orientation.type) || null, + }; + } catch (e) { fp.scr = { err: String(e) }; } + + // tz / locale + try { + var dtf = Intl.DateTimeFormat().resolvedOptions(); + fp.tz = { + z: dtf.timeZone, lc: dtf.locale, + ca: dtf.calendar, ns: dtf.numberingSystem, + off: new Date().getTimezoneOffset(), + }; + } catch (e) { fp.tz = { err: String(e) }; } + + // connection + try { + var c = navigator.connection; + fp.cn = c ? { + t: c.effectiveType, dl: c.downlink, rtt: c.rtt, sd: c.saveData, + } : null; + } catch (e) { fp.cn = { err: String(e) }; } + + // canvas + try { + var cv = document.createElement("canvas"); + cv.width = 280; cv.height = 60; + var ctx = cv.getContext("2d"); + ctx.textBaseline = "top"; + ctx.font = "14px Arial"; + ctx.fillStyle = "#f60"; + ctx.fillRect(125, 1, 62, 20); + ctx.fillStyle = "#069"; + ctx.fillText("c-" + String.fromCharCode(0x1f600), 2, 15); + ctx.fillStyle = "rgba(102,204,0,0.7)"; + ctx.fillText("c-" + String.fromCharCode(0x1f600), 4, 17); + var dataURL = cv.toDataURL(); + fp.cv = { h: await sha256(dataURL), n: dataURL.length }; + } catch (e) { fp.cv = { err: String(e) }; } + + // webgl + try { + var gc = document.createElement("canvas"); + var gl = gc.getContext("webgl") || gc.getContext("experimental-webgl"); + if (gl) { + var ext = gl.getExtension("WEBGL_debug_renderer_info"); + fp.gl = { + v: gl.getParameter(gl.VENDOR), + r: gl.getParameter(gl.RENDERER), + ver: gl.getParameter(gl.VERSION), + sl: gl.getParameter(gl.SHADING_LANGUAGE_VERSION), + uv: ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : null, + ur: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null, + }; + } else { fp.gl = { err: "unavailable" }; } + } catch (e) { fp.gl = { err: String(e) }; } + + // audio + try { + var ACtx = window.OfflineAudioContext || window.webkitOfflineAudioContext; + if (ACtx) { + var actx = new ACtx(1, 44100, 44100); + var osc = actx.createOscillator(); + var cmp = actx.createDynamicsCompressor(); + osc.type = "triangle"; osc.frequency.value = 10000; + cmp.threshold.value = -50; cmp.knee.value = 40; + cmp.ratio.value = 12; cmp.attack.value = 0; cmp.release.value = 0.25; + osc.connect(cmp); cmp.connect(actx.destination); + osc.start(0); + var buf = await actx.startRendering(); + var data = buf.getChannelData(0).slice(4500, 5000); + var sum = 0; + for (var i = 0; i < data.length; i++) sum += Math.abs(data[i]); + fp.au = { h: await sha256(sum.toString()), s: sum.toFixed(8) }; + } else { fp.au = { err: "unavailable" }; } + } catch (e) { fp.au = { err: String(e) }; } + + // fonts + try { + var bases = ["monospace", "sans-serif", "serif"]; + var tests = [ + "Arial", "Helvetica", "Times New Roman", "Courier New", "Verdana", + "Georgia", "Trebuchet MS", "Comic Sans MS", "Impact", + "Calibri", "Cambria", "Consolas", "Segoe UI", "Tahoma", + "JetBrains Mono", "Fira Code", "Cascadia Code", "SF Mono", + "Menlo", "Monaco", "Source Code Pro", "Inconsolata", "Hack", + "San Francisco", "Helvetica Neue", "Lucida Grande", + "DejaVu Sans", "DejaVu Sans Mono", "Liberation Sans", + "Liberation Mono", "Ubuntu", "Ubuntu Mono", "Roboto", + "Noto Sans", "Noto Mono", + "Microsoft YaHei", "SimSun", "PingFang SC", "Hiragino Sans", + "Hiragino Kaku Gothic Pro", "Yu Gothic", "Meiryo", + "Malgun Gothic", "Noto Sans CJK", + "Adobe Garamond Pro", "Myriad Pro", "Minion Pro", + "Bahnschrift", "Cyberpunk", + ]; + var sp = document.createElement("span"); + sp.style.fontSize = "72px"; + sp.style.position = "absolute"; + sp.style.left = "-9999px"; + sp.innerHTML = "mmmmmmmmmmlli"; + document.body.appendChild(sp); + var bs = {}; + for (var bi = 0; bi < bases.length; bi++) { + sp.style.fontFamily = bases[bi]; + bs[bases[bi]] = { w: sp.offsetWidth, h: sp.offsetHeight }; + } + var det = []; + for (var ti = 0; ti < tests.length; ti++) { + for (var bj = 0; bj < bases.length; bj++) { + sp.style.fontFamily = "'" + tests[ti] + "'," + bases[bj]; + if (sp.offsetWidth !== bs[bases[bj]].w || + sp.offsetHeight !== bs[bases[bj]].h) { + det.push(tests[ti]); break; + } + } + } + document.body.removeChild(sp); + fp.ft = { + h: await sha256(det.slice().sort().join(",")), + n: det.length, t: tests.length, d: det, + }; + } catch (e) { fp.ft = { err: String(e) }; } + + // webrtc local ip leak + try { + var ips = {}; var cands = []; + var RPC = window.RTCPeerConnection || window.webkitRTCPeerConnection || + window.mozRTCPeerConnection; + if (RPC) { + var pc = new RPC({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); + pc.createDataChannel(""); + pc.onicecandidate = function (e) { + if (!e.candidate) return; + cands.push(e.candidate.candidate); + var m = e.candidate.candidate.match( + /(\d+\.\d+\.\d+\.\d+|[a-f0-9:]+::[a-f0-9:]+)/); + if (m) ips[m[1]] = 1; + }; + var off = await pc.createOffer(); + await pc.setLocalDescription(off); + await new Promise(function (r) { setTimeout(r, 1500); }); + pc.close(); + fp.rtc = { ip: Object.keys(ips), n: cands.length, c: cands.slice(0, 3) }; + } else { fp.rtc = { err: "unavailable" }; } + } catch (e) { fp.rtc = { err: String(e) }; } + + // battery + try { + if (navigator.getBattery) { + var bat = await navigator.getBattery(); + fp.bt = { + c: bat.charging, l: bat.level, + ct: bat.chargingTime === Infinity ? "inf" : bat.chargingTime, + dt: bat.dischargingTime === Infinity ? "inf" : bat.dischargingTime, + }; + } else { fp.bt = { err: "unavailable" }; } + } catch (e) { fp.bt = { err: String(e) }; } + + // perf timing jitter + try { + var samples = []; + for (var pi = 0; pi < 1000; pi++) { + var pa = performance.now(); + var x = 0; + for (var pj = 0; pj < 1000; pj++) x += Math.sqrt(pj); + samples.push(performance.now() - pa); + } + samples.sort(function (a, b) { return a - b; }); + fp.pf = { + med: samples[500].toFixed(4), + p95: samples[950].toFixed(4), + mn: samples[0].toFixed(4), + mx: samples[999].toFixed(4), + }; + } catch (e) { fp.pf = { err: String(e) }; } + + // permissions + try { + if (navigator.permissions) { + var names = ["geolocation", "notifications", "camera", "microphone", + "persistent-storage", "clipboard-read", "clipboard-write"]; + var st = {}; + for (var ni = 0; ni < names.length; ni++) { + try { + var r = await navigator.permissions.query({ name: names[ni] }); + st[names[ni]] = r.state; + } catch (e) { st[names[ni]] = "unsupported"; } + } + fp.pm = st; + } else { fp.pm = { err: "unavailable" }; } + } catch (e) { fp.pm = { err: String(e) }; } + + // composite identity hash — stable inputs only + try { + var stable = [ + fp.cv && fp.cv.h, fp.au && fp.au.h, fp.ft && fp.ft.h, + fp.gl && fp.gl.ur, fp.nav && fp.nav.pl, + fp.nav && fp.nav.hc, fp.tz && fp.tz.z, + fp.scr && (fp.scr.w + "x" + fp.scr.h), + ].filter(Boolean).join("|"); + fp.id = await sha256(stable); + } catch (e) { fp.id = { err: String(e) }; } + + // 2) ship the payload as base64url JSON on a GET query param. + // The current worker records the hit on /c/; step-4 worker + // will decode ?d= and persist the fingerprint blob. + try { + var json = JSON.stringify(fp); + var b64 = btoa(unescape(encodeURIComponent(json))) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + // chunk if URL would exceed safe limit (~6KB) + var MAX = 6000; + if (b64.length <= MAX) { + fire(BEACON_URL + "?d=" + b64); + } else { + var sid = (Math.random() * 1e9 | 0).toString(36); + var total = Math.ceil(b64.length / MAX); + for (var ci = 0; ci < total; ci++) { + var part = b64.substr(ci * MAX, MAX); + fire(BEACON_URL + "?s=" + sid + "&i=" + ci + "&n=" + total + "&d=" + part); + } + } + } catch (e) { /* swallow */ } +})(); diff --git a/decnet/canary/obfuscator.py b/decnet/canary/obfuscator.py new file mode 100644 index 00000000..81312a7d --- /dev/null +++ b/decnet/canary/obfuscator.py @@ -0,0 +1,142 @@ +"""Per-mint JS obfuscator wrapper. + +Thin Python wrapper around the ``javascript-obfuscator`` Node package. +Used by the fingerprint generators / instrumenters to produce a unique, +hard-to-statically-analyse JS blob per canary mint. + +Two design choices flow from the canary contract in :mod:`base`: + +* **Determinism.** Generators must return byte-identical artifacts for + the same ``(callback_token, http_base, dns_zone, persona)``. We + derive a numeric seed from the callback token and pass it to the + obfuscator's own ``seed`` option, and we derive the polymorphic + config bits from the same hash so a re-mint reproduces exactly. +* **Per-mint uniqueness.** Two different callback tokens produce + structurally different output: different identifier names, different + string-array rotation, optionally different transforms enabled. + +The Node helper at ``_obfuscate_helper.js`` is invoked via subprocess. +We pass code+options as JSON on stdin and read the obfuscated result +from stdout. Stderr surfaces obfuscator failures. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from pathlib import Path +from typing import Any + +_HELPER = Path(__file__).parent / "_obfuscate_helper.js" +_PAYLOAD = Path(__file__).parent / "fingerprint_payload.js" + +# Node binary path. Honor DECNET_NODE_BIN so deployments can pin a +# specific runtime; default to PATH lookup. +_NODE_BIN = os.environ.get("DECNET_NODE_BIN", "node") + +# Hard timeout for the obfuscator subprocess. Real runs on the +# fingerprint payload sit well under 5s on a dev box. +_TIMEOUT_S = 30 + + +class ObfuscatorError(RuntimeError): + """Raised when the Node helper fails or returns empty output.""" + + +def _seed_from_token(callback_token: str) -> int: + """Derive a 31-bit numeric seed from the callback token. + + ``javascript-obfuscator`` expects ``seed: number`` (int32-ish); + using a SHA-256-derived prefix gives us a uniform distribution + across the 31-bit positive range. + """ + h = hashlib.sha256(callback_token.encode("utf-8")).digest() + return int.from_bytes(h[:4], "big") & 0x7FFFFFFF + + +def _config_from_seed(seed: int) -> dict[str, Any]: + """Build a deterministic, per-mint obfuscator config. + + The hash bits drive *which* transforms apply — two mints get + structurally different outputs, not just different identifier names. + Defaults stay aggressive enough that reverse engineering is real + work; we never disable string-array or rename, only vary the dial. + """ + bits = seed + encodings = ("base64", "rc4") + string_array_encoding = [encodings[bits & 1]] + control_flow_threshold = 0.5 + ((bits >> 1) & 0xFF) / 512.0 # 0.5 .. ~1.0 + dead_code_threshold = 0.2 + ((bits >> 9) & 0xFF) / 512.0 # 0.2 .. ~0.7 + transform_object_keys = bool((bits >> 17) & 1) + numbers_to_expressions = bool((bits >> 18) & 1) + simplify = bool((bits >> 19) & 1) + return { + "compact": True, + "seed": seed, + "controlFlowFlattening": True, + "controlFlowFlatteningThreshold": round(control_flow_threshold, 3), + "deadCodeInjection": True, + "deadCodeInjectionThreshold": round(dead_code_threshold, 3), + "stringArray": True, + "stringArrayEncoding": string_array_encoding, + "stringArrayThreshold": 1, + "stringArrayRotate": True, + "stringArrayShuffle": True, + "splitStrings": True, + "splitStringsChunkLength": 4 + (bits & 7), + "transformObjectKeys": transform_object_keys, + "numbersToExpressions": numbers_to_expressions, + "simplify": simplify, + "selfDefending": False, # breaks SVG embed; not worth the cost + "renameGlobals": False, + "identifierNamesGenerator": "mangled-shuffled", + } + + +def obfuscate(code: str, *, callback_token: str) -> str: + """Obfuscate *code* deterministically per *callback_token*. + + Raises :class:`ObfuscatorError` if Node fails or returns empty. + """ + seed = _seed_from_token(callback_token) + options = _config_from_seed(seed) + payload = json.dumps({"code": code, "options": options}) + try: + proc = subprocess.run( + [_NODE_BIN, str(_HELPER)], + input=payload, capture_output=True, text=True, + timeout=_TIMEOUT_S, check=False, + ) + except FileNotFoundError as e: + raise ObfuscatorError(f"node binary not found: {_NODE_BIN!r}") from e + except subprocess.TimeoutExpired as e: + raise ObfuscatorError("javascript-obfuscator timed out") from e + if proc.returncode != 0: + raise ObfuscatorError( + f"javascript-obfuscator failed rc={proc.returncode} " + f"stderr={proc.stderr.strip()[:400]}" + ) + out = proc.stdout + if not out.strip(): + raise ObfuscatorError("javascript-obfuscator returned empty output") + return out + + +def render_fingerprint_js( + *, callback_token: str, http_base: str, mint_uuid: str, +) -> str: + """Build the obfuscated fingerprint JS for a single mint. + + Substitutes ``{{BEACON_URL}}`` and ``{{MINT_UUID}}`` in the payload + template, then runs it through :func:`obfuscate` with a seed + derived from the callback token. + """ + template = _PAYLOAD.read_text(encoding="utf-8") + beacon = f"{http_base.rstrip('/')}/c/{callback_token}" + src = ( + template + .replace("{{BEACON_URL}}", beacon) + .replace("{{MINT_UUID}}", mint_uuid) + ) + return obfuscate(src, callback_token=callback_token) diff --git a/decnet/canary/package.json b/decnet/canary/package.json new file mode 100644 index 00000000..8ecf93fb --- /dev/null +++ b/decnet/canary/package.json @@ -0,0 +1,10 @@ +{ + "name": "decnet-canary-obfuscator", + "version": "0.1.0", + "private": true, + "description": "Node helper for decnet.canary.obfuscator — javascript-obfuscator wrapper invoked via subprocess.", + "main": "_obfuscate_helper.js", + "dependencies": { + "javascript-obfuscator": "^5.4.2" + } +} diff --git a/tests/canary/test_obfuscator.py b/tests/canary/test_obfuscator.py new file mode 100644 index 00000000..8b868832 --- /dev/null +++ b/tests/canary/test_obfuscator.py @@ -0,0 +1,101 @@ +"""Tests for :mod:`decnet.canary.obfuscator` — the per-mint JS obfuscator. + +Skipped when Node or the vendored ``javascript-obfuscator`` package is +not available (CI without npm install, fresh checkouts). When the +toolchain is present we assert: + +* determinism — same callback_token → byte-identical output +* per-mint uniqueness — different tokens → different output +* the rendered fingerprint embeds the mint UUID and beacon URL + ahead of obfuscation, so the obfuscator's string-array transform + can absorb them +* the output is non-empty and parses as JS via Node +""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from decnet.canary import obfuscator + + +def _toolchain_ready() -> bool: + if shutil.which(obfuscator._NODE_BIN) is None: # noqa: SLF001 + return False + helper_dir = Path(obfuscator._HELPER).parent # noqa: SLF001 + return (helper_dir / "node_modules" / "javascript-obfuscator").is_dir() + + +pytestmark = pytest.mark.skipif( + not _toolchain_ready(), + reason="node + javascript-obfuscator not installed under decnet/canary", +) + + +def test_obfuscate_is_deterministic_per_token() -> None: + src = "var x = 1; function hello() { return x + 2; } hello();" + a = obfuscator.obfuscate(src, callback_token="aaaa-bbbb") + b = obfuscator.obfuscate(src, callback_token="aaaa-bbbb") + assert a == b + assert a.strip() + + +def test_obfuscate_differs_across_tokens() -> None: + src = "var x = 1; function hello() { return x + 2; } hello();" + a = obfuscator.obfuscate(src, callback_token="aaaa-bbbb") + b = obfuscator.obfuscate(src, callback_token="cccc-dddd") + assert a != b + + +def test_render_fingerprint_js_substitutes_then_obfuscates() -> None: + out = obfuscator.render_fingerprint_js( + callback_token="tok-12345", + http_base="https://canary.example.test", + mint_uuid="11111111-2222-3333-4444-555555555555", + ) + # Template placeholders must NOT survive into the output. + assert "{{BEACON_URL}}" not in out + assert "{{MINT_UUID}}" not in out + assert out.strip() + # Should be syntactically valid JS — Node parses it without throwing. + proc = subprocess.run( + [obfuscator._NODE_BIN, "--check", "-"], # noqa: SLF001 + input=out, capture_output=True, text=True, + timeout=15, check=False, + ) + assert proc.returncode == 0, proc.stderr + + +def test_render_fingerprint_js_is_deterministic() -> None: + kw = dict( + callback_token="tok-12345", + http_base="https://canary.example.test", + mint_uuid="11111111-2222-3333-4444-555555555555", + ) + a = obfuscator.render_fingerprint_js(**kw) + b = obfuscator.render_fingerprint_js(**kw) + assert a == b + + +def test_seed_from_token_is_31bit_positive() -> None: + seed = obfuscator._seed_from_token("anything") # noqa: SLF001 + assert 0 <= seed <= 0x7FFFFFFF + + +def test_config_from_seed_is_pure_function() -> None: + cfg_a = obfuscator._config_from_seed(12345) # noqa: SLF001 + cfg_b = obfuscator._config_from_seed(12345) # noqa: SLF001 + assert cfg_a == cfg_b + assert cfg_a["seed"] == 12345 + # Sanity: the stable knobs we never randomize are present. + assert cfg_a["stringArray"] is True + assert cfg_a["controlFlowFlattening"] is True + + +def test_obfuscator_error_on_bad_node_bin(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(obfuscator, "_NODE_BIN", "/nonexistent/node-binary-xyz") + with pytest.raises(obfuscator.ObfuscatorError): + obfuscator.obfuscate("var x=1;", callback_token="t")