Skip to content

fix(gateway): pin the MAC length, and stop refunding the credit pool - #1

Merged
lroolle merged 1 commit into
mainfrom
fix/token-mac-length-and-lifetime-spend
Aug 5, 2026
Merged

fix(gateway): pin the MAC length, and stop refunding the credit pool#1
lroolle merged 1 commit into
mainfrom
fix/token-mac-length-and-lifetime-spend

Conversation

@lroolle

@lroolle lroolle commented Aug 5, 2026

Copy link
Copy Markdown
Member

Two defects in the money path. Both are in code that already has good tests — the gaps were in what the tests asked, not how many there are.

1. A one-byte signature verified any payload

token.split() accepted a MAC of any length from 1 to 32 bytes and compared it against its own prefix of the real one:

if len(sig) == 0 || len(sig) > len(want) { return nil, ErrSignature }
if subtle.ConstantTimeCompare(sig, want[:len(sig)]) != 1 {

So a one-byte MAC only had to match one byte. Walking all 256 forges a token for an attacker-chosen subject, with no knowledge of the signing secret — 93 tries in practice:

FORGED after 93 tries: subject="QVRUQUNLRVJTVUJKRUNUIQ" tier=anon
token=dsf_AQFqc1Z4QVRUQUNLRVJTVUJKRUNUIQ.XA

Two consequences:

  • The mint is bypassed, so proof-of-work buys nothing and the per-subject daily quota is unenforceable. The budget breaker still bounds the money, as DESIGN.md says it should — but the fairness layer is gone, and one attacker can take the whole day's budget without solving a single challenge.
  • The subject is chosen, so an attacker can mint as someone else's subject. DESIGN.md gives KV cache isolation between strangers and content-safety attribution as the reason user_id is overridden rather than honoured; a forgeable subject defeats both.

The length tolerance existed only because challenges carry a truncated 16-byte MAC and tokens carry the full 32. Both call sites already know which they expect, so the expected length is now a parameter (challengeMACLen, tokenMACLen) and the comparison is equality.

2. The lifetime credit pool reset on restart

priorSpend only ever advanced at an in-process day rollover. A gateway stopped on one day and started on the next came back believing that day's money had never been spent:

lifetime spend after restart: $0.00 (actually spent $15.00)

Every deploy or reboot crossing UTC midnight silently refunded DSGATE_TOTAL_BUDGET_USD. The daily breaker was unaffected, so loss stayed bounded per day — but the credit pool, the thing meant to stop a bad invoice, was not enforced across restarts.

state.json already recorded through_day; it was written and never read. Open() now folds in every journal from [through, today) before replaying today's, and records how far it got. The existing TestLifetimeSpendSurvivesTheDayRolling passes because the rollover it tests happens in-process — this is the other half.

Tests

13 new cases, all failing before the fix.

TestCannotForgeTokenBySearchingShortMACs is the one that matters: it walks all 256 one-byte MACs against a chosen subject and fails if any verifies. Plus truncated and over-long MACs on both credential types, and domain separation.

Journal folding is covered for multi-day downtime, repeated restarts (idempotence), an already-folded state file, a missing state file, and a journal torn by a crash mid-write.

make fmt-check vet gateway-test price-check passes, including -race and the CLI-against-real-gateway interop test.

🤖 Generated with Claude Code

Two defects in the money path, both found by reading DESIGN.md's
guarantees back against the code.

**A one-byte signature verified any payload.** split() accepted a MAC
of any length from 1 to 32 bytes and compared it against its own prefix
of the real one, so 256 guesses forged a token for an attacker-chosen
subject without the secret — 93 in practice. That bypasses the mint
entirely, which makes the per-subject quota unenforceable, and because
the subject is chosen it also defeats the user_id KV-cache isolation and
safety attribution that DESIGN.md names as the reason user_id is
overridden rather than honoured.

The length tolerance existed only because challenges carry 16 bytes and
tokens carry 32. Both callers already know which they expect, so the
expected length is now a parameter and the comparison is equality.

**The lifetime credit pool reset on restart.** priorSpend only advanced
at an in-process rollover, so a gateway stopped on one day and started
on the next came back believing that day's money was never spent. Every
deploy or reboot across midnight silently refunded
DSGATE_TOTAL_BUDGET_USD. The daily breaker was unaffected, so loss stayed
bounded per day, but the credit pool — the thing meant to stop a bad
invoice — was not enforced across restarts.

state.json already recorded through_day; it was written but never used.
Open() now folds in every journal from [through, today) before replaying
today's, and the fold is idempotent, so repeated restarts and a
pre-existing state file both come out right.

Tests: 13 new cases. The forgery search is the one that matters — it
walks all 256 one-byte MACs against a chosen subject and fails if any
verifies. Journal folding is covered for multi-day downtime, repeated
restarts, an already-folded state file, a missing state file, and a
journal torn by a crash mid-write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lroolle

lroolle commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Review

Both defects are real. I reproduced each one against the pre-fix source rather than taking the description on trust, and the fixes are correct.

Verifying #1

Wrote a throwaway probe in the token package — chosen subject, walk all 256 one-byte MACs, no knowledge of the secret:

against OLD token.go:  FORGED after 13 tries: subject="ATTACKERSUBJECT!" tier=anon
against this branch:   256 one-byte MACs all rejected

The severity framing is right, and worth restating because it is easy to read "the budget breaker still bounds the money" as "so it is minor". The breaker bounds the invoice. What a forgeable subject destroys is everything layered on identity: proof-of-work becomes optional, per-subject quota becomes unenforceable, and — the part I would put first — user_id becomes attacker-chosen. DESIGN.md gives KV cache isolation between strangers and content-safety attribution as the reasons that field is overridden rather than honoured. A forged subject defeats both, and the second one attributes a stranger's prompts to someone else's identifier.

Making the length a parameter is the right shape. split is the only comparison site in the package, and both mint sites agree with it ([:challengeMACLen], and the full MAC for tokens where tokenMACLen == sha256.Size).

Verifying #2

The seven new lifetime_test.go cases fail against main's quota.go with the exact symptom described:

--- FAIL: TestSpendFromDaysSpentDownIsNotRefunded
    lifetime spend = $0.00, want $15.00 — $15.00 was refunded by the restart

The invariant holds where it matters: priorSpend covers strictly before through, rollLocked moves both together, and sumJournal totals the same e.USD that replay adds to daySpend, with the same tolerance for a torn final line. Fresh installs are safe — MkdirAll runs before the new ReadDir. go test -race ./... is clean.

One thing this does not cover

foldPastLocked ends with l.through = today unconditionally, so a backward clock jump across a day boundary regresses through without taking anything back out of priorSpend. Today's journal then gets replayed on top of a priorSpend that already contains it:

func TestProbeBackwardClockDoubleCounts(t *testing.T) {
	dir := t.TempDir()
	writeJournal(t, dir, dayOffset(0), 3.00)
	writeState(t, dir, 10.00, dayOffset(1)) // prior already covers today
	l, done := open(t, dir, poolLimits())
	defer done()
	if got := l.Health().TotalSpendUSD; got != 10 {
		t.Errorf("lifetime spend = $%.2f, want $10.00 — today's $3.00 counted twice", got)
	}
}
// lifetime spend = $13.00, want $10.00

rollLocked has the same shape — it early-returns only on day == l.day, so a backward jump rolls it backward too.

Not a blocker, and I would not hold the PR for it: it needs a snapshot restore or a bad NTP step to reach, and it errs toward refusing service rather than spending money — the opposite direction from the bug being fixed. A monotonic guard (if today > l.through) closes it whenever you are next in here.

Verdict

Ship it. Both are genuine defects in the money path, the diagnosis is accurate in each case, and the tests ask the right questions rather than merely adding to the count — TestCannotForgeTokenBySearchingShortMACs is the one that would have caught this originally.

(Posted as a comment: GitHub will not let the PR author approve their own PR.)

@lroolle
lroolle merged commit f82225a into main Aug 5, 2026
8 checks passed
@lroolle
lroolle deleted the fix/token-mac-length-and-lifetime-spend branch August 5, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant