Guided hide/reveal/capacity wizard in internal/tui as a pure view over internal/engine (no carrier logic): HCL gradient engine, live capacity meter, embed animation, and a secure-options form. Bare crypha on a TTY launches it; piped or --help prints help. Adds engine.Overhead and engine.EnvelopeSize for exact payload-fit preflight (flate pass only, no KDF).
Reimplements the QR internals needed for the covert channel from
ISO/IEC 18004: the function-module map, format-info parse, data mask,
zigzag placement, and error-correction block de-interleave, plus a
from-scratch Reed-Solomon decoder over GF(2^8) (syndromes, Berlekamp-
Massey, Chien search, and a Vandermonde magnitude solve). skip2 generates
the clean symbol; crypha introspects it and reuses none of its internals.
The payload is hidden as up to floor(t/2) correctable codeword errors per
block in the data region, leaving the error-correction codewords intact,
so any scanner's Reed-Solomon decoder self-heals to the cover and never
sees it. Reveal reads the module grid, RS-decodes each block itself, and
diffs the corrected data against the stego to recover the payload. EC
level is fixed at H; versions 1-10 auto-select by cover and payload size.
Capacity is tens of bytes, so an encrypted envelope (which exceeds it) is
rejected cleanly.
Differentially tested: the extracted codewords match skip2 as valid RS
codewords across all supported versions, and every stego still scans back
to the cover via gozxing at the full injection budget. skip2 is a runtime
dependency (the generator); gozxing is test-only. No toolchain bump; the
go directive stays 1.25.0.
M4 audio: 16-bit PCM WAV LSB carrier. Cover input is WAV or FLAC
(FLAC decoded via the mewkiz decoder); output is always 16-bit PCM
WAV. Native FLAC output is deferred. uint32 length-prefix framing
with overflow-safe bounds; in-memory WriteSeeker so the WAV encoder
can seek back and patch chunk sizes through the io.Writer interface.
M5 pdf: three techniques behind one carrier. Attachment (default,
pdfcpu embedded-file, lossless), metadata (base64url payload chunked
across custom Info-dict keys), and append-after-EOF (raw trailing
bytes, O(1) end-seek). Reveal auto-tries all three; technique
selection is exposed via New(Technique). The package disables the
pdfcpu config directory for hermeticity.
Both carriers self-register and are blank-imported in carrier/all.
No toolchain bump (go directive stays 1.25.0).
M2 image carrier: LSB embedding in PNG and 24-bit BMP covers via the
mandatory NRGBA conversion (avoids the premultiply LSB-corruption trap on
both encode paths), RGB-only with alpha untouched, uint32 length prefix,
paletted/16-bit/JPEG rejection, Capacity and Sniff, self-registering.
M3 text carrier: encrypted payload hidden as zero-width Unicode
(U+200B/U+2060) appended after cover text, magic + length framing that
survives incidental zero-width in the cover and NFC/NFD/NFKC/NFKD
normalization. Frame extraction consumes exactly to the end of the
carrier-bit stream so nested stego reveals the last-hidden layer.
New internal/carrier/all sentinel blank-imports carriers into the
registry. Adds golang.org/x/image/bmp and (test-only) golang.org/x/text.
go build ./cmd/healthcheck (run for verification, not via the bin/ recipe)
drops the binary in the working dir. Untracked previously; landed in the
last commit by accident. Gitignore now lists each cmd binary explicitly so
the same mistake can't recur for canary, healthcheck, or the two pdf/docx
template builders.
Three things that all blocked tunnel-start:
1. justfile: dropped set dotenv-load / set export — they pulled
.env.development into every recipe shell, where empty values
beat docker compose --env-file .env per shell-env precedence
(POSTGRES_PASSWORD was the visible casualty). Dev recipes now
pass --env-file .env.development explicitly so they're not
affected by the change.
2. canary image is distroless/static, so the wget-based healthcheck
had no binary to run and every check failed → nginx/tunnel never
started. Added cmd/healthcheck (tiny Go HTTP probe of /healthz),
built alongside canary, swapped compose healthcheck to /healthcheck.
3. Added tunnel-build and tunnel-rebuild recipes so the next person
doesn't have to remember which compose files the tunnel overlay
needs.
Operator request: give the webbug some personality. Trigger response is now
a small JPEG embedded into the binary via //go:embed, served from
backend/internal/token/generators/webbug/asset/pixel.jpg. Default is a
545-byte 32x32 yellow placeholder I generated with imagemagick — replace
the file with any image (same path, same filename, doesn't matter what
size or content) and the next backend rebuild picks it up. No other code
needs to change.
Scope: ONLY webbug. The docx, pdf, and envfile generators keep their own
Trigger functions that still call pixel.Clone() for the canonical
invisible 1x1 GIF — those types embed the trigger URL inside their
respective artifact bytes (a Word footer, a PDF /AA action, a fake .env
INTERNAL_METRICS_ENDPOINT line) and need the response to stay invisible
so the attacker doesn't notice they pinged us. Stealth preserved where
it matters.
The pixel package itself is untouched (still has the 43-byte GIF
constant); only webbug stops importing it.
Tradeoff baked in: a webbug whose response is a visible image is
*technically* less stealthy than the canonical 1x1 — the attacker can
see "huh there's a small image here." But the visible mechanism is real
too (think Mailchimp / SendGrid email-open tracking, which use sized
"social proof" images, not just 1x1s), so this is a legitimate variant.
For an operator who wants strict 1x1-invisible behavior in prod, the
revert is a 3-line change in webbug/generator.go (swap embed for
pixel.Clone() again).
Tests updated: webbug/generator_test.go no longer asserts gifByteLength
or pixel.Clone() equality; it now asserts the body starts with the JPEG
SOI marker (0xff 0xd8) + ContentType is image/jpeg. The
"independent-copy-per-call" invariant test still passes (bytes.Clone in
the Trigger ensures each Body slice is a fresh allocation).
Cache headers unchanged (no-store / no-cache / must-revalidate) so every
fetch hits the trigger handler.
Upstream 8d2599c0 added a mysqlEnabled parameter to token.NewHandler and
took a long line over the limit in handler_test. Pass the parameter from
the e2e stack (true: integration test exercises mysql token creation) and
reflow the long handler_test return so golines is happy.
Operator request: when MYSQL_FAKE_ENABLED=false (the dev default + the only
viable setting behind Cloudflare Tunnel since CF Tunnel doesn't carry raw
TCP), surface that in the UI so users can see the species but understand
they can't deploy it on this instance. Avoids the silent-broken case where
someone creates a mysql token, gets a connection_string artifact, points
their MySQL client at it, and just times out with no clue why.
Backend (3-line change):
- TypeDescriptor gains `Enabled bool` + optional `DisabledReason string`
- TypeDescriptors() now takes `mysqlEnabled bool`; mysql is the one whose
Enabled tracks the flag, all others are always true (they don't need
any deployment-level infra beyond HTTP)
- Handler stores mysqlEnabled, passes to descriptors call; main.go wires
cfg.MySQL.Enabled in. Handler tests updated to pass false explicitly.
Frontend (matching schema + visual treatment):
- typeDescriptorSchema picks up `enabled` (default true for backward compat)
and `disabled_reason` (optional string)
- TypeCard reads descriptor.enabled, sets `data-disabled` on the label,
disables the radio input, replaces the blurb with the disabled_reason
when present (so the user sees WHY rather than guessing), and renders
a small "disabled" pill in the top-right corner of the card
- SCSS: dashed border, muted palette, opacity 0.55, glyph faded, cursor
not-allowed, hover effect suppressed — reads as "shelf is here but the
specimen is checked out" rather than a broken button
In the operator UI: mysql card renders ghosted with the explanatory text
"Requires direct TCP exposure (port 3306). Not reachable via Cloudflare
Tunnel — only enable on a VPS with raw TCP access." Selecting it is
mechanically impossible (input is disabled), no form-level guard needed.
Flip MYSQL_FAKE_ENABLED=true in env when you stand up a VPS later and the
card re-enables with no other code changes.
Backend builds clean + token-package tests pass.
Frontend gates green: typecheck + biome + lint:scss + build (431ms).
Audit F23: integration tests covered repositories and individual handlers
but no test exercised the full chain. The PDF padding bug (F2) had passing
unit tests for years because the substring-only assertion masked the
embedded underscore padding.
New //go:build integration test in internal/server spins up Postgres via
testcontainers + miniredis, walks each of webbug, slowredirect, docx, pdf,
kubeconfig, envfile, mysql: POSTs /api/tokens, extracts the trigger URL
from the artifact bytes the way a victim would (regex for non-binary, ZIP
walk for docx, base64 decode for pdf), GETs the URL, then asserts the
manage view sees the event and the notifier fired. PDF subtest asserts the
byte after the embedded /c/<id> is not '_' — the structural check that
catches F2-style regressions.
Audit F3: validateURL checked scheme/host/userinfo but never resolved the
host, so an operator-supplied webhook_url could point at the canary's own
Redis (redis:6379), Postgres, link-local IMDS (169.254.169.254), or any
RFC1918 host on the docker network or VPS subnet — a classic confused-deputy
SSRF triggered by self-triggering a token after creation.
validateURL now resolves the hostname (or parses a literal IP) and rejects
loopback, RFC1918, CGNAT, link-local, multicast, unspecified, IMDS, and
IPv6 unique-local. The default HTTP client also installs a DialContext that
re-checks the dialed IP for defense-in-depth against DNS rebinding. The
Config gains an AllowPrivateHosts flag (default false) that test code opts
into when targeting httptest.NewServer.
Audit F2: the PDF placeholder was padded with underscores embedded directly
in the /URI path, so Acrobat fetched /c/<id>____________ and the chi route
extracted the padded id. Lookup returned ErrNotFound, the webbug fallback
served a 1x1 GIF, and event recording skipped because tok was nil. PDF
tokens silently never recorded events.
Part B (durable): pad inside a ?p= query string so the trigger path stays
canonical and chi extracts the real id. Part A (defense in depth): handler
TrimRight on _ so PDFs already in the field also resolve correctly. Added
two regression tests: byte-after-id assertion in the PDF artifact, and the
handler's trim path.
Audit F10: script-src 'self' blocked the Turnstile bootstrap script
(https://challenges.cloudflare.com/turnstile/v0/api.js) and its widget
iframe. Added challenges.cloudflare.com to script-src, connect-src, and a
new frame-src directive so the widget can render under prod CSP.
Audit F4: compose.yml already passed VITE_TURNSTILE_SITE_KEY as a build arg,
but vite.prod never declared an ARG/ENV for it, so Vite's import.meta.env
resolved to undefined. The Turnstile widget never rendered in prod, causing
the backend to reject every create-token request with TURNSTILE_FAILED.
Audit F1: prod.nginx had only the SPA fallback so all API requests, trigger
requests, and kubeconfig requests returned index.html, breaking the prod
deployment end-to-end. Added an upstream canary block to nginx.prod.conf and
three proxy_pass location blocks mirroring dev.nginx (plus CF-Connecting-IP
header since prod sits behind Cloudflare Tunnel).
Two tightly-coupled bugs visible after the PUBLIC_BASE_URL fix landed:
1. Trigger URLs now correctly show :22784 (the host-exposed nginx port),
but opening one in the browser rendered the SPA's NotFound page instead
of hitting the backend trigger handler.
Root cause: dev.nginx only had `location /` → frontend_dev. No location
block for `/c/`, `/k/`, or even `/api/`. Every request went to the SPA;
only `/api/*` happened to work because vite's internal proxy caught it
on its way through frontend_dev → canary. /c/* and /k/* (the trigger
paths) just fell through to the SPA fallback.
Fix: add `upstream canary { server canary:8080 }` to nginx.conf and
three new location blocks in dev.nginx for /api/, /c/, /k/. Each one
proxy_passes directly to the canary upstream with X-Real-IP +
X-Forwarded-For + X-Forwarded-Proto set so middleware.RealIP() resolves
the actual visitor IP rather than the docker bridge. Mirrors what
spec §12.3 prescribes for prod nginx.
2. Manage URL in responses still showed :8080 even after PUBLIC_BASE_URL
was set to :22784. Two separate config keys: canary.base_url
(PUBLIC_BASE_URL → trigger_url) and canary.manage_url (CANARY_MANAGE_URL
→ manage_url). The latter has no env-name overlap with the spec's
PUBLIC_BASE_URL, so it kept its default literal "http://localhost:8080".
Fix: in config.Load(), after unmarshal, fall manage_url back to base_url
when it's empty or still equals the bare default. Operators setting one
env var (PUBLIC_BASE_URL) now get both URLs computed off it. Explicit
CANARY_MANAGE_URL still wins if set — preserves the prod ability to
serve manage at a different domain than triggers. Extracted the literal
to a `defaultCanaryBaseURL` const so the default and the fallback
sentinel reference the same string.
After this + the prior PUBLIC_BASE_URL alias, a fresh token from the form
should produce trigger_url + manage_url both pointing at
http://localhost:${NGINX_HOST_PORT}/... — clickable, reachable, and triggers
hitting /c/ now route to canary and record events.
Operator action: just dev-down && just dev-up to rebuild nginx + canary
containers with the new config.