LinkedIn logo Xing logo DBLP logo University logo GitLab logo

Using a YubiKey to unlock LUKS and Root on ZFS with native encryption

Recreating the partition layout of Ubuntu 25.04 with encrypted ZFS and using a YubiKey with a custom dracut module instead of a passphrase to unlock and boot Gentoo Linux.

First published: 1 June 2025.

I have been using Gentoo Linux with root on ZFS for a while now, but I have not yet used an encrypted setup. When attempting to create an encrypted root pool, I decided to first examine how Ubuntu implemented this and then enhance it with hardware-based authentication using a YubiKey.

ZFS: A Brief History and Overview

ZFS (Zettabyte File System) was originally developed by Sun Microsystems for Solaris in the mid-2000s as a revolutionary approach to storage management. Following Oracle's acquisition of Sun, the open-source community created ZFS on Linux (ZoL) to bring ZFS capabilities to Linux systems. Today, the OpenZFS project maintains a unified codebase that runs across multiple operating systems, including Linux, FreeBSD, and others.

ZFS stands out as a combined file system and logical volume manager with several compelling advantages:

Understanding ZFS Terminology

To work effectively with ZFS, it's essential to understand its core concepts:

Ubuntu's Encrypted ZFS Implementation

Ubuntu provides encrypted ZFS in its distribution, offering a well-designed reference implementation. While I'm primarily interested in the ZFS features rather than Ubuntu specifically, their layout provides an excellent foundation to build upon. Ubuntu creates four partitions for encrypted ZFS systems:

  1. An EFI System Partition (ESP) formatted with FAT32 and used for GRUB's EFI executable.
  2. A ZFS /boot partition (called bpool) to store the kernel, initramfs, and bootloader configuration.
  3. An encrypted LUKS container that contains the swap partition.
  4. An encrypted ZFS pool (rpool) that stores the root partition and all relevant datasets.

The /boot partition remains separate from the root's ZFS pool because GRUB does not support all ZFS features. Thus the /boot partition is created with a feature set compatible with GRUB.

Interestingly, rpool contains an unencrypted zvol called keystore-rpool. This zvol contains a LUKS container that is encrypted with a passphrase. Within that LUKS container, there is an ext4 partition that stores a key file which is ultimately used to decrypt the swap partition and rpool. When booting, Ubuntu asks for a password for the zvol containing the LUKS container with the key file, then uses that key file to unlock swap and rpool.

Enhanced Design with YubiKey Authentication

At this point, I wondered if it was possible to replace the password-protected zvol with a YubiKey while keeping the remaining layout compatible. This approach offers several compelling benefits over traditional password-based encryption.

Hardware Security Keys

A YubiKey is a hardware security key manufactured by Yubico that provides strong two-factor authentication and supports multiple protocols including OpenPGP. For our use case, the YubiKey serves as a secure storage device for cryptographic keys, eliminating the need to enter passphrases during boot while maintaining strong security.

The YubiKey's advantages include:

Cryptographic Architecture

Instead of Ubuntu's symmetric encryption approach, our design uses asymmetric cryptography:

Eventually, I decided to generate separate key files for swap and rpool and encrypt them with an OpenPGP private key stored on my YubiKey. The encrypted key files are then built into the initramfs. During boot, the initramfs will prompt to unlock the YubiKey, write the decrypted key files to a tmpfs at /run/keys, and then unlock swap and rpool.

Design Considerations and Limitations

The partition layout remains identical to Ubuntu's implementation, providing several advantages:

This implementation includes some intentional limitations that could be addressed in future iterations:

Security and Memory Management

The keys are decrypted to /run/keys which is mounted on a tmpfs (memory-based filesystem). While keys residing in memory don't pose a significant security risk for encrypted-at-rest data, minimizing their lifetime is still desirable. The dracut module includes a cleanup script that attempts to overwrite the keys with zeros as soon as they are no longer needed.

However, I must acknowledge uncertainty about whether this approach effectively overwrites the keys, as I'm not entirely familiar with how tmpfs manages memory allocation and deallocation. Even if the explicit overwriting doesn't guarantee memory clearing, the data remains protected at rest due to the full-disk encryption, and the keys have a limited lifetime within the boot process.

