Unlocking ZFS datasets on boot with a YubiKey
My home server runs Rocky Linux 9 on ZFS. Its main root dataset is encrypted, and until a few weeks ago I had to manually enter the dataset password on the boot console (fortunately the iDRAC allows me to do that remotely). With a modern server, I would use the TPM2 chip to provide the decryption key; however, my home server is a PowerEdge R330 and only has the obsolete TPM1.2 chip. I had a spare YubiKey 5 I bought when Cloudflare offered them at $10 each, so I decided to put that YubiKey into the internal USB port and use it to unlock datasets without user input. You could argue that it’s as useful as having no encryption, because the YubiKey has no way to detect whether the boot was from a trusted source or not. Still, I decided to encrypt my root ZFS pool mostly to be capable of sending encrypted raw sends for backup purposes.
I’m sharing the custom dracut module I built to serve this purpose. It simply loads a symmetrically encrypted GPG file stored in the initramfs and decrypts it with a passphrase generated by the YubiKey HMAC feature. The decrypted contents of the GPG file provide the decryption key to ZFS.
How it works
During boot, dracut calls our custom hook before mounting ZFS datasets and checks whether the rootfs dataset has the
zfs_yubikey:keylocation
andzfs_yubikey:slot
custom attributes set. The former specifies where the encrypted GPG file containing the ZFS key resides in the initramfs; the latter is optional and suggests a particular YubiKey HMAC slot to use (defaults to 1).If the rootfs requires YubiKey decryption, a HMAC challenge will be generated from the SHA256 sum of the string
YUBIKEY_ZFS_V1;<machine_id>;<pool_guid>;<dataset_objsetid>
.The
ykchalresp
binary sends the aforementioned 256-bit challenge to the YubiKey on the specified slot and waits for a response.The HMAC response is used to decrypt the GPG file and the resulting plaintext is sent to
zfs load-key -L prompt <dataset>
.
Setup
The code for the dracut module is provided below. To setup your dataset for automatic unlock,
first setup your YubiKey for HMAC challenge-response on one of the available slots.
Then open a shell prompt and source zfs-yubikey-lib.sh
; run
get_response <dataset> [<yubikey_slot>]
to ask the YubiKey the generate the HMAC response and
use it to encrypt the ZFS key with GnuPG. Save the resulting encrypted file in /etc/zfs/yubikey/
and set the zfs_yubikey:keylocation
property to the path of the file you just saved.
Regenerate the initramfs and you’re done.
Shell examples:
source zfs-yubikey-lib.sh
# Set DATASET to your ZFS dataset
DATASET=pool/various/elements/to/dataset
ykinfo -H || echo 'YubiKey not found!'
# Write your ZFS key to the stdin of the following command
gpg --symmetric --pinentry-mode loopback --passphrase-fd 3 --armor \
--output "/etc/zfs/yubikey/${DATASET##*/}.gpg" 3< <(get_response "${DATASET}")
zfs set zfs_yubikey:keylocation="/etc/zfs/yubikey/${DATASET##*/}.gpg" "${DATASET}"
dracut --regenerate-all --force
Code
A dracut module is composed of a module-setup.sh
(executed on initramfs generation) and an
arbitrary number of hooks and files installed by the module. The directory structure of our module
is the following:
zfs-yubikey
├── module-setup.sh (executable)
├── zfs-yubikey-lib.sh (executable)
└── zfs-yubikey-load-key.sh (executable)
This directory should be copied to /usr/lib/dracut/modules.d/91zfs-yubikey
and dracut should be
configured to include this module (see man 5 dracut.conf
). Code follows.
module-setup.sh
#!/usr/bin/bash
check() {
require_binaries sha256sum gpg ykchalresp ykinfo || return 1
return 0
}
depends() {
echo zfs
return 0
}
install() {
inst_multiple gpg gpg-agent gpg-connect-agent ykchalresp ykinfo sha256sum ||
{ dfatal "Failed to install essential binaries"; exit 1; }
inst_hook pre-mount 85 "${moddir}/zfs-yubikey-load-key.sh"
inst_script "${moddir}/zfs-yubikey-lib.sh" "/lib/dracut-zfs-yubikey-lib.sh"
inst_multiple -o -H /etc/zfs/yubikey/*
}
zfs-yubikey-lib.sh
#!/usr/bin/sh
command -v ykchalresp &>/dev/null || return 127
command -v ykinfo &>/dev/null || return 127
command -v zpool &>/dev/null || return 127
command -v zfs &>/dev/null || return 127
command -v gpg &>/dev/null || return 127
generate_challenge () {
local dataset="${1}"
local pool="${dataset%%/*}"
local machine_id=''
if [ -n "$ZFS_YUBI_USE_MACHINE_ID" ]; then
machine_id="$(< /etc/machine-id)"
fi
local pool_guid="$(zpool get -Ho value guid "$pool")"
local dataset_objsetid="$(zfs get -Ho value objsetid "$dataset")"
local key="$(printf 'YUBIKEY_ZFS_V1;%s;%s;%s' "$machine_id" "$pool_guid" "$dataset_objsetid")"
sha256sum < <(printf %s "$key") | cut -f1 -d' '
}
get_response () {
if [ -z "$1" ]; then return 1; fi
local dataset="${1}"
local slot="${2:-1}"
if [ "$slot" != 1 -a "$slot" != 2 ]; then
echo "Invalid slot number!" >&2; return 1
fi
local challenge="$(generate_challenge "$dataset")"
ykchalresp -"$slot" -x "$challenge"
}
zfs-yubikey-load-key.sh
#!/usr/bin/sh
. /lib/dracut-zfs-lib.sh
. /lib/dracut-zfs-yubikey-lib.sh
# decode_root_args || return 0
decode_root_args
# There is a race between the zpool import and the pre-mount hooks, so we wait for a pool to be imported
while ! systemctl is-active --quiet zfs-import.target; do
systemctl is-failed --quiet zfs-import-cache.service zfs-import-scan.service && return 1
sleep 0.1s
done
BOOTFS="$root"
if [ "$BOOTFS" = "zfs:AUTO" ]; then
BOOTFS="$(zpool get -Ho value bootfs | grep -m1 -vFx -)"
fi
[ "$(zpool get -Ho value feature@encryption "${BOOTFS%%/*}")" = 'active' ] || return 0
_load_key_yubi_cb() {
ENCRYPTIONROOT="$(zfs get -Ho value encryptionroot "${1}")"
[ "${ENCRYPTIONROOT}" = "-" ] && return 0
[ "$(zfs get -Ho value keystatus "${ENCRYPTIONROOT}")" = "unavailable" ] || return 0
local yubi_keylocation="$(zfs get -Ho value zfs_yubikey:keylocation "${ENCRYPTIONROOT}")"
[ "${yubi_keylocation}" = "-" ] && return 0
[ -r "${yubi_keylocation}" ] || return 0
local yubi_slot="$(zfs get -Ho value zfs_yubikey:slot "${ENCRYPTIONROOT}")"
[ "${yubi_slot}" = "-" ] && yubi_slot=1
udevadm settle
info "ZFS-YubiKey: Checking for YubiKey..."
ykinfo -v &>/dev/null && break
gpg --passphrase-file <(get_response "${ENCRYPTIONROOT}" "${yubi_slot}") --pinentry-mode loopback \
--decrypt "${yubi_keylocation}" | zfs load-key -L prompt "${ENCRYPTIONROOT}"
}
_load_key_yubi_cb "$BOOTFS"
for_relevant_root_children "$BOOTFS" _load_key_yubi_cb