Using GnuPG to unlock your ZFS dataset on boot (in NixOS)
so i’ve got my gpg smartcard compatible hardware key now, was thinking about using it to hold my filesystem encryption keys. and i went around and didn’t find anything about openzfs supporting any of the non-trivial standards, pkcs or anything else i’ve yet looked into. there’s one folk posting on reddit a bunch of a apparently dead links claiming to be a “zfs 2fa encryption helper for yubikey”. well i guess even if the link isn’t dead, i would still want to figure out how it work before i apply any of those to my own data.
i started to look into my own solution because how hard can it be, like i’m pretty sure i can just somehow copy the procedure in luks and make it work for zfs instead. the original plan is to generate a key (in raw format) for this zfs dataset, encrypt it with gpg (which yubikey and mine obviously support), put it into my boot partition, and configure initrd to decrypt and load that key for me; i’d just need to enter the pin protecting my key, and touch the button on it, and system does the rest of stuff for me, without ever exposing the private key, and hopefully keep the zfs symmetric keys in memory.
echo "meowmeow" | gpg --encrypt --recipient hexadecimaaal@gmail.com
# 0�b�z�U%�I�%���,�0�'�����s���D�[����*�)f}�Y媠���
#''�J�.=�����)0/��A������ɓ-X��"$A��F��;���{g!�MZ�&��y��{��
# ��e��a����?
# you get the idea
echo "meowmeow" | gpg --encrypt --recipient hexadecimaaal@gmail.com | gpg --decrypt
# Please insert the card with serial number:
#
# F1D0 [redacted]
# OK
# Cancel
# [oc]? o
# Please unlock the card
#
# Number: F1D0 [redacted]
# Holder: Hexadecimal
# PIN:
# gpg: encrypted with 255-bit ECDH key, ID [redacted], created [redacted]
# "Hexadecimal <hexadecimaaal@gmail.com>"
# meowmeow
i thought the best way is to keep it all in a chain of standard unix piping, and just let the shell automatically destroy those pipes for me, that would be less of a hassle to track down and clean those up after the key is safely guarded to kernel space.
echo "meowmeow" | gpg --encrypt --recipient hexadecimaaal@gmail.com --armor
# -----BEGIN PGP MESSAGE-----
#
# hF4DRLkUNiQAMo4SAQdAoe/dhWTcm4JFDW2H21Z/abH30Vf/SmMsRMSQ4l7EuQAw
# SoUmW4glrJWWfYn/4rll9+t/h9U9IkTlqF63bzaCxV8MpBDCdjmaSBbjlL0VjqJI
# 0kEB0pfqu6xBlA82Fz2UwE0GDoFuYHTcJO65eLSYQE6Ty0xtz8cKtIObIl5am5B3
# oFvTOiPTCSbhc7gbJf+vybPvmQ==
# =0n9U
# -----END PGP MESSAGE-----
after i played with gpg cmdline for a while, i feel like the default output mode makes more sense for a script, than the “armor” output that emits the base64 encoded version and a bunch of fixed header text. but then it’s rather hard to work with in terminal, and i need to play with it to make sure i understand how everything work… and i suddenly remembered, that i can set and get zfs dataset property fairly easily on cmdline, and the thing is, they are available before i load the encryption key for the dataset. and even better is that i don’t have to worry about blasting my efi partition someday for whatever reason, and then remembered i haven’t backed up the key yet… so that’s the right way to go, zfs properties.
echo "meowmeow" | gpg --encrypt --recipient hexadecimaaal@gmail.com | base64 -w0
# hF4DRLkUNiQAMo4SAQdAJ3oKnR3kEs2N4j740OggvsKKM4pn0hs5mIAlVZ3mzgYwFF1UFGPW5+iJppQ/rW0WHPrLbf0xA5i6XCceTG/DLHhhNVvW5MRYrIUzZjg8wIu/0kEBABqTYjfxATFuUP5InlsE0qNJVxQS43/Kb8KSq8Ub/rmcpHu2xUbcaibYm7gMxias2QU+LyDnEGIbueGm3ZkyKw==
echo "meowmeow" | gpg --encrypt --recipient hexadecimaaal@gmail.com | base64 -w0 | base64 -d | gpg --decrypt
# gpg: encrypted with 255-bit ECDH key, ID [redacted], created [redacted]
# "Hexadecimal <hexadecimaaal@gmail.com>"
# meowmeow
the next thing is, i’m pretty sure zfs won’t let me send the raw binary data
through its zfs set
command, that this command only accepts data as argument
written directly on cmdline. so, base64 it then.
sudo zfs set ca.hexade:gpg-cipher=hF4DRLkUNiQAMo4SAQdAJ3oKnR3kEs2N4j740OggvsKKM4pn0hs5mIAlVZ3mzgYwFF1UFGPW5+iJppQ/rW0WHPrLbf0xA5i6XCceTG/DLHhhNVvW5MRYrIUzZjg8wIu/0kEBABqTYjfxATFuUP5InlsE0qNJVxQS43/Kb8KSq8Ub/rmcpHu2xUbcaibYm7gMxias2QU+LyDnEGIbueGm3ZkyKw== salt/test
# for testing (and demonstration) purpose, i just copied the last output and
# pasted it here. i'll go over how to automate this (at least for a little bit).
zfs get ca.hexade:gpg-cipher salt/test
# NAME PROPERTY VALUE SOURCE
# salt/test ca.hexade:gpg-cipher hF4DRLkUNiQAMo4SAQdAJ3oKnR3kEs2N4j740OggvsKKM4pn0hs5mIAlVZ3mzgYwFF1UFGPW5+iJppQ/rW0WHPrLbf0xA5i6XCceTG/DLHhhNVvW5MRYrIUzZjg8wIu/0kEBABqTYjfxATFuUP5InlsE0qNJVxQS43/Kb8KSq8Ub/rmcpHu2xUbcaibYm7gMxias2QU+LyDnEGIbueGm3ZkyKw== local
# to make the output more workable, supply zfs-get with `-H` (omit header) and
# `-o value` (well, only output column "value").
zfs get -H -o value ca.hexade:gpg-cipher salt/test
# hF4DRLkUNiQAMo4SAQdAJ3oKnR3kEs2N4j740OggvsKKM4pn0hs5mIAlVZ3mzgYwFF1UFGPW5+iJppQ/rW0WHPrLbf0xA5i6XCceTG/DLHhhNVvW5MRYrIUzZjg8wIu/0kEBABqTYjfxATFuUP5InlsE0qNJVxQS43/Kb8KSq8Ub/rmcpHu2xUbcaibYm7gMxias2QU+LyDnEGIbueGm3ZkyKw==
and i was thinking about finding some random attribute to jug my encrypted raw
key in, like some field for commentary or something, notes? but sadly to no
avail. then yet again i remembered there’s this “user zfs property” thing,
basically lets you write whatever you like into a zfs dataset metadata section.
after poking around and googling fanatically for a while, the correct format for
user property name turns out to be, upper case letters aren’t allowed, and the
name must include at least one semicolon. so i decided to call this field
ca.hexade:gpg-cipher
, you can name it whatever you like in your version.
# (for testing purpose, unload-key first.)
zfs get -H -o value ca.hexade:gpg-cipher salt/test | base64 -d | gpg --decrypt | sudo zfs load-key salt/test
# gpg: encrypted with 255-bit ECDH key, ID [redacted], created [redacted]
# "Hexadecimal <hexadecimaaal@gmail.com>"
so it works now, great. next thing down the line is to make sure it can unlock
with other GNUPG_HOME
set, since we’ll only have initramfs loaded, we have to
unlock it with a completely bare GNUPG_HOME
. and again, after intense poking
around, i found out that, gpg just don’t want to use your private key (or
secret-key as in gpg terminology) if you don’t have the corresponding public key
in GNUPG_HOME
. the standard way of coming around this (with your gpg smartcard
compatibles) is to store an url in your card for downloading your gpg public
key. we’ll need it in initrd, which probably won’t have access to your network
interfaces, let alone connecting to the internet1. so i figured the best way
is probably just store the public key besides the cipher.
gpg --export [your keygrip] | base64 -w0 | wc -c
# 1184
and i’m kind of afraid this is too long for a zfs property. looked through
something written by oracle, the zfs in solaris (which is very diverged from
openzfs now) says a user property can have 1024 characters in them at most, and
can have a name 256 characters long at most. i figured to just give it a spin
and see what comes, and it didn’t complain about anything. i stored this in
an attribute i decided to call ca.hexade:gpg-pubkey
.
zfs get -H -o value ca.hexade:gpg-pubkey salt/test | base64 -d | gpg --import
# ... gpg: Total number processed: 1 ...
now the setup is good to go, we just need to tell initrd how to do these things automatically. again again, after intense bash-fu poking and consulting with dram and re-stackoverflow-ing, here’s my version of initrd script for the process:
gpg-agent --daemon \
--scdaemon-program /bin/scdaemon \
--allow-loopback-pinentry
zfs list -H -o name | while IFS= read -r dataset; do
pubkey="$(zfs get -H -o value ca.hexade:gpg-pubkey "$dataset")"
if [ "$pubkey" != "-" ]; then
echo "$pubkey" | base64 -d | gpg --import
fi
done
gpg --card-status > /dev/null 2> /dev/null
while ! gpg --card-status > /dev/null 2> /dev/null; do
read -p "GPG smartcard not present. try again? (Y/n)" input
if [ "$input" == "n" -o "$input" == "N" ]; then
break
fi
done
exec 13< <(zfs list -H -o name)
# we need another fifo here because there's another `read` in the loop later
while IFS= read -r dataset <&13; do
cipher="$(zfs get -H -o value ca.hexade:gpg-cipher "$dataset")"
if [ "$cipher" != "-" ]; then # zfs returns `-` for valid but unavailable properties
for i in $(seq 3); do
# sadly, none of the `pinentry-program`s work in initrd.
# so we have to do it ourselves.
read -s -p "enter GPG smartcard PIN:" pin
echo "$cipher" | base64 -d \
| gpg --batch --decrypt --pinentry-mode loopback \
--passphrase-file <(echo "$pin") \
| zfs load-key "$dataset"
if [ "$?" == 0 ]; then break; fi
done
fi
done
exec 13<&-
zfs load-key -a
might be a better way out there to cache your smartcard pin and use it later,
but i have only one encryptionroot
anyways, and i think my version works for
multiple hardware keys. don’t want to introduce more complexity. use this to
replace whatever section responsible for unlocking your zfs datasets in initrd.
and also remember to change the script responsible of composing your initramfs,
copy gpg
gpg-agent
scdaemon
and their libraries to initramfs archive.
takeaway for nixos:
boot.zfs.requestEncryptionCredentials = [];
boot.initrd = {
extraUtilsCommands = ''
copy_bin_and_libs ${pkgs.gnupg}/bin/gpg
copy_bin_and_libs ${pkgs.gnupg}/bin/gpg-agent
copy_bin_and_libs ${pkgs.gnupg}/libexec/scdaemon
'';
extraUtilsCommandsTest = ''
$out/bin/gpg --version
$out/bin/gpg-agent --version
$out/bin/scdaemon --version
'';
postDeviceCommands = lib.mkAfter ''
gpg-agent --daemon \
--scdaemon-program ${pkgs.gnupg}/libexec/scdaemon \
--allow-loopback-pinentry
zfs list -H -o name | while IFS= read -r dataset; do
pubkey="$(zfs get -H -o value ca.hexade:gpg-pubkey "$dataset")"
if [ "$pubkey" != "-" ]; then
echo "$pubkey" | base64 -d | gpg --import
fi
done
gpg --card-status > /dev/null 2> /dev/null
while ! gpg --card-status > /dev/null 2> /dev/null; do
read -p "GPG smartcard not present. try again? (Y/n)" input
if [ "$input" == "n" -o "$input" == "N" ]; then
break
fi
done
exec 13< <(zfs list -H -o name)
while IFS= read -r dataset <&13; do
cipher="$(zfs get -H -o value ca.hexade:gpg-cipher "$dataset")"
if [ "$cipher" != "-" ]; then
for i in $(seq 3); do
read -s -p "enter GPG smartcard PIN:" pin
echo "$cipher" | base64 -d \
| gpg --batch --decrypt --pinentry-mode loopback \
--passphrase-file <(echo "$pin") \
| zfs load-key "$dataset"
if [ "$?" == 0 ]; then break; fi
done
fi
done
exec 13<&-
zfs load-key -a
'';
};
PS: was thinking about using some automatic tool to capitalize text for me after finished writing, but doesn’t seem to be that easy. anyways.
-
well many people actually set up their device to ssh to somewhere and load key from there, i don’t want to jump through these hoops and i want to use it on my future laptop, which i guess won’t have connection to said ssh server anyways. ↩