escrow: a recovery code can no longer contain a hyphenated word (v0.93.0)

The EFF large list has exactly 4 entries containing the join separator
(drop-down, felt-tip, t-shirt, yo-yo). Drawing one made a code read as 11
words instead of 10 - ambiguous to transcribe in precisely the situation R
exists for. Filter them at init; the draw space goes 7776 -> 7772 and the
10-word code goes 129.248 -> 129.241 bits, still well over the 128 floor.

Generation-only: already-issued codes stay valid, R is verified as a whole
passphrase and never re-split.

Also fixes the ~1/5 flake this same defect caused: the test counted words by
splitting the joined string. It now counts what the generator drew and
asserts segmentation separately, plus a deterministic red-proof fixture.
This commit is contained in:
2026-07-21 14:46:51 +02:00
parent 935904fa4e
commit a452dc3314
5 changed files with 232 additions and 23 deletions
+25
View File
@@ -1,3 +1,28 @@
## v0.93.0 — a recovery code can no longer contain a hyphenated word (2026-07-21)
**Generation-only change. Every recovery code already issued remains valid** — R is consumed as a
whole passphrase by the PBS scrypt KDF (`Wrap`/`Unwrap`) and is never re-split, so nothing about
verification moves. Nothing in the KDF/consume path, the word count, or the joiner changed.
The EFF large wordlist contains exactly four entries that themselves contain the hyphen we join
words with: `drop-down`, `felt-tip`, `t-shirt`, `yo-yo`. Drawing one produced a code that reads as
11 words rather than 10 — ambiguous to transcribe in exactly the situation R exists for, a customer
reading a code back during a disaster. The generator now draws from the list filtered of those four
(`joinSafe`), so a code always segments back into exactly `RecoveryCodeWords`.
- **Entropy floor holds, with the numbers asserted in the tests:** the draw space goes 7776 → 7772,
so a 10-word code goes 129.248 → 129.241 bits. The cost is 0.007 bits against a 128-bit floor.
- **The long-standing ~1/5 test flake was this defect, not a flaky test.**
`TestGenerateRecoveryCode_EntropyAndFormat` counted words by splitting the joined string, which
conflates "how many words were drawn" with "how many segments the code has". It now counts what
the generator drew, and asserts the segmentation property separately — the property `joinSafe`
actually buys. (`felhom.eu/REPORT.md` §6 item 3 recorded it at 3/8 in one session.)
- **Deterministic red-proof, in-tree:** `TestGeneratedCodeSegments_FilteredVsUnfiltered` drives the
generator against a fixture list where every word is hyphenated, so the pre-fix defect reproduces
with probability 1 instead of ~1/5, and shows the same list through `joinSafe` refuses to generate.
- New for audit: `WordlistFilteredOut()`, `RecoveryCodeSep`. `WordlistSize()` now reports the
effective (filtered) draw space, 7772.
## v0.92.1 — ship the guestnet sudoers grant with the binary (supersedes v0.92.0) (2026-07-21)
**Supersedes v0.92.0; that artifact is materially incomplete — do not vouch it.** It was published