When setting up this project, I decided to use the desktop live ISO from Ubuntu 25.04 «Plucky Puffin» and I will assume an empty storage device. In this case, I will install to /dev/vda. To keep the overhead of configuring Gentoo Linux minimal, I decided to use gentoo-kernel-bin along with dracut.

Setting up the YubiKey

We will use GnuPG to create the OpenPGP key locally, then move the private key to the YubiKey. This has the advantage that the secret key can be backed up and possibly moved to a second YubiKey. Of course, you can also generate the key pair on the YubiKey itself if you prefer. In this case, you cannot export the private key, but you can still back up the key files. However, this is out of scope for this blog post.

For this tutorial, I decided it is easiest to generate the OpenPGP keys when booted into the Ubuntu live installer. So at this stage, boot up the Ubuntu live installer, close the setup window, and open a shell. Initially, I will not work as root. You can see the current user and environment in the shell prompt. Begin by installing packages:

ubuntu@ubuntu:~$ sudo apt update
ubuntu@ubuntu:~$ sudo apt install -y gnupg2 scdaemon pcscd

Ensure that the YubiKey is detected. gpg --card-status should show your plugged-in YubiKey. You need some workarounds for scdaemon according to this [blog post][1].

ubuntu@ubuntu:~$ sudo systemctl start pcscd
ubuntu@ubuntu:~$ echo "disable-ccid" > ~/.gnupg/scdaemon.conf
ubuntu@ubuntu:~$ echo "pcsc-shared" >> ~/.gnupg/scdaemon.conf
ubuntu@ubuntu:~$ gpgconf --kill gpg-agent
ubuntu@ubuntu:~$ gpg --card-status

We will now create the OpenPGP key pair locally, then create an export/backup, and finally move the secret key to the YubiKey. Begin by generating the key pair:

ubuntu@ubuntu:~$ gpg --full-generate-key

When prompted, I prefer to select ECC (sign and encrypt) and Curve 25519. Also fill in the remaining data, i.e., set an expiration time and your key information for identification. Make sure to select a strong password and store it in your password manager. You are only expected to use the password in case of recovery.

You can then list the keys with the --keyid-format long parameter:

ubuntu@ubuntu:~$ gpg --list-keys --keyid-format long
gpg: checking the trustdb
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   1  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 1u
/home/ubuntu/.gnupg/pubring.kbx
-------------------------------
pub   ed25519/AEBE2C18B81E0004 2025-05-28 [SC]
      40A05218B0004124DE58CFA3AEBE2C18B81E0004
uid                 [ultimate] User Name <mail@example.com>
sub   cv25519/3A4BA0CA6B0A5CC2 2025-05-28 [E]

Copy your key identifier (the part after ed25519/—in this case AEBE2C18B81E0004) and export the public key:

ubuntu@ubuntu:~$ gpg --export --armor AEBE2C18B81E0004 > public.asc

And also export the secret key:

ubuntu@ubuntu:~$ gpg --export-secret-keys --armor AEBE2C18B81E0004 > private.asc

Now is a good time to create a backup of these two keys. public.asc contains no secrets. private.asc contains your secret private key, which is encrypted with the password you chose earlier. If you have chosen a good (!) password, you can send them via email.

Now we want to move the secret key to the YubiKey. The YubiKey should still be plugged in. In fact, don't remove it before we have booted into our Gentoo system.

ubuntu@ubuntu:~$ gpg --edit-key AEBE2C18B81E0004
gpg (GnuPG) 2.4.4; Copyright (C) 2024 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Secret key is available.

sec  ed25519/AEBE2C18B81E0004
     created: 2025-05-28  expires: never       usage: SC
     trust: ultimate      validity: ultimate
ssb  cv25519/3A4BA0CA6B0A5CC2
     created: 2025-05-28  expires: never       usage: E
[ultimate] (1). User Name <mail@example.com>

gpg>

Select the appropriate key. In this case, type key 1.

gpg> key 1

sec  ed25519/AEBE2C18B81E0004
     created: 2025-05-28  expires: never       usage: SC
     trust: ultimate      validity: ultimate
ssb* cv25519/3A4BA0CA6B0A5CC2
     created: 2025-05-28  expires: never       usage: E
[ultimate] (1). User Name <mail@example.com>

Now move the secret key to the YubiKey:

gpg> keytocard
Please select where to store the key:
   (2) Encryption key
Your selection? 2

