Skip to main content

max / alloy

Make the phrase check exactly the gate the user will meet The confirmation lowercased both sides and collapsed runs of whitespace, so that a phrase read off a screen and typed back at an unfamiliar keymap was not refused over a capital or a double space. That had the failure backwards. enroll_plan hands the phrase to systemd-cryptenroll as a password and LUKS compares passwords as bytes: no case folding, no whitespace collapsing, leading and trailing spaces significant. So the lenient check blessed transcriptions that cannot open the disk. Someone who wrote the words down capitalised was told they had it right, and would find out otherwise on the one day it mattered, with no second chance and nothing left on screen to look at. The gate exists to prove the user can get back in. It is only worth having if passing it means that, which means being exactly as forgiving as the real thing and no more. Being stricter would be its own bug; being looser was this one. Nothing about what is displayed changes: the generated phrase is already lowercase words joined by single spaces, and a test now pins that, because an exact check against a phrase carrying invisible spacing would refuse the very words it printed. The hint under the field moves with it. "case and spacing do not matter" was a promise about the disk, not about the field, and the disk never made it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 18:53 UTC
Signed with PGP, not checked
Commit: 33eeabd5ee229f1e362d7f21ebf769d1fa714b37
Parent: 2d00dd2
2 files changed, +82 insertions, -40 deletions
M Cargo.lock +17 -13
@@ -2171,14 +2171,6 @@
2171 2171 source = "registry+https://github.com/rust-lang/crates.io-index"
2172 2172 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2173 2173
2174 - [[patch.unused]]
2175 - name = "synckit-client"
2176 - version = "0.8.0"
2177 -
2178 - [[patch.unused]]
2179 - name = "synckit-config"
2180 - version = "0.2.0"
2181 -
2182 2174 [[patch.unused]]
2183 2175 name = "kberg"
2184 2176 version = "0.1.0"
@@ -2195,21 +2187,33 @@
2195 2187 name = "tagtree"
2196 2188 version = "0.4.0"
2197 2189
2190 + [[patch.unused]]
2191 + name = "synckit-client"
2192 + version = "0.8.0"
2193 +
2194 + [[patch.unused]]
2195 + name = "synckit-config"
2196 + version = "0.2.0"
2197 +
2198 2198 [[patch.unused]]
2199 2199 name = "docengine"
2200 2200 version = "0.7.0"
2201 2201
2202 2202 [[patch.unused]]
2203 2203 name = "quasi-axum"
2204 - version = "0.2.0"
2204 + version = "0.3.0"
2205 2205
2206 2206 [[patch.unused]]
2207 2207 name = "quasi-http"
2208 - version = "0.2.0"
2208 + version = "0.3.0"
2209 +
2210 + [[patch.unused]]
2211 + name = "quasi-immediate"
2212 + version = "0.3.0"
2209 2213
2210 2214 [[patch.unused]]
2211 2215 name = "quasi-router"
2212 - version = "0.2.0"
2216 + version = "0.3.0"
2213 2217
2214 2218 [[patch.unused]]
2215 2219 name = "quasi-store"
@@ -2217,8 +2221,8 @@
2217 2221
2218 2222 [[patch.unused]]
2219 2223 name = "quasi-tauri"
2220 - version = "0.2.0"
2224 + version = "0.3.0"
2221 2225
2222 2226 [[patch.unused]]
2223 2227 name = "quasi-webview"
2224 - version = "0.2.0"
2228 + version = "0.3.0"
@@ -254,28 +254,34 @@
254 254 Ok(())
255 255 }
256 256
257 - /// A recovery phrase reduced to what actually distinguishes it.
258 - ///
259 - /// Lowercased, and runs of whitespace collapsed to single spaces. The phrase is
260 - /// read off a screen and typed back by hand, often at a console with a keymap
261 - /// that is not the user's; refusing a correct transcription over a double space
262 - /// or a capital would teach them the check is arbitrary, and the check is the
263 - /// whole point of showing it.
264 - ///
265 - /// Nothing else is forgiven. The words themselves must be right and in order,
266 - /// because a phrase that opens the disk is exactly those eight words in that
267 - /// sequence, and a check that accepted less would be theatre.
268 - fn normalize_phrase(phrase: &str) -> String {
269 - phrase
270 - .split_whitespace()
271 - .map(str::to_lowercase)
272 - .collect::<Vec<String>>()
273 - .join(" ")
274 - }
275 -
276 257 /// Whether what the user typed back is the phrase that was enrolled.
258 + ///
259 + /// **Byte-exact, because the thing it stands in for is.** This was lenient
260 + /// until 2026-08-14: it lowercased both sides and collapsed runs of whitespace,
261 + /// reasoning that the phrase is read off a screen and typed back at a console
262 + /// whose keymap may not be the user's, and that refusing a correct
263 + /// transcription over a double space or a capital would teach them the check
264 + /// was arbitrary.
265 + ///
266 + /// That reasoning had the failure backwards. [`enroll_plan`] hands the phrase
267 + /// to `systemd-cryptenroll` as a password, and LUKS compares passwords as
268 + /// bytes: no case folding, no whitespace collapsing, leading and trailing
269 + /// spaces significant. So the lenient check accepted transcriptions that
270 + /// **cannot open the disk.** Someone who wrote the words down capitalised was
271 + /// told they had it right, and would find out otherwise on the one day they
272 + /// needed it, with no second chance and nothing on screen to look at.
273 + ///
274 + /// A confirmation gate is only worth having if passing it means what it looks
275 + /// like it means. This one exists to prove the user can get back in, so it has
276 + /// to be exactly the gate they will meet, generosity included. Being stricter
277 + /// than the real gate would be its own bug; being looser is this one.
278 + ///
279 + /// The generated phrase is already lowercase words joined by single spaces
280 + /// (see [`crate::recovery::phrase`]), so nothing about what is displayed
281 + /// changes. What changes is that typing it back differently is now refused
282 + /// here rather than at a LUKS prompt in a year.
277 283 fn phrase_matches(expected: &str, typed: &str) -> bool {
278 - normalize_phrase(expected) == normalize_phrase(typed)
284 + expected == typed
279 285 }
280 286
281 287 /// Check the disk passphrase pair.
@@ -4142,10 +4148,14 @@
4142 4148 ),
4143 4149 ];
4144 4150
4151 + // This line said "case and spacing do not matter" while the check was
4152 + // lenient. Both changed together and have to: the hint is a promise
4153 + // about the disk, not about the field, and the disk never forgave
4154 + // either one.
4145 4155 if self.error.is_none() {
4146 4156 lines.push(Line::from(text::muted(
4147 4157 theme,
4148 - format!("{:LABEL_WIDTH$} case and spacing do not matter", ""),
4158 + format!("{:LABEL_WIDTH$} exactly as shown, including spacing", ""),
4149 4159 )));
4150 4160 }
4151 4161
@@ -5944,13 +5954,27 @@
5944 5954 assert!(view.error.unwrap().contains("not the phrase"));
5945 5955 }
5946 5956
5947 - // Typed off a screen onto paper and back at a console whose keymap is not
5948 - // the user's. Refusing a correct transcription over spacing or case would
5949 - // teach them the check is arbitrary.
5957 + // The check must be the gate the user will actually meet. LUKS compares
5958 + // passwords as bytes, so every one of these is a key that does not open
5959 + // the disk, and accepting one here would be telling the user they had it.
5960 + //
5961 + // This test asserted the opposite until 2026-08-14, when the leniency it
5962 + // was pinning turned out to be the bug.
5950 5963 #[test]
5951 - fn case_and_spacing_are_forgiven_but_the_words_are_not() {
5952 - assert!(phrase_matches("alpha bravo", " ALPHA bravo "));
5953 - assert!(phrase_matches("alpha bravo", "Alpha\tBravo"));
5964 + fn nothing_is_forgiven_because_the_disk_forgives_nothing() {
5965 + assert!(phrase_matches("alpha bravo", "alpha bravo"));
5966 +
5967 + // Case. The words are right and the key is wrong.
5968 + assert!(!phrase_matches("alpha bravo", "Alpha Bravo"));
5969 + assert!(!phrase_matches("alpha bravo", "ALPHA BRAVO"));
5970 +
5971 + // Spacing, in all three places it can go wrong.
5972 + assert!(!phrase_matches("alpha bravo", "alpha bravo"));
5973 + assert!(!phrase_matches("alpha bravo", " alpha bravo"));
5974 + assert!(!phrase_matches("alpha bravo", "alpha bravo "));
5975 + assert!(!phrase_matches("alpha bravo", "alpha\tbravo"));
5976 +
5977 + // And the ways it was already refused, which have not changed.
5954 5978 assert!(!phrase_matches("alpha bravo", "alpha brave"));
5955 5979 // Order is part of the phrase: these are the same words and a
5956 5980 // different key.
@@ -5959,6 +5983,20 @@
5959 5983 assert!(!phrase_matches("alpha bravo", "alpha bravo charlie"));
5960 5984 }
5961 5985
5986 + // The generated phrase is what gets enrolled, so it has to be a phrase the
5987 + // gate accepts unchanged. If `recovery::phrase` ever grew a capital or a
5988 + // double space, an exact check would refuse the very words it displayed.
5989 + #[test]
5990 + fn a_generated_phrase_matches_itself_untouched() {
5991 + let phrase = crate::recovery::phrase().expect("the test host has a random source");
5992 +
5993 + assert!(phrase_matches(&phrase, &phrase));
5994 + assert!(
5995 + !phrase.contains(" ") && phrase.trim() == phrase,
5996 + "the displayed phrase must not carry spacing a user cannot see: {phrase:?}"
5997 + );
5998 + }
5999 +
5962 6000 // Esc is the routine way out of every other screen, and here it would cost
5963 6001 // the user the phrase.
5964 6002 #[test]