New blogpost on ZFS dataset unlocking w/ YubiKeys

This commit is contained in:
Tito Sacchi 2024-03-23 12:56:04 +01:00
parent ce2e5ffb14
commit 805977c4c5
1 changed files with 197 additions and 0 deletions

View File

@ -0,0 +1,197 @@
---
title: Unlocking ZFS datasets on boot with a YubiKey
summary: 'Custom initramfs scripts to unlock ZFS datasets with a YubiKey.'
tags: sysadmin, linux, zfs
---
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` and `zfs_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:
```sh
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.
<br/>
* `module-setup.sh`
```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/*
}
```
<br/>
* `zfs-yubikey-lib.sh`
```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"
}
```
<br/>
* `zfs-yubikey-load-key.sh`
```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
```