sec  ed25519/AEBE2C18B81E0004
     created: 2025-05-28  expires: never       usage: SC
     trust: ultimate      validity: ultimate
ssb* cv25519/3A4BA0CA6B0A5CC2
     created: 2025-05-28  expires: never       usage: E
[ultimate] (1). User Name <mail@example.com>

Note: the local copy of the secret key will only be deleted with "save".

Finalize by typing save:

gpg> save
ubuntu@ubuntu:~$

We have finished setting up the YubiKey. We can now move on to creating the key files.

Creating the key files

We create the key files by piping 32 bytes (256 bits) from /dev/random to gpg and encrypting them with our public key:

ubuntu@ubuntu:~$ dd if=/dev/random bs=32 count=1 status=none | gpg  --encrypt --recipient AEBE2C18B81E0004 --output rpool.key.gpg
ubuntu@ubuntu:~$ dd if=/dev/random bs=32 count=1 status=none | gpg  --encrypt --recipient AEBE2C18B81E0004 --output swap.key.gpg

Next, we decrypt the keys into /run/keys. /run is mounted on a tmpfs and we will also use it later in our initramfs. First, create the /run/keys folder and give it user permissions for now:

ubuntu@ubuntu:~$ sudo mkdir /run/keys
ubuntu@ubuntu:~$ sudo chown ubuntu /run/keys

Now, decrypt the keys to /run/keys/rpool.key and /run/keys/swap.key.

ubuntu@ubuntu:~$ gpg --decrypt rpool.key.gpg > /run/keys/rpool.key
gpg: encrypted with cv25519 key, ID 3A4BA0CA6B0A5CC2, created 2025-05-28
      "User Name <mail@example.com>"
ubuntu@ubuntu:~$ gpg --decrypt swap.key.gpg > /run/keys/swap.key
gpg: encrypted with cv25519 key, ID 3A4BA0CA6B0A5CC2, created 2025-05-28
      "User Name <mail@example.com>"

Note that we could have directly generated the keys in /run/keys and then encrypted them with our public key. However, this serves as a useful test that decryption works correctly.

From now on, we want to start working as root and we do not need user access to the keys anymore. So we change the permissions to be readable only by root:

ubuntu@ubuntu:~$ sudo chown root /run/keys && sudo chmod 0700 /run/keys

Now become root:

ubuntu@ubuntu:~$ sudo su -
root@ubuntu:~#

Disk partitioning and file system creation

I intend to create four partitions on /dev/vda just as the Ubuntu installer does. Using parted, we will create a 512 MiB EFI System Partition, a 1 GiB partition for /boot, an 8 GiB partition for swap, and use the remaining space for the ZFS root pool:

root@ubuntu:~# parted /dev/vda
GNU Parted 3.6
Using /dev/vda
Welcome to GNU Parted! Type 'help' to view a list of commands.
(parted) mklabel gpt
Warning: The existing disk label on /dev/vda will be destroyed and all data on this disk will be lost. Do you want to continue?
Yes/No? yes
(parted) unit mib
(parted) mkpart ESP 1 513
(parted) set 1 boot on
(parted) mkpart gentoo-boot 513 1537
(parted) mkpart gentoo-luks 1537 9729
(parted) mkpart gentoo-zfs 9729 -1
(parted) print free
Model: Virtio Block Device (virtblk)
Disk /dev/vda: 65536MiB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:

Number  Start     End       Size      File system  Name         Flags
        0.02MiB   1.00MiB   0.98MiB   Free Space
 1      1.00MiB   513MiB    512MiB                 ESP          boot, esp
 2      513MiB    1537MiB   1024MiB                gentoo-boot
 3      1537MiB   9729MiB   8192MiB                gentoo-luks
 4      9729MiB   65535MiB  55806MiB               gentoo-zfs
        65535MiB  65536MiB  0.98MiB   Free Space

(parted) quit
Information: You may need to update /etc/fstab.

root@ubuntu:~#

Next, the EFI System Partition is formatted with FAT32:

root@ubuntu:~# mkfs.vfat -F32 /dev/vda1
mkfs.fat 4.2 (2021-01-31)

The /boot partition is formatted with ext4:

