diff options
| -rwxr-xr-x | installer/archangel | 47 | ||||
| -rw-r--r-- | tests/unit/test_archangel.bats | 95 |
2 files changed, 142 insertions, 0 deletions
diff --git a/installer/archangel b/installer/archangel index 4805796..b9817e8 100755 --- a/installer/archangel +++ b/installer/archangel @@ -925,6 +925,47 @@ configure_ssh() { fi } +# Close the second passphrase prompt on encrypted ZFS boots. +# +# ZFSBootMenu unlocks the pool to read this kernel and initramfs, then kexecs +# into it — and the loaded key does not survive kexec. With no key inside the +# initramfs, the zfs hook re-imports the pool, finds keylocation=prompt, and +# asks for the same passphrase a second time. +# +# Pointing the encryption root at a keyfile that lives inside the encrypted +# dataset closes it. ZFSBootMenu cannot read a file in a dataset it has not +# unlocked, so it overrides the file:// URI and prompts once — documented +# upstream behavior, not a side effect. The booted initramfs carries the +# keyfile and loads the key silently. +# +# keyformat stays passphrase: it is what lets ZFSBootMenu accept the typed +# value, and a raw key would leave it no way in at all. keylocation alone is +# settable with `zfs set` (zfsprops(7)), so this never reaches for `zfs +# change-key`, which would rekey the pool and prompt for new key material +# mid-install. +# +# Never relocate this keyfile onto the ESP or into a custom ZFSBootMenu image. +# Both are unencrypted; the protection here comes entirely from the keyfile and +# the initramfs living inside the encrypted dataset. +configure_zfs_keyfile() { + local passphrase="$1" + local pool="$2" + local keyfile="/etc/zfs/zroot.key" + + mkdir -p "$MNTPOINT$(dirname "$keyfile")" + + # No trailing newline: ZFS reads the file's bytes as the passphrase, so a + # stray newline would not match what the user types at the ZBM prompt. + printf '%s' "$passphrase" > "$MNTPOINT$keyfile" + chmod 000 "$MNTPOINT$keyfile" + + zfs set keylocation="file://$keyfile" "$pool" \ + || error "Failed to point $pool at $keyfile" + + ensure_initramfs_files "$keyfile" "$MNTPOINT/etc/mkinitcpio.conf" + info "Keyfile embedded in initramfs - one passphrase prompt at boot." +} + configure_initramfs() { step "Configuring Initramfs for ZFS" @@ -984,6 +1025,12 @@ EOF # system. (Audited 2026-04-27 against silent-sed pattern.) sed -i 's/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block zfs filesystems)/' $MNTPOINT/etc/mkinitcpio.conf + # Embed the pool key so the booted initramfs doesn't re-prompt. Must run + # before mkinitcpio -P below, which is what bakes FILES= into the image. + if [[ "$NO_ENCRYPT" != "yes" ]]; then + configure_zfs_keyfile "$ZFS_PASSPHRASE" "$POOL_NAME" + fi + # Get the installed kernel version (not the running kernel) local kernel_ver kernel_ver=$(ls $MNTPOINT/usr/lib/modules | grep lts | head -1) diff --git a/tests/unit/test_archangel.bats b/tests/unit/test_archangel.bats index 645b6e6..983bfd2 100644 --- a/tests/unit/test_archangel.bats +++ b/tests/unit/test_archangel.bats @@ -401,3 +401,98 @@ setup() { run network_available [ "$status" -eq 0 ] } + +############################# +# configure_zfs_keyfile +############################# +# Encrypted ZFS installs prompt for the same passphrase twice: +# ZFSBootMenu unlocks the pool to read the kernel and initramfs, then +# kexecs, and the key doesn't survive kexec — so the booted initramfs +# re-imports the pool, finds keylocation=prompt, and asks again. +# +# configure_zfs_keyfile closes the second prompt the same way the Btrfs +# path already closes its LUKS equivalent: write the passphrase to a +# keyfile inside the encrypted root, point the encryption root at it, +# and bake it into the initramfs via FILES=. ZFSBootMenu can't read a +# file inside a dataset it hasn't unlocked yet, so it still prompts +# once — that surviving prompt is the intended behavior, not a bug. +# +# zfs is the stubbed system boundary. The keyfile write, its +# permissions, and the FILES= wiring are exercised for real. + +zfs_keyfile_fixture() { + TEST_ROOT=$(mktemp -d) + MNTPOINT="$TEST_ROOT" + ZFS_ARGS_LOG="$TEST_ROOT/zfs-args" + mkdir -p "$MNTPOINT/etc" + printf '%s\n' 'FILES=()' > "$MNTPOINT/etc/mkinitcpio.conf" + zfs() { echo "$*" >> "$ZFS_ARGS_LOG"; return 0; } +} + +@test "configure_zfs_keyfile writes the passphrase with no trailing newline" { + zfs_keyfile_fixture + configure_zfs_keyfile "correct horse" zroot + # The keyfile lands mode 000, which locks out the owner too — only root + # bypasses that, and these tests don't run as root. Restore read access to + # inspect the content; the mode itself is asserted separately below. + chmod u+r "$MNTPOINT/etc/zfs/zroot.key" + # A trailing newline would become part of the passphrase ZFS reads back, + # so the key would never match what's typed at the ZBM prompt. 13 bytes, + # not 14: no terminator. + [ "$(wc -c < "$MNTPOINT/etc/zfs/zroot.key")" -eq 13 ] + [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = "correct horse" ] + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile points the encryption root at the keyfile" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + grep -qE '^set +keylocation=file:///etc/zfs/zroot\.key +zroot$' "$ZFS_ARGS_LOG" + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile changes the location without rekeying the pool" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + # keylocation is settable with plain `zfs set` (zfsprops(7)), and + # keyformat is already passphrase from pool creation. Reaching for + # `zfs change-key` here would rekey the pool and prompt for new key + # material mid-install — and losing keyformat=passphrase would leave + # ZFSBootMenu with no way to accept a typed passphrase at all. + ! grep -qF 'change-key' "$ZFS_ARGS_LOG" + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile bakes the keyfile into the initramfs" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + grep -qF 'FILES=(/etc/zfs/zroot.key)' "$MNTPOINT/etc/mkinitcpio.conf" + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile leaves the keyfile unreadable to other users" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + # Protected at rest by the encrypted dataset, but a stray mode 644 + # would expose it to any local user on the running system. + [ "$(stat -c '%a' "$MNTPOINT/etc/zfs/zroot.key")" -eq 0 ] + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile preserves a passphrase containing shell metacharacters" { + zfs_keyfile_fixture + configure_zfs_keyfile 'a$b "c" \d*' zroot + chmod u+r "$MNTPOINT/etc/zfs/zroot.key" + [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = 'a$b "c" \d*' ] + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile aborts when the key change fails" { + zfs_keyfile_fixture + zfs() { return 1; } + run configure_zfs_keyfile testpass zroot + # Silently continuing would ship an initramfs whose keyfile doesn't + # match the pool, turning one prompt into an unbootable system. + [ "$status" -eq 1 ] + rm -rf "$TEST_ROOT" +} |
