| 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 |
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 |
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 |
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]
|