aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xinstaller/archangel47
-rw-r--r--tests/unit/test_archangel.bats95
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"
+}