Skip to main content

max / alloy

Ask whether to encrypt the disk, and for the passphrase that opens it A fifth step between the account and the summary: a checkbox that starts ticked, and the passphrase pair it governs. Unticking hides the pair and says what an unencrypted disk costs, because this is the only answer in the wizard the installed machine can never be talked out of. The answer is recorded but not yet acted on. Wiring it to bootc's --block-setup and to the enrollment that follows the deploy is next. field_line takes focus as a bool rather than a slot index now. Two screens draw those lines against two different focus rings, and a slot number only means something next to the ring it indexes.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 15:18 UTC
Signed with PGP, not checked
Commit: 6d3437dd795061a4a1c5a6b1b785cf5fd9e84312
Parent: 3ace184
1 file changed, +344 insertions, -15 deletions
@@ -79,7 +79,16 @@
79 79 /// The disk comes first because it is the one that can be wrong in a way
80 80 /// nothing later recovers from, and because a user who cannot see their disk in
81 81 /// the list should find that out before typing anything.
82 - const STEPS: [Step; 4] = [Step::Disk, Step::Hostname, Step::Account, Step::Summary];
82 + /// Encryption sits after the account rather than beside the disk it applies to.
83 + /// Both screens that take a secret are then adjacent, so the passphrases are
84 + /// typed in one stretch, and the review screen stays last.
85 + const STEPS: [Step; 5] = [
86 + Step::Disk,
87 + Step::Hostname,
88 + Step::Account,
89 + Step::Encryption,
90 + Step::Summary,
91 + ];
83 92
84 93 /// Which question the wizard is on.
85 94 ///
@@ -91,6 +100,7 @@
91 100 Disk,
92 101 Hostname,
93 102 Account,
103 + Encryption,
94 104 Summary,
95 105 }
96 106
@@ -100,6 +110,7 @@
100 110 Self::Disk => "select a disk",
101 111 Self::Hostname => "name this machine",
102 112 Self::Account => "create your account",
113 + Self::Encryption => "encrypt the disk",
103 114 Self::Summary => "review and install",
104 115 }
105 116 }
@@ -107,7 +118,7 @@
107 118 /// Whether this step takes typing, which decides if the shell keeps
108 119 /// claiming `q` as quit while it is on screen.
109 120 const fn types(self) -> bool {
110 - matches!(self, Self::Hostname | Self::Account)
121 + matches!(self, Self::Hostname | Self::Account | Self::Encryption)
111 122 }
112 123 }
113 124
@@ -130,6 +141,16 @@
130 141 const SLOT_TIMEZONE: usize = 1;
131 142 const HOSTNAME_SLOTS: usize = 2;
132 143
144 + /// Which slot of the encryption step has focus.
145 + ///
146 + /// The checkbox leads because it decides whether the two fields under it mean
147 + /// anything, and a screen whose first slot is a field the answer may discard
148 + /// reads backwards.
149 + const SLOT_ENCRYPT: usize = 0;
150 + const FIELD_PASSPHRASE: usize = 1;
151 + const FIELD_PASSPHRASE_CONFIRM: usize = 2;
152 + const ENCRYPT_SLOTS: usize = 3;
153 +
133 154 /// Longest username `useradd` accepts.
134 155 const USERNAME_MAX: usize = 32;
135 156
@@ -190,6 +211,23 @@
190 211 Ok(())
191 212 }
192 213
214 + /// Check the disk passphrase pair.
215 + ///
216 + /// Separate from [`validate_password`] only so the complaints name the right
217 + /// thing: a screen that asks for a disk passphrase and answers "a password is
218 + /// required" is talking about a different field. The no-minimum stance above
219 + /// applies here for the same reason, and the pair still has to agree — more so,
220 + /// since a typo in this one is not recoverable by logging in.
221 + fn validate_passphrase(passphrase: &str, confirm: &str) -> Result<(), String> {
222 + if passphrase.is_empty() {
223 + return Err("a passphrase is required to encrypt the disk".into());
224 + }
225 + if passphrase != confirm {
226 + return Err("the passphrases do not match".into());
227 + }
228 + Ok(())
229 + }
230 +
193 231 /// The hostname an install gets if the user does not change it.
194 232 ///
195 233 /// Matches `etc/hostname` in the image, so the installer's default and the
@@ -364,6 +402,19 @@
364 402 /// not to do. Left off, the install sets no timezone at all and the system
365 403 /// comes up UTC, which the hostname pane says on screen.
366 404 pub locate_timezone: bool,
405 + /// Whether the install encrypts the disk with LUKS.
406 + ///
407 + /// The passphrase is deliberately not here, for the same reason the account
408 + /// password is not: it stays in its [`TextField`] until the plan is built.
409 + ///
410 + /// `Default` gives `false` here while the checkbox the user sees starts
411 + /// ticked, which is the one place in this struct where the derived default
412 + /// is not the offered one. It is safe only because the summary cannot be
413 + /// reached without passing the encryption step, and that step writes this
414 + /// field whichever way it went. Nothing should read it before then: an
415 + /// unset value here reads as "no encryption", which is the wrong way for
416 + /// this particular question to fail.
417 + pub encrypt: bool,
367 418 }
368 419
369 420 /// Where the installer mounts the target's root filesystem to configure it.
@@ -1735,6 +1786,13 @@
1735 1786 locate_timezone: bool,
1736 1787 /// Which of the three account fields has focus.
1737 1788 fields: FocusRing,
1789 + passphrase: TextField,
1790 + passphrase_confirm: TextField,
1791 + /// Which of the three encryption-step slots has focus.
1792 + crypt: FocusRing,
1793 + /// The encryption checkbox, until the step is confirmed and it becomes an
1794 + /// answer. Starts ticked: Alloy encrypts unless told not to.
1795 + encrypt: bool,
1738 1796 answers: Answers,
1739 1797 error: Option<String>,
1740 1798 /// The install, once it has been confirmed and started.
@@ -1771,6 +1829,10 @@
1771 1829 machine: FocusRing::new(HOSTNAME_SLOTS),
1772 1830 locate_timezone: false,
1773 1831 fields: FocusRing::new(ACCOUNT_FIELDS),
1832 + passphrase: TextField::new(),
1833 + passphrase_confirm: TextField::new(),
1834 + crypt: FocusRing::new(ENCRYPT_SLOTS),
1835 + encrypt: true,
1774 1836 answers: Answers::default(),
1775 1837 error: None,
1776 1838 running: None,
@@ -1944,22 +2006,119 @@
1944 2006 Flow::Continue
1945 2007 }
1946 2008
2009 + /// The passphrase field the encryption step's focus ring is on, if it is on
2010 + /// one. `None` means the checkbox has focus, which is not a field.
2011 + fn focused_crypt_field(&mut self) -> Option<&mut TextField> {
2012 + match self.crypt.current() {
2013 + FIELD_PASSPHRASE => Some(&mut self.passphrase),
2014 + FIELD_PASSPHRASE_CONFIRM => Some(&mut self.passphrase_confirm),
2015 + _ => None,
2016 + }
2017 + }
2018 +
2019 + /// Take the encryption answer, if the passphrase it needs is answerable.
2020 + ///
2021 + /// Turning encryption off skips the pair entirely rather than validating
2022 + /// empty fields: with no LUKS volume there is nothing for a passphrase to
2023 + /// open, and complaining about a blank field the user deliberately left
2024 + /// blank is how a screen teaches people to ignore it.
2025 + fn encrypt_disk(&mut self) -> Flow {
2026 + if self.encrypt
2027 + && let Err(message) =
2028 + validate_passphrase(self.passphrase.value(), self.passphrase_confirm.value())
2029 + {
2030 + self.error = Some(message);
2031 + // The confirm, for the same reason the account step picks it: a
2032 + // mismatch is far more often a typo in the second of the pair.
2033 + self.crypt.focus(if self.passphrase.value().is_empty() {
2034 + FIELD_PASSPHRASE
2035 + } else {
2036 + FIELD_PASSPHRASE_CONFIRM
2037 + });
2038 + return Flow::Continue;
2039 + }
2040 +
2041 + self.answers.encrypt = self.encrypt;
2042 + self.error = None;
2043 + self.steps.advance();
2044 + Flow::Continue
2045 + }
2046 +
2047 + /// Keys for the encryption step: a checkbox and the passphrase pair.
2048 + ///
2049 + /// Same shape as the hostname step's checkbox and the account step's
2050 + /// fields, because it is both of them on one screen.
2051 + fn edit_encryption(&mut self, key: KeyEvent) -> Flow {
2052 + match key.code {
2053 + KeyCode::Tab | KeyCode::Down => self.crypt.next(),
2054 + KeyCode::BackTab | KeyCode::Up => self.crypt.prev(),
2055 + KeyCode::Enter => return self.encrypt_disk(),
2056 + KeyCode::Char(' ') if self.crypt.current() == SLOT_ENCRYPT => {
2057 + self.encrypt = !self.encrypt;
2058 + }
2059 + // Typing into the checkbox edits nothing, the same rule the hostname
2060 + // step follows. The fields below it stay typable while encryption is
2061 + // off so that turning it back on does not lose what was already
2062 + // entered; only the answer gates on the box.
2063 + _ if self.crypt.current() == SLOT_ENCRYPT => {}
2064 + KeyCode::Char(c) => {
2065 + if let Some(field) = self.focused_crypt_field() {
2066 + field.insert(c);
2067 + }
2068 + }
2069 + KeyCode::Backspace => {
2070 + if let Some(field) = self.focused_crypt_field() {
2071 + field.backspace();
2072 + }
2073 + }
2074 + KeyCode::Delete => {
2075 + if let Some(field) = self.focused_crypt_field() {
2076 + field.delete();
2077 + }
2078 + }
2079 + KeyCode::Left => {
2080 + if let Some(field) = self.focused_crypt_field() {
2081 + field.left();
2082 + }
2083 + }
2084 + KeyCode::Right => {
2085 + if let Some(field) = self.focused_crypt_field() {
2086 + field.right();
2087 + }
2088 + }
2089 + KeyCode::Home => {
2090 + if let Some(field) = self.focused_crypt_field() {
2091 + field.home();
2092 + }
2093 + }
2094 + KeyCode::End => {
2095 + if let Some(field) = self.focused_crypt_field() {
2096 + field.end();
2097 + }
2098 + }
2099 + _ => {}
2100 + }
2101 + Flow::Continue
2102 + }
2103 +
1947 2104 /// One labelled field line, masked if it holds a password.
1948 2105 ///
1949 2106 /// Masking happens here rather than in [`TextField`] so the field stays a
1950 2107 /// plain text buffer: a widget that knows how to hide itself would have to
1951 2108 /// be trusted to hide itself everywhere, and this is the only place that
1952 2109 /// draws one.
2110 + ///
2111 + /// Focus arrives as a bool rather than a slot index because two screens
2112 + /// draw these lines now, against two different [`FocusRing`]s, and a slot
2113 + /// number only means something next to the ring it indexes.
1953 2114 fn field_line<'a>(
1954 - &self,
1955 2115 theme: &Theme,
1956 2116 label: &'a str,
1957 2117 field: &TextField,
1958 - slot: usize,
2118 + focused: bool,
1959 2119 masked: bool,
1960 2120 width: u16,
1961 2121 ) -> Line<'a> {
1962 - let focused = self.fields.is_focused(slot);
1963 2122 let (before, under, after) = field.split();
1964 2123
1965 2124 let (mut before, under, mut after) = if masked {
@@ -2027,11 +2186,39 @@
2027 2186 "The account you will log in with. It can become root with run0.",
2028 2187 )),
2029 2188 Line::default(),
2030 - self.field_line(theme, "username", &self.username, FIELD_USERNAME, false, w),
2031 - self.field_line(theme, "password", &self.password, FIELD_PASSWORD, true, w),
2032 - self.field_line(theme, "confirm", &self.confirm, FIELD_CONFIRM, true, w),
2189 + Self::field_line(
2190 + theme,
2191 + "username",
2192 + &self.username,
2193 + self.fields.is_focused(FIELD_USERNAME),
2194 + false,
2195 + w,
2196 + ),
2197 + Self::field_line(
2198 + theme,
2199 + "password",
2200 + &self.password,
2201 + self.fields.is_focused(FIELD_PASSWORD),
2202 + true,
2203 + w,
2204 + ),
2205 + Self::field_line(
2206 + theme,
2207 + "confirm",
2208 + &self.confirm,
2209 + self.fields.is_focused(FIELD_CONFIRM),
2210 + true,
2211 + w,
2212 + ),
2033 2213 Line::default(),
2034 - self.field_line(theme, "ssh key", &self.pubkey, FIELD_PUBKEY, false, w),
2214 + Self::field_line(
2215 + theme,
2216 + "ssh key",
2217 + &self.pubkey,
2218 + self.fields.is_focused(FIELD_PUBKEY),
2219 + false,
2220 + w,
2221 + ),
2035 2222 ];
2036 2223
2037 2224 // Said here rather than only at the summary, because this is where the
@@ -2060,6 +2247,91 @@
2060 2247 frame.render_widget(Paragraph::new(lines), area);
2061 2248 }
2062 2249
2250 + /// The encryption pane: the checkbox, then the passphrase pair it governs.
2251 + fn render_encryption(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
2252 + let w = area.width;
2253 + let boxed = self.crypt.is_focused(SLOT_ENCRYPT);
2254 + let box_glyph = if self.encrypt { "[x]" } else { "[ ]" };
2255 +
2256 + let mut lines = vec![
2257 + Line::from(text::muted(
2258 + theme,
2259 + "Encrypt this disk, so pulling it out of the machine reveals nothing.",
2260 + )),
2261 + Line::default(),
2262 + // Same focus vocabulary as the hostname checkbox: reversed block on
2263 + // the box, and the label dims with it.
2264 + Line::from(vec![
2265 + if boxed {
2266 + Span::styled(
2267 + format!("{box_glyph} "),
2268 + Style::default()
2269 + .fg(theme.content_primary)
2270 + .add_modifier(Modifier::REVERSED),
2271 + )
2272 + } else {
2273 + text::muted(theme, format!("{box_glyph} "))
2274 + },
2275 + if boxed {
2276 + text::bold(theme, "encrypt this disk")
2277 + } else {
2278 + text::muted(theme, "encrypt this disk")
2279 + },
2280 + ]),
2281 + Line::default(),
2282 + ];
2283 +
2284 + if self.encrypt {
2285 + lines.push(Self::field_line(
2286 + theme,
2287 + "passphrase",
2288 + &self.passphrase,
2289 + self.crypt.is_focused(FIELD_PASSPHRASE),
2290 + true,
2291 + w,
2292 + ));
2293 + lines.push(Self::field_line(
2294 + theme,
2295 + "confirm",
2296 + &self.passphrase_confirm,
2297 + self.crypt.is_focused(FIELD_PASSPHRASE_CONFIRM),
2298 + true,
2299 + w,
2300 + ));
2301 + lines.push(Line::default());
2302 + // What the passphrase is for, said where it is chosen. The machine
2303 + // unlocks itself from the TPM in the ordinary case, so a user who
2304 + // is not told this will reasonably conclude the passphrase went
2305 + // unused and forget it, which is exactly when they need it.
2306 + for line in [
2307 + "asked for at boot only when the TPM cannot open the disk,",
2308 + "so it is the one you will almost never type and must not",
2309 + "lose. A recovery phrase is generated as a third way in.",
2310 + ] {
2311 + lines.push(Line::from(text::muted(
2312 + theme,
2313 + format!("{:LABEL_WIDTH$} {line}", ""),
2314 + )));
2315 + }
2316 + } else {
2317 + // The consequence, stated where it is chosen and not only on the
2318 + // summary. This is the one answer on any pane that cannot be
2319 + // revisited after the install: encryption is set up while the disk
2320 + // is partitioned, and no update can retrofit it.
2321 + for line in [
2322 + "Anyone who takes this disk can read everything on it.",
2323 + "Encryption cannot be added later without reinstalling.",
2324 + ] {
2325 + lines.push(Line::from(Span::styled(
2326 + format!(" {line}"),
2327 + Severity::Warn.style(theme),
2328 + )));
2329 + }
2330 + }
2331 +
2332 + frame.render_widget(Paragraph::new(lines), area);
2333 + }
2334 +
2063 2335 /// The summary pane: the answers, then the commands they produce.
2064 2336 ///
2065 2337 /// Showing the actual argv is the point. docs/CONSOLE.md commits the
@@ -2109,6 +2381,25 @@
2109 2381 " no ssh access until one is added".into(),
2110 2382 ),
2111 2383 },
2384 + // Named whichever way it went, like the timezone below, and for a
2385 + // sharper reason: this is the only answer on the screen that the
2386 + // installed machine can never be talked out of. An unencrypted
2387 + // install says so here in the same words the pane used.
2388 + if self.answers.encrypt {
2389 + row(
2390 + theme,
2391 + "encryption",
2392 + "on",
2393 + " TPM, passphrase, recovery phrase".into(),
2394 + )
2395 + } else {
2396 + row(
2397 + theme,
2398 + "encryption",
2399 + "off",
2400 + " cannot be added without reinstalling".into(),
2401 + )
2402 + },
2112 2403 // Named on the summary whichever way it went. "UTC" is a decision
2113 2404 // the install is about to make, and a review screen that only
2114 2405 // listed the answers someone changed would hide the defaults it is
@@ -2390,6 +2681,11 @@
2390 2681 hint("enter", "next"),
2391 2682 ],
2392 2683 Step::Account => vec![hint("tab", "field"), hint("enter", "next")],
2684 + Step::Encryption => vec![
2685 + hint("tab", "field"),
2686 + hint("space", "toggle"),
2687 + hint("enter", "next"),
2688 + ],
2393 2689 Step::Summary => vec![hint("enter", "install")],
2394 2690 };
2395 2691 if !self.steps.is_first() {
@@ -2423,6 +2719,7 @@
2423 2719 match self.step() {
2424 2720 Step::Hostname => return self.render_hostname(frame, inner, theme),
2425 2721 Step::Account => return self.render_account(frame, inner, theme),
2722 + Step::Encryption => return self.render_encryption(frame, inner, theme),
2426 2723 Step::Summary => return self.render_summary(frame, inner, theme),
2427 2724 Step::Disk => {}
2428 2725 }
@@ -2460,6 +2757,7 @@
2460 2757 match self.step() {
2461 2758 Step::Hostname => return self.edit_hostname(key),
2462 2759 Step::Account => return self.edit_account(key),
2760 + Step::Encryption => return self.edit_encryption(key),
2463 2761 Step::Summary => {
2464 2762 if key.code == KeyCode::Enter {
2465 2763 return self.confirm_install();
@@ -2772,6 +3070,10 @@
2772 3070 machine: FocusRing::new(HOSTNAME_SLOTS),
2773 3071 locate_timezone: false,
2774 3072 fields: FocusRing::new(ACCOUNT_FIELDS),
3073 + passphrase: TextField::new(),
3074 + passphrase_confirm: TextField::new(),
3075 + crypt: FocusRing::new(ENCRYPT_SLOTS),
3076 + encrypt: true,
2775 3077 answers: Answers::default(),
2776 3078 error: None,
2777 3079 running: None,
@@ -3029,14 +3331,14 @@
3029 3331 }
3030 3332
3031 3333 #[test]
3032 - fn a_complete_account_advances_to_the_summary() {
3334 + fn a_complete_account_advances_to_the_encryption() {
3033 3335 let (mut view, mut log) = at_account();
3034 3336 fill_account(&mut view, "max", "hunter2", "hunter2");
3035 3337
3036 3338 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
3037 3339
3038 3340 assert_eq!(view.answers.username.as_deref(), Some("max"));
3039 - assert_eq!(view.step(), Step::Summary);
3341 + assert_eq!(view.step(), Step::Encryption);
3040 3342 }
3041 3343
3042 3344 // Tab moves between fields; typing has to land in whichever one has focus.
@@ -3080,7 +3382,7 @@
3080 3382
3081 3383 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
3082 3384
3083 - assert_eq!(view.step(), Step::Summary);
3385 + assert_eq!(view.step(), Step::Encryption);
3084 3386 assert_eq!(view.answers.username.as_deref(), Some("max"));
3085 3387 }
3086 3388
@@ -3149,13 +3451,165 @@
3149 3451 assert_eq!(view.username.value(), "q");
3150 3452 }
3151 3453
3152 - // ---- the summary and the confirm ----
3454 + // ---- the encryption step ----
3153 3455
3154 - fn at_summary() -> (InstallView, CommandLog) {
3456 + fn at_encryption() -> (InstallView, CommandLog) {
3155 3457 let (mut view, mut log) = at_account();
3156 3458 fill_account(&mut view, "max", "hunter2", "hunter2");
3157 3459 view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
3158 - assert_eq!(view.step(), Step::Summary, "fixture stalled on the account");
3460 + assert_eq!(
3461 + view.step(),
3462 + Step::Encryption,
3463 + "fixture stalled on the account"
3464 + );
3465 + (view, log)
3466 + }
3467 +
3468 + /// Fill the passphrase pair, leaving focus on the confirm.
3469 + fn fill_passphrase(view: &mut InstallView, passphrase: &str, confirm: &str) {
3470 + let mut log = CommandLog::new();
3471 + view.crypt.focus(FIELD_PASSPHRASE);
3472 + type_into(view, passphrase, &mut log);
3473 + view.crypt.focus(FIELD_PASSPHRASE_CONFIRM);
3474 + type_into(view, confirm, &mut log);
3475 + }
3476 +
3477 + // The default is the decision for almost every machine, so it is the one
3478 + // worth pinning. Encryption is chosen while the disk is partitioned and no
3479 + // update can add it afterwards, which makes an off-by-default installer a
3480 + // fleet of permanently unencrypted machines.
3481 + #[test]
3482 + fn encryption_is_on_before_anyone_touches_the_box() {
3483 + let (view, _log) = at_encryption();
3484 + assert!(view.encrypt, "the installer offered to skip encryption");
3485 + }
3486 +
3487 + #[test]
Lines truncated