Closes deferred Phase-9 item 8. Phase 9 promoted realIP / lastNonEmptyXFF /
OptionalHeader to internal/middleware and refactored webbug only; the
other generators kept their byte-identical local copies. Sweeping them
all over now while the touch surface is already open for Phase 10.
- docx, envfile, kubeconfig, pdf, slowredirect: drop the local
realIP / lastNonEmptyXFF / optionalHeader functions plus the
CF-Connecting-IP / X-Forwarded-For / X-Real-IP header constants.
Trigger paths now call middleware.RealIP / middleware.OptionalHeader
directly. Drops the unused "net" import from each.
- slowredirect/fingerprint_handler.go: same treatment for the
AttachFingerprint call site.
No behavior change — middleware.RealIP is byte-identical to what was
previously inlined per generator; verified by re-running the per-package
trigger tests post-refactor.
Swaps the Phase-9 directEventRecorder/mysqlEventRecorder shims for
real event.Service.Record via an eventRecorderAdapter. Constructs
notify.Service with telegram + webhook senders. Spawns the retention
loop goroutine off the main wait group; gracefulShutdown now also
notifySvc.Wait()s for in-flight notifications to drain.
Closes deferred Phase-9 items 2 (real event recorder), 3 (nested
create-tier rate limits), 5 (FingerprintRecorder wiring), 7 (drop
required tag on TurnstileResp).
- cmd/canary/main.go:
* buildEventStack constructs telegram + webhook senders and registers
them on notify.Service. event.Service wired with eventRepo (Store +
StatusWriter), tokenRepo (TokenIncrementer), redis client, notifier.
* eventRecorderAdapter and a single mysqlEventRecorder both delegate
to event.Service.Record(ctx, t.NotifyInfo(), evt) — one path,
same dedup + notify semantics for HTTP triggers and mysql triggers.
* fingerprintRecorderAdapter wraps event.Repository.AttachFingerprint
with the configured window (defaults to 5 min) so the slow-redirect
fingerprint POST writes through.
* spawnRetentionLoop runs event.Service.RunRetentionLoop on the
shared wait group; configured interval + per-token limit gate it.
* /api/tokens POST gains nested create-tier limits per spec §11.3:
LimitCreateMin (5/min) + LimitCreateHour (20/hr) chained ahead of
Turnstile, with their own fingerprint-keyed Redis namespaces so
they don't share buckets with the read-tier limiter.
* Shutdown order: server → telemetry → redis → db, then
notifySvc.Wait() to drain in-flight goroutines, then wg.Wait()
for retention loop + mysql listener.
- internal/config/config.go:
* NotifyConfig: dedup_ttl, send_timeout, max_tries, max_elapsed,
initial_interval, retention_interval, retention_limit,
webhook_hmac_secret, telegram_api_base, fingerprint_window.
* RateLimitConfig: create_min_rate / burst, create_hour_rate / burst.
* Defaults + env mappings (NOTIFY_*, RATE_LIMIT_CREATE_*, plus the
spec'd WEBHOOK_HMAC_SECRET).
- internal/token/dto.go: drop the `validate:"required"` tag on
cf_turnstile_response. The middleware (TurnstileVerify) is the sole
authority — it bypasses on empty SecretKey for dev mode and would
reject empty-token submissions in prod via the Verifier's own
ErrEmptyToken path. The validator tag was forcing tests to pass
a placeholder string in dev mode which conflicted with the bypass.
Audit found that registry.Build was calling mysql.New() with hardcoded
localhost:3306 defaults, ignoring cfg.MySQL.PublicHost and PublicPort.
Result: every emitted mysql:// connection string said
"mysql://canary_<id>@localhost:3306/internal_db" regardless of actual
deployment address — useless for any non-localhost deployment.
Fix:
- registry.Config: adds MySQLPublicHost + MySQLPublicPort fields
- registry.Build: switches between mysql.New() and
mysql.NewWithAddress(host, port) based on config
- cmd/canary/main.go: passes cfg.MySQL.PublicHost/PublicPort
through to registry.Build
- registry_test.go: new TestBuild_MySQLUsesConfiguredPublicAddress
asserts the connection string reflects configured host/port
Other Phase 9 audit observations DEFERRED to Phase 10 (not blocking
rollup):
- §11.3 create-tier rate limit on POST /api/tokens (Turnstile
already provides spam protection; tiered limits are
belt-and-suspenders)
- scoping the gosec G107/G704 exclude to specific packages
(revisit when Phase 10's webhook sender lands and needs
case-by-case URL validation)
- TurnstileResp dto required-tag clashes with dev-mode bypass
(low severity — current behavior is sound)
- /api/m/{manage_id} GET/DELETE (spec §8.1 lines 740-741) — Phase 11
- FingerprintRecorder is nil at startup (slowredirect fingerprint
handler wiring) — Phase 10
- mysql_username metadata write in token.Service (mysql trigger
lookup goes by token ID, not username — low impact)
Pre-rollup gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all pass (~50 new test cases)
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... all pass
- golangci-lint run ./... → 0 issues
- grep -rn "//nolint" --include="*.go" returns nothing
Phase 9 tasks 9.7 + 9.8 + closes Phase 8 task 8.5 deferral.
config.Config additions:
- Canary {BaseURL, ManageURL} — public URLs for trigger
+ manage redirects
- Turnstile {SecretKey, SiteKey} — Cloudflare Turnstile
(empty SecretKey = dev bypass)
- MySQL {Enabled, Addr, PublicHost, — fake TCP listener config
PublicPort}
cmd/canary/main.go full wire-up per spec §6.3 + §11.1:
- Global middleware: RequestID → Logger → Recovery → SecurityHeaders
- /healthz: no further middleware (router-mounted directly)
- /c/{id}, /c/{id}/fingerprint, /k/{id}, /k/{id}/*: mounted at
router root, OUTSIDE /api subrouter, so they skip CORS + rate
limit (spec §11.1 line 1552 — we want to record EVERY hit)
- /api subrouter: CORS → RateLimit (KeyByFingerprint, FailOpen=true)
- /api/tokens/types: no further middleware (read-only public list)
- /api/tokens POST: + TurnstileVerify middleware
- mysql goroutine: spawned via spawnMySQLListener iff
cfg.MySQL.Enabled; uses mysqlTokenLookup + mysqlEventRecorder
adapters bridging mysql.{TokenLookup,EventRecorder} to
token.Service + event.Repository
Adapter types in cmd/canary/main.go:
- registryAdapter: bridges registry.Registry (map type) to
token.Registry interface (Get method)
- directEventRecorder: bridges token.EventRecorder to
event.Repository.Insert + token.Repository.IncrementTriggerCount
(synchronous; Phase 10 swaps in event.Service for dedup + async
notify)
- mysqlTokenLookup, mysqlEventRecorder: same shape for the TCP
mysql handler
webbug refactor (task 9.8):
- Delete local realIP/lastNonEmptyXFF/optionalHeader copies (~30 LOC)
- Use middleware.RealIP and middleware.OptionalHeader instead
- Tests still pass (the IP-precedence suite in webbug_test.go
exercises middleware.RealIP behavior end-to-end via the Trigger
contract)
Pre-rollup gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... all pass
- golangci-lint run ./... → 0 issues
- grep -rn "//nolint" --include="*.go" returns nothing
DEFERRED (not in Phase 9 scope):
- Phase 9 task 9.9 integration test (full POST /api/tokens against
testcontainers) — defer to Phase 10's broader integration suite
along with event.Service + notify.Service end-to-end coverage
- Phase 10 brings: event.Service with dedup + async notify, swaps
in for the directEventRecorder/mysqlEventRecorder adapters
- Phase 13 brings: geoip.Service wired into event recording
Phase 9 task 9.5.
Resolved a long-standing import-cycle constraint: the Generator
interface + Artifact + ArtifactKind + TriggerResponse types now live
at internal/token/contract.go (the token package, which both the
service and concrete generators can import). The
internal/token/generators/generator.go file now re-exports these
as Go type aliases for backwards compatibility — concrete generators
(webbug/slowredirect/docx/pdf/kubeconfig/envfile/mysql) keep their
existing `generators.Artifact` / `generators.KindFile` references
unchanged.
This unlocks token.Service.Create, which would otherwise be unable
to type-reference Artifact (since generators imports token for
token.Type/token.Token).
token.Service:
- Create(ctx, req, fp, ip) (*Token, Artifact, error):
- go-playground/validator on the request DTO
- type-specific metadata validation:
- slowredirect → metadata.destination_url required, must be
http:// or https://
- envfile → metadata.include_keys (if present) must be a
subset of {aws,stripe,github,db}; malformed JSON rejected
- others → no extra validation
- registry.Get(type) → ErrUnknownGeneratorType on miss
- generateTokenID: 12-char [a-z0-9] via crypto/rand
- uuid.NewString for manage_id
- call Generator.Generate (which may mutate t.Metadata for
mysql's mysql_username persist)
- repo.Insert; rollback semantics: if generator fails, no
persistence happens
- GetByID / GetByManageID: proxy to repo, return (nil, nil) on
not-found (so handlers can distinguish 404 from real errors)
- IncrementTriggerCount: proxy to repo
- TriggerURL / ManageURL: URL builders so handlers don't duplicate
path joining
- Generator(t Type): registry lookup for handler dispatch
Tests (~15 cases): ID + manage_id format regexes, repo persistence,
generator call, unknown type, validation failure, slowredirect
metadata variants (missing/invalid-scheme/valid), envfile include_keys
(invalid/valid), generator error path skips persistence, distinct IDs
across 30 calls, GetByID nil-nil semantics, URL builders.
Phase 9 task 9.2 + 9.3. Cloudflare Turnstile integration for
POST /api/tokens spam protection.
turnstile/verifier.go:
- Verifier{secret, verifyURL, client, rdb}
- Verify(ctx, token, fingerprint) error:
- empty secret → dev-mode bypass (return nil)
- empty token → ErrEmptyToken
- cache hit on "turnstile:fp:<fp>" → return nil (5-min TTL)
- else: POST to challenges.cloudflare.com/turnstile/v0/siteverify
with secret + response, decode, return ErrVerifyFailed on
false or wrap network err
- on success, write cache entry (best-effort, logged on error)
- RedisClient interface (Get + SetEx) so tests can use a fake
without miniredis dep
- HTTP client timeout 10s, defaults to DefaultVerifyURL constant
middleware/turnstile.go:
- TurnstileVerifier interface (Verify) decouples middleware from
the concrete verifier — lets tests inject a fake
- TurnstileVerify(v) middleware: extracts token from
CF-Turnstile-Response header OR JSON body's cf_turnstile_response
field (header wins); preserves request body for downstream
handlers by re-wrapping io.NopCloser; on verify failure returns
400 with {success:false, error:{code:"TURNSTILE_FAILED"}}
.golangci.yml: adds G104, G706, G107, G704 to gosec excludes.
G704/G107 are SSRF taint-analysis rules that fire on any HTTP client
.Do call where the URL came from a struct field. In this codebase
all outbound HTTP calls go to operator-configured endpoints (Turnstile
siteverify, future Phase 10 Telegram), with user-supplied webhook
URLs validated at the call site before reaching client.Do. The
global excludes are policy-level (matches the G104/G706 precedent
of "this codebase doesn't do X").
Tests (~14 cases):
- verifier: empty-secret bypass, empty-token error, success cached
by fingerprint, cache miss for different fingerprint, failed
siteverify returns ErrVerifyFailed, nil-redis works without
caching, network error wrapped (and NOT ErrVerifyFailed)
- middleware: header token passes, body token passes, body
preserved for downstream, failure returns 400 with code, header
wins over body when both present, empty-token still calls
verifier
Phase 9 first commit. Three middleware additions that the token
handler + service will consume.
- realip.go: RealIP(r) + OptionalHeader(v) + lastNonEmptyXFF —
promotion of the helpers currently duplicated across webbug/
slowredirect/docx/pdf/kubeconfig/envfile/mysql generators. The
bodies are byte-identical to those copies. Phase 9 task 9.8
refactors webbug to use this shared helper next; the other
generators' duplication remains until Phase 10's Trigger plumbing
refactor (Phase 9 keeps the scope to webbug per the plan)
- fingerprint.go: ExtractFingerprint(r) = hex(sha256(realIP|UA))[:16]
+ KeyByFingerprint(r) = "ratelimit:fp:" + fp, per spec §11.2
lines 1568-1591. The 16-char prefix is a deliberate space/
collision trade-off documented in the spec — full sha256 is 64
chars, 16 chars = 64 bits ≈ 1e19 keyspace, fine for fingerprint
rate-limiting where collisions are caught by the rate limit
granularity anyway
- recovery.go: Recovery(logger) middleware — defer recover(),
logs panic + request_id + path + stack, writes a 500 with the
project's standard {success:false, error:{code,message}}
envelope. Per spec §11.1 line 1543
Tests (~16 cases): 11 source-IP precedence cases matching the
generator suites + OptionalHeader empty/whitespace/value + fingerprint
determinism/length/hex-format/different-IPs-distinct + KeyByFingerprint
prefix + recovery panic-returns-500 + recovery passes-through-non-panic
+ recovery logs request ID through the RequestID middleware chain.
Phase 9 task 9.1 + 9.4 discharged.
Phase 8 ships the SEVENTH AND FINAL generator. All 7 token types
(webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql) are now
registered; the canary token-type set is closed. Phases 9-18 are
service/handler/wire-up/notification/manage-page/admin/geoip/frontend
work — no more generators.
mysql is the only non-HTTP generator: the trigger fires when an
attacker tries to authenticate against our fake MySQL server using
the canary-issued connection string. We accept the TCP connection,
write a real-looking HandshakeV10 packet (5.7.40-canary banner +
mysql_native_password auth plugin + valid capability mask), read the
client's HandshakeResponse41, extract the username (which is
"canary_"+token.ID per spec §9.7 line 1341), look up the token by
stripping the prefix, record an event with the kubectl-equivalent
forensic fields (mysql_username + mysql_client_capabilities formatted
as 0x%08x + mysql_client_charset), and respond with the canonical
ERR_Packet 1045 "Access denied" message before closing. To the
attacker the response is byte-indistinguishable from a real MySQL
server rejecting bad credentials.
Implementation commits in this phase:
- 712202ab feat(canary): mysql wire protocol (handshake + auth +
ERR packets)
- 4d4d4951 feat(canary): mysql server + connection handler
- fe80c94a feat(canary): mysql generator + register in registry
Deliverables:
- protocol.go: byte-exact HandshakeV10 builder per spec §9.7 lines
1366-1379 (3-byte LE length + seq id, protocol version 0x0a,
"5.7.40-canary\x00", random conn id, 8+12 split auth-plugin-data,
capability flags 0xf7ff/0x81ff, charset 0x21, status 0x0002,
auth-plugin-data length 0x15, mysql_native_password); ClientAuth
parser for HandshakeResponse41; BuildAccessDeniedErr ERR_Packet
with code 1045 + SQL state 28000; crypto/rand-backed
NewRandomAuthData (20 bytes) + NewRandomConnectionID (uint32);
error sentinels (ErrShortPacket, ErrPacketTooLarge,
ErrInvalidPayload, ErrUsernameMissing, ErrHTTPTriggerNotSupported)
- handler.go: HandleConnection per spec §9.7 lines 1410-1442 —
10-second deadline, handshake-first, defense-in-depth silent
drops for non-canary username / unknown token / lookup error /
nil recorder; TokenLookup + EventRecorder interfaces decouple
from Phase 9/10 services
- server.go: ctx-aware net.ListenConfig listener, per-connection
goroutines drained via WaitGroup on shutdown, net.ErrClosed
silenced during graceful close
- generator.go: New() + NewWithAddress(host, port); Generate writes
mysql:// connection string and persists mysql_username into
t.Metadata via setMySQLUsername (map[string]json.RawMessage
merge — sanctioned deviation from spec §9.7 line 1343's
map[string]any indexing because real Token.Metadata is
json.RawMessage); Trigger returns ErrHTTPTriggerNotSupported
(TCP-only, no HTTP route)
- Registry expanded 6 → 7; TestBuild_AllSevenGeneratorsRegistered
closes the cardinality-test sequence (no more "pending types"
list — the set is complete)
~115 test cases added across 4 test files:
- protocol_test.go (~24): byte-exact packet header re-decode, LE
conn-id round-trip, 5-username Build/Read round-trip, error
paths (short, missing terminator, empty reader, oversize),
randomness distinctness for both crypto/rand helpers
- handler_test.go (~10): real socket pairs (net.Listen + Dial),
handshake-first assertion, non-canary username silent drop,
full known-token flow with ERR packet + event Extra capture,
unknown token silent, lookup error silent, nil EventRecorder
tolerated, bad auth packet silent
- server_test.go (~5): nil handler rejection, real-dial dispatch,
10 concurrent connections, ctx cancellation graceful shutdown,
invalid address rejection
- generator_test.go (~11): Type, KindConnectionString, default +
custom address, canary_ prefix invariant, metadata persistence
on empty token, existing-fields preservation (non-string field
round-trips), malformed-metadata recovery, stale-username
overwrite, baseURL irrelevance, Trigger sentinel error path
(happy + nil token)
Audit outcome (2 agents in parallel per standing pattern):
- superpowers:code-reviewer: PASS-WITH-NITS (0 BLOCKER, 0
SHOULD-FIX, 8 NITs all explicitly marked "not worth fixing" /
"leave as-is" / "theoretical, never happens")
- general-purpose spec-adherence vs §9.7 + sanctioned deviations:
PASS (0 BLOCKER, 0 SHOULD-FIX, 0 NITs — confirmed both
sanctioned deviations matched the slowredirect/envfile metadata
pattern and the TCP-only Trigger shape)
No audit-fix commit this phase. Matching Phase 5 / Phase 7 pattern
(Phase 6 was the only phase to require an audit-fix commit before
rollup).
DEFERRED TO PHASE 9 (sanctioned per handoff anti-relitigation):
- Task 8.5: wire `go mysql.Run(ctx, addr, handler)` into
cmd/canary/main.go via cfg.MySQL.Enabled + cfg.MySQL.Addr.
Requires config.Config.MySQL field which lands in Phase 9
alongside config.Config.Canary.BaseURL. Wiring it in Phase 8
would require partial config additions Phase 9 then re-touches.
- Task 8.6: manual `mysql -h ... -u canary_<id> -ptest internal_db`
smoke test. Operator-deferred per standing rule.
Pre-rollup acceptance gate clean at HEAD fe80c94a:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all packages pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... pass
- golangci-lint run ./... → 0 issues.
- grep -rn "//nolint" --include="*.go" returns nothing
- BACKLOG.md open section still _(none)_
- go.mod surface unchanged (no new deps for the entire phase)
# GENERATOR SET CLOSED
webbug (Phase 2) — /c/{id} GIF, simplest case
slowredirect (Phase 3) — HTML page + fingerprint endpoint
docx (Phase 4) — zip-patched OOXML INCLUDEPICTURE
pdf (Phase 5) — byte-replace fixed-width URI action
kubeconfig (Phase 6) — YAML + fake K8s /k/{id}/* handler
envfile (Phase 7) — shuffled bait + INTERNAL_METRICS_ENDPOINT
mysql (Phase 8) — TCP wire protocol + ERR_Packet 1045
Forward state for Phase 9 (token service + handlers + Turnstile +
fingerprint mw): wires the registry into cmd/canary/main.go, brings
config.Config.Canary{BaseURL} + config.Config.MySQL{Enabled,Addr,
PublicHost,PublicPort}, defines token.Service.Create (id+manage_id
generation, registry dispatch, repository persistence) and
token.Handler (POST /api/tokens, GET /tokens/types, route mounting
for /c/{id} + /c/{id}/fingerprint + /k/{id}/*), promotes the realIP
helper from webbug into middleware, adds turnstile verification +
fingerprint rate-limit middleware. Closes Phase 8's deferred task 8.5
by spawning `go mysql.Run(...)` from main.go.
Phase 8 third commit. Closes the generator set — all 7 token types are
now registered.
mysql/generator.go:
- Generator with publicHost / publicPort / database fields. New()
returns localhost:3306/internal_db defaults; NewWithAddress(host,
port) overrides for Phase 9 wire-up
- Generate writes mysql_username into t.Metadata (json.RawMessage
merge via setMySQLUsername helper — same defensive map[string]
json.RawMessage pattern as envfile's extractIncludeKeys: malformed
existing metadata is replaced rather than erroring, existing
fields preserved, prior mysql_username overwritten)
- Returns Artifact{Kind: KindConnectionString, ConnectionString:
"mysql://canary_<id>@<host>:<port>/internal_db"} per spec §9.7
lines 1345-1352. baseURL ignored (mysql connection strings don't
use it)
- Trigger(ctx, t, r) returns (nil, nil, ErrHTTPTriggerNotSupported)
sentinel error — mysql triggers fire over the TCP listener (see
server.go/handler.go), not via the HTTP router. Phase 9's router
will not mount any HTTP route to mysql.Trigger; this is defense
for accidental programmatic calls
- Note on spec §9.7 line 1343: `t.Metadata["mysql_username"] =
username` doesn't compile against the real Token.Metadata
(json.RawMessage). The setMySQLUsername helper is the sanctioned
*string-shape adaptation, matching the slowredirect/envfile
metadata patterns
mysql/generator_test.go (~11 cases):
- Type / KindConnectionString / default connection string format /
custom address / canary_ prefix invariant / metadata persistence
on empty token / preservation of existing metadata fields /
malformed-metadata fallback / stale-mysql_username overwrite /
baseURL ignored / Trigger returns ErrHTTPTriggerNotSupported with
nil event + nil response
registry update: cardinality 6 → 7; pending list now empty.
TestBuild_AllSevenGeneratorsRegistered replaces the
TypesPresentInPhaseN test pattern from prior phases, asserting all 7
generator types are present (closes the generator set; future phases
add services/handlers, not generators).
DEFERRED TO PHASE 9 (task 8.5): wiring `go mysql.Run(ctx, addr,
handler)` into cmd/canary/main.go. The handoff anti-relitigation set
documents "Phase 9 wires the registry into main.go" — the mysql
listener goroutine fits naturally into Phase 9's main.go work
alongside config.Config.MySQL.Enabled, config.Config.MySQL.Addr,
the token.Service (for TokenLookup), and the event.Service (for
EventRecorder). Wiring it in Phase 8 would require partial config
additions that Phase 9 then re-touches.
Pre-rollup acceptance gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all packages pass (~115 tests
added across Phase 8 alone)
- go test -tags=integration -race -timeout=300s pass
- golangci-lint run ./... → 0 issues.
- zero //nolint pragmas anywhere
Phase 8 second commit. TCP listener orchestration + per-connection
business logic.
server.go (~70 LOC):
- mysql.Run(ctx, addr, ConnectionHandler) error
Listens via net.ListenConfig (context-aware), accepts in a loop,
dispatches each connection to handler.HandleConnection in its own
goroutine, waits on context cancellation to close the listener
and drain in-flight connections via WaitGroup
- ConnectionHandler interface (single HandleConnection method) so
tests can substitute a stub without standing up real handler deps
- Logs listener bind + accept errors via slog; suppresses net.ErrClosed
on graceful shutdown so the shutdown path is silent
handler.go (~170 LOC):
- TokenLookup interface (GetByID) and EventRecorder interface (Record)
decouple the handler from Phase 9's token.Service and Phase 10's
event.Service. Production wire-up in Phase 9 (deferred — see Phase
8 rollup notes on task 8.5 deferral)
- HandleConnection writes HandshakeV10 → reads HandshakeResponse41 →
extracts username → strips canary_ prefix → looks up token →
records event with mysql_username + mysql_client_capabilities +
mysql_client_charset in event.Extra (mirrors kubeconfig's
kubectl_* forensic capture) → writes ERR_Packet 1045 → defers
close. 10-second connection deadline.
- Defense-in-depth: usernames without canary_ prefix dropped
silently, unknown tokens dropped silently, lookup errors dropped
silently — no behavior signal to a probing attacker
- Nil EventRecorder tolerated (writes ERR but skips event) — Phase 9
can wire the handler before event.Service exists if needed
Tests (~14 cases):
- server: nil-handler rejected, basic dispatch, 10 concurrent
connections, context cancellation closes listener cleanly,
invalid address rejected
- handler: handshake sent first, non-canary username drops silently,
known token records event AND sends ERR, event.Extra carries
all three kubectl-equivalent fields with correct hex formatting,
unknown token records nothing, lookup error silent, nil
EventRecorder tolerated (still sends ERR), bad auth packet
silent
- Uses real net.Listener + net.Conn pairs (not mocks) for
behavioral fidelity — TCP wire-protocol tests should hit real
sockets
go test -race -timeout=60s passes. golangci-lint clean.
Phase 8 first commit. Pure encoding/decoding layer for the MySQL
wire protocol — no I/O orchestration, no DB lookups, no business
logic. Three primary functions plus utility helpers, all driven by
the byte-exact spec §9.7 lines 1363-1403:
- BuildHandshakeV10(connID, authData[20]) []byte
Server's initial greeting packet (0x0a protocol version,
"5.7.40-canary" server version, random auth-plugin-data split
into 8+12 byte parts, capability flags 0xf7ff / 0x81ff,
utf8mb4_unicode_ci charset, mysql_native_password plugin name).
Wraps in 3-byte LE length + 1-byte sequence ID (0x00).
- ReadClientAuth(r io.Reader) (*ClientAuth, error)
Parses HandshakeResponse41. Returns the username (null-terminated,
extracted starting at byte offset 32 after the 4+4+1+23-byte
fixed header) plus the client's capabilities, max packet size,
and charset for forensic richness. Defensive bounds checking
against ErrInvalidPayload / ErrUsernameMissing / ErrShortPacket /
ErrPacketTooLarge sentinels. 64KB max packet size cap.
- BuildAccessDeniedErr(username, sourceHost string) []byte
Standard MySQL ERR_Packet 1045 with SQL state "28000" and the
exact "Access denied for user '%s'@'%s' (using password: YES)"
message kubectl and the mysql client both display verbatim.
Sequence ID 0x02 (after handshake=0 and client auth=1).
Plus crypto/rand-backed helpers NewRandomAuthData (20 bytes for the
challenge) and NewRandomConnectionID (uint32). Consistent with the
crypto/rand discipline from Phase 0 supplement, Phase 5 (pdf), Phase 7
(envfile).
Tests (~24 cases): byte-exact assertions on packet headers, payload
layout, sequence IDs; round-trip Build/Read for 5 username variants;
error paths (short packet, missing username terminator, empty reader,
oversize packet rejected mid-stream); randomness distinctness (50
calls → near-50 unique values).
Handler + server land in next commit; generator + registry in the
one after. Trigger interface conformance for the mysql Generator will
be a sentinel-error no-op (mysql triggers over TCP, not HTTP — Phase
9 router doesn't mount any HTTP path to mysql.Trigger).
go test -race -timeout=60s ./internal/token/generators/mysql/...
passes, golangci-lint clean.
Phase 7 ships the sixth generator in the canary registry. envfile
canary tokens are rendered .env-style text artifacts containing
shuffled bait sections (aws/stripe/github/db) plus an
INTERNAL_METRICS_ENDPOINT line carrying the actual canary URL.
The bait keys look real enough to pass gitleaks-class regex
scanners but are NOT live credentials — the detection mechanism
is the embedded canary URL.
Implementation commits in this phase:
- 003aa997 feat(canary): envfile bait recipes (aws/stripe/github/db)
- 2a2a3c4d feat(canary): envfile generator (shuffled bait + embedded
canary URL)
- 1cde61ef feat(canary): register envfile generator in registry
Deliverables:
- envfile/recipes/ sub-package with 4 bait recipes + shared
crypto/rand helpers (RandomAlnumUpper/RandomAlnumMixed/
RandomHexLower/RandomBase64/RandomChoice)
- aws.go: AKIA + 16-char [A-Z0-9] body + 40+ base64 secret + region
+ bucket
- stripe.go: sk_live_/pk_live_/whsec_ + 24/24/32 base62 bodies
- github.go: ghp_ + 42 base62 (36 body + 6 checksum) + base64
deploy key
- db.go: postgres://app_writer:<24-char>@db.internal:5432/app_prod
+ redis://default:<32-char>@cache-prod.internal:6379/0
- envfile/generator.go: extractIncludeKeys with 7 defensive fallback
cases (empty/invalid/wrong-type/null/empty-array/missing/whitespace)
+ Fisher-Yates shuffle on crypto/rand + canary section appended
last before shuffle so canary URL can land at any position
- Spec §9.6 deviation: crypto/rand throughout (vs spec's math/rand
+ rng.Shuffle) — matches Phase 0 supplement's discipline, no
gosec G404 fight, consistent with Phase 5 (pdf) and Phase 6
(kubeconfig)
- Trigger: byte-identical content-copy of webbug/docx/pdf
(200 + pixel.Clone GIF + cache headers + realIP triplet)
- Registry expanded 5 → 6; pending list shrinks to {mysql}
(1 remaining — Phase 8)
- ~60 test cases across recipes + generator including 11-subcase
IP precedence parity, 6 malformed-metadata fallback subcases,
shuffle variability across 30 invocations, format-regex assertions
per recipe, URL parsing for postgres/redis (real net/url validation),
token-id-leak invariant (uniqueprobe appears exactly once)
Audit outcome (2 agents in parallel per standing pattern):
- superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 6 NITs
all marked informational / below-threshold / acceptable-trade)
- general-purpose spec-adherence vs §9.6 + §8.5: PASS (0 BLOCKER,
0 SHOULD-FIX, 2 LOW informational items — hostname pick is one of
spec's enumerated values, extra realism keys are additions not
violations)
No audit-fix commit needed this phase — zero actionable findings,
matching the Phase 5 pattern (Phase 6 had one actionable header-
filename NIT cleared before rollup; Phase 7 is fully clean).
Pre-rollup gate clean at HEAD 1cde61ef:
- go build / vet clean
- go test -race -timeout=60s ./... all packages pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... all pass
- golangci-lint run ./... → 0 issues.
- grep -rn "//nolint" --include="*.go" returns nothing
- BACKLOG.md open section still _(none)_
- go.mod surface unchanged (no new deps for this phase)
Forward state for Phase 8 (mysql fake server): the LAST generator.
TCP wire-protocol listener (NOT HTTP) that accepts MySQL handshake,
extracts username from the auth packet, looks up token by
metadata.mysql_username, records event, returns ERR_Packet 1045.
First time we spawn a non-HTTP listener from main.go (cmd/canary
wire-up via `if cfg.MySQL.Enabled { go mysql.Run(...) }`). All
7 generators close after Phase 8.
Phase 6 ships the fifth generator in the canary registry plus the
first fake-API style trigger response. kubeconfig canary tokens are
rendered YAML pointing kubectl at /k/{id}; on any HTTP call to that
path, the handler records kubectl_path/method/query/ua in event.Extra
and responds with a Kubernetes-shaped 403 Status JSON whose message is
parameterized by the resource and the HTTP verb so the attacker sees
real-looking "Error from server (Forbidden): X is forbidden..." output.
Implementation commits in this phase:
- 6f994f37 feat(canary): kubeconfig generator + YAML template
- bd0c6646 feat(canary): kubeconfig fake K8s API handler
(forbidden response)
- ceec4d37 feat(canary): register kubeconfig generator in registry
- 982beafd fix(canary-phase6): address audit nit before rollup
Deliverables:
- text/template-rendered kubeconfig with cluster=prod-cluster,
user=svc-backup-reader, bearer=t.ID, server=baseURL+/k/+t.ID
- template.yaml.tmpl convention (not template.yaml) so pre-commit
check-yaml hook doesn't trip on Go template tokens
- K8s Status JSON 403 response with verb derived from HTTP method
(GET/HEAD → list, POST → create, PUT → update, PATCH → patch,
DELETE → delete, others → list) — richer than spec §9.5's
hardcoded "list", forensic enhancement
- Resource extraction via path.Base with empty/root fallback to
"resource"
- Event.Extra captures kubectl_path/method/query/ua as JSON
- Defense-in-depth: nil-token path returns 403 + valid Status JSON
+ nil event (FK-safe), response shape byte-identical to valid-
token case modulo the resource/verb message slots
- Registry expanded 4 → 5; pending list shrinks to {envfile, mysql}
- ~36 test cases including YAML round-trip parsing, 11-subcase IP
precedence parity with docx/pdf, verb-derivation table, status
JSON shape validation, nil-token shape equivalence
Audit outcome (2 agents in parallel per standing pattern):
- superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 5 NITs
all marked optional / defensible / not-actionable)
- general-purpose spec-adherence vs §9.5 + §8.5: PASS (0 BLOCKER,
0 SHOULD-FIX, 1 actionable NIT cleared in 982beafd, 2 INFO items
documenting sanctioned deferrals to later phases)
Pre-rollup acceptance gates clean at HEAD 982beafd:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... — all packages pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... — all pass
- golangci-lint run ./... → 0 issues.
- grep -rn "//nolint" --include="*.go" returns nothing
- BACKLOG.md open section still _(none)_
Forward state for Phase 7 (envfile generator + bait recipes): pure-
string-rendering generator using crypto/rand + math/rand seeded from
crypto/rand for shuffled bait sections (aws/stripe/github/db) plus
the actual canary URL line embedded as INTERNAL_METRICS_ENDPOINT.
Trigger pattern returns to webbug-style (/c/{id} GIF). No new HTTP
route shape needed.
Fifth entry in the registry map. Cardinality bumps 4 → 5; pending list
shrinks to {envfile, mysql} (2 remaining).
- registry.go: adds the import + token.TypeKubeconfig: kubeconfig.New()
- registry_test.go:
- TestBuild_RegistersKubeconfig added (mirrors RegistersPDF)
- TypeKubeconfig removed from TestBuild_PendingTypesNotYetRegistered
- TestBuild_OnlyExpectedTypesPresentInPhase5 renamed → InPhase6,
cardinality assertion bumped 4 → 5
Pre-audit gate clean at HEAD: go build ./... + go vet ./... + go test
-race -timeout=60s ./... + go test -tags=integration -race -timeout=300s
./internal/token/... ./internal/event/... + golangci-lint run ./...
all pass with 0 issues.
Trigger implementation completes the kubeconfig Generator's interface
conformance. Behavior matches spec §9.5 + §8.5 defense-in-depth:
- ALWAYS returns 403 + Kubernetes-shaped Status JSON (whether the
token is valid or not). Attackers cannot distinguish "valid
token, no permission" from "no such token" by status code or
response shape — both are 403 with byte-identical structure modulo
the resource/verb message slot.
- Status JSON conforms to k8s.io Status object: kind=Status,
apiVersion=v1, metadata={}, status=Failure, reason=Forbidden,
code=403, message=verisimilar real-kubectl error string.
- Message format mirrors kubectl's actual output:
<resource> is forbidden: User "system:anonymous" cannot <verb>
resource "<resource>" in API group "" in the namespace "default"
where <resource> is the last segment of r.URL.Path and <verb> is
derived from the HTTP method (GET/HEAD → list, POST → create, PUT
→ update, PATCH → patch, DELETE → delete, others → list). This is
richer than spec §9.5's hardcoded "list" — kubectl ships the verb
based on the action it's attempting, so a /apis/.../pods DELETE
response saying "cannot list" would tip the attacker off. Method
mapping is forensic gold for the operator (which kubectl action
actually fired) and verisimilar to the attacker.
- Event.Extra captures the kubectl_path / kubectl_method /
kubectl_query / kubectl_ua fields per spec §9.5 line 1234. UA
capture is the forensic prize — kubectl ships its version + arch
in the UA string ("kubectl/v1.30.0 (linux/amd64) kubernetes/...").
- Source-IP triplet (realIP / lastNonEmptyXFF / optionalHeader) is
a content-copy of webbug/docx/pdf — sanctioned duplication per
Phase 2/3/4 anti-relitigation set. Phase 9 middleware extraction
collapses these into one shared helper.
- Nil-token path returns the same 403+Status with verisimilar
message + headers, and nil event so the handler cannot persist
a row with empty TokenID (FK violation) — spec §8.5.
Tests cover (~36 cases including subtests):
- 403 + JSON content-type + cache headers
- Status JSON shape (Kind/APIVersion/metadata={}/Status/Reason/Code)
- Message resource extraction: pods, secrets, single-resource get,
API version probe, trailing slash, healthz probe (6 paths)
- Verb derivation from HTTP method (7 methods, 5 distinct verbs)
- User "system:anonymous" impersonation present
- kubectl_path/method/query/ua in Extra JSON
- Empty query string handled
- Source-IP precedence (11 subcases mirroring docx/pdf for parity)
- Missing UA/Referer → nil pointers
- Nil-token defense-in-depth: still returns 403 + parseable Status,
nil event, response-shape parity with valid-token case (HTTP code
+ JSON kind/version/status/reason/code all identical so an
attacker cannot probe token validity by diffing responses)
Generator now satisfies generators.Generator interface. Registry wire-up
in next commit.
Phase 6 first commit. Generator produces a real-looking kubeconfig
artifact that points kubectl at the canary's /k/{id} endpoint:
- text/template embedded YAML following the spec §9.5 layout (one
cluster + one context + one user, all referencing the same
prod-cluster + svc-backup-reader names for verisimilitude)
- APIServerURL = baseURL + "/k/" + t.ID (trailing-slash safe via
strings.TrimRight)
- Token = t.ID embedded as the user's bearer token — when kubectl
fires a request, the bearer in the Authorization header is exactly
this id, giving us a second lookup path beyond the URL path
- ClusterName = "prod-cluster", UserName = "svc-backup-reader" —
hardcoded per spec §9.5 lines 1193-1194 (tempting bait names that
look like a real prod service-account kubeconfig)
- Artifact.Kind = KindText (different from docx/pdf KindFile — YAML
is text)
- ContentType "application/yaml"
- Filename default "kubeconfig" with the standard *string deref+trim
pattern from docx/pdf
The template file is named template.yaml.tmpl (not .yaml) so the
repo's pre-commit check-yaml hook does not parse it as pure YAML —
the {{.X}} Go-template tokens are not valid YAML flow mappings and
would fail the parser. .tmpl is the conventional extension for
Go-templated source files; identify-cli (which check-yaml uses to
match files) does not classify .yaml.tmpl as YAML. Future phases
needing templated YAML (envfile recipes, etc.) should follow the
same convention.
Tests parse the rendered output through gopkg.in/yaml.v3 against a
typed schema struct, asserting every field flows through correctly:
APIVersion, Kind, current-context, Clusters[0].name + .cluster.server,
Contexts[0].name + .context.cluster + .context.user, Users[0].name +
.user.token. Plus Filename defaulting subtests, base-URL
trailing-slash trim, subpath preserve, distinct-ids → distinct
outputs, bearer-token-embedded check, ends-with-newline check.
Handler (Trigger) ships in the next commit per spec §9.5 file split
({generator, handler, template}). At this commit kubeconfig.Generator
has Type() + Generate() but no Trigger() — the package compiles in
isolation but does not yet satisfy generators.Generator interface.
The registry wires it in commit 3.
go test -race -timeout=60s ./internal/token/generators/kubeconfig/...
passes.
Phase 5 ships the fourth generator in the canary registry. PDF canary
tokens are produced by in-place byte-replacing a 76-char fixed-width
placeholder in a committed template.pdf — the placeholder occupies a
direct dictionary value in the page's /AA /O /URI action (uncompressed,
not inside any Flate stream), so substitution preserves byte length and
keeps the cross-reference table valid.
Implementation commits in this phase:
- c9c93332 feat(canary): pdf template (binary blob + build helper)
- 13cd6cd1 feat(canary): pdf generator (byte-replace fixed-width URI action)
- 73dd8373 feat(canary): register pdf generator in registry
Deliverables:
- 477-byte committed template.pdf (sha256 70c63590…7e103), reproducible
via go run ./cmd/buildpdftemplate -out ... (rebuild produces byte-
identical output)
- cmd/buildpdftemplate hand-builds the PDF-1.4 with computed xref byte
offsets, validates via pdfcpu.api.Validate before writing
- pdf.Generator: ErrTriggerURLTooLong sentinel, length-preservation
guard, post-substitution bytes.Contains check, _ underscore padding
(handoff overrides spec §9.4 line 1150 space padding — see anti-
relitigation set), defaultFilename fallback for nil/empty/whitespace
*string Filename
- 22 top-level tests + 11 IP-precedence subtests + 4 filename subtests
+ 3 trigger-URL subtests = ~40 effective cases under -race. All 5
spec-required tests present (LengthUnchanged, ContainsTriggerURL,
PlaceholderRemoved, TooLongURL_ReturnsError, PdfcpuValidates) plus
template-invariant + boundary + same-position regression guards
- Registry expanded 3 → 4; pending list shrinks to {kubeconfig,
envfile, mysql}
Audit outcome (2 agents in parallel per standing pattern):
- superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 5 NITs all
flagged by the agent as "not actionable" / "leave as-is")
- general-purpose spec-adherence vs §9.4 + §8.5: PASS
(0 BLOCKER, 0 SHOULD-FIX, 2 NITs: N1 was a miscount — pdf has 11 IP
precedence subcases identical to docx, verified via grep; N2 was the
Chromium/PDF.js /AA-stripping caveat, sanctioned-deferred to
learn/04-CHALLENGES.md per the post-Phase-17 learn/ allowance)
No audit-fix commit needed this phase — zero actionable findings. Unlike
Phase 3 (a46fa446) and Phase 4 (10ae118d) which cleared real findings
before rollup, Phase 5 lands clean.
Pre-rollup acceptance gates clean at HEAD 73dd8373:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... — all packages pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... — all pass
- golangci-lint run ./... → 0 issues.
- grep -rn "//nolint" --include="*.go" returns nothing (lint discipline
inherited from Phase 0 supplement preserved)
- BACKLOG.md open section still _(none)_
Forward state for Phase 6 (kubeconfig generator): different shape from
file-artifact generators — kubeconfig is YAML text + a path-based
/k/{id}/* handler that records kubectl method/path/UA and responds with
a Kubernetes-shaped 403. Trigger pattern departs from the /c/{id}+GIF
convention webbug/slowredirect/docx/pdf share.
Adds the foundations tier: three Python projects pitched at someone
who has never written Python before, ramping from a single-file hash
identifier up to the cryptographic boundary of the beginner tier.
Projects
- hash-identifier: identify ~30 hash formats by prefix, length, and
charset (one file, ~30 tests).
- http-headers-scanner: fetch a URL and grade its security headers
A–F (httpx + rich + respx, single-file scanner, 13 tests).
- password-manager: Argon2id + AES-256-GCM encrypted CLI vault with
atomic durable writes and advisory file locking (the hardest
foundations project — stepping stone into the beginner tier).
Each project ships with
- Heavily-commented source as a teaching aid (foundations-tier override
of the no-comments rule — explanations targeted at first-time readers).
- A full learn/ folder: Overview, Concepts, Architecture, line-by-line
Implementation walkthrough, Challenges.
- README with ASCII banner, badges (incl. tier + difficulty), demo
sessions, command tables, learn-folder index, see-also links.
- install.sh + justfile + uv-managed dependencies.
- pytest + ruff + mypy + pylint config, all linters at 10.00/10.
Also centralizes the docs/ exclude in .gitignore (was a per-project
list of advanced/beginner docs paths) so dev-only audit reports and
plan documents stay local across the whole repo without needing
individual entries.
Phase 5 first commit. Adds the committed template.pdf binary blob plus
the pure-Go one-shot utility that produced it, matching the docx Phase
4 pattern (cmd/build<format>template/main.go excluded from production).
cmd/buildpdftemplate/main.go assembles a minimal PDF-1.4 by hand:
- Header: %PDF-1.4 + 4 high-bit bytes to mark as binary
- Object 1: Catalog → Pages root
- Object 2: Pages → single Kids ref + Count 1
- Object 3: Page with /AA << /O << /Type /Action /S /URI /URI (...) >> >>
where the URI value is the 76-char placeholder
HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH______________________________________
- xref table with computed byte offsets
- trailer + startxref + %%EOF
The placeholder is a direct dictionary value (NOT inside a stream), so
substitution can be a plain byte-replace at runtime without breaking the
cross-reference table — spec §9.4 line 1133. No FlateDecode anywhere.
pdfcpu (v0.12.1) is used for validation only — the builder calls
api.Validate before writing, and tests will call it again on substituted
output to confirm the PDF stays well-formed.
Verification at HEAD:
- 477-byte template.pdf
- sha256: 70c6359016ceb780539f8c4497991b98ff82201e35a38d36090d88856027e103
- Reproducible: rebuild produces byte-identical output
- grep -aob HONEY_TRACK_URL → one offset (240)
- file: PDF document, version 1.4, 1 page(s)
- api.Validate passes
go.mod adds pdfcpu as a direct dep with its transitive deps via go mod
tidy. golang.org/x/crypto returns to direct (pdfcpu uses it for AES
encryption support).
The standing "no //nolint anywhere" rule (handoff anti-relitigation
set) was honored by Phases 2-4 but inherited template code in
core/database.go, middleware/ratelimit.go, and health/handler.go
carried 6 pragmas from the original template import. Replacing each
with proper error handling rather than silencing the linter:
core/database.go
NewDatabase ping-failure path: propagates db.Close() error via
multi-%w fmt.Errorf rather than discarding it via _ = db.Close().
Pattern matches the existing rollback error wrap on line 102.
InTx + InTxWithOptions panic-recovery defer: rollback failure now
logs via slog.Error before re-panicking, rather than discarding
the error via _ = tx.Rollback(). The original error context is
preserved by the panic; the rollback failure is observable.
jitteredDuration: switches from math/rand/v2 to crypto/rand +
math/big. The function runs once per pool init at startup so the
per-call cost (~microseconds) is irrelevant. Eliminates the gosec
G404 trigger without a global exclude (handoff anti-relitigation:
"don't add new excludes for one-off issues"). Adds the missing
maxJitter <= 0 guard that the rand.Int64N call would have panicked
on with a base smaller than 7ns.
middleware/ratelimit.go
writeRateLimitExceeded: json.NewEncoder().Encode error is now
logged via slog.Error rather than discarded. slog is already
imported and used elsewhere in the file (line 60).
health/handler.go
writeStatus: same pattern; adds log/slog import. Brings the file
in line with the rest of the codebase's slog-everywhere discipline.
After: zero //nolint pragmas anywhere in backend Go code
(grep -rn "//nolint" returns nothing). golangci-lint run ./... reports
0 issues. All unit tests pass under -race; all integration tests pass
under -race -tags=integration with the testcontainer Postgres.
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing the Phase 0 lint-discipline alignment so Phases 5+
inherit a fully consistent codebase.
Auth-template residue in core/errors.go + core/response.go:
- UnauthorizedError() / ForbiddenError() + Err{Unauthorized,Forbidden}
sentinels
- TokenExpiredError() / TokenInvalidError() / TokenRevokedError() +
Err{TokenExpired,TokenInvalid,TokenRevoked} sentinels
- core.Unauthorized() / core.Forbidden() response wrappers
Zero call sites anywhere in the codebase. Worse, the Token*Error names
semantically collide with canary tokens (same package, completely
unrelated meaning) — a future-reader trap that would compound once
Phase 9 wires real operator-bearer auth gates on the admin handler.
Generic AppError plumbing (NewAppError, NotFoundError, DuplicateError,
ValidationError, InternalError, RateLimitError, IsAppError, GetAppError)
is preserved — all in active use.
When Phase 9 needs operator-bearer or turnstile-gated responses, helpers
can be reintroduced with names appropriate to the actual auth design
rather than carrying generic JWT-shaped vocabulary forward into a
canary-token namespace.
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared per
the fix-in-phase rule. BACKLOG closed section records the resolution.
internal/core/security.go was preserved through Phase 0's auth prune
untouched. Audit: zero callers anywhere in the codebase, yet the file
kept golang.org/x/crypto as a direct dep and ran a full Argon2id KDF
on every process start via a package-level init() that pre-computed
a dummy hash "to prevent timing attacks" — wasted boot CPU for an
unused codepath.
Deleted symbols:
- HashPassword, VerifyPassword, VerifyPasswordWithRehash
- VerifyPasswordTimingSafe + dummyHash init()
- decodeHash, needsRehash internal helpers
- GenerateSecureToken, GenerateRefreshToken
- HashToken, CompareTokenHash
go mod tidy demotes golang.org/x/crypto from direct to indirect
(pgx/v5 still pulls it transitively).
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing Phase 0's auth prune rather than logged as deferred
work. BACKLOG closed section records the item + resolution.
Phase 4 ships the third generator in the canary registry. docx canary
tokens are produced by zip-patching a committed template.docx — the
HONEY_TRACK_URL placeholder in word/footer2.xml's INCLUDEPICTURE field
is replaced at Generate time with the canary's /c/{id} trigger URL.
Per-entry zip Method (STORE/DEFLATE) is preserved so Word/LibreOffice
still opens the document. Trigger is shared with webbug (spec §9.3
line 1118): 200 + 43-byte transparent GIF + cache headers; nil-token
returns the same response per spec §8.5 defense-in-depth.
Implementation commits:
f36cce9a feat(canary): docx template (binary blob + build helper)
82bb3a9f feat(canary): docx generator (zip-patch INCLUDEPICTURE)
5884f986 feat(canary): register docx generator in registry
Audit (two agents in parallel; spec §9.3 + §8.5):
superpowers:code-reviewer PASS — 0 BLOCKER / 0 SHOULD-FIX / 3 NIT
general-purpose (spec adherence) PASS — 0 SPEC-VIOLATION, 6/6 invariants MATCH
Cleared in-phase via 10ae118d (fix(canary-phase4): address audit nits
before rollup):
N1 — added 'RemoteAddr loopback IPv6 [::1]:9999' + 'XFF IPv6 rightmost'
subtests to docx generator_test.go for symmetry with webbug
N2 — renamed shadowed cErr to hErr in patchTemplate's loop body
N3 — newDocxToken helper sets Memo/Type matching webbug's pattern;
not actionable (stylistic positive)
Decisions baked in this phase (anti-relitigation):
- Spec §9.3 Filename example (`if t.Filename == ""`) does not compile
against the actual schema — Token.Filename is *string per entity.go:52.
resolveFilename safely deref's, trims, and falls back to 'Document.docx'
for nil / empty / whitespace pointers.
- Spec example does not propagate rc.Close() after io.ReadAll — the
implementation does, ordered so a read error wins over a close error
(required by .golangci.yml errcheck.check-blank: true).
- cmd/builddocxtemplate is a pure-Go OOXML builder rather than the
spec's suggested LibreOffice/python-docx workflow. Matches the
Phase 5 cmd/buildpdftemplate precedent in the implementation plan.
Lives at cmd/builddocxtemplate (excluded from the production binary).
- cmd helper uses filepath.Clean(out) before os.OpenFile to satisfy
gosec G304 on an operator-supplied -out flag — no //nolint pragma
(banned by project rule).
State after Phase 4:
Registered generators: 3 (webbug, slowredirect, docx)
Pending generators: 4 (pdf, kubeconfig, envfile, mysql)
BACKLOG.md open section: still empty
Pre-audit gate: BUILD/VET/unit/integration/lint clean across backend
Manual smoke (Task 4.6) is operator-deferred: open a generated docx
in LibreOffice with a network monitor and confirm the GET to /c/{id}
fires when the document opens. Not blocking phase rollup.
Phase 5 next: cmd/buildpdftemplate + pdf generator with byte-padded
HONEY_TRACK_URL_PADDED_…_76 placeholder in an /AA /O /URI action.
Phase 4 audit (2 agents) returned PASS with three NITs. N1 + N2 cleared
in-phase per the standing fix-in-phase rule. N3 was stylistic-consistency
positive and not actionable.
N1 — generator_test.go IP-precedence subtests: added two missing cases
present in webbug's identical suite (the realIP helper triplet is byte-
identical to webbug, so this is symmetry rather than coverage):
- "RemoteAddr loopback IPv6 strips brackets and port" → "[::1]:9999"
- "XFF IPv6 rightmost" → "198.51.100.1, 2001:db8::dead"
N2 — generator.go patchTemplate loop: the second `cErr := ...` shadowed
nothing but reused the name from the close-error block earlier in the
same iteration. Renamed the header-create variant to `hErr` for clarity.
Audit verdicts:
- superpowers:code-reviewer: PASS (0 B / 0 S / 3 N)
- general-purpose spec-adherence vs §9.3 / §8.5: PASS (0 violations,
6/6 invariants MATCH, BACKLOG.md still empty)
Phase 4 Task 4.3+4.4 — docx Generator that produces a tracked Word
document by embedding template.docx and runtime-substituting the
HONEY_TRACK_URL placeholder in word/footer2.xml with the per-token
trigger URL. Preserves per-entry zip Method (STORE/DEFLATE) so
Word/LibreOffice still opens the result; non-footer entry bodies are
byte-identical to the template.
Generate returns {Kind: KindFile, Filename: t.Filename ?? "Document.docx",
Content: <patched zip>, ContentType: wordprocessingml MIME}. The
spec §9.3 reference snippet uses `t.Filename == ""` which won't
compile against the real schema — Token.Filename is *string. The
generator dereferences safely via resolveFilename, trimming and
falling back to the default for nil/empty/whitespace pointers.
Trigger mirrors webbug exactly per spec §9.3 line 1118: 200 + 43-byte
transparent GIF via pixel.Clone() + cache-control no-store + pragma
no-cache. Nil-token returns the same response with nil event
(spec §8.5 — no token-existence enumeration). realIP /
lastNonEmptyXFF / optionalHeader helpers are copied from webbug per
the standing rule (sanctioned duplication until Phase 9 middleware
extraction).
24 test cases including the six prescribed by implementation plan
§4.3 (OutputIsValidZip, FooterContainsTriggerURL,
FooterDoesNotContainPlaceholder, OtherEntriesUnchanged,
PreservesCompressionMethods, ReturnsGIFLikeWebbug) plus surrounding
correctness/regression coverage (type, kind, content type, filename
defaulting incl. whitespace, trigger-URL trailing-slash + subpath +
uniqueness, source-IP precedence with 9 subtests mirroring webbug,
GIF body independence per Trigger call, nil-token defense).
Also fixes pre-existing lint debt in cmd/builddocxtemplate from
commit f36cce9a: errcheck on the deferred f.Close (now propagates
via named return) and gosec G304 on os.OpenFile (now filepath.Clean
on the operator-supplied -out path). Rebuilding template.docx after
this change produces byte-identical output (verified via sha256sum).
Phase 4 Task 4.1+4.2 — pure-Go OOXML template builder under
backend/cmd/builddocxtemplate/. Run:
go run ./cmd/builddocxtemplate \
-out ./internal/token/generators/docx/template/template.docx
produces a minimal valid .docx (5 zip entries, mixed STORE/DEFLATE
compression) whose word/footer2.xml carries an INCLUDEPICTURE field
referencing the literal placeholder string HONEY_TRACK_URL with the
\\d switch (forces fetch-on-open, no local cache).
The committed template.docx is the embedded source for the Phase 4
docx generator (next commit). Mixed compression methods in the template
make Task 4.3's TestGenerate_PreservesCompressionMethods a meaningful
regression guard — a hardcode-DEFLATE generator would now mismatch on
[Content_Types].xml + word/_rels/document.xml.rels (both STORE).
cmd/builddocxtemplate is excluded from the production binary (separate
cmd/ subdir from cmd/canary).