diff --git a/decnet/canary/factory.py b/decnet/canary/factory.py index 345443f1..149327a6 100644 --- a/decnet/canary/factory.py +++ b/decnet/canary/factory.py @@ -21,6 +21,8 @@ KNOWN_GENERATORS: Tuple[str, ...] = ( "honeydoc_docx", "honeydoc_pdf", "mysql_dump", + "fingerprint_html", + "fingerprint_svg", ) KNOWN_INSTRUMENTERS: Tuple[str, ...] = ( @@ -64,6 +66,16 @@ def get_generator(name: str) -> CanaryGenerator: if name == "mysql_dump": from decnet.canary.generators.mysql_dump import MySQLDumpGenerator return MySQLDumpGenerator() + if name == "fingerprint_html": + from decnet.canary.generators.fingerprint_html import ( + FingerprintHtmlGenerator, + ) + return FingerprintHtmlGenerator() + if name == "fingerprint_svg": + from decnet.canary.generators.fingerprint_svg import ( + FingerprintSvgGenerator, + ) + return FingerprintSvgGenerator() raise ValueError( f"Unknown canary generator: {name!r}. Known: {KNOWN_GENERATORS}" ) diff --git a/decnet/canary/generators/fingerprint_html.py b/decnet/canary/generators/fingerprint_html.py new file mode 100644 index 00000000..9046538f --- /dev/null +++ b/decnet/canary/generators/fingerprint_html.py @@ -0,0 +1,137 @@ +"""HTML fingerprint canary — plausible-looking page with an obfuscated +browser-fingerprinting payload inlined at the bottom of ````. + +The visible content is a deliberately mundane "internal directory" +table — the kind of file a curious attacker pulls off a decky's +filesystem and opens locally to triage. When the file is opened in +*any* network-connected browser the obfuscated payload runs and beacons +to ``/c/``: first a bare-open pixel, then a chunked +fingerprint dump (canvas, audio, fonts, WebGL, WebRTC local IPs, +timing jitter, permissions, composite identity hash). + +Determinism: the mint UUID is derived from the callback token via +:func:`uuid.uuid5` so the same ``ctx`` always produces byte-identical +output, satisfying the generator contract in :mod:`decnet.canary.base`. +The obfuscator's seed and polymorphic config bits are likewise +callback-token-derived (see :mod:`decnet.canary.obfuscator`). +""" +from __future__ import annotations + +import hashlib +import uuid + +from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator +from decnet.canary.obfuscator import render_fingerprint_js + +_MINT_NAMESPACE = uuid.UUID("a3f7c821-9d1e-4b6a-8c2d-1e4f9a7b3c5d") + + +def _mint_uuid_for(callback_token: str) -> str: + return str(uuid.uuid5(_MINT_NAMESPACE, callback_token)) + + +def _stable_int(callback_token: str, salt: str = "") -> int: + """Deterministic non-negative int derived from the callback token. + + ``builtins.hash`` is salted per-process — useless for a generator + that must be byte-identical across runs. SHA-256 prefix is + overkill but free. + """ + h = hashlib.sha256((callback_token + "|" + salt).encode("utf-8")).digest() + return int.from_bytes(h[:4], "big") + + +_PAGE_TEMPLATE = """ + + + +Internal Asset Directory + + + +

Internal Asset Directory

+
last sync: {sync_label} · {row_count} entries · CONFIDENTIAL
+ + +{rows} +
HostnameOwnerRoleVLANNotes
+
page generated by directory-sync v2.4.1 — do not redistribute
+ + + +""" + + +_ROW_POOL = ( + ("ny-app-01.corp.local", "k.tanaka", "app server", "vlan20", "primary"), + ("ny-db-01.corp.local", "ops", "postgres primary", "vlan30", "backup nightly"), + ("ny-build-02.corp.local", "ci-bot", "jenkins agent", "vlan40", ""), + ("sf-vpn-01.corp.local", "netsec", "wireguard endpoint", "vlan10", "external"), + ("ldn-mail-03.corp.local", "j.weber", "exchange edge", "vlan50", ""), + ("hk-cache-01.corp.local", "ops", "redis replica", "vlan30", "lag <1s"), + ("br-dev-04.corp.local", "m.silva", "dev sandbox", "vlan60", "ephemeral"), + ("eu-bastion-02.corp.local", "secops", "ssh jump host", "vlan10", "mfa required"), + ("us-archive-01.corp.local", "compliance", "log archive", "vlan70", "retain 7y"), +) + + +def _build_rows(callback_token: str) -> tuple[str, int]: + pick = _stable_int(callback_token, "pick") % len(_ROW_POOL) + take = 5 + (_stable_int(callback_token, "take") % 4) + selected = [_ROW_POOL[(pick + i) % len(_ROW_POOL)] for i in range(take)] + cells = "\n".join( + "" + "".join(f"{c}" for c in row) + "" + for row in selected + ) + return cells, len(selected) + + +def _sync_label(callback_token: str) -> str: + day = _stable_int(callback_token, "day") % 28 + 1 + hour = _stable_int(callback_token, "hour") % 24 + return f"2026-04-{day:02d} {hour:02d}:14 UTC" + + +class FingerprintHtmlGenerator(CanaryGenerator): + """Synthesise an HTML page that fingerprints the browser opening it.""" + + name = "fingerprint_html" + + def generate(self, ctx: CanaryContext) -> CanaryArtifact: + mint_uuid = _mint_uuid_for(ctx.callback_token) + payload = render_fingerprint_js( + callback_token=ctx.callback_token, + http_base=ctx.http_base, + mint_uuid=mint_uuid, + ) + rows, row_count = _build_rows(ctx.callback_token) + body = _PAGE_TEMPLATE.format( + sync_label=_sync_label(ctx.callback_token), + row_count=row_count, + rows=rows, + payload=payload, + ) + beacon = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}" + return CanaryArtifact( + path="", + content=body.encode("utf-8"), + mode=0o644, + mtime_offset=-86400 * 14, + generator=self.name, + notes=[ + f"obfuscated fingerprinter beacons={beacon}", + f"mint_uuid={mint_uuid}", + ], + ) diff --git a/decnet/canary/generators/fingerprint_svg.py b/decnet/canary/generators/fingerprint_svg.py new file mode 100644 index 00000000..fb6c418a --- /dev/null +++ b/decnet/canary/generators/fingerprint_svg.py @@ -0,0 +1,85 @@ +"""SVG fingerprint canary — standalone SVG with an embedded `` + +""" + + +_REGIONS = ("us-east", "eu-central", "ap-south", "us-west", "sa-east") + + +class FingerprintSvgGenerator(CanaryGenerator): + """Synthesise an SVG that fingerprints the browser opening it.""" + + name = "fingerprint_svg" + + def generate(self, ctx: CanaryContext) -> CanaryArtifact: + mint_uuid = _mint_uuid_for(ctx.callback_token) + payload = render_fingerprint_js( + callback_token=ctx.callback_token, + http_base=ctx.http_base, + mint_uuid=mint_uuid, + ) + region = _REGIONS[_stable_int(ctx.callback_token, "reg") % len(_REGIONS)] + ver = 1 + (_stable_int(ctx.callback_token, "ver") % 6) + day = _stable_int(ctx.callback_token, "day") % 28 + 1 + body = _DIAGRAM_TEMPLATE.format( + region=region, + ver=ver, + review=f"2026-03-{day:02d}", + payload=payload, + ) + beacon = f"{ctx.http_base.rstrip('/')}/c/{ctx.callback_token}" + return CanaryArtifact( + path="", + content=body.encode("utf-8"), + mode=0o644, + mtime_offset=-86400 * 30, + generator=self.name, + notes=[ + f"obfuscated fingerprinter beacons={beacon}", + f"mint_uuid={mint_uuid}", + ], + ) diff --git a/decnet/canary/paths.py b/decnet/canary/paths.py index 5700ad0f..b2b731a5 100644 --- a/decnet/canary/paths.py +++ b/decnet/canary/paths.py @@ -28,6 +28,8 @@ _LINUX_DEFAULTS: dict[str, str] = { "honeydoc": "/home/{user}/Documents/quarterly_report.html", "honeydoc_docx": "/home/{user}/Documents/quarterly_report.docx", "honeydoc_pdf": "/home/{user}/Documents/quarterly_report.pdf", + "fingerprint_html": "/home/{user}/Documents/asset_directory.html", + "fingerprint_svg": "/home/{user}/Documents/network_topology.svg", } _WINDOWS_DEFAULTS: dict[str, str] = { @@ -38,6 +40,8 @@ _WINDOWS_DEFAULTS: dict[str, str] = { "honeydoc": "/home/{user}/Documents/quarterly_report.html", "honeydoc_docx": "/home/{user}/Documents/quarterly_report.docx", "honeydoc_pdf": "/home/{user}/Documents/quarterly_report.pdf", + "fingerprint_html": "/home/{user}/Documents/asset_directory.html", + "fingerprint_svg": "/home/{user}/Documents/network_topology.svg", } diff --git a/tests/canary/test_fingerprint_generators.py b/tests/canary/test_fingerprint_generators.py new file mode 100644 index 00000000..b2dd4c4f --- /dev/null +++ b/tests/canary/test_fingerprint_generators.py @@ -0,0 +1,103 @@ +"""Tests for the HTML/SVG fingerprint canary generators. + +Skipped when the Node toolchain (or vendored javascript-obfuscator) is +not installed, mirroring :mod:`tests.canary.test_obfuscator`. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from decnet.canary import CanaryContext, get_generator + + +def _toolchain_ready() -> bool: + if shutil.which("node") is None: + return False + canary_dir = Path(__file__).resolve().parents[2] / "decnet" / "canary" + return (canary_dir / "node_modules" / "javascript-obfuscator").is_dir() + + +pytestmark = pytest.mark.skipif( + not _toolchain_ready(), + reason="node + javascript-obfuscator not installed under decnet/canary", +) + + +def _ctx(callback_token: str = "fp-tok-123") -> CanaryContext: + return CanaryContext( + callback_token=callback_token, + http_base="https://canary.example.test", + dns_zone="canary.example.test", + persona="linux", + ) + + +def test_fingerprint_html_renders_full_page() -> None: + art = get_generator("fingerprint_html").generate(_ctx()) + body = art.content.decode("utf-8") + assert body.startswith("") + assert "" in body + assert "Internal Asset Directory" in body + assert "" in body + # Beacon URL must NOT appear in plaintext — it's inside the + # obfuscated string array. (Sanity: this is the whole point of + # obfuscating the payload.) + assert "/c/fp-tok-123" not in body + # Visible content shouldn't leak the slug either. + assert "fp-tok-123" not in body + assert art.mode == 0o644 + assert art.generator == "fingerprint_html" + + +def test_fingerprint_html_is_deterministic_per_token() -> None: + a = get_generator("fingerprint_html").generate(_ctx("tokA")) + b = get_generator("fingerprint_html").generate(_ctx("tokA")) + assert a.content == b.content + + +def test_fingerprint_html_differs_across_tokens() -> None: + a = get_generator("fingerprint_html").generate(_ctx("tokA")) + b = get_generator("fingerprint_html").generate(_ctx("tokB")) + assert a.content != b.content + + +def test_fingerprint_html_notes_carry_mint_uuid_and_beacon() -> None: + art = get_generator("fingerprint_html").generate(_ctx("tok-notes")) + joined = " | ".join(art.notes) + assert "mint_uuid=" in joined + assert "https://canary.example.test/c/tok-notes" in joined + + +def test_fingerprint_svg_renders_valid_svg_with_script() -> None: + art = get_generator("fingerprint_svg").generate(_ctx()) + body = art.content.decode("utf-8") + assert body.startswith("" in body + assert " None: + a = get_generator("fingerprint_svg").generate(_ctx("svgTokA")) + b = get_generator("fingerprint_svg").generate(_ctx("svgTokA")) + assert a.content == b.content + + +def test_fingerprint_svg_differs_across_tokens() -> None: + a = get_generator("fingerprint_svg").generate(_ctx("svgTokA")) + b = get_generator("fingerprint_svg").generate(_ctx("svgTokB")) + assert a.content != b.content + + +def test_mint_uuid_stable_across_html_and_svg() -> None: + # Same callback token → same mint UUID across both generators, so + # the worker can correlate beacons regardless of artifact shape. + html = get_generator("fingerprint_html").generate(_ctx("shared-tok")) + svg = get_generator("fingerprint_svg").generate(_ctx("shared-tok")) + html_uuid = next(n for n in html.notes if n.startswith("mint_uuid=")) + svg_uuid = next(n for n in svg.notes if n.startswith("mint_uuid=")) + assert html_uuid == svg_uuid diff --git a/tests/canary/test_generators.py b/tests/canary/test_generators.py index dae7dc5f..5b35ac89 100644 --- a/tests/canary/test_generators.py +++ b/tests/canary/test_generators.py @@ -9,12 +9,31 @@ the artifact" property. from __future__ import annotations import re +import shutil +from pathlib import Path import pytest from decnet.canary import CanaryContext, get_generator from decnet.canary.factory import KNOWN_GENERATORS +# fingerprint_* generators shell out to javascript-obfuscator via Node. +# Skip those parametrized cases when the toolchain isn't installed so a +# bare CI checkout doesn't fail before `npm install` runs. +_NEEDS_NODE = {"fingerprint_html", "fingerprint_svg"} + + +def _node_toolchain_ready() -> bool: + if shutil.which("node") is None: + return False + canary_dir = Path(__file__).resolve().parents[2] / "decnet" / "canary" + return (canary_dir / "node_modules" / "javascript-obfuscator").is_dir() + + +def _maybe_skip(name: str) -> None: + if name in _NEEDS_NODE and not _node_toolchain_ready(): + pytest.skip(f"{name} requires node + javascript-obfuscator") + def _ctx(**kw) -> CanaryContext: defaults = dict( @@ -29,6 +48,7 @@ def _ctx(**kw) -> CanaryContext: @pytest.mark.parametrize("name", KNOWN_GENERATORS) def test_generator_is_deterministic(name: str) -> None: + _maybe_skip(name) g = get_generator(name) a = g.generate(_ctx()) b = g.generate(_ctx()) @@ -184,5 +204,7 @@ def test_artifacts_carry_notes() -> None: # check what we did before the file lands. Empty notes would mean # the operator is staring at opaque bytes. for name in KNOWN_GENERATORS: + if name in _NEEDS_NODE and not _node_toolchain_ready(): + continue art = get_generator(name).generate(_ctx()) assert art.notes, f"{name} produced no notes"