Skip to main content

max / alloy

Encrypt the disk, and enroll a way back in bootc's --block-setup tpm2-luks is the only encryption to-disk offers, and it finishes by wiping every LUKS slot but the TPM's. A machine installed that way has exactly one way in: clear the TPM, swap the board, or move the disk and the data is gone. So the flag is half the work and enroll_plan is the other half, adding the user's passphrase and a generated eight-word recovery phrase while the volume is still open and the TPM will still authorize a change. The phrase is generated once, at confirm time, because the same words have to be enrolled and shown. A failed random source refuses the install rather than falling back: the disk is untouched at that point, which makes refusing free, and a predictable recovery phrase would be found out much later by someone who needed it to work. Mounting now asks the disk what it found rather than trusting the answer the user gave. An encrypted root partition is a LUKS container with the filesystem on a mapper device inside it, and the mapper name is bootc's to choose. A container found closed is an error that says so, rather than a mount that fails three stages later about a superblock. Invocation grows secret environment variables for this. cryptenroll asks for a new passphrase through ask-password, which reads the terminal and not the pipe, so a secret on stdin does not fail cleanly here. It hangs the install on a prompt drawn where the run screen is not. $NEWPASSWORD is narrower than argv either way: /proc/pid/environ is owner-only where ps shows argv to everyone. The recovery phrase is enrolled but not yet displayed. Showing it, and taking it back to prove it was written down, is the last step.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 15:26 UTC
Signed with PGP, not checked
Commit: c5b51def56c128060fea779e07555b434ed6d481
Parent: 6d3437d
5 files changed, +1059 insertions, -51 deletions
@@ -160,6 +160,21 @@
160 160 /// long as the command runs. `chpasswd` reads from stdin for this reason,
161 161 /// and it is why a secret cannot be modelled as just another arg.
162 162 stdin: Option<Secret>,
163 + /// Secret environment variables. Never displayed, never logged.
164 + ///
165 + /// A worse carrier than [`stdin`](Self::stdin) and used only where the
166 + /// program insists on it. `systemd-cryptenroll` reads a new passphrase
167 + /// through `$NEWPASSWORD` and takes an existing one through `$PASSWORD`; it
168 + /// will not read either from a pipe, because it asks through
169 + /// `ask-password`, which reads the terminal rather than stdin. Feeding it
170 + /// on stdin does not fail, it hangs on a tty prompt the run screen cannot
171 + /// show.
172 + ///
173 + /// The exposure is real but narrower than argv: `/proc/<pid>/environ` is
174 + /// readable only by the process owner, where `ps` shows argv to everyone.
175 + /// The installer is root on a live ISO with no other users logged in, and
176 + /// the window is one command long.
177 + env: Vec<(String, Secret)>,
163 178 }
164 179
165 180 impl Invocation {
@@ -168,9 +183,20 @@
168 183 program: program.into(),
169 184 args: Vec::new(),
170 185 stdin: None,
186 + env: Vec::new(),
171 187 }
172 188 }
173 189
190 + /// Set `name` in the child's environment to a secret value.
191 + ///
192 + /// Not shown by [`display`](Self::display), for the same reason
193 + /// [`stdin`](Self::stdin) is not. See the field's own note for why this
194 + /// carrier exists at all when stdin is the better one.
195 + pub(crate) fn env_secret(mut self, name: impl Into<String>, value: Secret) -> Self {
196 + self.env.push((name.into(), value));
197 + self
198 + }
199 +
174 200 /// Pipe `secret` to the command on stdin.
175 201 ///
176 202 /// The value is not shown by [`display`](Self::display) and therefore never
@@ -273,6 +299,10 @@
273 299 self.stdin.is_none(),
274 300 "a suspended command inherits stdio and cannot carry a secret"
275 301 );
302 + debug_assert!(
303 + self.env.is_empty(),
304 + "a suspended command drops its secret environment"
305 + );
276 306 let mut command = Command::new(&self.program);
277 307 command.args(&self.args);
278 308 command
@@ -322,6 +352,7 @@
322 352 } else {
323 353 Stdio::null()
324 354 });
355 + self.apply_env(&mut command);
325 356
326 357 let started = command.spawn();
327 358 log.record(
@@ -387,6 +418,7 @@
387 418 // stdin sees EOF rather than the console's own terminal.
388 419 Stdio::null()
389 420 });
421 + self.apply_env(&mut command);
390 422
391 423 let mut child = command.spawn()?;
392 424
@@ -400,6 +432,17 @@
400 432
401 433 Ok(child.wait_with_output()?)
402 434 }
435 +
436 + /// Put [`env`](Self::env) onto a command about to be spawned.
437 + ///
438 + /// Non-UTF-8 is not a concern: every value here is a passphrase this
439 + /// process generated or read from a [`TextField`](alloy_tui::TextField),
440 + /// both of which are `String`s already.
441 + fn apply_env(&self, command: &mut Command) {
442 + for (name, value) in &self.env {
443 + command.env(name, String::from_utf8_lossy(value.expose()).as_ref());
444 + }
445 + }
403 446 }
404 447
405 448 /// Something the console does, which is usually but not always a command.
@@ -69,6 +69,7 @@
69 69 use alloy_tui::{Cursor, FocusRing};
70 70
71 71 use crate::cli::{CommandLog, Invocation, Secret};
72 + use crate::recovery;
72 73 use crate::run::{Sequence, Stage};
73 74 use crate::shell::{Confirm, Flow, TICK, View, block_title, truncate};
74 75 use crate::wizard::Steps;
@@ -501,6 +502,69 @@
501 502 .arg(disk)
502 503 }
503 504
505 + /// What lsblk calls a LUKS container in its `FSTYPE` column.
506 + const LUKS_FSTYPE: &str = "crypto_LUKS";
507 +
508 + /// Ask lsblk what filesystem is on `partition`, and what it holds open.
509 + fn partition_contents(partition: &str) -> Invocation {
510 + Invocation::new("lsblk")
511 + .args(["-J", "-o", "PATH,FSTYPE"])
512 + .arg(partition)
513 + }
514 +
515 + /// The device actually holding the root filesystem.
516 + ///
517 + /// Without encryption that is the root partition itself. With it, the partition
518 + /// is a LUKS container and the filesystem is on the mapper device opened inside
519 + /// it, so mounting the partition would fail with a bad superblock. Which one it
520 + /// is comes from lsblk rather than from the answer the user gave: reading the
521 + /// disk that exists is the only thing that stays right if bootc changes how it
522 + /// lays one out, and the mapper device's name is bootc's to choose, not ours.
523 + ///
524 + /// A closed container is an error rather than something to open here. bootc
525 + /// deploys into the volume it opened, so finding it shut afterwards means the
526 + /// install did not end where this code assumes it did, and guessing at an
527 + /// unlock sequence on top of that wrong assumption is how the recovery path
528 + /// gets written against a disk state nobody has seen.
529 + fn filesystem_device(listing: &str, partition: &str) -> Result<String, String> {
530 + #[derive(Deserialize)]
531 + struct Listing {
532 + blockdevices: Vec<Node>,
533 + }
534 + #[derive(Deserialize)]
535 + struct Node {
536 + path: String,
537 + #[serde(default)]
538 + fstype: Option<String>,
539 + #[serde(default)]
540 + children: Vec<Node>,
541 + }
542 +
543 + let parsed: Listing = serde_json::from_str(listing)
544 + .map_err(|err| format!("lsblk emitted invalid JSON: {err}"))?;
545 +
546 + let node = parsed
547 + .blockdevices
548 + .iter()
549 + .find(|node| node.path == partition)
550 + .ok_or_else(|| format!("lsblk did not report {partition}"))?;
551 +
552 + if !node
553 + .fstype
554 + .as_deref()
555 + .is_some_and(|fstype| fstype.eq_ignore_ascii_case(LUKS_FSTYPE))
556 + {
557 + return Ok(partition.to_string());
558 + }
559 +
560 + node.children
561 + .first()
562 + .map(|child| child.path.clone())
563 + .ok_or_else(|| {
564 + format!("{partition} is an unopened LUKS container; the deploy left it closed")
565 + })
566 + }
567 +
504 568 /// Ask lsblk which mountpoints are backed by `device`, one per line.
505 569 ///
506 570 /// lsblk rather than `findmnt --source`, which answers the same question and
@@ -964,6 +1028,74 @@
964 1028 }]
965 1029 }
966 1030
1031 + /// The two secrets that open an encrypted disk besides the TPM, and where they
1032 + /// go.
1033 + ///
1034 + /// Grouped rather than passed as three more arguments because they travel
1035 + /// together through four nested resolvers, and because `Option<&Encryption>`
1036 + /// says "encrypted or not" in the type where three parallel options would let a
1037 + /// passphrase exist without a partition to enroll it into.
1038 + /// The same two secrets, owned, for the trip from the view into the plan.
1039 + ///
1040 + /// [`Encryption`] borrows because it is built at the bottom of four nested
1041 + /// resolvers, where the partition path finally exists. This one is what the
1042 + /// caller hands over, and `Option` is what carries the user's answer: `None` is
1043 + /// an unencrypted install, which is why nothing downstream needs a bool.
1044 + #[derive(Debug)]
1045 + struct EncryptionChoice {
1046 + passphrase: String,
1047 + recovery: String,
1048 + }
1049 +
1050 + #[derive(Debug)]
1051 + struct Encryption<'a> {
1052 + /// The LUKS container, which is the root partition itself rather than the
1053 + /// mapper device opened inside it. The header being enrolled into lives
1054 + /// here.
1055 + partition: &'a str,
1056 + /// What the user typed on the encryption step.
1057 + passphrase: &'a str,
1058 + /// What [`crate::recovery::phrase`] generated, and what the installer must
1059 + /// show before it finishes.
1060 + recovery: &'a str,
1061 + }
1062 +
1063 + /// Enroll the two slots bootc does not, into the LUKS header on `partition`.
1064 + ///
1065 + /// bootc 1.16.3 finishes `--block-setup tpm2-luks` by running `systemd-cryptenroll
1066 + /// --wipe-slot=all --tpm2-device=auto`, which leaves the disk with exactly one
1067 + /// way in and no recovery at all: clear the TPM, replace the board, or move the
1068 + /// disk to another machine and the data is gone. These two stages are what makes
1069 + /// that survivable.
1070 + ///
1071 + /// Both are authorized by the slot that does exist. `--unlock-tpm2-device=auto`
1072 + /// asks the TPM to release the key so cryptenroll can decrypt the header it is
1073 + /// about to add to, which works here because the machine doing the install is
1074 + /// the machine the TPM is bound to.
1075 + ///
1076 + /// `--password` for both, never `--recovery-key`: that flag would generate its
1077 + /// own modhex string and ignore what we hand it. See [`crate::recovery`] for
1078 + /// why words win over modhex for the one slot a person transcribes by hand.
1079 + ///
1080 + /// The value travels in `$NEWPASSWORD` rather than on stdin, which is the one
1081 + /// place in this file that does. cryptenroll asks for a new passphrase through
1082 + /// `ask-password`, which reads the terminal and not the pipe, so a secret on
1083 + /// stdin here does not fail cleanly — it hangs the install on a prompt drawn
1084 + /// somewhere the run screen is not. See [`Invocation::env_secret`].
1085 + fn enroll_plan(partition: &str, passphrase: &str, recovery: &str) -> Vec<Stage> {
1086 + let enroll = |secret: &str| {
1087 + Stage::Run(
1088 + Invocation::new("systemd-cryptenroll")
1089 + .arg("--unlock-tpm2-device=auto")
1090 + .arg("--password")
1091 + .arg(partition)
1092 + .env_secret("NEWPASSWORD", Secret::new(secret.to_string())),
1093 + )
1094 + };
1095 +
1096 + vec![enroll(passphrase), enroll(recovery)]
1097 + }
1098 +
967 1099 /// The commands that configure an already-deployed system.
968 1100 ///
969 1101 /// `root` is the ostree deployment directory, not the mountpoint. See
@@ -979,6 +1111,7 @@
979 1111 password: &str,
980 1112 pubkey: Option<&str>,
981 1113 locate_timezone: bool,
1114 + encryption: Option<&Encryption<'_>>,
982 1115 root: &str,
983 1116 ) -> Result<Vec<Stage>, String> {
984 1117 let var = stateroot_var(root)?;
@@ -1168,6 +1301,20 @@
1168 1301 // network. Nothing below it depends on the result.
1169 1302 stages.extend(timezone_stages(locate_timezone, root));
1170 1303
1304 + // Before finalize and umount, because the TPM slot that authorizes these is
1305 + // only guaranteed to answer while the volume this deployment sits in is
1306 + // still open. After the account, because an install that gets this far and
1307 + // then fails to enroll has still produced a machine the user can log into
1308 + // with the TPM alone, where the reverse order would leave slots on a disk
1309 + // with no account on it.
1310 + if let Some(encryption) = encryption {
1311 + stages.extend(enroll_plan(
1312 + encryption.partition,
1313 + encryption.passphrase,
1314 + encryption.recovery,
1315 + ));
1316 + }
1317 +
1171 1318 stages.extend([
1172 1319 // Upstream: "optional, but recommended to run as the penultimate step
1173 1320 // before unmounting the target filesystem. This command will perform
@@ -1334,11 +1481,13 @@
1334 1481 password: &str,
1335 1482 pubkey: Option<&str>,
1336 1483 locate_timezone: bool,
1484 + encryption: Option<EncryptionChoice>,
1337 1485 ) -> Vec<Stage> {
1338 1486 let hostname = hostname.to_string();
1339 1487 let username = username.to_string();
1340 1488 let password = password.to_string();
1341 1489 let pubkey = pubkey.map(str::to_string);
1490 + let encrypt = encryption.is_some();
1342 1491
1343 1492 vec![
1344 1493 // --wipe is explicit rather than implied by the confirm the user just
@@ -1370,6 +1519,18 @@
1370 1519 // registry install machines that update from it. See
1371 1520 // [`update_image`].
1372 1521 install = install.args(["--target-imgref", &update_image()]);
1522 + // The only encryption `to-disk` offers, and the reason it is worth
1523 + // taking as-is: the alternative is `to-filesystem`, which would make
1524 + // Alloy own partitioning, mkfs, LUKS format and the ESP on the one
1525 + // path where a bug costs the user their disk.
1526 + //
1527 + // What it leaves behind is a volume with exactly one way in. bootc
1528 + // finishes by wiping every slot but the TPM's, so the enrollment in
1529 + // [`enroll_plan`] is not an enhancement — without it a cleared TPM
1530 + // is a permanently unreadable disk.
1531 + if encrypt {
1532 + install = install.args(["--block-setup", "tpm2-luks"]);
1533 + }
1373 1534 install.arg(disk)
1374 1535 }),
1375 1536 // bootc returns when the install is done, not when the kernel and
@@ -1390,55 +1551,80 @@
1390 1551 invocation: partition_types(disk),
1391 1552 then: Box::new(move |listing| {
1392 1553 let partition = root_partition(listing)?.clone();
1393 - Ok(vec![
1394 - // Release whatever bootc left mounted on the partition
1395 - // before mounting it. Its own leftover is read-only, and a
1396 - // second mount of a filesystem that is already mounted
1397 - // shares the first one's superblock rather than getting a
1398 - // fresh one, so the read-only travels to this mount too.
1399 - Stage::Resolve {
1400 - invocation: mounts_of(&partition),
1401 - then: Box::new(move |listing| {
1402 - Ok(leftover_mounts(listing)
1403 - .iter()
1404 - .map(|target| Stage::Run(unmount(target)))
1405 - .collect())
1406 - }),
1407 - },
1408 - Stage::Run(
1409 - Invocation::new("mount")
1410 - .args(["-o", "rw"])
1411 - .arg(&partition)
1412 - .arg(TARGET_MOUNT),
1413 - ),
1414 - // Prove it took. `mount` warns and exits 0 when it falls
1415 - // back to read-only, so success here is not evidence.
1416 - Stage::Resolve {
1417 - invocation: mount_options(TARGET_MOUNT),
1418 - then: Box::new(move |options| {
1419 - writable_mount(options)?;
1420 - // Second discovery, only possible once mounted:
1421 - // where the deployment is inside the sysroot.
1422 - Ok(vec![Stage::Resolve {
1423 - invocation: deployment_dir(TARGET_MOUNT),
1424 - then: Box::new(move |printed| {
1425 - let deployment = printed.trim();
1426 - if deployment.is_empty() {
1427 - return Err("ostree reported no current deployment".into());
1428 - }
1429 - configure_plan(
1430 - &hostname,
1431 - &username,
1432 - &password,
1433 - pubkey.as_deref(),
1434 - locate_timezone,
1435 - deployment,
1436 - )
1554 + // Second discovery: whether that partition holds the filesystem
1555 + // or a LUKS container with the filesystem inside it. Asked of
1556 + // the disk rather than inferred from `encrypt`, so the mount
1557 + // follows what bootc actually built. See [`filesystem_device`].
1558 + Ok(vec![Stage::Resolve {
1559 + invocation: partition_contents(&partition),
1560 + then: Box::new(move |contents| {
1561 + let device = filesystem_device(contents, &partition)?;
1562 + Ok(vec![
1563 + // Release whatever bootc left mounted on the device
1564 + // before mounting it. Its own leftover is read-only,
1565 + // and a second mount of a filesystem that is already
1566 + // mounted shares the first one's superblock rather
1567 + // than getting a fresh one, so the read-only travels
1568 + // to this mount too.
1569 + Stage::Resolve {
1570 + invocation: mounts_of(&device),
1571 + then: Box::new(move |listing| {
1572 + Ok(leftover_mounts(listing)
1573 + .iter()
1574 + .map(|target| Stage::Run(unmount(target)))
1575 + .collect())
1437 1576 }),
1438 - }])
1439 - }),
1440 - },
1441 - ])
1577 + },
1578 + Stage::Run(
1579 + Invocation::new("mount")
1580 + .args(["-o", "rw"])
1581 + .arg(&device)
1582 + .arg(TARGET_MOUNT),
1583 + ),
1584 + // Prove it took. `mount` warns and exits 0 when it
1585 + // falls back to read-only, so success here is not
1586 + // evidence.
1587 + Stage::Resolve {
1588 + invocation: mount_options(TARGET_MOUNT),
1589 + then: Box::new(move |options| {
1590 + writable_mount(options)?;
1591 + // Third discovery, only possible once
1592 + // mounted: where the deployment is inside
1593 + // the sysroot.
1594 + Ok(vec![Stage::Resolve {
1595 + invocation: deployment_dir(TARGET_MOUNT),
1596 + then: Box::new(move |printed| {
1597 + let deployment = printed.trim();
1598 + if deployment.is_empty() {
1599 + return Err(
1600 + "ostree reported no current deployment".into(),
1601 + );
1602 + }
1603 + // The container, not the mapper
1604 + // device: the header being enrolled
1605 + // into is on the partition.
1606 + let encryption =
1607 + encryption.as_ref().map(|choice| Encryption {
1608 + partition: partition.as_str(),
1609 + passphrase: &choice.passphrase,
1610 + recovery: &choice.recovery,
1611 + });
1612 + configure_plan(
1613 + &hostname,
1614 + &username,
1615 + &password,
1616 + pubkey.as_deref(),
1617 + locate_timezone,
1618 + encryption.as_ref(),
1619 + deployment,
1620 + )
1621 + }),
1622 + }])
1623 + }),
1624 + },
1625 + ])
1626 + }),
1627 + }])
1442 1628 }),
1443 1629 },
1444 1630 ]
@@ -1790,6 +1976,12 @@
1790 1976 passphrase_confirm: TextField,
1791 1977 /// Which of the three encryption-step slots has focus.
1792 1978 crypt: FocusRing,
1979 + /// The generated recovery phrase, once the install has been confirmed.
1980 + ///
1981 + /// `None` until then, and on an unencrypted install for good. Held rather
1982 + /// than regenerated because it is enrolled into the disk and shown on
1983 + /// screen, and those have to be the same eight words.
1984 + recovery: Option<String>,
1793 1985 /// The encryption checkbox, until the step is confirmed and it becomes an
1794 1986 /// answer. Starts ticked: Alloy encrypts unless told not to.
1795 1987 encrypt: bool,
@@ -1832,6 +2024,7 @@
1832 2024 passphrase: TextField::new(),
1833 2025 passphrase_confirm: TextField::new(),
1834 2026 crypt: FocusRing::new(ENCRYPT_SLOTS),
2027 + recovery: None,
1835 2028 encrypt: true,
1836 2029 answers: Answers::default(),
1837 2030 error: None,
@@ -2517,7 +2710,7 @@
2517 2710 /// Empty if an answer is missing, which cannot happen from the summary step
2518 2711 /// (every earlier step gates on its own validation) but returning nothing
2519 2712 /// beats rendering a command line with a hole in it.
2520 - fn plan(&self, password: &str) -> Vec<Stage> {
2713 + fn plan(&self, password: &str, passphrase: &str, recovery: &str) -> Vec<Stage> {
2521 2714 let (Some(disk), Some(hostname), Some(username)) = (
2522 2715 self.answers.disk.as_deref(),
2523 2716 self.answers.hostname.as_deref(),
@@ -2533,6 +2726,10 @@
2533 2726 password,
2534 2727 self.answers.pubkey.as_deref(),
2535 2728 self.answers.locate_timezone,
2729 + self.answers.encrypt.then(|| EncryptionChoice {
2730 + passphrase: passphrase.to_string(),
2731 + recovery: recovery.to_string(),
2732 + }),
2536 2733 )
2537 2734 }
2538 2735
@@ -2551,7 +2748,7 @@
2551 2748 /// together, and `no_line_of_the_plan_carries_the_password` pins the claim
2552 2749 /// above it.
2553 2750 fn plan_display(&self) -> Vec<String> {
2554 - self.plan("").iter().map(Stage::display).collect()
2751 + self.plan("", "", "").iter().map(Stage::display).collect()
2555 2752 }
2556 2753
2557 2754 /// The hostname pane: a prompt, the field with its caret, and what the
@@ -2784,8 +2981,31 @@
2784 2981 /// disk. Nothing here blocks, which is the whole point — `bootc install
2785 2982 /// to-disk` takes minutes and the frame has to keep drawing for all of them.
2786 2983 fn confirmed(&mut self, _log: &mut CommandLog) -> Flow {
2984 + // Generated once, here, rather than inside the plan: the same phrase has
2985 + // to be enrolled and then shown to the user, and a plan that made its
2986 + // own would enroll one nobody ever sees. Held on the view because the
2987 + // run screen is what displays it.
2988 + //
2989 + // A failed random source refuses the install rather than falling back.
2990 + // The disk is still untouched at this point, which makes this the last
2991 + // moment refusing is free; a predictable recovery phrase would be
2992 + // discovered much later, by someone who needed it to work.
2993 + if self.answers.encrypt {
2994 + match recovery::phrase() {
2995 + Ok(phrase) => self.recovery = Some(phrase),
2996 + Err(message) => {
2997 + self.error = Some(message);
2998 + return Flow::Continue;
2999 + }
3000 + }
3001 + }
3002 +
2787 3003 self.error = None;
2788 - self.running = Some(Sequence::new(self.plan(self.password.value())));
3004 + self.running = Some(Sequence::new(self.plan(
3005 + self.password.value(),
3006 + self.passphrase.value(),
3007 + self.recovery.as_deref().unwrap_or_default(),
3008 + )));
2789 3009 // Stays on the view: the run screen it just switched to is the whole
2790 3010 // point of answering yes.
2791 3011 Flow::Continue
@@ -3073,6 +3293,7 @@
3073 3293 passphrase: TextField::new(),
3074 3294 passphrase_confirm: TextField::new(),
3075 3295 crypt: FocusRing::new(ENCRYPT_SLOTS),
3296 + recovery: None,
3076 3297 encrypt: true,
3077 3298 answers: Answers::default(),
3078 3299 error: None,
@@ -3641,6 +3862,149 @@
3641 3862 assert_eq!(view.step(), Step::Encryption);
3642 3863 }
3643 3864
3865 + // ---- encryption in the plan ----
3866 +
3867 + /// The first line of an install plan built with `encrypt` either way.
3868 + fn deploy_line(encrypt: bool) -> String {
3869 + let encryption = encrypt.then(|| EncryptionChoice {
3870 + passphrase: String::new(),
3871 + recovery: String::new(),
3872 + });
3873 + install_plan("/dev/sda", "host", "user", "pw", None, false, encryption)
3874 + .first()
3875 + .expect("the plan installs")
3876 + .display()
3877 + }
3878 +
3879 + // The flag that decides whether the disk is readable out of the machine.
3880 + // Asserted both ways round: a conditional that is always true passes the
3881 + // on-test and is still wrong.
3882 + #[test]
3883 + fn the_deploy_asks_for_luks_only_when_encryption_was_chosen() {
3884 + assert!(deploy_line(true).contains("--block-setup tpm2-luks"));
3885 + assert!(!deploy_line(false).contains("--block-setup"));
3886 + }
3887 +
3888 + // An unencrypted partition holds the filesystem directly, so the thing to
3889 + // mount is the partition.
3890 + #[test]
3891 + fn a_plain_partition_is_its_own_filesystem_device() {
3892 + let listing = r#"{"blockdevices":[{"path":"/dev/sda3","fstype":"xfs"}]}"#;
3893 + assert_eq!(
3894 + filesystem_device(listing, "/dev/sda3").unwrap(),
3895 + "/dev/sda3"
3896 + );
3897 + }
3898 +
3899 + // An encrypted one does not. Mounting the container would fail on a bad
3900 + // superblock, and the filesystem is on the mapper device bootc opened
3901 + // inside it, whose name is bootc's to choose.
3902 + #[test]
3903 + fn a_luks_partition_mounts_the_mapper_device_inside_it() {
3904 + let listing = r#"{"blockdevices":[{"path":"/dev/sda3","fstype":"crypto_LUKS",
3905 + "children":[{"path":"/dev/mapper/root","fstype":"xfs"}]}]}"#;
3906 + assert_eq!(
3907 + filesystem_device(listing, "/dev/sda3").unwrap(),
3908 + "/dev/mapper/root"
3909 + );
3910 + }
3911 +
3912 + // The branch this code cannot verify against a real disk from here. If bootc
3913 + // ever leaves the container shut, the install has to say so rather than
3914 + // mount the container and fail three stages later about a superblock.
3915 + #[test]
3916 + fn a_closed_luks_container_is_an_error_that_names_itself() {
3917 + let listing = r#"{"blockdevices":[{"path":"/dev/sda3","fstype":"crypto_LUKS"}]}"#;
3918 + let err = filesystem_device(listing, "/dev/sda3").unwrap_err();
3919 + assert!(err.contains("unopened LUKS"), "{err}");
3920 + }
3921 +
3922 + // Both slots bootc wipes, enrolled against the container rather than the
3923 + // mapper device: the header is on the partition.
3924 + #[test]
3925 + fn enrollment_adds_a_slot_for_the_passphrase_and_the_phrase() {
3926 + let shown: Vec<String> = enroll_plan("/dev/sda3", "opensesame", "eight words here")
3927 + .iter()
3928 + .map(Stage::display)
3929 + .collect();
3930 +
3931 + assert_eq!(shown.len(), 2, "{shown:#?}");
3932 + for line in &shown {
3933 + assert!(line.contains("systemd-cryptenroll"), "{line}");
3934 + assert!(line.contains("--unlock-tpm2-device=auto"), "{line}");
3935 + assert!(line.contains("--password"), "{line}");
3936 + assert!(line.ends_with("/dev/sda3"), "{line}");
3937 + // --recovery-key would make cryptenroll generate its own modhex and
3938 + // ignore the words we mean to enroll.
3939 + assert!(!line.contains("--recovery-key"), "{line}");
3940 + }
3941 + }
Lines truncated
@@ -14,6 +14,7 @@
14 14 mod mesh;
15 15 mod net;
16 16 mod pkg;
17 + mod recovery;
17 18 mod run;
18 19 mod schema;
19 20 mod settings;
@@ -1,0 +1,166 @@
1 + //! The recovery phrase for an encrypted root.
2 + //!
3 + //! A machine whose disk is unlocked by its TPM has one failure mode that ends
4 + //! in permanent data loss: the TPM stops answering. Clearing it, replacing the
5 + //! board, or moving the disk to another machine all do that, and bootc's own
6 + //! `--block-setup tpm2-luks` leaves nothing else enrolled — it runs
7 + //! `systemd-cryptenroll --wipe-slot=all` after binding the TPM, so the
8 + //! temporary keyfile slot is gone by the time the install finishes. Alloy
9 + //! enrolls this phrase into a slot of its own so that there is a way back.
10 + //!
11 + //! **Words rather than `systemd-cryptenroll --recovery-key`.** That flag emits
12 + //! a 256-bit key in modhex, eight groups of six characters like
13 + //! `dhkjng-ctfhrb-…`. It is stronger per character and it is unusable in the
14 + //! situation it exists for: a person reading it off one screen and typing it
15 + //! into another, months later, having written it on paper in a hurry. Words are
16 + //! transcribed correctly and modhex is not.
17 + //!
18 + //! <!-- wiki: alloy-privilege -->
19 +
20 + /// The wordlist, one word per line.
21 + ///
22 + /// BIP-0039's English list, from the `bitcoin/bips` repository, which is
23 + /// 2-clause BSD and so carries no copyleft obligation (see the standing rule in
24 + /// `~/Code/CLAUDE.md`: never GPL). Taken rather than written because the list
25 + /// is built for exactly this job and has properties a hand-filtered spell-check
26 + /// dictionary does not:
27 + ///
28 + /// - **2048 words**, which is 2^11, so a word is exactly 11 bits and sampling
29 + /// needs no division.
30 + /// - **Every word is unique in its first four characters**, so a phrase can be
31 + /// matched from a prefix and a mistranscribed tail is detectable rather than
32 + /// silently another word.
33 + /// - **Curated for common vocabulary and screened for anything unpleasant**,
34 + /// which matters for a string the installer puts on screen and a person reads
35 + /// aloud to someone else.
36 + const WORDLIST: &str = include_str!("wordlist.txt");
37 +
38 + /// How many words a phrase carries.
39 + ///
40 + /// Eight, for 88 bits. LUKS2 hashes with argon2id, so the offline attack this
41 + /// resists is already expensive per guess and fewer words would defend fine on
42 + /// paper. Eight is chosen against the other failure: this phrase is the only
43 + /// thing standing between a dead TPM and a lost disk, it is generated once and
44 + /// never rotated, and it may sit on paper for years. The cost of the extra two
45 + /// words is two more words to write down.
46 + const PHRASE_WORDS: usize = 8;
47 +
48 + /// The number of words in [`WORDLIST`], as a power of two.
49 + ///
50 + /// [`word_at`] depends on this being exactly 2^11: it masks eleven bits off a
51 + /// random `u16` rather than taking a remainder, which is what keeps the
52 + /// distribution uniform. [`the_wordlist_is_exactly_two_to_the_eleven`] fails the
53 + /// build's tests if the file ever stops holding 2048 lines.
54 + const WORDLIST_BITS: u32 = 11;
55 + const WORDLIST_LEN: usize = 1 << WORDLIST_BITS;
56 +
57 + /// The word at `index`, which must be less than [`WORDLIST_LEN`].
58 + fn word_at(index: usize) -> &'static str {
59 + WORDLIST
60 + .lines()
61 + .nth(index)
62 + .expect("the wordlist is 2048 lines, and the index is masked to 11 bits")
63 + }
64 +
65 + /// A freshly generated recovery phrase, words separated by single spaces.
66 + ///
67 + /// **Uniform by masking, not by remainder.** Taking `u16 % 2048` would be the
68 + /// obvious way and would be subtly wrong for a list whose length did not divide
69 + /// 65536; masking the low eleven bits is uniform for a power-of-two list and
70 + /// stays correct without a rejection loop. The list being 2^11 is what buys
71 + /// that, and is asserted in the tests rather than assumed.
72 + ///
73 + /// The error is returned rather than papered over with a fallback: a phrase
74 + /// from a failed random source would be predictable, and a predictable recovery
75 + /// phrase on an encrypted disk is worse than refusing to encrypt at all.
76 + pub(crate) fn phrase() -> Result<String, String> {
77 + let mut bytes = [0u8; PHRASE_WORDS * 2];
78 + getrandom::fill(&mut bytes)
79 + .map_err(|err| format!("could not generate a recovery phrase: {err}"))?;
80 +
81 + let words: Vec<&str> = bytes
82 + .chunks_exact(2)
83 + .map(|pair| {
84 + let raw = u16::from_le_bytes([pair[0], pair[1]]);
85 + word_at(usize::from(raw) & (WORDLIST_LEN - 1))
86 + })
87 + .collect();
88 +
89 + Ok(words.join(" "))
90 + }
91 +
92 + #[cfg(test)]
93 + mod tests {
94 + use super::*;
95 + use std::collections::HashSet;
96 +
97 + // The masking in `phrase` is only uniform because the list is a power of
98 + // two, and `word_at`'s expect is only safe because it is exactly 2048. A
99 + // wordlist edit that broke either would otherwise show up as a panic during
100 + // an install, on the one screen where a panic costs the user their disk.
101 + #[test]
102 + fn the_wordlist_is_exactly_two_to_the_eleven() {
103 + assert_eq!(WORDLIST.lines().count(), WORDLIST_LEN);
104 + assert_eq!(WORDLIST_LEN, 2048);
105 + }
106 +
107 + // The property the list was chosen for. A phrase is transcribed by hand, so
108 + // a word that is ambiguous in its first four characters would make a
109 + // mistyped tail resolve to a different valid word rather than to an error.
110 + #[test]
111 + fn every_word_is_unique_in_its_first_four_characters() {
112 + let prefixes: HashSet<&str> = WORDLIST
113 + .lines()
114 + .map(|word| &word[..word.len().min(4)])
115 + .collect();
116 + assert_eq!(prefixes.len(), WORDLIST_LEN);
117 + }
118 +
119 + // Lowercase ASCII only. The phrase is typed at a boot-time prompt with
120 + // whatever keymap the initramfs has, which is not the user's; anything
121 + // outside a-z is a key they may not be able to reach.
122 + #[test]
123 + fn every_word_is_plain_lowercase_ascii() {
124 + for word in WORDLIST.lines() {
125 + assert!(
126 + !word.is_empty() && word.bytes().all(|b| b.is_ascii_lowercase()),
127 + "not plain lowercase: {word:?}",
128 + );
129 + }
130 + }
131 +
132 + #[test]
133 + fn a_phrase_is_eight_words_from_the_list() {
134 + let phrase = phrase().expect("the random source answered");
135 + let words: Vec<&str> = phrase.split(' ').collect();
136 +
137 + assert_eq!(words.len(), PHRASE_WORDS, "{phrase:?}");
138 + let known: HashSet<&str> = WORDLIST.lines().collect();
139 + for word in words {
140 + assert!(known.contains(word), "not from the list: {word:?}");
141 + }
142 + }
143 +
144 + // Single spaces and no surrounding whitespace, because the phrase is
145 + // handed to systemd-cryptenroll on stdin and a stray newline or double
146 + // space would be part of the passphrase — enrolled invisibly, and
147 + // impossible to reproduce by typing the words at a prompt.
148 + #[test]
149 + fn a_phrase_carries_no_stray_whitespace() {
150 + let phrase = phrase().expect("the random source answered");
151 + assert_eq!(phrase.trim(), phrase, "{phrase:?}");
152 + assert!(!phrase.contains(" "), "{phrase:?}");
153 + assert_eq!(phrase.split(' ').count(), PHRASE_WORDS, "{phrase:?}");
154 + }
155 +
156 + // Two phrases in a row must differ. Catches a generator wired to a constant
157 + // seed or to a source that silently returns zeros, which would otherwise
158 + // pass every other test here while enrolling the same phrase on every
159 + // machine Alloy installs.
160 + #[test]
161 + fn two_phrases_are_not_the_same() {
162 + let first = phrase().expect("the random source answered");
163 + let second = phrase().expect("the random source answered");
164 + assert_ne!(first, second);
165 + }
166 + }
@@ -1,0 +1,2048 @@
1 + abandon
2 + ability
3 + able
4 + about
5 + above
6 + absent
7 + absorb
8 + abstract
9 + absurd
10 + abuse
11 + access
12 + accident
13 + account
14 + accuse
15 + achieve
16 + acid
17 + acoustic
18 + acquire
19 + across
20 + act
21 + action
22 + actor
23 + actress
24 + actual
25 + adapt
26 + add
27 + addict
28 + address
29 + adjust
30 + admit
31 + adult
32 + advance
33 + advice
34 + aerobic
35 + affair
36 + afford
37 + afraid
38 + again
39 + age
40 + agent
41 + agree
42 + ahead
43 + aim
44 + air
45 + airport
46 + aisle
47 + alarm
48 + album
49 + alcohol
50 + alert
51 + alien
52 + all
53 + alley
54 + allow
55 + almost
56 + alone
57 + alpha
58 + already
59 + also
60 + alter
61 + always
62 + amateur
63 + amazing
64 + among
65 + amount
66 + amused
67 + analyst
68 + anchor
69 + ancient
70 + anger
71 + angle
72 + angry
73 + animal
74 + ankle
75 + announce
76 + annual
77 + another
78 + answer
79 + antenna
80 + antique
81 + anxiety
82 + any
83 + apart
84 + apology
85 + appear
86 + apple
87 + approve
88 + april
89 + arch
90 + arctic
91 + area
92 + arena
93 + argue
94 + arm
95 + armed
96 + armor
97 + army
98 + around
99 + arrange
100 + arrest
101 + arrive
102 + arrow
103 + art
104 + artefact
105 + artist
106 + artwork
107 + ask
108 + aspect
109 + assault
110 + asset
111 + assist
112 + assume
113 + asthma
114 + athlete
115 + atom
116 + attack
117 + attend
118 + attitude
119 + attract
120 + auction
121 + audit
122 + august
123 + aunt
124 + author
125 + auto
126 + autumn
127 + average
128 + avocado
129 + avoid
130 + awake
131 + aware
132 + away
133 + awesome
134 + awful
135 + awkward
136 + axis
137 + baby
138 + bachelor
139 + bacon
140 + badge
141 + bag
142 + balance
143 + balcony
144 + ball
145 + bamboo
146 + banana
147 + banner
148 + bar
149 + barely
150 + bargain
151 + barrel
152 + base
153 + basic
154 + basket
155 + battle
156 + beach
157 + bean
158 + beauty
159 + because
160 + become
161 + beef
162 + before
163 + begin
164 + behave
165 + behind
166 + believe
167 + below
168 + belt
169 + bench
170 + benefit
171 + best
172 + betray
173 + better
174 + between
175 + beyond
176 + bicycle
177 + bid
178 + bike
179 + bind
180 + biology
181 + bird
182 + birth
183 + bitter
184 + black
185 + blade
186 + blame
187 + blanket
188 + blast
189 + bleak
190 + bless
191 + blind
192 + blood
193 + blossom
194 + blouse
195 + blue
196 + blur
197 + blush
198 + board
199 + boat
200 + body
201 + boil
202 + bomb
203 + bone
204 + bonus
205 + book
206 + boost
207 + border
208 + boring
209 + borrow
210 + boss
211 + bottom
212 + bounce
213 + box
214 + boy
215 + bracket
216 + brain
217 + brand
218 + brass
219 + brave
220 + bread
221 + breeze
222 + brick
223 + bridge
224 + brief
225 + bright
226 + bring
227 + brisk
228 + broccoli
229 + broken
230 + bronze
231 + broom
232 + brother
233 + brown
234 + brush
235 + bubble
236 + buddy
237 + budget
238 + buffalo
239 + build
240 + bulb
241 + bulk
242 + bullet
243 + bundle
244 + bunker
245 + burden
246 + burger
247 + burst
248 + bus
249 + business
250 + busy
251 + butter
252 + buyer
253 + buzz
254 + cabbage
255 + cabin
256 + cable
257 + cactus
258 + cage
259 + cake
260 + call
261 + calm
262 + camera
263 + camp
264 + can
265 + canal
266 + cancel
267 + candy
268 + cannon
269 + canoe
270 + canvas
271 + canyon
272 + capable
273 + capital
274 + captain
275 + car
276 + carbon
277 + card
278 + cargo
279 + carpet
280 + carry
281 + cart
282 + case
283 + cash
284 + casino
285 + castle
286 + casual
287 + cat
288 + catalog
289 + catch
290 + category
291 + cattle
292 + caught
293 + cause
294 + caution
295 + cave
296 + ceiling
297 + celery
298 + cement
299 + census
300 + century
301 + cereal
302 + certain
303 + chair
304 + chalk
305 + champion
306 + change
307 + chaos
308 + chapter
309 + charge
310 + chase
311 + chat
312 + cheap
313 + check
314 + cheese
315 + chef
316 + cherry
317 + chest
318 + chicken
319 + chief
320 + child
321 + chimney
322 + choice
323 + choose
324 + chronic
325 + chuckle
326 + chunk
327 + churn
328 + cigar
329 + cinnamon
330 + circle
331 + citizen
332 + city
333 + civil
334 + claim
335 + clap
336 + clarify
337 + claw
338 + clay
339 + clean
340 + clerk
341 + clever
342 + click
343 + client
344 + cliff
345 + climb
346 + clinic
347 + clip
348 + clock
349 + clog
350 + close
351 + cloth
352 + cloud
353 + clown
354 + club
355 + clump
356 + cluster
357 + clutch
358 + coach
359 + coast
360 + coconut
361 + code
362 + coffee
363 + coil
364 + coin
365 + collect
366 + color
367 + column
368 + combine
369 + come
370 + comfort
371 + comic
372 + common
373 + company
374 + concert
375 + conduct
376 + confirm
377 + congress
378 + connect
379 + consider
380 + control
381 + convince
382 + cook
383 + cool
384 + copper
385 + copy
386 + coral
387 + core
388 + corn
389 + correct
390 + cost
391 + cotton
392 + couch
393 + country
394 + couple
395 + course
396 + cousin
397 + cover
398 + coyote
399 + crack
400 + cradle
401 + craft
402 + cram
403 + crane
404 + crash
405 + crater
406 + crawl
407 + crazy
408 + cream
409 + credit
410 + creek
411 + crew
412 + cricket
413 + crime
414 + crisp
415 + critic
416 + crop
417 + cross
418 + crouch
419 + crowd
420 + crucial
421 + cruel
422 + cruise
423 + crumble
424 + crunch
425 + crush
426 + cry
427 + crystal
428 + cube
429 + culture
430 + cup
431 + cupboard
432 + curious
433 + current
434 + curtain
435 + curve
436 + cushion
437 + custom
438 + cute
439 + cycle
440 + dad
441 + damage
442 + damp
443 + dance
444 + danger
445 + daring
446 + dash
447 + daughter
448 + dawn
449 + day
450 + deal
451 + debate
452 + debris
453 + decade
454 + december
455 + decide
456 + decline
457 + decorate
458 + decrease
459 + deer
460 + defense
461 + define
462 + defy
463 + degree
464 + delay
465 + deliver
466 + demand
467 + demise
468 + denial
469 + dentist
470 + deny
471 + depart
472 + depend
473 + deposit
474 + depth
475 + deputy
476 + derive
477 + describe
478 + desert
479 + design
480 + desk
481 + despair
482 + destroy
483 + detail
484 + detect
485 + develop
486 + device
487 + devote
488 + diagram
489 + dial
490 + diamond
491 + diary
492 + dice
493 + diesel
494 + diet
495 + differ
496 + digital
497 + dignity
498 + dilemma
499 + dinner
500 + dinosaur
Lines truncated