| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
+ |
}
|