max / alloy
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. |