root@ubuntu:~# mkfs.ext4 /dev/vda2
mke2fs 1.47.2 (1-Jan-2025)
Discarding device blocks: done
Creating filesystem with 262144 4k blocks and 65536 inodes
Filesystem UUID: 3cf82667-5f92-47b7-b48e-4b119b48632a
Superblock backups stored on blocks:
	32768, 98304, 163840, 229376

Allocating group tables: done
Writing inode tables: done
Creating journal (8192 blocks): done
Writing superblocks and filesystem accounting information: done

root@ubuntu:~#

Next, we will create the encrypted container that is used for swap. It uses the swap.key key file in /run/keys. Using --batch-mode prevents it from asking questions. After creation, we open the container and it will create the block device /dev/mapper/luks-swap:

root@ubuntu:~# cryptsetup luksFormat /dev/vda3 --type luks2 --key-file=/run/keys/swap.key --batch-mode
root@ubuntu:~# cryptsetup open /dev/vda3 luks-swap --key-file=/run/keys/swap.key

Now we format the device with mkswap and enable swap with swapon:

root@ubuntu:~# mkswap /dev/mapper/luks-swap
Setting up swapspace version 1, size = 8 GiB (8573153280 bytes)
no label, UUID=5ce443e6-2ea2-47f5-9cce-dcc5af917af9
root@ubuntu:~# swapon /dev/mapper/luks-swap

If we want the root dataset to be encrypted, we can only do so at zpool creation. Any child dataset will automatically inherit the encryption settings from its parent. Generate a hostid and then create the zpool called rpool on /dev/vda4 with the respective parameters:

root@ubuntu:~# zgenhostid -f
root@ubuntu:~# zpool create -f -o ashift=12 -O compression=zstd -O atime=off -O xattr=sa -O acltype=posixacl -O encryption=aes-256-gcm -O keyformat=raw -O keylocation=file:///run/keys/rpool.key -O mountpoint=none rpool /dev/vda4

We will create the datasets rpool/ROOT and rpool/ROOT/gentoo and set the latter to be the bootfs of rpool. Ubuntu creates more datasets than these, but I want to keep it simple here.

root@ubuntu:~# zfs create -o mountpoint=none rpool/ROOT
root@ubuntu:~# zfs create -o mountpoint=/ -o canmount=noauto rpool/ROOT/gentoo
root@ubuntu:~# zpool set bootfs=rpool/ROOT/gentoo rpool

Now we will reimport rpool and mount it to /mnt/gentoo so that we can continue later with the official Gentoo handbook.

root@ubuntu:~# zpool export rpool
root@ubuntu:~# mkdir /mnt/gentoo
root@ubuntu:~# zpool import -N -R /mnt/gentoo rpool
root@ubuntu:~# zfs load-key rpool
root@ubuntu:~# zfs mount rpool/ROOT/gentoo

With /mnt/gentoo being mounted, we can also mount the EFI System Partition into /mnt/gentoo/efi:

root@ubuntu:~# mkdir /mnt/gentoo/efi
root@ubuntu:~# mount /dev/vda1 /mnt/gentoo/efi

And also mount our /boot partition into /mnt/gentoo/boot:

root@ubuntu:~# mkdir /mnt/gentoo/boot
root@ubuntu:~# mount /dev/vda2 /mnt/gentoo/boot

Ensure everything was mounted correctly:

root@ubuntu:~# mount | grep gentoo
rpool/ROOT/gentoo on /mnt/gentoo type zfs (rw,noatime,xattr,posixacl,casesensitive)
/dev/vda1 on /mnt/gentoo/efi type vfat (rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro)
/dev/vda2 on /mnt/gentoo/boot type ext4 (rw,relatime)

Setting up for the Gentoo installation

Before we enter the chroot, it makes sense to copy some files from Ubuntu to the Gentoo mountpoint. I like to have the GPG-encrypted key files in /boot/keys. We need to have them accessible somewhere for our setup and I also like this location for recovery purposes.

root@ubuntu:~# mkdir -p /mnt/gentoo/boot/keys
root@ubuntu:~# cp /home/ubuntu/public.asc /mnt/gentoo/boot/keys/.
root@ubuntu:~# cp /home/ubuntu/rpool.key.gpg /mnt/gentoo/boot/keys/.
root@ubuntu:~# cp /home/ubuntu/swap.key.gpg /mnt/gentoo/boot/keys/.

Also copy the hostid generated earlier from the Ubuntu host to the Gentoo installation:

