Skip to main content

max / alloy

Show the recovery phrase, and take it back before finishing The phrase cannot be written to the disk it unlocks, and the ESP dies with the drive, so the only durable copy is the one the user makes on paper. That makes the screen showing it the single point where the whole recovery path succeeds or is lost, and a keypress there proves only that the screen was dismissed. So the pane asks for the phrase back, and holds the finished screen until it matches. Case and spacing are forgiven because it is transcribed by hand and typed at a console whose keymap is not the user's. The words and their order are not: those eight words in that sequence are the key. Esc is refused here rather than leaving. Nothing can force someone to copy it down and a determined user can still cut the power, but the exit that looks routine should not be how the phrase is lost. A failed install asks for nothing: there is no disk to recover. Also fixes a hazard in the test added alongside it. Ticking a view whose sequence came from confirmed() runs the real plan, and its first command is bootc install to-disk --wipe /dev/sda. Every other test stops at confirmed() for that reason; this one now replaces the sequence before it ticks, and says why.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 15:41 UTC
Signed with PGP, not checked
Commit: 19525336d00a573d28b2bdc2ee4b71c3fb9758be
Parent: c5b51de
1 file changed, +337 insertions, -1 deletion
@@ -212,6 +212,30 @@
212 212 Ok(())
213 213 }
214 214
215 + /// A recovery phrase reduced to what actually distinguishes it.
216 + ///
217 + /// Lowercased, and runs of whitespace collapsed to single spaces. The phrase is
218 + /// read off a screen and typed back by hand, often at a console with a keymap
219 + /// that is not the user's; refusing a correct transcription over a double space
220 + /// or a capital would teach them the check is arbitrary, and the check is the
221 + /// whole point of showing it.
222 + ///
223 + /// Nothing else is forgiven. The words themselves must be right and in order,
224 + /// because a phrase that opens the disk is exactly those eight words in that
225 + /// sequence, and a check that accepted less would be theatre.
226 + fn normalize_phrase(phrase: &str) -> String {
227 + phrase
228 + .split_whitespace()
229 + .map(str::to_lowercase)
230 + .collect::<Vec<String>>()
231 + .join(" ")
232 + }
233 +
234 + /// Whether what the user typed back is the phrase that was enrolled.
235 + fn phrase_matches(expected: &str, typed: &str) -> bool {
236 + normalize_phrase(expected) == normalize_phrase(typed)
237 + }
238 +
215 239 /// Check the disk passphrase pair.
216 240 ///
217 241 /// Separate from [`validate_password`] only so the complaints name the right
@@ -1982,6 +2006,16 @@
1982 2006 /// than regenerated because it is enrolled into the disk and shown on
1983 2007 /// screen, and those have to be the same eight words.
1984 2008 recovery: Option<String>,
2009 + /// The phrase as the user types it back.
2010 + recovery_typed: TextField,
2011 + /// Set once the typed phrase matched, which is what releases the finished
2012 + /// screen.
2013 + ///
2014 + /// The install is over by then and nothing here can undo it. What this
2015 + /// gates is the installer saying "finished, reboot", because a user who
2016 + /// reboots without these words written down has a machine whose disk dies
2017 + /// with its TPM, and nothing will ever show them again.
2018 + recovery_ack: bool,
1985 2019 /// The encryption checkbox, until the step is confirmed and it becomes an
1986 2020 /// answer. Starts ticked: Alloy encrypts unless told not to.
1987 2021 encrypt: bool,
@@ -2025,6 +2059,8 @@
2025 2059 passphrase_confirm: TextField::new(),
2026 2060 crypt: FocusRing::new(ENCRYPT_SLOTS),
2027 2061 recovery: None,
2062 + recovery_typed: TextField::new(),
2063 + recovery_ack: false,
2028 2064 encrypt: true,
2029 2065 answers: Answers::default(),
2030 2066 error: None,
@@ -2628,6 +2664,110 @@
2628 2664 frame.render_widget(Paragraph::new(lines), area);
2629 2665 }
2630 2666
2667 + /// Whether the recovery phrase is waiting to be written down.
2668 + ///
2669 + /// True only between a successful install and the user typing the phrase
2670 + /// back. A failed install does not ask: there is no disk to recover, and
2671 + /// demanding a transcription from someone whose install just broke would be
2672 + /// asking them to copy down a secret for a machine that does not exist.
2673 + fn recovery_pending(&self) -> bool {
2674 + !self.recovery_ack
2675 + && self.recovery.is_some()
2676 + && self
2677 + .running
2678 + .as_ref()
2679 + .is_some_and(|sequence| matches!(sequence.outcome(), Some(Ok(()))))
2680 + }
2681 +
2682 + /// Keys for the recovery pane: a field, and Enter to check it.
2683 + fn edit_recovery(&mut self, key: KeyEvent) -> Flow {
2684 + match key.code {
2685 + KeyCode::Enter => {
2686 + let Some(expected) = self.recovery.as_deref() else {
2687 + return Flow::Continue;
2688 + };
2689 + if phrase_matches(expected, self.recovery_typed.value()) {
2690 + self.recovery_ack = true;
2691 + self.error = None;
2692 + // Dropped rather than left in the field. The words are on
2693 + // screen above it anyway until this frame, but there is no
2694 + // reason for a second copy to outlive the check.
2695 + self.recovery_typed = TextField::new();
2696 + } else {
2697 + self.error = Some("that is not the phrase; check it word by word".into());
2698 + }
2699 + }
2700 + KeyCode::Char(c) => self.recovery_typed.insert(c),
2701 + KeyCode::Backspace => self.recovery_typed.backspace(),
2702 + KeyCode::Delete => self.recovery_typed.delete(),
2703 + KeyCode::Left => self.recovery_typed.left(),
2704 + KeyCode::Right => self.recovery_typed.right(),
2705 + KeyCode::Home => self.recovery_typed.home(),
2706 + KeyCode::End => self.recovery_typed.end(),
2707 + _ => {}
2708 + }
2709 + Flow::Continue
2710 + }
2711 +
2712 + /// The recovery pane: the phrase, and the field that proves it was copied.
2713 + ///
2714 + /// Shown once, here, and never again. It cannot be written to the disk it
2715 + /// unlocks, and the ESP dies with the drive, so the only durable copy is the
2716 + /// one the user makes. That is why this asks for the phrase back rather than
2717 + /// taking a keypress: a keypress proves the screen was dismissed, and the
2718 + /// failure this exists to prevent is a phrase that was never legibly written
2719 + /// down in the first place.
2720 + fn render_recovery(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
2721 + let Some(phrase) = self.recovery.as_deref() else {
2722 + return;
2723 + };
2724 +
2725 + let mut lines = vec![
2726 + Line::from(Span::styled(
2727 + "Installation finished. Write this down before you reboot.".to_string(),
2728 + Severity::Healthy.style(theme),
2729 + )),
2730 + Line::default(),
2731 + Line::from(text::muted(
2732 + theme,
2733 + "This phrase opens the disk if the TPM ever stops answering:",
2734 + )),
2735 + Line::from(text::muted(
2736 + theme,
2737 + "a cleared TPM, a replaced board, or the disk read in another",
2738 + )),
2739 + Line::from(text::muted(
2740 + theme,
2741 + "machine. It is shown once and is not stored anywhere.",
2742 + )),
2743 + Line::default(),
2744 + // The one thing on screen worth reading carefully, so it is the one
2745 + // thing drawn in the action color, on its own line, indented clear
2746 + // of the prose.
2747 + Line::from(text::action(theme, format!(" {phrase}"))),
2748 + Line::default(),
2749 + Line::from(text::muted(theme, "Type it back to confirm you have it:")),
2750 + Line::default(),
2751 + Self::field_line(
2752 + theme,
2753 + "phrase",
2754 + &self.recovery_typed,
2755 + true,
2756 + false,
2757 + area.width,
2758 + ),
2759 + ];
2760 +
2761 + if self.error.is_none() {
2762 + lines.push(Line::from(text::muted(
2763 + theme,
2764 + format!("{:LABEL_WIDTH$} case and spacing do not matter", ""),
2765 + )));
2766 + }
2767 +
2768 + frame.render_widget(Paragraph::new(lines), area);
2769 + }
2770 +
2631 2771 /// The run screen: progress, then the command output as it arrives.
2632 2772 ///
2633 2773 /// The step is a count with no total. How many commands the install runs is
@@ -2864,6 +3004,12 @@
2864 3004 }
2865 3005
2866 3006 fn hints(&self) -> Vec<Hint> {
3007 + // The recovery pane offers exactly one thing, and listing esc there
3008 + // would advertise a way out that is refused.
3009 + if self.recovery_pending() {
3010 + return vec![hint("enter", "confirm")];
3011 + }
3012 +
2867 3013 // Esc is listed from the second step on, where it means "back". On the
2868 3014 // first it closes the installer, which is the shell's own `q`.
2869 3015 let mut hints = match self.step() {
@@ -2909,6 +3055,12 @@
2909 3055 let inner = block.inner(area);
2910 3056 frame.render_widget(block, area);
2911 3057
3058 + // Ahead of the run screen, which would otherwise say "finished, reboot"
3059 + // under a phrase nobody has copied down yet.
3060 + if self.recovery_pending() {
3061 + return self.render_recovery(frame, inner, theme);
3062 + }
3063 +
2912 3064 if let Some(sequence) = &self.running {
2913 3065 return self.render_run(frame, inner, theme, sequence);
2914 3066 }
@@ -2940,10 +3092,20 @@
2940 3092 /// The hostname step types, so the shell must stop reading `q` as quit
2941 3093 /// while it is on screen.
2942 3094 fn text_entry(&self) -> bool {
2943 - self.running.is_none() && self.step().types()
3095 + // The recovery pane types, and it is the one typing screen that appears
3096 + // while an install is running rather than instead of one. Without this
3097 + // the shell reads the `q` in a word like `quiz` as quit, on the screen
3098 + // where leaving early costs the user the phrase.
3099 + self.recovery_pending() || (self.running.is_none() && self.step().types())
2944 3100 }
2945 3101
2946 3102 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
3103 + // Before the running check below: this is the one question asked while
3104 + // an install is on screen.
3105 + if self.recovery_pending() {
3106 + return self.edit_recovery(key);
3107 + }
3108 +
2947 3109 // A running install answers no questions. The only key that means
2948 3110 // anything is the one that leaves once it has finished, and the shell's
2949 3111 // own `q` already does that.
@@ -3042,6 +3204,14 @@
3042 3204 /// and the questions behind it no longer describe anything. Esc there means
3043 3205 /// leave, and only once it has stopped.
3044 3206 fn cancel(&mut self) -> Flow {
3207 + // Esc does not leave a phrase unwritten. Nothing here can force a user
3208 + // to copy it down, and a determined one can still cut the power, but
3209 + // the way out that looks routine should not be the way the words are
3210 + // lost. Backing up is meaningless anyway: the install is finished.
3211 + if self.recovery_pending() {
3212 + self.error = Some("write the phrase down and type it back first".into());
3213 + return Flow::Continue;
3214 + }
3045 3215 if let Some(sequence) = &self.running {
3046 3216 return if sequence.is_done() {
3047 3217 Flow::Exit
@@ -3294,6 +3464,8 @@
3294 3464 passphrase_confirm: TextField::new(),
3295 3465 crypt: FocusRing::new(ENCRYPT_SLOTS),
3296 3466 recovery: None,
3467 + recovery_typed: TextField::new(),
3468 + recovery_ack: false,
3297 3469 encrypt: true,
3298 3470 answers: Answers::default(),
3299 3471 error: None,
@@ -3862,6 +4034,170 @@
3862 4034 assert_eq!(view.step(), Step::Encryption);
3863 4035 }
3864 4036
4037 + // ---- the recovery phrase pane ----
4038 +
4039 + /// A view sitting on a finished install with a phrase waiting to be copied.
4040 + ///
4041 + /// The sequence is empty so it completes on the first tick: what is under
4042 + /// test is the pane between a successful install and the reboot, not the
4043 + /// install.
4044 + fn at_recovery(phrase: &str) -> (InstallView, CommandLog) {
4045 + let (mut view, mut log) = at_summary();
4046 + view.recovery = Some(phrase.to_string());
4047 + view.running = Some(Sequence::new(Vec::new()));
4048 + view.tick(&mut log);
4049 +
4050 + assert!(view.recovery_pending(), "the fixture is not on the pane");
4051 + (view, log)
4052 + }
4053 +
4054 + fn type_phrase(view: &mut InstallView, text: &str) {
4055 + let mut log = CommandLog::new();
4056 + type_into(view, text, &mut log);
4057 + }
4058 +
4059 + #[test]
4060 + fn the_phrase_is_shown_and_the_pane_waits_for_it() {
4061 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
4062 + type_phrase(&mut view, "alpha bravo charlie delta");
4063 +
4064 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4065 +
4066 + assert!(view.recovery_ack);
4067 + assert!(!view.recovery_pending());
4068 + assert!(view.error.is_none());
4069 + }
4070 +
4071 + // The failure the pane exists to catch. A wrong transcription must not
4072 + // release the screen, because after the reboot there is no way to look the
4073 + // phrase up again.
4074 + #[test]
4075 + fn a_wrong_phrase_does_not_release_the_pane() {
4076 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
4077 + type_phrase(&mut view, "alpha bravo charlie echo");
4078 +
4079 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4080 +
4081 + assert!(!view.recovery_ack);
4082 + assert!(view.recovery_pending(), "a wrong phrase got through");
4083 + assert!(view.error.unwrap().contains("not the phrase"));
4084 + }
4085 +
4086 + // Typed off a screen onto paper and back at a console whose keymap is not
4087 + // the user's. Refusing a correct transcription over spacing or case would
4088 + // teach them the check is arbitrary.
4089 + #[test]
4090 + fn case_and_spacing_are_forgiven_but_the_words_are_not() {
4091 + assert!(phrase_matches("alpha bravo", " ALPHA bravo "));
4092 + assert!(phrase_matches("alpha bravo", "Alpha\tBravo"));
4093 + assert!(!phrase_matches("alpha bravo", "alpha brave"));
4094 + // Order is part of the phrase: these are the same words and a
4095 + // different key.
4096 + assert!(!phrase_matches("alpha bravo", "bravo alpha"));
4097 + assert!(!phrase_matches("alpha bravo", "alpha"));
4098 + assert!(!phrase_matches("alpha bravo", "alpha bravo charlie"));
4099 + }
4100 +
4101 + // Esc is the routine way out of every other screen, and here it would cost
4102 + // the user the phrase.
4103 + #[test]
4104 + fn esc_does_not_leave_the_phrase_unwritten() {
4105 + let (mut view, _log) = at_recovery("alpha bravo charlie delta");
4106 +
4107 + assert!(matches!(view.cancel(), Flow::Continue));
4108 +
4109 + assert!(view.recovery_pending());
4110 + assert!(view.error.is_some(), "esc was refused without saying why");
4111 + }
4112 +
4113 + // ...and once the phrase is in, esc leaves the way it does on any finished
4114 + // install.
4115 + #[test]
4116 + fn esc_leaves_once_the_phrase_is_confirmed() {
4117 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
4118 + type_phrase(&mut view, "alpha bravo charlie delta");
4119 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4120 +
4121 + assert!(matches!(view.cancel(), Flow::Exit));
4122 + }
4123 +
4124 + // The pane types, so the shell must not read the q in a word like `quiz`
4125 + // as quit on the one screen where leaving early loses the phrase.
4126 + #[test]
4127 + fn the_recovery_pane_releases_q() {
4128 + let (mut view, _log) = at_recovery("quiz quote quick quarter");
4129 + assert!(view.text_entry(), "the shell was not told to release q");
4130 +
4131 + type_phrase(&mut view, "quiz");
4132 +
4133 + assert_eq!(view.recovery_typed.value(), "quiz");
4134 + }
4135 +
4136 + // A failed install asks for nothing. There is no disk to recover, and
4137 + // asking someone whose install just broke to copy down a secret for a
4138 + // machine that does not exist is noise at the worst moment.
4139 + #[test]
4140 + fn a_failed_install_does_not_ask_for_the_phrase() {
4141 + let (mut view, _log) = at_summary();
4142 + view.recovery = Some("alpha bravo".into());
4143 + view.running = Some(Sequence::new(vec![Stage::Run(Invocation::new(
4144 + "definitely-not-a-program-on-this-machine",
4145 + ))]));
4146 + let mut log = CommandLog::new();
4147 + for _ in 0..4 {
4148 + view.tick(&mut log);
4149 + }
4150 +
4151 + assert!(
4152 + matches!(
4153 + view.running.as_ref().and_then(Sequence::outcome),
4154 + Some(Err(_))
4155 + ),
4156 + "the fixture did not fail"
4157 + );
4158 + assert!(!view.recovery_pending());
4159 + }
4160 +
4161 + // An unencrypted install has no phrase, so the pane must never appear:
4162 + // there is nothing to write down and nothing that would open the disk.
4163 + //
4164 + // The sequence `confirmed` built is replaced before anything ticks. What it
4165 + // holds is the real plan, and its first command is `bootc install to-disk
4166 + // --wipe /dev/sda`: ticking it here would run that against whatever /dev/sda
4167 + // is on the machine running the tests. `confirmed` itself is safe because
4168 + // `Sequence::new` queues without starting.
4169 + #[test]
4170 + fn an_unencrypted_install_never_shows_the_pane() {
4171 + let (mut view, mut log) = at_encryption();
4172 + view.handle(KeyEvent::from(KeyCode::Char(' ')), &mut log);
4173 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4174 + assert_eq!(view.step(), Step::Summary);
4175 +
4176 + view.confirmed(&mut log);
4177 + assert!(view.recovery.is_none(), "a phrase was generated anyway");
4178 +
4179 + view.running = Some(Sequence::new(Vec::new()));
4180 + view.tick(&mut log);
4181 +
4182 + assert!(!view.recovery_pending());
4183 + }
4184 +
4185 + // Confirming an encrypted install generates the phrase before anything
4186 + // touches the disk, and enrolls the same words it is about to show.
4187 + #[test]
4188 + fn confirming_generates_one_phrase_for_both_uses() {
4189 + let (mut view, mut log) = at_summary();
4190 + assert!(view.answers.encrypt, "the fixture is not encrypting");
4191 +
4192 + view.confirmed(&mut log);
4193 +
4194 + let phrase = view.recovery.clone().expect("no phrase was generated");
4195 + assert_eq!(phrase.split(' ').count(), 8, "{phrase:?}");
4196 + // The same string reaches the plan, rather than a second generated one.
4197 + let enrolled = view.plan("pw", "passphrase", &phrase);
4198 + assert!(!enrolled.is_empty());
4199 + }
4200 +
3865 4201 // ---- encryption in the plan ----
3866 4202
3867 4203 /// The first line of an install plan built with `encrypt` either way.