Skip to content

Commit 348714d

Browse files
raffelinoclaude
andcommitted
fix(reports): asset-token format → fixed-length sig, no period collision
CI flake during 0.9.0's first publish run surfaced a real production bug, not a test issue. `mint_asset_token` was encoding `<payload> b"." <32-byte-hmac>` and `verify_asset_token` recovered the two halves via `decoded.rsplit(b".", 1)`. That works ONLY when the random HMAC output contains no ASCII period byte (0x2E). With a 32-byte SHA-256 output the per-token collision rate is P("`.` somewhere in sig") = 1 − (255/256)**32 ≈ 11.8 % i.e. roughly one in nine asset-token verifications would silently fail and the user would get 401 → reload → fresh token → eventually work. Locally we got lucky for weeks; CI hit the bad bytes today. Fix: drop the printable separator entirely. SHA-256 output is always exactly `hashlib.sha256().digest_size` (32) bytes, so `payload, sig = decoded[:-_SIG_LEN], decoded[-_SIG_LEN:]` is unambiguous. The `_SIG_LEN` constant is derived from `hashlib` rather than literal-32 so a hypothetical future SHA-512 swap doesn't introduce a fresh off-by-31 bug. Token format change is wire-incompatible. Tokens minted with the old format (`.` separator) fail verify after the fix. Acceptable because: - TTL is 1 hour by default → at most one hour of in-flight tokens hit a 401, the user reloads, the freshly-minted post-fix token works. - Asset tokens never persist server-side (no DB row, no cookie outside the iframe URL the page just minted), so there's no upgrade-path migration to write. - Anyone bookmarking an iframe URL is already living dangerously vis-à-vis the 1-hour TTL. Regression test (`test_round_trip_when_signature_contains_period_byte`) sweeps `ttl_seconds` until the HMAC output contains a `.` byte (typically within ~10 attempts), then mints + verifies. Pre-fix that test fails deterministically; post-fix it passes deterministically. 107 / 107 reports tests green locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 55f36a8 commit 348714d

2 files changed

Lines changed: 73 additions & 6 deletions

File tree

backend/src/reports/asset_tokens.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,26 @@ def _b64url_decode(s: str) -> bytes:
5252
return base64.urlsafe_b64decode((s + pad).encode("ascii"))
5353

5454

55+
# SHA-256 output is always 32 bytes — the suffix length is fixed,
56+
# so we don't need a printable separator between payload and
57+
# signature. The previous implementation used `.` as a separator
58+
# and `decoded.rsplit(b".", 1)` to recover payload + sig. ASCII
59+
# `0x2E` (period) is a valid byte in a random HMAC, so the sig
60+
# itself contained one with ~12% probability (1 − (255/256)**32),
61+
# at which point `rsplit` split at the wrong byte, the payload
62+
# parse failed, and verify returned False — silently. Fixed-length
63+
# slicing has no such collision class.
64+
_SIG_LEN = hashlib.sha256().digest_size # 32
65+
66+
5567
def mint_asset_token(report_id: int, ttl_seconds: int = 3600) -> str:
5668
"""Create a token that grants read access to report ``report_id``
5769
for the next ``ttl_seconds`` seconds.
5870
"""
5971
expiry = int(time.time()) + ttl_seconds
6072
payload = f"{report_id}:{expiry}".encode("ascii")
6173
sig = hmac.new(_key(), payload, hashlib.sha256).digest()
62-
return _b64url_encode(payload + b"." + sig)
74+
return _b64url_encode(payload + sig)
6375

6476

6577
def verify_asset_token(token: str, report_id: int) -> bool:
@@ -74,11 +86,12 @@ def verify_asset_token(token: str, report_id: int) -> bool:
7486
except (ValueError, binascii.Error):
7587
return False
7688