root@ubuntu:~# cp /etc/hostid /mnt/gentoo/etc/.

Gentoo setup

Continue with «[The stage file][2]» and «[Installing base system][3]» from the «[Gentoo AMD64 Handbook][4]», then come back. You should now be within a chroot in your Gentoo installation.

Configure /etc/fstab with the appropriate UUIDs:

(chroot) ubuntu ~ # export EFI_UUID=$(blkid -s UUID -o value /dev/vda1)
(chroot) ubuntu ~ # export BOOT_UUID=$(blkid -s UUID -o value /dev/vda2)
(chroot) ubuntu ~ # export SWAP_UUID=$(blkid -s UUID -o value /dev/mapper/luks-swap)
(chroot) ubuntu ~ # echo "UUID=${EFI_UUID}   /efi   vfat  defaults,noatime  0 2" >> /etc/fstab
(chroot) ubuntu ~ # echo "UUID=${BOOT_UUID}  /boot  ext4  defaults,noatime  0 2" >> /etc/fstab
(chroot) ubuntu ~ # echo "UUID=${SWAP_UUID}  none   swap  sw                0 0" >> /etc/fstab
(chroot) ubuntu ~ # cat /etc/fstab
UUID=BD91-ADB6   /efi   vfat  defaults,noatime  0 2
UUID=3cf82667-5f92-47b7-b48e-4b119b48632a  /boot  ext4  defaults,noatime  0 2
UUID=5ce443e6-2ea2-47f5-9cce-dcc5af917af9  none   swap  sw                0 0

Make sure to set USE="dist-kernel dracut zfs" in your make.conf when configuring USE flags. Also set GRUB_PLATFORMS="efi-64" in make.conf. Also ensure that the USE flags smartcard and usb are set for app-crypt/gnupg:

(chroot) ubuntu ~ # echo "app-crypt/gnupg smartcard usb" >> /etc/portage/package.use/gnupg

I like to keep a set with the essentials for booting the system in /etc/portage/sets:

(chroot) ubuntu ~ # cat /etc/portage/sets/essentials
# Base tools for system setup
app-eselect/eselect-repository

# Kernel and firmware
sys-kernel/gentoo-kernel-bin
sys-kernel/linux-firmware
sys-firmware/sof-firmware

# Bootloader
sys-boot/grub

# ZFS support
sys-fs/zfs
sys-fs/zfs-kmod

# Encryption
app-crypt/gnupg
sys-fs/cryptsetup

# Optional tools
sys-apps/memtest86+

Now we can just emerge @essentials.

Our custom dracut module

Dracut is a modern initramfs generation tool that creates initial ramdisk images for Linux systems. Unlike traditional approaches that copy static files, dracut dynamically assembles the initramfs based on the current system configuration and required modules. It provides a modular architecture where functionality is organized into discrete modules that can be selectively included. For our YubiKey integration, we need to create a custom dracut module that handles the GPG decryption process at boot time. The module must install the necessary hooks at precise points in the boot sequence:

Now we need to write our custom dracut module, configure dracut, and generate the initramfs. The module consists of three files: decrypt-keys.sh, cleanup-keys.sh, and module-setup.sh. We will put these into /usr/lib/dracut/modules.d/99yubikey/, which first needs to be created:

(chroot) ubuntu ~ # mkdir -p /usr/lib/dracut/modules.d/99yubikey

decrypt-keys.sh waits for the YubiKey to be plugged in and asks the user for the PIN. It then starts to decrypt the GPG-encrypted key files into /run/keys where they are expected by ZFS.

(chroot) ubuntu ~ # cat /usr/lib/dracut/modules.d/99yubikey/decrypt-keys.sh
#!/bin/sh

COLORS="\033[1;33m \033[1;36m \033[1;35m \033[1;32m"
RESET="\033[0m"
MSG="  Waiting for YubiKey..."

# Wait for the YubiKey to become available
while true; do
    if gpg --card-status >/dev/null 2>&1; then
        printf "\r\033[1;32m YubiKey detected.                      ${RESET}\n"
        break
    fi

    for COLOR in $COLORS; do
        printf "${COLOR}${MSG}${RESET}\r"
        sleep 0.5
    done
done

# Prepare key destination directory
mkdir -p /run/keys
chmod 0700 /run/keys
export GPG_TTY=/dev/console

