aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-02 23:12:39 -0500
committerCraig Jennings <c@cjennings.net>2026-08-02 23:12:39 -0500
commit72dc6c49636aefd12f707ffc3eeb244744133cae (patch)
treedf9e816ef3109536d4c2b57cbb630040834c4479
parent1da64394f637347414ffc4954daf9c72d180e2d0 (diff)
downloadarchangel-72dc6c49636aefd12f707ffc3eeb244744133cae.tar.gz
archangel-72dc6c49636aefd12f707ffc3eeb244744133cae.zip
fix(install): stop encrypted ZFS boots asking for the passphrase twiceHEADmain
ZFSBootMenu unlocks the pool to read the kernel and initramfs, then kexecs into it. The loaded key doesn't survive kexec. The booted initramfs re-imports the pool, finds keylocation=prompt, and asks for the same passphrase again. I write the passphrase to /etc/zfs/zroot.key inside the encrypted root, point the encryption root at it, and bake it into the initramfs. ZFSBootMenu can't read a file in a dataset it hasn't unlocked, so it overrides the file:// URI and prompts once. The booted initramfs then loads the key silently. Nothing weakens at rest. Both the keyfile and the initramfs live inside the encrypted dataset, which only holds because ZFSBootMenu keeps the initramfs inside the boot environment rather than on the ESP. keyformat stays passphrase, since that's what lets ZFSBootMenu accept the typed value. keylocation alone is settable with zfs set, so this never reaches for zfs change-key and never rekeys the pool mid-install. The Btrfs path already did this for LUKS, so I reused its ensure_initramfs_files helper.
-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"
+}