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
+
+| Hostname | Owner | Role | VLAN | Notes |
+{rows}
+
+
+
+
+
+"""
+
+
+_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 "