198 lines
6.8 KiB
Markdown
198 lines
6.8 KiB
Markdown
|
---
|
||
|
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
|
||
|
```
|