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

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.


#!/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/*
}


#!/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"
}


#!/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