Skip to main content

max / alloy

install: fix the password and home directory against a real target chpasswd --root hashes through PAM, and the installer's PAM is the host's: it fails with "pam_chauthtok() failed, error: Module is unknown" because the modules the target names are not installed here. Hash with sha-crypt in-process and hand the result over with --encrypted, which skips PAM entirely. The salt is generated here rather than left to sha-crypt's default. crypt(3) reads at most 16 salt characters and returns the string it used, with the rest cut off; PAM compares that whole string against the stored one, so sha-crypt's 22-character salt gives an account no correct password can log into. Verified against glibc's crypt. useradd --create-home resolves the home against --root, which is the deployment, whose var the stateroot's var is mounted over at boot. The home is made in the stateroot var instead, with skel copied and ownership taken from the target's own passwd rather than the host's.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 01:43 UTC
Signed with PGP, not checked
Commit: 70d10e57acfe3bca46d969c8f6ba3ff9cb231dee
Parent: faa98e9
5 files changed, +483 insertions, -68 deletions
M Cargo.lock +114 -6
@@ -24,10 +24,12 @@
24 24 "alloy_tui",
25 25 "anyhow",
26 26 "clap",
27 + "getrandom 0.4.3",
27 28 "makeover",
28 29 "ratatui",
29 30 "serde",
30 31 "serde_json",
32 + "sha-crypt",
31 33 "toml 0.9.12+spec-1.1.0",
32 34 ]
33 35
@@ -118,6 +120,12 @@
118 120 source = "registry+https://github.com/rust-lang/crates.io-index"
119 121 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
120 122
123 + [[package]]
124 + name = "base64ct"
125 + version = "1.8.3"
126 + source = "registry+https://github.com/rust-lang/crates.io-index"
127 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
128 +
121 129 [[package]]
122 130 name = "bit-set"
123 131 version = "0.5.3"
@@ -154,6 +162,15 @@
154 162 "generic-array",
155 163 ]
156 164
165 + [[package]]
166 + name = "block-buffer"
167 + version = "0.12.1"
168 + source = "registry+https://github.com/rust-lang/crates.io-index"
169 + checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
170 + dependencies = [
171 + "hybrid-array",
172 + ]
173 +
157 174 [[package]]
158 175 name = "bumpalo"
159 176 version = "3.20.3"
@@ -227,6 +244,12 @@
227 244 source = "registry+https://github.com/rust-lang/crates.io-index"
228 245 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
229 246
247 + [[package]]
248 + name = "cmov"
249 + version = "0.5.4"
250 + source = "registry+https://github.com/rust-lang/crates.io-index"
251 + checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
252 +
230 253 [[package]]
231 254 name = "colorchoice"
232 255 version = "1.0.5"
@@ -265,6 +288,15 @@
265 288 "libc",
266 289 ]
267 290
291 + [[package]]
292 + name = "cpufeatures"
293 + version = "0.3.0"
294 + source = "registry+https://github.com/rust-lang/crates.io-index"
295 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
296 + dependencies = [
297 + "libc",
298 + ]
299 +
268 300 [[package]]
269 301 name = "crossterm"
270 302 version = "0.29.0"
@@ -302,6 +334,15 @@
302 334 "typenum",
303 335 ]
304 336
337 + [[package]]
338 + name = "crypto-common"
339 + version = "0.2.2"
340 + source = "registry+https://github.com/rust-lang/crates.io-index"
341 + checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
342 + dependencies = [
343 + "hybrid-array",
344 + ]
345 +
305 346 [[package]]
306 347 name = "csscolorparser"
307 348 version = "0.6.2"
@@ -312,6 +353,15 @@
312 353 "phf",
313 354 ]
314 355
356 + [[package]]
357 + name = "ctutils"
358 + version = "0.4.2"
359 + source = "registry+https://github.com/rust-lang/crates.io-index"
360 + checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
361 + dependencies = [
362 + "cmov",
363 + ]
364 +
315 365 [[package]]
316 366 name = "darling"
317 367 version = "0.20.11"
@@ -390,8 +440,18 @@
390 440 source = "registry+https://github.com/rust-lang/crates.io-index"
391 441 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
392 442 dependencies = [
393 - "block-buffer",
394 - "crypto-common",
443 + "block-buffer 0.10.4",
444 + "crypto-common 0.1.7",
445 + ]
446 +
447 + [[package]]
448 + name = "digest"
449 + version = "0.11.3"
450 + source = "registry+https://github.com/rust-lang/crates.io-index"
451 + checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
452 + dependencies = [
453 + "block-buffer 0.12.1",
454 + "crypto-common 0.2.2",
395 455 ]
396 456
397 457 [[package]]
@@ -565,6 +625,15 @@
565 625 source = "registry+https://github.com/rust-lang/crates.io-index"
566 626 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
567 627
628 + [[package]]
629 + name = "hybrid-array"
630 + version = "0.4.13"
631 + source = "registry+https://github.com/rust-lang/crates.io-index"
632 + checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
633 + dependencies = [
634 + "typenum",
635 + ]
636 +
568 637 [[package]]
569 638 name = "ident_case"
570 639 version = "1.0.1"
@@ -749,6 +818,15 @@
749 818 "toml 0.8.23",
750 819 ]
751 820
821 + [[package]]
822 + name = "mcf"
823 + version = "0.6.0"
824 + source = "registry+https://github.com/rust-lang/crates.io-index"
825 + checksum = "2044bdbade272ded4a1529324f4f30ca7b0c5b496541f2a9095bbd1c9d03cb06"
826 + dependencies = [
827 + "base64ct",
828 + ]
829 +
752 830 [[package]]
753 831 name = "memchr"
754 832 version = "2.8.2"
@@ -890,6 +968,12 @@
890 968 "windows-link",
891 969 ]
892 970
971 + [[package]]
972 + name = "password-hash"
973 + version = "0.6.1"
974 + source = "registry+https://github.com/rust-lang/crates.io-index"
975 + checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
976 +
893 977 [[package]]
894 978 name = "pest"
895 979 version = "2.8.7"
@@ -1277,6 +1361,19 @@
1277 1361 "serde_core",
1278 1362 ]
1279 1363
1364 + [[package]]
1365 + name = "sha-crypt"
1366 + version = "0.6.0"
1367 + source = "registry+https://github.com/rust-lang/crates.io-index"
1368 + checksum = "32ee31e0190f9dc366d26b032ac36a99328532f40a237a79385a20d2d7f29810"
1369 + dependencies = [
1370 + "base64ct",
1371 + "ctutils",
1372 + "mcf",
1373 + "password-hash",
1374 + "sha2 0.11.0",
1375 + ]
1376 +
1280 1377 [[package]]
1281 1378 name = "sha2"
1282 1379 version = "0.10.9"
@@ -1284,8 +1381,19 @@
1284 1381 checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1285 1382 dependencies = [
1286 1383 "cfg-if",
1287 - "cpufeatures",
1288 - "digest",
1384 + "cpufeatures 0.2.17",
1385 + "digest 0.10.7",
1386 + ]
1387 +
1388 + [[package]]
1389 + name = "sha2"
1390 + version = "0.11.0"
1391 + source = "registry+https://github.com/rust-lang/crates.io-index"
1392 + checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
1393 + dependencies = [
1394 + "cfg-if",
1395 + "cpufeatures 0.3.0",
1396 + "digest 0.11.3",
1289 1397 ]
1290 1398
1291 1399 [[package]]
@@ -1449,7 +1557,7 @@
1449 1557 "pest",
1450 1558 "pest_derive",
1451 1559 "phf",
1452 - "sha2",
1560 + "sha2 0.10.9",
1453 1561 "signal-hook",
1454 1562 "siphasher",
1455 1563 "terminfo",
@@ -1759,7 +1867,7 @@
1759 1867 dependencies = [
1760 1868 "getrandom 0.3.4",
1761 1869 "mac_address",
1762 - "sha2",
1870 + "sha2 0.10.9",
1763 1871 "thiserror 1.0.69",
1764 1872 "uuid",
1765 1873 ]
@@ -21,3 +21,5 @@
21 21 serde_json = "1.0.150"
22 22 toml = "0.9"
23 23 makeover.workspace = true
24 + sha-crypt = "0.6.0"
25 + getrandom = "0.4.3"
@@ -108,9 +108,6 @@
108 108 pub struct Secret(Vec<u8>);
109 109
110 110 impl Secret {
111 - // Constructed only by the tests below until the installer's user step lands,
112 - // which is the one place in the console that has a password to carry.
113 - #[allow(dead_code)]
114 111 pub fn new(value: impl Into<Vec<u8>>) -> Self {
115 112 Self(value.into())
116 113 }
@@ -176,10 +173,6 @@
176 173 /// reaches the log pane. What the pane shows instead is the argv plus a note
177 174 /// that input was withheld, so the line stays honest about the fact that
178 175 /// something was piped in without being honest about what.
179 - ///
180 - /// Called only by the tests below until the user step lands; see
181 - /// [`Secret::new`].
182 - #[allow(dead_code)]
183 176 pub fn stdin(mut self, secret: Secret) -> Self {
184 177 self.stdin = Some(secret);
185 178 self
@@ -24,7 +24,16 @@
24 24 //! mountpoint instead would write a hostname where nothing reads it and boot a
25 25 //! system with none of the answers applied and no error to say why.
26 26 //!
27 - //! **Nothing here has run against real bootc.** The shape follows upstream's
27 + //! The configure step has now been run against a real target, and two of its
28 + //! commands did not survive contact. `chpasswd --root` hashes through PAM, and
29 + //! the host's PAM cannot service the target's configuration ("pam_chauthtok()
30 + //! failed, error: Module is unknown"), so the password is hashed separately and
31 + //! handed over with `--encrypted`. `useradd --create-home` resolves the home
32 + //! against `--root`, which is the deployment, whose `var` the stateroot's var
33 + //! is mounted over at boot; the home is made in the stateroot var instead. Both
34 + //! are documented where they happen.
35 + //!
36 + //! **The rest has not run against real bootc.** The shape follows upstream's
28 37 //! documentation, and the pieces that are guesses rather than quotes are the
29 38 //! arguments to `bootc install finalize`, which that page does not document.
30 39 //!
@@ -39,6 +48,7 @@
39 48 use ratatui::text::{Line, Span};
40 49 use ratatui::widgets::Paragraph;
41 50 use serde::Deserialize;
51 + use sha_crypt::{PasswordHasher, ShaCrypt};
42 52
43 53 use alloy_tui::{Cursor, FocusRing};
44 54
@@ -242,17 +252,6 @@
242 252 .arg("--print-current-dir")
243 253 }
244 254
245 - /// The commands an install runs, in order.
246 - ///
247 - /// Built as a list rather than executed inline for the reason the backends
248 - /// return [`Invocation`]s: it keeps the sequence testable on a machine with
249 - /// neither bootc nor a spare disk, and it lets the summary step show the user
250 - /// exactly what is about to run. The console's promise is that every action
251 - /// shows its invocation; here the invocations are shown *before* the action,
252 - /// which is the strongest form of it the installer can offer.
253 - ///
254 - /// `chpasswd` takes the password on stdin rather than as an argument. See
255 - /// [`Secret`] for why argv is not an option.
256 255 /// DPS partition type GUID for the root filesystem on this architecture.
257 256 ///
258 257 /// From bootc 1.11 the default layout follows the Discoverable Partitions
@@ -319,46 +318,213 @@
319 318 })
320 319 }
321 320
321 + /// Where home directories live on the installed system.
322 + ///
323 + /// Fedora bootc puts them under `/var/home`; `/home` is a symlink to it. The
324 + /// path recorded in `/etc/passwd` is written out rather than left to useradd's
325 + /// default so that the entry names the real location instead of the symlink.
326 + const HOME_PARENT: &str = "/var/home";
327 +
328 + /// Mode for a new home directory, matching Fedora's `HOME_MODE`.
329 + const HOME_MODE: &str = "700";
330 +
331 + /// Where the stateroot's `/var` is, given the deployment directory.
332 + ///
333 + /// **A deployment's own `var` is not the system's `/var`.** It is an empty
334 + /// directory that ostree bind-mounts the stateroot's var over at boot, so
335 + /// anything written into `<deployment>/var` is invisible to the installed
336 + /// system. That is the second half of the same mistake [`deployment_dir`]
337 + /// documents: `--root <deployment>` is right for `/etc` and wrong for `/var`.
338 + ///
339 + /// The real one is two levels up, at
340 + /// `<mount>/ostree/deploy/<stateroot>/var` — a sibling of the `deploy`
341 + /// directory the checksum lives in.
342 + fn stateroot_var(deployment: &str) -> Result<String, String> {
343 + let stateroot = deployment
344 + .trim_end_matches('/')
345 + .rsplit_once('/')
346 + .and_then(|(parent, _checksum)| parent.strip_suffix("/deploy"))
347 + .ok_or_else(|| format!("not an ostree deployment directory: {deployment}"))?;
348 + Ok(format!("{stateroot}/var"))
349 + }
350 +
351 + /// Pull a user's numeric ids out of an `/etc/passwd`, as `uid:gid`.
352 + ///
353 + /// Numeric because the only `chown` available is the host's, and a name would
354 + /// be resolved against the host's passwd rather than the target's. On a live
355 + /// installer those are different files, and the wrong one gives an install
356 + /// whose home directory belongs to whoever happens to hold that name here.
357 + ///
358 + /// Formatted as `uid:gid` because that is what `chown` takes, and building the
359 + /// pair anywhere else would mean two places knowing the shape.
360 + fn passwd_ids(listing: &str, username: &str) -> Result<String, String> {
361 + listing
362 + .lines()
363 + .filter_map(|line| {
364 + let mut fields = line.split(':');
365 + (fields.next()? == username).then_some(())?;
366 + let (_password, uid, gid) = (fields.next()?, fields.next()?, fields.next()?);
367 + Some(format!("{uid}:{gid}"))
368 + })
369 + .next()
370 + .ok_or_else(|| format!("useradd left no passwd entry for {username}"))
371 + }
372 +
373 + /// Hash a password into the form `/etc/shadow` stores.
374 + ///
375 + /// **`chpasswd --root` cannot do this itself.** It hashes through PAM, and PAM
376 + /// on a live installer is the host's: it fails with "pam_chauthtok() failed,
377 + /// error: Module is unknown", because the modules the target's configuration
378 + /// names are not installed on the machine doing the installing. `--encrypted`
379 + /// takes an already-hashed password and skips PAM entirely, which is the only
380 + /// part of chpasswd that was ever going to work across that boundary.
381 + ///
382 + /// Hashed in-process rather than by shelling out to `openssl passwd -6`. The
383 + /// installer would otherwise depend on a binary the image is not obliged to
384 + /// carry, and would find out it was missing at the one moment there is no
385 + /// recovering from — target deployed, disk rewritten, no account. It also keeps
386 + /// the password from crossing a process boundary at all, which is a stronger
387 + /// version of what [`Secret`] is for than piping it to a child.
388 + ///
389 + /// SHA-512 (`$6$`) because that is what Fedora's shadow-utils writes and what
390 + /// glibc on the target will verify against.
391 + ///
392 + /// The salt is generated here rather than left to `hash_password`, and the
393 + /// length is the whole reason. **crypt(3) takes at most 16 salt characters and
394 + /// silently ignores the rest**, while sha-crypt's own salt is 22. glibc hashes
395 + /// the same digest either way — it truncates before hashing — but it returns
396 + /// the string it used, with the salt cut down. PAM authenticates by hashing the
397 + /// typed password and comparing the whole string against the stored one, so a
398 + /// 22-character salt gives an entry that no correct password ever matches: an
399 + /// account locked out from the moment it is created, on a machine that has
400 + /// already rebooted into it. Verified against glibc's `crypt`, which is what
401 + /// does the comparing on the installed system.
402 + ///
403 + /// [`SALT_BYTES`] is chosen so the encoded salt lands exactly on that limit.
404 + fn hash_password(password: &str) -> Result<String, String> {
405 + let mut salt = [0u8; SALT_BYTES];
406 + // Fails only if the system RNG does. Reported as itself rather than as a
407 + // password that mysteriously did not take.
408 + getrandom::fill(&mut salt).map_err(|err| format!("could not generate a salt: {err}"))?;
409 +
410 + ShaCrypt::default()
411 + .hash_password_with_salt(password.as_bytes(), &salt)
412 + .map(|hash| hash.as_str().to_string())
413 + .map_err(|err| format!("could not hash the password: {err}"))
414 + }
415 +
416 + /// Longest salt crypt(3) reads. Anything past this is discarded.
417 + const SALT_MAX: usize = 16;
418 +
419 + /// Raw salt bytes, chosen for what they encode to.
420 + ///
421 + /// The salt is base64'd before it reaches the hash, four characters per three
422 + /// bytes, so this is [`SALT_MAX`] worked backwards: the most randomness that
423 + /// fits in a salt crypt(3) will read whole. See [`hash_password`] for what
424 + /// overshooting costs.
425 + const SALT_BYTES: usize = SALT_MAX / 4 * 3;
426 +
322 427 /// The commands that configure an already-deployed system.
323 428 ///
324 429 /// `root` is the ostree deployment directory, not the mountpoint. See
325 - /// [`deployment_dir`] for why that distinction is the whole ballgame.
326 - fn configure_plan(hostname: &str, username: &str, password: &str, root: &str) -> Vec<Invocation> {
327 - vec![
328 - Invocation::new("systemd-firstboot")
329 - .arg(format!("--root={root}"))
330 - .arg(format!("--hostname={hostname}"))
331 - // Without --force firstboot skips any setting already present in
332 - // the image, and etc/hostname ships with "alloy" in it. The user's
333 - // answer would be silently discarded.
334 - .arg("--force"),
335 - // -m creates the home directory; sysusers-style entries do not, which
336 - // is why this is useradd. wheel is what sudo grants on Fedora, and an
430 + /// [`deployment_dir`] for why that distinction is the whole ballgame, and
431 + /// [`stateroot_var`] for the half of it that `--root` does not cover.
432 + ///
433 + /// [`Stage`]s rather than plain [`Invocation`]s because two of these depend on
434 + /// values that only exist once the ones before them have run: the account's
435 + /// numeric ids, and the password hash.
436 + fn configure_plan(
437 + hostname: &str,
438 + username: &str,
439 + password: &str,
440 + root: &str,
441 + ) -> Result<Vec<Stage>, String> {
442 + let var = stateroot_var(root)?;
443 + // Where the home is now, through the mounted target...
444 + let home = format!("{var}/home/{username}");
445 + // ...and where it will be once the system it belongs to is running.
446 + let installed_home = format!("{HOME_PARENT}/{username}");
447 +
448 + let hash = hash_password(password)?;
449 +
450 + let ids_root = root.to_string();
451 + let ids_user = username.to_string();
452 + let ids_home = home.clone();
453 +
454 + Ok(vec![
455 + Stage::Run(
456 + Invocation::new("systemd-firstboot")
457 + .arg(format!("--root={root}"))
458 + .arg(format!("--hostname={hostname}"))
459 + // Without --force firstboot skips any setting already present in
460 + // the image, and etc/hostname ships with "alloy" in it. The user's
461 + // answer would be silently discarded.
462 + .arg("--force"),
463 + ),
464 + // useradd rather than a sysusers entry because this account needs a home
465 + // and a supplementary group. wheel is what sudo grants on Fedora, and an
337 466 // install whose only account cannot escalate is one with no way to
338 467 // administer itself.
339 - Invocation::new("useradd")
340 - .args(["--root", root, "--create-home", "--groups", "wheel"])
341 - .arg(username),
342 - // `chpasswd` reads `user:password` lines, so the username is part of the
343 - // secret rather than an argument. Built here so exactly one place knows
468 + //
469 + // --no-create-home despite the account needing one: --create-home
470 + // resolves the path against --root, which puts the directory in the
471 + // deployment's own var, where nothing will ever look for it. useradd
472 + // says as much — "chown on '/var/home/<user>' failed" — because the
473 + // stateroot var ships with cache, lib, log, run and tmp and no home at
474 + // all. The directory is made below, in the var that is real.
475 + Stage::Run(
476 + Invocation::new("useradd")
477 + .args(["--root", root, "--no-create-home"])
478 + .args(["--home-dir", &installed_home])
479 + .args(["--groups", "wheel"])
480 + .arg(username),
481 + ),
482 + // -p because /var/home does not exist yet either.
483 + Stage::Run(Invocation::new("mkdir").args(["-p", &home])),
484 + // The dotfiles --create-home would have copied. Without them an
485 + // interactive shell never sources /etc/bashrc.
486 + Stage::Run(
487 + Invocation::new("cp")
488 + .arg("-a")
489 + .arg(format!("{root}/etc/skel/."))
490 + .arg(&home),
491 + ),
492 + // First discovery: which uid and gid useradd picked. Reading the file
493 + // rather than asking getent, because getent answers about this machine.
494 + Stage::Resolve {
495 + invocation: Invocation::new("cat").arg(format!("{ids_root}/etc/passwd")),
496 + then: Box::new(move |listing| {
497 + let ids = passwd_ids(listing, &ids_user)?;
498 + Ok(vec![
499 + Stage::Run(Invocation::new("chown").args(["-R", &ids, &ids_home])),
500 + Stage::Run(Invocation::new("chmod").args([HOME_MODE, &ids_home])),
501 + ])
502 + }),
503 + },
504 + // `chpasswd` reads `user:hash` lines, so the username is part of the
505 + // input rather than an argument. Built here so exactly one place knows
344 506 // the wire format, and with a trailing newline because chpasswd parses
345 507 // lines and a final one without it is silently ignored by some builds.
346 - Invocation::new("chpasswd")
347 - .args(["--root", root])
348 - .stdin(Secret::new(format!("{username}:{password}\n"))),
508 + Stage::Run(
509 + Invocation::new("chpasswd")
510 + .args(["--encrypted", "--root", root])
511 + .stdin(Secret::new(format!("{username}:{hash}\n"))),
512 + ),
349 513 // Upstream: "optional, but recommended to run as the penultimate step
350 514 // before unmounting the target filesystem. This command will perform
351 515 // some basic sanity checks and may also perform fixups on the target
352 516 // root." Its arguments are not documented on that page; the mountpoint
353 517 // is passed positionally, which is the one thing it plausibly wants and
354 518 // is flagged in the module header as unverified.
355 - Invocation::new("bootc")
356 - .args(["install", "finalize"])
357 - .arg(TARGET_MOUNT),
519 + Stage::Run(
520 + Invocation::new("bootc")
521 + .args(["install", "finalize"])
522 + .arg(TARGET_MOUNT),
523 + ),
358 524 // Leaving the target mounted would strand the filesystem dirty across
359 525 // the reboot the user is about to perform.
360 - Invocation::new("umount").arg(TARGET_MOUNT),
361 - ]
526 + Stage::Run(Invocation::new("umount").arg(TARGET_MOUNT)),
527 + ])
362 528 }
363 529
364 530 /// The whole install, as stages.
@@ -398,10 +564,7 @@
398 564 if deployment.is_empty() {
399 565 return Err("ostree reported no current deployment".into());
400 566 }
401 - Ok(configure_plan(&hostname, &username, &password, deployment)
402 - .into_iter()
403 - .map(Stage::Run)
404 - .collect())
567 + configure_plan(&hostname, &username, &password, deployment)
405 568 }),
406 569 },
407 570 ])
@@ -1875,23 +2038,21 @@
1875 2038 for stage in view.plan() {
1876 2039 assert!(!stage.display().contains("hunter2"), "{}", stage.display());
1877 2040 }
1878 - for invocation in configure_plan("alloy", "max", "hunter2", "/deploy") {
1879 - let shown = invocation.display();
2041 + for shown in configured() {
1880 2042 assert!(!shown.contains("hunter2"), "password on screen: {shown}");
1881 2043 }
1882 2044 }
1883 2045
2046 + /// A deployment directory shaped the way ostree names them.
2047 + const DEPLOYMENT: &str = "/mnt/alloy-target/ostree/deploy/default/deploy/abc123.0";
2048 +
1884 2049 /// The configure half, against a deployment directory as discovered.
1885 2050 fn configured() -> Vec<String> {
1886 - configure_plan(
1887 - "workshop",
1888 - "max",
1889 - "hunter2",
1890 - "/mnt/alloy-target/ostree/deploy/x/deploy/abc",
1891 - )
1892 - .iter()
1893 - .map(Invocation::display)
1894 - .collect()
2051 + configure_plan("workshop", "max", "hunter2", DEPLOYMENT)
2052 + .expect("a well-formed deployment path")
2053 + .iter()
2054 + .map(Stage::display)
2055 + .collect()
1895 2056 }
1896 2057
1897 2058 // --force matters: etc/hostname ships with "alloy" already in it, and
@@ -1911,6 +2072,7 @@
1911 2072 #[test]
1912 2073 fn configuration_targets_the_deployment_not_the_mountpoint() {
1913 2074 for line in configured() {
2075 + // finalize and umount take the mountpoint by definition.
1914 2076 if line.starts_with("bootc") || line.starts_with("umount") {
1915 2077 continue;
1916 2078 }
@@ -1922,16 +2084,146 @@
1922 2084 }
1923 2085
1924 2086 // An account that cannot escalate leaves an install with no way to
1925 - // administer itself, and --create-home is what makes the home exist.
2087 + // administer itself.
1926 2088 #[test]
1927 - fn the_account_gets_a_home_and_a_way_to_sudo() {
2089 + fn the_account_can_sudo() {
1928 2090 let useradd = configured().remove(1);
1929 2091
1930 - assert!(useradd.contains("--create-home"), "{useradd}");
1931 2092 assert!(useradd.contains("wheel"), "{useradd}");
1932 2093 assert!(useradd.ends_with("max"), "{useradd}");
1933 2094 }
1934 2095
2096 + // --create-home resolves its path against --root, which is the deployment,
2097 + // whose var is an empty directory the stateroot's var is mounted over at
2098 + // boot. A home made there is a home the installed system never sees.
2099 + #[test]
2100 + fn the_home_is_made_in_the_stateroot_var_not_the_deployments() {
2101 + let lines = configured();
2102 + let useradd = &lines[1];
2103 + let mkdir = &lines[2];
2104 +
2105 + assert!(useradd.contains("--no-create-home"), "{useradd}");
2106 + assert!(useradd.contains("--home-dir /var/home/max"), "{useradd}");
2107 + assert_eq!(
2108 + mkdir,
2109 + "mkdir -p /mnt/alloy-target/ostree/deploy/default/var/home/max"
2110 + );
2111 + }
2112 +
2113 + // The password reaches chpasswd already hashed: `chpasswd --root` hashes
2114 + // through the host's PAM, and the host's PAM cannot service the target
2115 + // ("Module is unknown"). --encrypted skips PAM entirely.
2116 + #[test]
2117 + fn the_password_is_handed_over_already_hashed() {
2118 + let chpasswd = configured()
2119 + .into_iter()
2120 + .find(|line| line.starts_with("chpasswd"))
2121 + .expect("the plan sets a password");
2122 +
2123 + assert!(chpasswd.contains("--encrypted"), "{chpasswd}");
2124 + // The user:hash line goes in on stdin, never in argv.
2125 + assert!(chpasswd.contains("# input withheld"), "{chpasswd}");
2126 + }
2127 +
2128 + // Nothing shells out to hash. An installer that depends on a binary the
2129 + // image is not obliged to carry finds out it is missing at the one moment
2130 + // there is no recovering from: target deployed, no account.
2131 + #[test]
2132 + fn hashing_needs_no_external_program() {
2133 + assert!(
2134 + !configured().iter().any(|line| line.starts_with("openssl")),
2135 + "the plan shells out to hash"
2136 + );
2137 + }
2138 +
2139 + // $6$ is SHA-512, which is what Fedora's shadow-utils writes and what glibc
2140 + // on the target verifies against. A hash in any other format is one nobody
2141 + // can log in with.
2142 + #[test]
2143 + fn the_hash_is_sha_512_crypt_and_matches_the_password() {
2144 + let hash = hash_password("hunter2").expect("hashing succeeds");
2145 +
2146 + assert!(hash.starts_with("$6$"), "{hash}");
2147 + assert!(
2148 + sha_crypt::PasswordVerifier::<str>::verify_password(
2149 + &ShaCrypt::default(),
2150 + b"hunter2",
2151 + hash.as_str()
2152 + )
2153 + .is_ok(),
2154 + "the hash does not verify against its own password: {hash}"
2155 + );
2156 + }
2157 +
2158 + // crypt(3) reads at most 16 salt characters and ignores the rest, but it
2159 + // returns the string it used — with the salt truncated. PAM compares that
2160 + // whole string against the stored one, so a longer salt is an account no
2161 + // correct password can log into, discovered only after the reboot.
2162 + // sha-crypt's own salt is 22 characters, which is why this is not left to
2163 + // its default.
2164 + #[test]
2165 + fn the_salt_is_no_longer_than_crypt_will_read() {
2166 + let hash = hash_password("hunter2").expect("hashing succeeds");
2167 + let salt = hash.split('$').nth(3).expect("$6$rounds=N$salt$digest");
2168 +
2169 + assert_eq!(
2170 + salt.len(),
2171 + SALT_MAX,
2172 + "a salt crypt(3) would truncate: {hash}"
2173 + );
2174 + }
2175 +
2176 + // A salt per install, so two machines with the same password do not get the
2177 + // same shadow entry.
2178 + #[test]
2179 + fn every_hash_gets_its_own_salt() {
2180 + assert_ne!(
2181 + hash_password("hunter2").unwrap(),
2182 + hash_password("hunter2").unwrap()
2183 + );
2184 + }
2185 +
2186 + // The stateroot var is a sibling of the `deploy` directory holding the
2187 + // checksummed deployment, not a child of the deployment.
2188 + #[test]
2189 + fn the_stateroot_var_is_two_levels_above_the_deployment() {
2190 + assert_eq!(
2191 + stateroot_var(DEPLOYMENT).unwrap(),
2192 + "/mnt/alloy-target/ostree/deploy/default/var"
2193 + );
2194 + }
2195 +
2196 + // A path that is not shaped like a deployment means ostree printed
2197 + // something unexpected, which is worth failing on rather than deriving a
2198 + // var directory from.
2199 + #[test]
2200 + fn a_path_that_is_not_a_deployment_is_a_named_failure() {
2201 + let error = stateroot_var("/mnt/alloy-target").unwrap_err();
2202 + assert!(error.contains("not an ostree deployment"), "{error}");
2203 + }
2204 +
2205 + // chown takes numbers because the only chown available is the host's, and a
2206 + // name would resolve against the host's passwd rather than the target's.
2207 + #[test]
2208 + fn the_accounts_ids_come_from_the_targets_passwd() {
2209 + let passwd = "root:x:0:0:root:/root:/bin/bash\n\
2210 + max:x:1000:1001:,,,:/var/home/max:/bin/bash\n";
2211 + assert_eq!(passwd_ids(passwd, "max").unwrap(), "1000:1001");
2212 + }
2213 +
2214 + // A prefix of another name is not that name.
2215 + #[test]
2216 + fn a_passwd_lookup_matches_the_whole_username() {
2217 + let passwd = "maxwell:x:1000:1000::/var/home/maxwell:/bin/bash\n";
2218 + assert!(passwd_ids(passwd, "max").is_err());
2219 + }
2220 +
2221 + #[test]
2222 + fn a_missing_passwd_entry_is_a_named_failure() {
2223 + let error = passwd_ids("root:x:0:0:root:/root:/bin/bash", "max").unwrap_err();
2224 + assert!(error.contains("no passwd entry"), "{error}");
2225 + }
2226 +
1935 2227 // Upstream tells installers that make changes to run finalize before
1936 2228 // unmounting, and leaving the target mounted would strand it dirty across
1937 2229 // the reboot.
@@ -305,6 +305,7 @@
305 305 #[cfg(test)]
306 306 mod tests {
307 307 use super::*;
308 + use crate::cli::Secret;
308 309
309 310 fn drive(sequence: &mut Sequence, log: &mut CommandLog) {
310 311 // Bounded so a hang fails the test rather than wedging the suite.
@@ -460,6 +461,25 @@
460 461 assert_eq!(sequence.outcome(), Some(&Ok(())));
461 462 }
462 463
464 + // A command handed a secret still shows its diagnostics. `chpasswd` writing
465 + // "Module is unknown" to stderr is the only way a failing password step
466 + // says anything at all, and withholding it because the command touched a
467 + // secret would leave the run screen with an exit code and no reason.
468 + #[test]
469 + fn a_command_fed_a_secret_still_reports_why_it_failed() {
470 + let mut log = CommandLog::new();
471 + let mut sequence = Sequence::new(vec![Stage::Run(
472 + Invocation::new("sh")
473 + .args(["-c", "cat >/dev/null; echo 'Module is unknown' >&2; exit 1"])
474 + .stdin(Secret::new("max:$6$hash\n")),
475 + )]);
476 +
477 + drive(&mut sequence, &mut log);
478 +
479 + assert!(sequence.outcome().expect("stopped").is_err());
480 + assert_eq!(sequence.output(), ["Module is unknown"]);
481 + }
482 +
463 483 // A resolver that cannot make sense of the output fails the sequence with
464 484 // its own message, rather than letting a later command fail obscurely.
465 485 #[test]