# Import public key
gpg --import /public.asc

# Suppress kernel messages during PIN entry
echo 0 > /proc/sys/kernel/printk

# Try to decrypt keys up to 3 times
success=0
for i in 1 2 3; do
    if gpg --quiet --batch --yes --decrypt /rpool.key.gpg > /run/keys/rpool.key && \
       gpg --quiet --batch --yes --decrypt /swap.key.gpg > /run/keys/swap.key; then
        success=1
        break
    fi
    echo "Decryption failed. Retrying..."
    sleep 1
done

# Restore kernel messages
echo 7 > /proc/sys/kernel/printk

# Check if decryption succeeded
if [ "$success" -ne 1 ] || [ ! -s /run/keys/rpool.key ]; then
    echo "All attempts failed. Key not decrypted."
    exit 1
fi

cleanup-keys.sh attempts to overwrite the keys with zeros as soon as they are no longer needed. Since we are operating within a tmpfs, I should note that I'm uncertain whether this approach effectively overwrites the keys in memory, as the behavior of tmpfs memory management isn't entirely clear to me.

(chroot) ubuntu ~ # cat /usr/lib/dracut/modules.d/99yubikey/cleanup-keys.sh
#!/bin/sh

dd if=/dev/zero of=/run/keys/rpool.key bs=32 count=1 conv=notrunc status=none
dd if=/dev/zero of=/run/keys/swap.key bs=32 count=1 conv=notrunc status=none
rm -rf /run/keys

module-setup.sh installs the above files into the initramfs, along with all their dependencies, and ensures that they are run at the appropriate time. decrypt-keys.sh must be run before ZFS loads the keys and before LUKS tries to open the device. cleanup-keys.sh must run after the devices have been opened.

(chroot) ubuntu ~ # cat /usr/lib/dracut/modules.d/99yubikey/module-setup.sh
#!/bin/bash

check() {
    return 0
}

depends() {
    return 0
}

install() {
    # Install this hook to run before ZFS load-key
    inst_hook pre-trigger 10 "$moddir/decrypt-keys.sh"
    inst_hook cleanup 99 "$moddir/cleanup-keys.sh"

    # Install GPG tools (Gentoo paths)
    inst /usr/bin/dd
    inst /usr/bin/gpg
    inst /usr/bin/gpg-agent
    inst /usr/bin/pinentry
    inst /usr/bin/pinentry-tty
    inst /usr/bin/pinentry-curses
    inst /usr/libexec/scdaemon
    inst /usr/libexec/keyboxd

    # Install key files (assumed on /boot)
    inst_simple /boot/keys/rpool.key.gpg /rpool.key.gpg
    inst_simple /boot/keys/swap.key.gpg /swap.key.gpg
    inst_simple /boot/keys/public.asc /public.asc
}

When creating these files, don't forget to make them executable with chmod +x.

We need to tell dracut to use our yubikey module. Also, dracut needs to know which partition contains our LUKS container. For this, we create a custom.conf in /etc/dracut.conf.d and populate it with the relevant information:

(chroot) ubuntu ~ # mkdir -p /etc/dracut.conf.d
(chroot) ubuntu ~ # export LUKS_UUID=$(blkid -s UUID -o value /dev/vda3)
(chroot) ubuntu ~ # echo "kernel_cmdline=\"rd.luks.uuid=${LUKS_UUID} rd.luks.name=${LUKS_UUID}=luks-swap rd.luks.key=/run/keys/swap.key:/\"" >> /etc/dracut.conf.d/custom.conf
(chroot) ubuntu ~ # echo "add_dracutmodules+=\" crypt yubikey \""  >> /etc/dracut.conf.d/custom.conf
(chroot) ubuntu ~ # cat /etc/dracut.conf.d/custom.conf
kernel_cmdline="rd.luks.uuid=1b2408a5-28df-4f21-9eff-dbd2c5749c9b rd.luks.name=1b2408a5-28df-4f21-9eff-dbd2c5749c9b=luks-swap rd.luks.key=/run/keys/swap.key:/"
add_dracutmodules+=" crypt yubikey zfs "

Finally, generate the new initramfs with dracut. We need to pass the kernel version with --kver as the running kernel from Ubuntu will not match the installed kernel within Gentoo. The corresponding kernel version can be obtained with ls /boot.