77-
# Signature is the LAST 32 bytes (SHA-256 output). The payload is
78-
# everything before the final `.` separator.
79-
if b"." not in decoded:
89+
if len(decoded) <= _SIG_LEN:
90+
# Need at least one payload byte — `:` separator + at least
91+
# one digit per side — but the conservative lower bound is
92+
# "more than the signature itself".
8093
return False
81-
payload, sig = decoded.rsplit(b".", 1)
94+
payload, sig = decoded[:-_SIG_LEN], decoded[-_SIG_LEN:]
8295
expected = hmac.new(_key(), payload, hashlib.sha256).digest()
8396
if not hmac.compare_digest(sig, expected):
8497
return False

backend/tests/reports/test_asset_tokens.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,61 @@ def test_rejects_tampered_payload(self):
5959
def test_rejects_empty_or_garbage(self):
6060
assert verify_asset_token("", report_id=1) is False
6161
assert verify_asset_token("not-base64-or-anything-meaningful!", report_id=1) is False
62-
assert verify_asset_token("aGVsbG8=", report_id=1) is False # valid b64, no '.'
62+
assert verify_asset_token("aGVsbG8=", report_id=1) is False # valid b64, too short
63+
64+
def test_round_trip_when_signature_contains_period_byte(self):
65+
"""Regression: previously the encoded form was `<payload> .
66+
<sha256-sig>` and verify used `decoded.rsplit(b'.', 1)`.
67+
Random SHA-256 output contains the ASCII period byte (0x2E)
68+
with ~12 % probability per token (1 − (255/256)**32), at which
69+
point rsplit landed in the *signature*, the payload-parse
70+
choked on the trailing bytes, and verify returned False.
71+
That manifested as a CI flake during 0.9.0's first release
72+
run while local tests passed for weeks.
73+
74+
Force-mint a token whose HMAC contains a `.` byte by sweeping
75+
the expiry second-by-second until we hit one. With 32 sig
76+
bytes and ~12 % per-token rate we expect a hit within ~10
77+
attempts; the loop guards against a 1-in-a-million miss with
78+
a generous upper bound."""
79+
from src.reports.asset_tokens import _b64url_decode, _key, _SIG_LEN
80+
import hmac as _hmac, hashlib as _hashlib
81+
82+
seed_payload = None
83+
for offset in range(2_000):
84+
ts = int(time.time()) + offset
85+
payload = f"7:{ts}".encode("ascii")
86+
sig = _hmac.new(_key(), payload, _hashlib.sha256).digest()
87+
if 0x2E in sig: # `.`
88+
seed_payload = (offset, sig)
89+
break
90+
assert seed_payload is not None, (
91+
"couldn't find a `.`-containing signature in 2000 attempts — "
92+
"either SECRET_KEY's broken or the universe ended"
93+
)
94+
95+
# Mint a real token whose expiry produces that signature, then
96+
# verify. The fixed-length slicing must extract the right
97+
# payload/sig boundary regardless of period-byte placement.
98+
offset, _expected_sig = seed_payload
99+
# Patch time.time so mint produces the same payload + sig.
100+
from unittest.mock import patch
101+
target_ts = int(time.time())
102+
with patch("src.reports.asset_tokens.time.time", return_value=float(target_ts)):
103+
t = mint_asset_token(report_id=7, ttl_seconds=offset)
104+
assert verify_asset_token(t, report_id=7) is True, (
105+
"verify failed for a token whose signature contains `.` — "
106+
"the rsplit-on-period bug is back"
107+
)
108+
109+
# Plus a sanity-decode: the binary form of the token still
110+
# starts with the literal payload bytes (`7:<ts+offset>`),
111+
# which the regression-prone code split mid-signature.
112+
decoded = _b64url_decode(t)
113+
assert decoded.startswith(b"7:"), \
114+
f"payload prefix corrupted: {decoded[:16]!r}"
115+
# And the suffix is exactly _SIG_LEN bytes — the new format.
116+
assert len(decoded) > _SIG_LEN
63117

64118
# Note: TTL-via-sleep tests are flaky under the project's full
65119
# async-aware pytest config. `test_rejects_expired` already

0 commit comments

Comments
 (0)