(chroot) ubuntu ~ # dracut -f --kver 6.12.28-gentoo-dist

From the installation of the kernel and zfs-kmod, there are likely old files in /boot. These can be removed:

(chroot) ubuntu ~ # rm /boot/*.old

Finalizing the Installation

We are ready to finalize the installation.

We need to tell GRUB to boot the correct dataset. As of this writing, automatically finding the correct pool is unreliable, so we do it manually. In /etc/default/grub, make sure to add root=ZFS=rpool/ROOT/gentoo to GRUB_CMDLINE_LINUX and make sure it is uncommented:

(chroot) ubuntu ~ # cat /etc/default/grub | grep ^GRUB_CMDLINE_LINUX
GRUB_CMDLINE_LINUX="root=ZFS=rpool/ROOT/gentoo"

Now install GRUB to the /efi directory:

(chroot) ubuntu ~ # grub-install --efi-directory=/efi
Installing for x86_64-efi platform.
Installation finished. No error reported.

Generate the configuration file for GRUB:

(chroot) ubuntu ~ # grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-6.12.28-gentoo-dist
Found initrd image: /boot/initramfs-6.12.28-gentoo-dist.img
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
Adding boot menu entry for UEFI Firmware Settings ...
Found memtest image: /boot/memtest86plus/memtest64.bios
Found memtest image: /boot/memtest86plus/memtest.efi64
done

Finally, we configure the ZFS service to run at startup:

(chroot) ubuntu ~ # rc-update add zfs-import boot
 * service zfs-import added to runlevel boot
(chroot) ubuntu ~ # rc-update add zfs-mount boot
 * service zfs-mount added to runlevel boot
(chroot) ubuntu ~ # rc-update add zfs-load-key boot
 * service zfs-load-key added to runlevel boot
(chroot) ubuntu ~ # rc-update add zfs-share default
 * service zfs-share added to runlevel default
(chroot) ubuntu ~ # rc-update add zfs-zed default
 * service zfs-zed added to runlevel default

Don't forget to set a password for root:

(chroot) ubuntu ~ # passwd

Now we can exit the chroot:

(chroot) ubuntu ~ # exit
exit

Unmount the file systems of the chroot. Remember to also close the LUKS container, unmount the ZFS datasets, and export the zpool.

root@ubuntu:~# cd
root@ubuntu:~# umount -l /mnt/gentoo/dev{/shm,/pts,}
root@ubuntu:~# umount -R /mnt/gentoo
root@ubuntu:~# swapoff /dev/mapper/luks-swap
root@ubuntu:~# cryptsetup close luks-swap
root@ubuntu:~# zfs unmount -a
root@ubuntu:~# rm -d /mnt/gentoo/
root@ubuntu:~# zpool export -f rpool
root@ubuntu:~# reboot

Conclusion and Future Work

Now you should be ready to boot into your new Gentoo installation and unlock swap and the root pool with your YubiKey.

This setup successfully combines hardware-based authentication with ZFS encryption while maintaining Ubuntu's proven partition layout. The YubiKey eliminates the need to enter passphrases during boot, which is particularly useful for systems that need to boot unattended or in environments where keyboard input might be compromised. The compatibility with Ubuntu's layout means that migration paths remain open, and the encrypted key files provide a robust backup strategy.

However, this implementation has several areas that could benefit from improvement. The hard-coded paths in the dracut module work well for a single-user setup but limit flexibility for different deployment scenarios. The memory cleanup approach using dd may not effectively clear keys from tmpfs, though the practical security impact is minimal given the full-disk encryption. Error handling could be more sophisticated, particularly for scenarios where the YubiKey is unavailable or malfunctioning.

For future iterations, dynamic path discovery would make the module more maintainable and adaptable to different system configurations. Support for multiple YubiKeys would provide hardware redundancy, addressing the single point of failure that the current design introduces. Network-based unlocking capabilities would be valuable for headless systems, though this would require careful consideration of the security implications.

The modular dracut approach proved effective for this implementation, making it straightforward to integrate GPG operations into the boot process. The timing of the hooks ensures that keys are available when needed and cleaned up promptly afterward. While there are certainly refinements possible, the current design provides a working foundation that successfully demonstrates the feasibility of hardware-based ZFS encryption unlocking.