diff --git a/README.md b/README.md index 8e7c5782..d6e25506 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Welcome to Clevis! Clevis is a pluggable framework for automated decryption. It can be used to provide automated decryption of data or even automated unlocking of LUKS -volumes. +volumes and ZFS encryption roots. ### Encrypting Data @@ -675,6 +675,82 @@ luks list command. For example: $ sudo clevis luks list -d /dev/sda1 ``` +### Binding ZFS Encryption Roots + +Clevis can be used to bind a ZFS native encryption root using a pin so that +its key can be loaded automatically. + +ZFS has a single passphrase or key for each encryption root, so Clevis encrypts +the existing one and stores the output JWE in user properties of the +encryption root. After changing the key with `zfs change-key`, bind the +encryption root again with `-f`. + +Here is an example where we bind the `rpool` encryption root using the Tang +pin: + +```bash +$ sudo clevis zfs bind -d rpool tang '{"url": "http://tang.local"}' +Enter existing ZFS password for rpool: +``` + +Each binding is stored under a label, `default` unless another one is given +with `-l`, so several bindings can unlock the same encryption root. + +The unlockers load only keys of encryption roots with `keylocation=prompt`. +They answer the regular password prompt of OpenZFS, so the password can still +be typed in. Network based unlocking works the same way as for LUKS volumes. + +#### ZFS Unlocker: Dracut + +With the OpenZFS dracut module installed, rebuild your initramfs after +installing Clevis: + +```bash +$ sudo dracut -f +``` + +Upon reboot, Clevis unlocks the encryption root of the root file system +automatically, while the password prompt is shown. + +#### ZFS Unlocker: Initramfs-tools + +With OpenZFS 2.2 or newer, Clevis installs the key loading hook +`/etc/zfs/initramfs-tools-load-key.d/clevis`, which the OpenZFS initramfs +script runs before prompting for the password. Rebuild your initramfs: + +```bash +sudo update-initramfs -u -k 'all' +``` + +#### ZFS Unlocker: Systemd + +The encryption roots loaded by the units of `zfs-mount-generator` ask for the +password through systemd. Clevis answers these prompts when the following unit +is enabled: + +```bash +$ sudo systemctl enable clevis-luks-askpass.path +``` + +#### ZFS Unlocker: Clevis command + +A ZFS encryption root bound to a Clevis policy can also be unlocked by using +the clevis zfs unlock command: + +```bash +$ sudo clevis zfs unlock -d tank +``` + +#### Unbinding and listing ZFS encryption roots + +The bindings can be removed using the clevis zfs unbind command, and listed +using the clevis zfs list command. For example: + +```bash +$ sudo clevis zfs unbind -d rpool +$ sudo clevis zfs list +``` + ## Installing Clevis Please don't install Clevis directly. Instead, use your preferred diff --git a/src/clevis.1.adoc b/src/clevis.1.adoc index b7f18a0f..c46f078e 100644 --- a/src/clevis.1.adoc +++ b/src/clevis.1.adoc @@ -114,10 +114,23 @@ automatically unlock your removable media in your desktop session. For more information, see link:clevis-luks-bind.1.adoc[*clevis-luks-bind*(1)]. +== ZFS BINDING + +Clevis can also be used to bind an existing ZFS encryption root to its +automation policy: + + $ clevis zfs bind -d rpool tang '{"url":...}' + +The encryption root can then be unlocked with your existing passphrase as well +as with the Clevis policy, see link:clevis-zfs-unlockers.7.adoc[*clevis-zfs-unlockers*(7)]. + +For more information, see link:clevis-zfs-bind.1.adoc[*clevis-zfs-bind*(1)]. + == SEE ALSO link:clevis-encrypt-tang.1.adoc[*clevis-encrypt-tang*(1)], link:clevis-encrypt-tpm2.1.adoc[*clevis-encrypt-tpm2*(1)], link:clevis-encrypt-sss.1.adoc[*clevis-encrypt-sss*(1)], link:clevis-luks-bind.1.adoc[*clevis-luks-bind*(1)], +link:clevis-zfs-bind.1.adoc[*clevis-zfs-bind*(1)], link:clevis-decrypt.1.adoc[*clevis-decrypt*(1)] diff --git a/src/initramfs-tools/hooks/clevis.in b/src/initramfs-tools/hooks/clevis.in index 323197c7..d0adf774 100755 --- a/src/initramfs-tools/hooks/clevis.in +++ b/src/initramfs-tools/hooks/clevis.in @@ -164,9 +164,11 @@ copy_exec @bindir@/clevis || die 1 "@bindir@/clevis not found" curl_bin=$(find_binary "curl") awk_bin=$(find_binary "awk") bash_bin=$(find_binary "bash") +flock_bin=$(find_binary "flock") copy_exec "${curl_bin}" || die 2 "Unable to copy ${curl_bin} to initrd image" copy_exec "${awk_bin}" || die 2 "Unable to copy ${awk_bin} to initrd image" copy_exec "${bash_bin}" || die 2 "Unable to copy ${bash_bin} to initrd image" +copy_exec "${flock_bin}" || die 2 "Unable to copy ${flock_bin} to initrd image" # Copy latest versions of shared objects needed for DNS resolution for so in $(ldconfig -p | sed -nr 's/^\s*libnss_files\.so\.[0-9]+\s.*=>\s*//p'); do diff --git a/src/initramfs-tools/scripts/clevis-functions.in b/src/initramfs-tools/scripts/clevis-functions.in new file mode 100644 index 00000000..881f5230 --- /dev/null +++ b/src/initramfs-tools/scripts/clevis-functions.in @@ -0,0 +1,179 @@ +#!/bin/bash +# +# Copyright (c) 2017 Red Hat, Inc. +# Copyright (c) 2017 Shawn Rose +# Copyright (c) 2017 Guilhem Moulin +# +# Author: Harald Hoyer +# Author: Nathaniel McCallum +# Author: Shawn Rose +# Author: Guilhem Moulin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Networking and TCSD setup for the clevis unlockers, needs /scripts/functions + +# This is a copy of 'all_netbootable_devices/all_non_enslaved_devices' for +# platforms that might not provide it. +clevis_all_netbootable_devices() { + for device in /sys/class/net/*; do + if [ ! -e "$device/flags" ]; then + continue + fi + + loop=$(($(cat "$device/flags") & 0x8 && 1 || 0)) + bc=$(($(cat "$device/flags") & 0x2 && 1 || 0)) + ptp=$(($(cat "$device/flags") & 0x10 && 1 || 0)) + + # Skip any device that is a loopback + if [ $loop = 1 ]; then + continue + fi + + # Skip any device that isn't a broadcast + # or point-to-point. + if [ $bc = 0 ] && [ $ptp = 0 ]; then + continue + fi + + # Skip any enslaved device (has "master" link + # attribute on it) + device=$(basename "$device") + ip -o link show "$device" | grep -q -w master && continue + if [ -z "$DEVICE" ]; then + DEVICE="$device" + else + DEVICE="$DEVICE $device" + fi + done + echo "$DEVICE" +} + +get_specified_device() { + local dev + dev="$(echo $IP | awk -F: '{ print $6 }')" + [ -z "$dev" ] || echo $dev +} + +# Workaround configure_networking() not waiting long enough for an interface +# to appear. This code can be removed once that has been fixed in all the +# distro releases we care about. +wait_for_device() { + local device + local ret=0 + + device=$(get_specified_device) + + if [ -n "$device" ]; then + log_begin_msg "clevis: Waiting for interface ${device} to become available" + local netdev_wait=0 + while [ $netdev_wait -lt 10 ]; do + if [ -e "/sys/class/net/${device}" ]; then + break + fi + netdev_wait=$((netdev_wait + 1)) + sleep 1 + done + if [ ! -e "/sys/class/net/${device}" ]; then + log_failure_msg "clevis: Interface ${device} did not appear in time" + ret=1 + fi + log_end_msg + fi + + wait_for_udev 10 + + return $ret +} + +# Subshell, so that the lock is released on return +do_configure_networking() ( + local conf lock_fd + + # Once per boot, shared by all clevis unlockers + exec {lock_fd}>/var/lock/clevis-network.lock + flock "${lock_fd}" + [ -f /tmp/clevis-network-attempted ] && return 0 + : > /tmp/clevis-network-attempted + + # Configured already, checked the same way as configure_networking does + for conf in /run/net-*.conf /run/net6-*.conf; do + [ -e "${conf}" ] && return 0 + done + + # Make sure networking is set up: if booting via nfs, it already is + if [ "$BOOT" != nfs ] && wait_for_device; then + clevis_net_cnt=$(clevis_all_netbootable_devices | tr ' ' '\n' | wc -l) + if [ -z "$IP" ] && [ "$clevis_net_cnt" -gt 1 ]; then + echo "" + echo "clevis: Warning: multiple network interfaces available but no ip= parameter provided." + fi + # Background processes must not inherit the lock + (exec {lock_fd}>&-; configure_networking) + + # Add DNS servers from configure_networking to /etc/resolv.conf + if [ ! -e /etc/resolv.conf ]; then + touch /etc/resolv.conf + for intf in /run/net-*.conf; do + # shellcheck disable=SC1090 # The net-*.conf files come from configure_networking + . "${intf}" + if [ ! -z "${IPV4DNS0}" ] && [ "${IPV4DNS0}" != "0.0.0.0" ]; then + echo nameserver "${IPV4DNS0}" >> /etc/resolv.conf + fi + if [ ! -z "${IPV4DNS1}" ] && [ "${IPV4DNS1}" != "0.0.0.0" ]; then + echo nameserver "${IPV4DNS1}" >> /etc/resolv.conf + fi + if [ ! -z "${IPV6DNS0}" ]; then + echo nameserver "${IPV6DNS0}" >> /etc/resolv.conf + fi + done + fi + fi +) + +# Subshell, so that the lock is released on return +do_configure_tpm1() ( + local tcsd_output= + local lock_fd + + [ -x @bindir@/clevis-decrypt-tpm1 ] && [ -f @libexecdir@/clevis-luks-tpm1-functions ] || return + + # Once per boot, shared by all clevis unlockers + exec {lock_fd}>/var/lock/clevis-tcsd.lock + flock "${lock_fd}" + [ -f /tmp/clevis-tcsd-attempted ] && return + : > /tmp/clevis-tcsd-attempted + + . @libexecdir@/clevis-luks-tpm1-functions + + log_begin_msg "clevis: Starting TCSD daemon" + + wait_for_udev 10 + + # shellcheck disable=SC2034 # setting default value + TCSD_NO_PRIVILEGE_DROP=0 + [ -f /conf/conf.d/clevis ] && . /conf/conf.d/clevis + + # The started TCSD must not inherit the lock + if ! tcsd_output=$(exec {lock_fd}>&-; start_tcsd 2>&1); then + if [ -n "$tcsd_output" ]; then + log_failure_msg "failed to start TCSD: $tcsd_output" + else + log_failure_msg "failed to start TCSD" + fi + fi + + log_end_msg +) diff --git a/src/initramfs-tools/scripts/local-top/clevis.in b/src/initramfs-tools/scripts/local-top/clevis.in index 8143544b..1687af39 100755 --- a/src/initramfs-tools/scripts/local-top/clevis.in +++ b/src/initramfs-tools/scripts/local-top/clevis.in @@ -115,8 +115,6 @@ clevisloop() { local sleep_time local CRYPTTAB_SOURCE local OLD_CRYPTTAB_SOURCE="" - local netcfg_attempted=0 - local tpm1cfg_attempted=0 local pins local PASSFIFO @@ -150,12 +148,10 @@ EOM [ "$CRYPTTAB_SOURCE" = "$OLD_CRYPTTAB_SOURCE" ] && continue OLD_CRYPTTAB_SOURCE="$CRYPTTAB_SOURCE" - if [[ " $pins " == *" tang "* ]] && [ $netcfg_attempted -eq 0 ]; then - netcfg_attempted=1 + if [[ " $pins " == *" tang "* ]]; then do_configure_networking fi - if [[ " $pins " == *" tpm1 "* ]] && [ $tpm1cfg_attempted -eq 0 ]; then - tpm1cfg_attempted=1 + if [[ " $pins " == *" tpm1 "* ]]; then do_configure_tpm1 fi @@ -175,135 +171,9 @@ EOM } . /scripts/functions +. /scripts/clevis-functions . clevis-luks-common-functions -# This is a copy of 'all_netbootable_devices/all_non_enslaved_devices' for -# platforms that might not provide it. -clevis_all_netbootable_devices() { - for device in /sys/class/net/*; do - if [ ! -e "$device/flags" ]; then - continue - fi - - loop=$(($(cat "$device/flags") & 0x8 && 1 || 0)) - bc=$(($(cat "$device/flags") & 0x2 && 1 || 0)) - ptp=$(($(cat "$device/flags") & 0x10 && 1 || 0)) - - # Skip any device that is a loopback - if [ $loop = 1 ]; then - continue - fi - - # Skip any device that isn't a broadcast - # or point-to-point. - if [ $bc = 0 ] && [ $ptp = 0 ]; then - continue - fi - - # Skip any enslaved device (has "master" link - # attribute on it) - device=$(basename "$device") - ip -o link show "$device" | grep -q -w master && continue - if [ -z "$DEVICE" ]; then - DEVICE="$device" - else - DEVICE="$DEVICE $device" - fi - done - echo "$DEVICE" -} - -get_specified_device() { - local dev="$(echo $IP | awk -F: '{ print $6 }')" - [ -z "$dev" ] || echo $dev -} - -# Workaround configure_networking() not waiting long enough for an interface -# to appear. This code can be removed once that has been fixed in all the -# distro releases we care about. -wait_for_device() { - local device - local ret=0 - - device=$(get_specified_device) - - if [ -n "$device" ]; then - log_begin_msg "clevis: Waiting for interface ${device} to become available" - local netdev_wait=0 - while [ $netdev_wait -lt 10 ]; do - if [ -e "/sys/class/net/${device}" ]; then - break - fi - netdev_wait=$((netdev_wait + 1)) - sleep 1 - done - if [ ! -e "/sys/class/net/${device}" ]; then - log_failure_msg "clevis: Interface ${device} did not appear in time" - ret=1 - fi - log_end_msg - fi - - wait_for_udev 10 - - return $ret -} - -do_configure_networking() { - # Make sure networking is set up: if booting via nfs, it already is - if [ "$boot" != nfs ] && wait_for_device; then - clevis_net_cnt=$(clevis_all_netbootable_devices | tr ' ' '\n' | wc -l) - if [ -z "$IP" ] && [ "$clevis_net_cnt" -gt 1 ]; then - echo "" - echo "clevis: Warning: multiple network interfaces available but no ip= parameter provided." - fi - configure_networking - - # Add DNS servers from configure_networking to /etc/resolv.conf - if [ ! -e /etc/resolv.conf ]; then - touch /etc/resolv.conf - for intf in /run/net-*.conf; do - . "${intf}" - if [ ! -z "${IPV4DNS0}" ] && [ "${IPV4DNS0}" != "0.0.0.0" ]; then - echo nameserver "${IPV4DNS0}" >> /etc/resolv.conf - fi - if [ ! -z "${IPV4DNS1}" ] && [ "${IPV4DNS1}" != "0.0.0.0" ]; then - echo nameserver "${IPV4DNS1}" >> /etc/resolv.conf - fi - if [ ! -z "${IPV6DNS0}" ]; then - echo nameserver "${IPV6DNS0}" >> /etc/resolv.conf - fi - done - fi - fi -} - -do_configure_tpm1() { - local tcsd_output= - - [ -x @bindir@/clevis-decrypt-tpm1 ] && [ -f @libexecdir@/clevis-luks-tpm1-functions ] || return - - . @libexecdir@/clevis-luks-tpm1-functions - - log_begin_msg "clevis: Starting TCSD daemon" - - wait_for_udev 10 - - # shellcheck disable=SC2034 # setting default value - TCSD_NO_PRIVILEGE_DROP=0 - [ -f /conf/conf.d/clevis ] && . /conf/conf.d/clevis - - if ! tcsd_output=$(start_tcsd 2>&1); then - if [ -n "$tcsd_output" ]; then - log_failure_msg "failed to start TCSD: $tcsd_output" - else - log_failure_msg "failed to start TCSD" - fi - fi - - log_end_msg -} - mkdir -p /var/cache/clevis-disks chmod 0700 /var/cache/clevis-disks diff --git a/src/initramfs-tools/scripts/meson.build b/src/initramfs-tools/scripts/meson.build index 2e7678b2..9be2700e 100644 --- a/src/initramfs-tools/scripts/meson.build +++ b/src/initramfs-tools/scripts/meson.build @@ -1,2 +1,9 @@ subdir('local-top') subdir('local-bottom') + +configure_file( + input: 'clevis-functions.in', + output: 'clevis-functions', + install_dir: initramfs_scripts_dir, + configuration: initramfs_data, +) diff --git a/src/luks/dracut/clevis-pin-tang/module-setup.sh.in b/src/luks/dracut/clevis-pin-tang/module-setup.sh.in index 431ac684..c14a477d 100755 --- a/src/luks/dracut/clevis-pin-tang/module-setup.sh.in +++ b/src/luks/dracut/clevis-pin-tang/module-setup.sh.in @@ -32,6 +32,8 @@ have_tang_bindings() { return 0 fi done + (. clevis-zfs-common-functions 2>/dev/null && zfs_have_bound_pin tang) \ + && return 0 return 1 } diff --git a/src/luks/dracut/clevis/clevis-cleanup.in b/src/luks/dracut/clevis/clevis-cleanup.in index e62fa555..78b89047 100755 --- a/src/luks/dracut/clevis/clevis-cleanup.in +++ b/src/luks/dracut/clevis/clevis-cleanup.in @@ -18,15 +18,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -[ -s /run/clevis.pid ] || exit 0 - -. clevis-luks-common-functions - +# The TCSD may be started without the LUKS unlocker if [ -f @libexecdir@/clevis-luks-tpm1-functions ]; then . @libexecdir@/clevis-luks-tpm1-functions stop_tcsd fi +[ -s /run/clevis.pid ] || exit 0 + +. clevis-luks-common-functions + pid=$(cat /run/clevis.pid) clevis_kill_pid $pid diff --git a/src/luks/dracut/clevis/clevis-lib.sh.in b/src/luks/dracut/clevis/clevis-lib.sh.in new file mode 100644 index 00000000..57b3c981 --- /dev/null +++ b/src/luks/dracut/clevis/clevis-lib.sh.in @@ -0,0 +1,51 @@ +#!/bin/bash +# vim: set ts=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: +# +# Copyright (c) 2024 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Functions shared by the clevis dracut unlockers + +command -v getarg > /dev/null || . /lib/dracut-lib.sh + +# Subshell, so that the lock is released on return +do_configure_tpm1() ( + local tcsd_output= + local lock_fd + + [ -x @bindir@/clevis-decrypt-tpm1 ] && [ -f @libexecdir@/clevis-luks-tpm1-functions ] || return + + # Once per boot, shared by all clevis unlockers + exec {lock_fd}>/var/lock/clevis-tcsd.lock + flock "${lock_fd}" + [ -f /tmp/clevis-tcsd-attempted ] && return + : > /tmp/clevis-tcsd-attempted + + . @libexecdir@/clevis-luks-tpm1-functions + + info "Starting TCSD daemon" + + # The started TCSD must not inherit the lock + if ! tcsd_output=$(exec {lock_fd}>&-; TCSD_NO_PRIVILEGE_DROP=0 start_tcsd 2>&1); then + if [ -n "$tcsd_output" ]; then + echo "Unable to start TCSD: $tcsd_output" | vwarn + else + warn "Unable to start TCSD" + fi + fi +) diff --git a/src/luks/dracut/clevis/clevis-password-unlocker.in b/src/luks/dracut/clevis/clevis-password-unlocker.in index 60cf887a..e807a9b5 100755 --- a/src/luks/dracut/clevis/clevis-password-unlocker.in +++ b/src/luks/dracut/clevis/clevis-password-unlocker.in @@ -27,6 +27,7 @@ . /lib/dracut-lib.sh . /lib/dracut-crypt-lib.sh +. /lib/dracut-clevis-lib.sh . clevis-luks-common-functions @@ -161,24 +162,6 @@ EOM done } -do_configure_tpm1() { - local tcsd_output= - - [ -x @bindir@/clevis-decrypt-tpm1 ] && [ -f @libexecdir@/clevis-luks-tpm1-functions ] || return - - . @libexecdir@/clevis-luks-tpm1-functions - - info "Starting TCSD daemon" - - if ! tcsd_output=$(TCSD_NO_PRIVILEGE_DROP=0 start_tcsd 2>&1); then - if [ -n "$tcsd_output" ]; then - echo "Unable to start TCSD: $tcsd_output" | vwarn - else - warn "Unable to start TCSD" - fi - fi -} - mkdir -p /var/cache/clevis-disks chmod 0700 /var/cache/clevis-disks diff --git a/src/luks/dracut/clevis/meson.build b/src/luks/dracut/clevis/meson.build index e6230f33..ea568669 100644 --- a/src/luks/dracut/clevis/meson.build +++ b/src/luks/dracut/clevis/meson.build @@ -28,6 +28,13 @@ if dracut.found() configuration: dracut_data, ) + configure_file( + input: 'clevis-lib.sh.in', + output: 'clevis-lib.sh', + install_dir: dracutdir, + configuration: dracut_data, + ) + configure_file( input: 'clevis-password-unlocker-prepare.in', output: 'clevis-password-unlocker-prepare', diff --git a/src/luks/dracut/clevis/module-setup.sh.in b/src/luks/dracut/clevis/module-setup.sh.in index cb0bd22f..8462e49f 100755 --- a/src/luks/dracut/clevis/module-setup.sh.in +++ b/src/luks/dracut/clevis/module-setup.sh.in @@ -50,6 +50,7 @@ install() { inst_script "$moddir"/clevis-cleanup /bin/clevis-cleanup inst_script "$moddir"/clevis-password-unlocker /bin/clevis-password-unlocker inst_script "$moddir"/clevis-password-unlocker-prepare /bin/clevis-password-unlocker-prepare + inst_simple "$moddir"/clevis-lib.sh /lib/dracut-clevis-lib.sh inst_multiple \ clevis-luks-unlock \ chmod \ diff --git a/src/luks/systemd/clevis-luks-askpass.in b/src/luks/systemd/clevis-luks-askpass.in index cee62030..53208ef8 100755 --- a/src/luks/systemd/clevis-luks-askpass.in +++ b/src/luks/systemd/clevis-luks-askpass.in @@ -22,6 +22,11 @@ set -eu . clevis-luks-common-functions +zfs= +if . clevis-zfs-common-functions 2>/dev/null; then + zfs=true +fi + # Make sure to exit cleanly if SIGTERM is received. trap 'echo "Exiting due to SIGTERM" && exit 0' TERM @@ -43,13 +48,28 @@ while true; do d= s= + zid= + zmsg= while read -r line; do case "$line" in Id=cryptsetup:*) d="${line##Id=cryptsetup:}";; + Id=zfs:*) zid="${line##Id=zfs:}";; + "Message=Encrypted ZFS password for "*) + zmsg="${line##Message=Encrypted ZFS password for }";; Socket=*) s="${line##Socket=}";; esac done < "$question" + # Generator prompts carry an Id, the initramfs ones only a message + z="${zid:-${zmsg}}" + if [ -n "${zfs}" ] && [ -n "${z}" ] && [ -S "${s}" ]; then + if pt="$(zfs_recover_prompt_key "${z}")" && [ -n "${pt}" ] \ + && printf '%s' "${pt}" | @SYSTEMD_REPLY_PASS@ 1 "${s}"; then + echo "Unlocked ZFS dataset ${z} successfully" >&2 + fi + continue + fi + [ -e "${d}" ] || continue [ -S "${s}" ] || continue diff --git a/src/meson.build b/src/meson.build index c4e696f6..a8011788 100644 --- a/src/meson.build +++ b/src/meson.build @@ -2,6 +2,7 @@ subdir('bash') subdir('luks') subdir('pins') subdir('initramfs-tools') +subdir('zfs') bins += join_paths(meson.current_source_dir(), 'clevis-decrypt') mans += join_paths(meson.current_source_dir(), 'clevis-decrypt.1') diff --git a/src/zfs/clevis-zfs-bind b/src/zfs/clevis-zfs-bind new file mode 100755 index 00000000..235c0de7 --- /dev/null +++ b/src/zfs/clevis-zfs-bind @@ -0,0 +1,110 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Binds a ZFS dataset using the specified policy" + +usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs bind [-f] [-y] [-k KEY] -d DATASET [-l LABEL] PIN CFG + + $SUMMARY: + + -f Do not prompt when overwriting configuration + -y Automatically answer yes for all questions + -d DATASET The ZFS dataset on which to perform binding + -l LABEL The label to use for this binding, "default" if not given. Valid characters: lowercase letters, numbers, underscores, dots and hyphens + + -k KEY Non-interactively read ZFS password from KEY file + -k - Non-interactively read ZFS password from standard input + + USAGE_END +} + +bind_zfs_dataset() { + local dataset="${1}" + local label="${2}" + local pin="${3}" + local cfg="${4}" + local key="${5}" + local overwrite="${6:-}" + local yes="${7:-}" + + local existing_key clevis_data + + if [[ -z "${overwrite}" ]] && zfs_is_bound "${dataset}" "${label}"; then + error "given label ${label} in dataset ${dataset} already has a Clevis binding, not overwriting." + fi + + existing_key="$(read_passphrase "${dataset}" "${key}")" + + if ! zfs_test_key "${dataset}" <<<"${existing_key}"; then + error "given key does not unlock ${dataset}" + fi + + echo >&2 -n 'creating new Clevis data... ' + clevis_data="$(clevis encrypt "${pin}" "${cfg}" ${yes:+"${yes}"} <<<"${existing_key}")" + echo >&2 'ok' + + if [[ -n "${overwrite}" ]] && zfs_is_bound "${dataset}" "${label}"; then + zfs_unbind_clevis_label "${dataset}" "${label}" + echo >&2 'unbound old clevis data' + fi + + zfs_bind_clevis_label "${dataset}" "${label}" "${clevis_data}" +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local pin + local cfg + local label='default' + local key='' + local overwrite='' + local yes='' + while getopts ":hyfd:l:k:" o; do + case "$o" in + h) usage; exit 0;; + y) yes='-y';; + f) overwrite='yes';; + d) dataset="$OPTARG";; + l) label="$OPTARG";; + k) key="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + check_valid_dataset "${dataset}" + + if ! is_valid_label "${label}"; then + error "invalid label: ${label}" + fi + + pin="${*:$((OPTIND++)):1}" + if [ -z "$pin" ]; then + error "did not specify a pin!" + elif ! command -v "clevis-encrypt-${pin}" >/dev/null; then + error "'$pin' is not a valid pin!" + fi + + cfg="${*:$((OPTIND++)):1}" + if [ -z "$cfg" ]; then + error "did not specify a pin config!" + fi + + bind_zfs_dataset "${dataset}" "${label}" "${pin}" "${cfg}" "${key}" "${overwrite}" "${yes}" + echo >&2 "label ${label} on dataset ${dataset} is successfully bound" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-bind.1.adoc b/src/zfs/clevis-zfs-bind.1.adoc new file mode 100644 index 00000000..d0f43e7c --- /dev/null +++ b/src/zfs/clevis-zfs-bind.1.adoc @@ -0,0 +1,75 @@ +CLEVIS-ZFS-BIND(1) +================== +:doctype: manpage + + +== NAME + +clevis-zfs-bind - Bind a ZFS encryption root using the specified policy + +== SYNOPSIS + +*clevis zfs bind* [-f] [-y] [-k KEY] -d DATASET [-l LABEL] PIN CFG + +== OVERVIEW + +The *clevis zfs bind* command binds a ZFS encryption root using the specified +policy. This is accomplished with a simple command: + + $ clevis zfs bind -d rpool tang '{"url":...}' + +This command performs three steps: + +1. Asks for the existing passphrase or key of the encryption root and checks it. +2. Encrypts the existing passphrase or key with Clevis. +3. Stores the Clevis JWE in user properties of the encryption root and checks + that it unlocks the encryption root. + +The encryption root can now be unlocked with your existing passphrase as well +as with the Clevis policy. You will additionally need one or more of the +Clevis ZFS unlockers. See link:clevis-zfs-unlockers.7.adoc[*clevis-zfs-unlockers*(7)]. + +An encryption root can have several bindings, each stored under its own label. + +== OPTIONS + +* *-f* : + Overwrite an existing binding with the same label + +* *-y* : + Automatically answer yes for all questions. When using _tang_, it + causes the advertisement trust check to be skipped, which can be + useful in automated deployments + +* *-d* _DATASET_ : + The ZFS encryption root on which to perform binding + +* *-l* _LABEL_ : + The label of the binding, _default_ if not given. Valid characters are + lowercase letters, numbers, underscores, dots and hyphens + +* *-k* _KEY_ : + Non-interactively read the ZFS passphrase or key from KEY file + +* *-k* - : + Non-interactively read the ZFS passphrase or key from standard input + +== CAVEATS + +ZFS has a single wrapping key for each encryption root, so Clevis stores the +existing passphrase or key itself, encrypted by the Clevis policy. After +changing it with *zfs change-key*, bind the encryption root again with *-f*. + +Only the _passphrase_ and _hex_ key formats are supported. + +The bindings are stored in the user properties _latchset.clevis:labels_ and +_latchset.clevis.label:LABEL_. Large bindings are split into several +numbered properties. + +== SEE ALSO + +link:clevis-zfs-unlockers.7.adoc[*clevis-zfs-unlockers*(7)], +link:clevis-zfs-unbind.1.adoc[*clevis-zfs-unbind*(1)], +link:clevis-encrypt-tang.1.adoc[*clevis-encrypt-tang*(1)], +link:clevis-encrypt-sss.1.adoc[*clevis-encrypt-sss*(1)], +link:clevis-decrypt.1.adoc[*clevis-decrypt*(1)] diff --git a/src/zfs/clevis-zfs-common-functions b/src/zfs/clevis-zfs-common-functions new file mode 100644 index 00000000..5a66b014 --- /dev/null +++ b/src/zfs/clevis-zfs-common-functions @@ -0,0 +1,404 @@ +#!/bin/bash + +# zfs user properties are limited to 8192 bytes +zfs_userprop_value_limit=8000 +zfs_userprop_name_limit=256 + +# all clevis userprops will be prefixed with "latchset.clevis:" as suggested by +# the User Properties section in zfsprops(8) +zfs_userprop_prefix='latchset.clevis' + +# This contains the space-separated list of labels that have been bound with clevis +zfs_labels_prop="${zfs_userprop_prefix}:labels" + +# The data for each label is saved into one or more zfs properties. +# E.g. the label 'mybinding' with data of 20k bytes and label 'other' with 4k bytes +# we suffix the label in the :labels property so we can easily find all numbered parts +# - latchset.clevis:labels = "mybinding:2 other" +# - latchset.clevis.label:mybinding:0 = "[clevis data first 8k]" +# - latchset.clevis.label:mybinding:1 = "[clevis data second 8k]" +# - latchset.clevis.label:mybinding:2 = "[clevis data final 4k]" +# - latchset.clevis.label:other = [clevis data 4k] +zfs_label_prefix="${zfs_userprop_prefix}.label" + + +# Interfacing functions with ZFS +################################ +zfs_remove_property() { + local dataset="${1}" + local property="${2}" + zfs inherit "${property}" "${dataset}" +} + +# valid characters of zfs user property names are: [0-9a-z:._-] (see zfsprops(7) ) +zfs_set_property() { + local dataset="${1}" + local property="${2}" + local value="${3}" + [[ "${#property}" -le "${zfs_userprop_name_limit}" ]] || error "property name longer than ${zfs_userprop_name_limit} characters '${property}'" + [[ "${#value}" -le "${zfs_userprop_value_limit}" ]] || error "property value longer than ${zfs_userprop_value_limit} characters '${value}'" + zfs set "${property}=${value}" "${dataset}" +} + +# defaults to getting just the value of the given property and only when it is set directly on the dataset ("local") +zfs_get_property() { + local dataset="${1}" + local property="${2}" + shift 2 + zfs get "${property}" "${dataset}" -H -o value -slocal "${@}" +} + +zfs_load_key() { + local dataset="${1}" + local args=( "-L" "prompt" ) + if [[ -n "${2:-}" ]]; then + args+=( "-n" ) + fi + zfs load-key "${args[@]}" "${dataset}" >/dev/null +} + +zfs_test_key() { + zfs_load_key "${1}" 'dry_run' +} + +# ZFS properties functions to deal with Clevis labels +############################################## + +# Valid characters of clevis-zfs labels are the ones of ZFS user property +# names except ':', which separates the chunk numbers +is_valid_label() { + local label="${1}" + # This length limit is quite arbitrary; + # but we have to draw the line somewhere and zfs-user-property names + # can be at most 256 characters long. We can't use all 256 characters + # because we need some space in the property name for the + # zfs_label_prefix and the chunk_counter suffix. + local limit=100 + local regex='^[0-9a-z_.-]+$' + + if [[ "${#label}" -gt "${limit}" ]]; then + echo >&2 "label is longer than ${limit} characters: ${label}" + return 1 + fi + + if [[ "${label}" =~ ${regex} ]]; then + return 0 + else + echo >&2 "label is invalid: '${label}'. Valid characters: a-z, 0-9, _ (underscore), . (dot), - (hyphen)" + return 1 + fi +} + +# get a list of all labels, including possible number suffixes +# if no labels are defined, succeeds and returns the empty string +zfs_get_labels() { + local dataset="${1}" + zfs_get_property "${dataset}" "${zfs_labels_prop}" 2>/dev/null || true +} + +# get a single label from the list of all labels, +# including possible number suffix +zfs_get_label() { + local dataset="${1}" + local label="${2%%:*}" + local l + for l in $(zfs_get_labels "${dataset}"); do + if [[ "${l%%:*}" == "${label}" ]]; then + echo "${l}" + return 0 + fi + done + return 1 +} + +# set the list of labels to the given value +zfs_set_labels() { + local dataset="${1}" + local new_labels="${2}" + zfs_set_property "${dataset}" "${zfs_labels_prop}" "${new_labels}" +} + +# add a single label to the existing list of labels +zfs_add_label() { + local dataset="${1}" + local new_label="${2}" + local labels + read -ra labels <<< "$(zfs_get_labels "${dataset}")" + labels+=( "${new_label}" ) + zfs_set_labels "${dataset}" "${labels[*]}" +} + +# remove a single label to the existing list of labels +zfs_remove_label() { + local dataset="${1}" + local old_label="${2%%:*}" + local labels l + read -ra labels <<< "$(zfs_get_labels "${dataset}")" + local new_labels=() + for l in "${labels[@]}"; do + [[ "${l%%:*}" == "${old_label}" ]] || new_labels+=( "${l}" ) + done + if [[ "${#new_labels[@]}" -eq 0 ]]; then + zfs_remove_property "${dataset}" "${zfs_labels_prop}" + else + zfs_set_labels "${dataset}" "${new_labels[*]}" + fi +} + +# functions for checking zfs datasets +##################################### + +# check if a dataset is bound to a specific label (or any label) +zfs_is_bound() { + local dataset="${1}" + local label="${2:-}" + + if [[ -z "${label}" ]]; then + [[ -n "$(zfs_get_labels "${dataset}")" ]] + else + zfs_get_label "${dataset}" "${label}" >/dev/null + fi +} + +# we can only load keys on encryptionroots +# it does not make sense to add a binding elsewhere +zfs_is_encryptionroot() { + local dataset="${1}" + [[ "$(zfs_get_property "${dataset}" 'encryptionroot' -snone )" == "${dataset}" ]] +} + +# does it even exist? +zfs_is_dataset() { + local dataset="${1}" + zfs_get_property "${dataset}" 'name' -snone &>/dev/null +} + +check_valid_dataset() { + local dataset="${1}" + + if ! zfs_is_dataset "${dataset}"; then + error "${dataset} is not a zfs dataset!" + fi + + if ! zfs_is_encryptionroot "${dataset}"; then + error "given dataset is not an encryptionroot: ${dataset}" + fi + + # The key is passed through shell variables, which cannot hold raw bytes + if [[ "$(zfs_get_property "${dataset}" 'keyformat' -snone)" == 'raw' ]]; then + error "raw keyformat is not supported: ${dataset}" + fi +} + + +# functions to deal with I/O to the user +read_passphrase() { + local dataset="${1}" + local key="${2?need keyinput argument}" + + # Get the existing passphrase/keyfile. + local existing_key + local keyfile + + case "${key}" in + "") IFS= read -r -s -p "Enter existing ZFS password for ${dataset}: " existing_key; + echo >&2 + ;; + -) IFS= read -r -s -p "" existing_key;; + *) keyfile="${key}" + if [ -r "${keyfile}" ]; then + existing_key="$(< "${keyfile}")" + else + error "cannot read key file '${keyfile}'" + fi + ;; + esac + echo "${existing_key}" +} + +error() { + echo >&2 -e "ERROR: ${*}" + usage + exit 1 +} + + +# functions to deal with too large Clevis data for a single ZFS property +######################################################################## + +cut_into_chunks() { + fold -w "${zfs_userprop_value_limit}" +} + +# Prints zero-padded chunk numbers 0..last_index, e.g. "00 01 ... 12" +num_list() { + local last_index="${1}" + local i + for ((i = 0; i <= last_index; i++)); do + printf "%0${#last_index}d " "${i}" + done +} + + +# functions to add/remove a Clevis binding +######################################### + +# Removes the data properties of a label, the label list is left untouched +zfs_remove_clevis_label_data() { + local dataset="${1}" + local label="${2%%:*}" + local last_index="${2#*:}" + local zfs_label_prop="${zfs_label_prefix}:${label}" + local num + + if [[ "${2}" != *:* ]]; then + zfs_remove_property "${dataset}" "${zfs_label_prop}" + else + for num in $(num_list "${last_index}"); do + zfs_remove_property "${dataset}" "${zfs_label_prop}:${num}" + done + fi +} + +zfs_bind_clevis_label() { + local dataset="${1}" + local label="${2}" + local clevis_data="${3}" + + local zfs_label_prop="${zfs_label_prefix}:${label}" + + echo >&2 -n 'binding new Clevis data... ' + # use a single prop without number suffix if it will fit in one prop + if [[ "${#clevis_data}" -le "${zfs_userprop_value_limit}" ]]; then + zfs_set_property "${dataset}" "${zfs_label_prop}" "${clevis_data}" + else + local clevis_chunks=() + mapfile -t clevis_chunks < <(cut_into_chunks <<< "${clevis_data}") + local last_index="$(( ${#clevis_chunks[@]} - 1 ))" + + local chunk_num i=0 + for chunk_num in $(num_list "${last_index}"); do + # e.g. latchset.clevis.label:${label}:01=chunk_data + zfs_set_property "${dataset}" "${zfs_label_prop}:${chunk_num}" "${clevis_chunks[i++]}" + done + + label="${label}:${last_index}" + fi + echo >&2 'ok' + + # check if unlocking works + echo >&2 -n 'testing new Clevis data... ' + if ! zfs_recover_key_with_label "${dataset}" "${label}" >/dev/null; then + zfs_remove_clevis_label_data "${dataset}" "${label}" + error "could not unlock dataset with clevis configuration: ${dataset}" + fi + echo >&2 'ok' + zfs_add_label "${dataset}" "${label}" +} + +zfs_unbind_clevis_label() { + local dataset="${1}" + local label + + label="$(zfs_get_label "${dataset}" "${2}")" || return 0 + + zfs_remove_clevis_label_data "${dataset}" "${label}" + zfs_remove_label "${dataset}" "${label}" +} + +zfs_get_clevis_label() { + local dataset="${1}" + local label="${2%%:*}" + local last_index="${2#*:}" + + local zfs_label_prop="${zfs_label_prefix}:${label}" + if [[ "${2}" != *:* ]]; then + zfs_get_property "${dataset}" "${zfs_label_prop}" + else + local clevis_data=() num + for num in $(num_list "${last_index}"); do + clevis_data+=( "$(zfs_get_property "${dataset}" "${zfs_label_prop}:${num}")" ) + done + local IFS='' + echo "${clevis_data[*]}" + fi +} + +# Prints the key of the label, when it unlocks the dataset +zfs_recover_key_with_label() { + local dataset="${1}" + local label="${2}" + local clevis_data key + + clevis_data="$(zfs_get_clevis_label "${dataset}" "${label}")" || return 1 + [[ -n "${clevis_data}" ]] || return 1 + key="$(printf "%s" "${clevis_data}" | clevis decrypt)" || return 1 + zfs_test_key "${dataset}" <<< "${key}" 2>/dev/null || return 1 + echo "${key}" +} + +# Prints the key of the first label (or of the given one) unlocking the dataset +zfs_recover_key() { + local dataset="${1}" + local label="${2:-}" + local labels l + + if [[ -n "${label}" ]]; then + labels="$(zfs_get_label "${dataset}" "${label}")" || return 1 + else + labels="$(zfs_get_labels "${dataset}")" + fi + + for l in ${labels}; do + zfs_recover_key_with_label "${dataset}" "${l}" && return 0 + done + return 1 +} + +# Prints the dataset's encryption root if it is locked, bound and prompting +zfs_get_prompt_encryptionroot() { + local dataset="${1}" + local root + + root="$(zfs get -H -o value encryptionroot "${dataset}" 2>/dev/null)" || return 1 + [[ -n "${root}" && "${root}" != "-" ]] || return 1 + [[ "$(zfs get -H -o value keylocation "${root}")" == "prompt" ]] || return 1 + [[ "$(zfs get -H -o value keystatus "${root}")" == "unavailable" ]] || return 1 + zfs_is_bound "${root}" || return 1 + echo "${root}" +} + +# Prints the key for a password prompt of the dataset +zfs_recover_prompt_key() { + local root + + root="$(zfs_get_prompt_encryptionroot "${1}")" || return 1 + zfs_recover_key "${root}" +} + +# Prints the used pins of all labels, space-separated and sorted, needs +# clevis-luks-common-functions +zfs_read_used_pins() { + local dataset="${1}" + local label jwe pins= + + for label in $(zfs_get_labels "${dataset}"); do + jwe="$(zfs_get_clevis_label "${dataset}" "${label}")" || continue + [[ -n "${jwe}" ]] || continue + pins=$(printf "%s\n%s" "${pins}" "$(clevis_luks_decode_used_pins "${jwe}")") + done + + echo -n "${pins}" | tr ' ' '\n' | sed -e '/^$/d' | sort -u | tr '\n' ' ' | sed -e 's/ $//' +} + +# Succeeds when a bound dataset uses the pin, needs clevis-luks-common-functions +zfs_have_bound_pin() { + local pin="${1}" + local dataset pins + + while IFS=$'\t' read -r dataset _; do + pins="$(zfs_read_used_pins "${dataset}")" + [[ " ${pins} " == *" ${pin} "* ]] && return 0 + done < <(zfs get -H -o name,value -s local "${zfs_labels_prop}" 2>/dev/null) + return 1 +} diff --git a/src/zfs/clevis-zfs-list b/src/zfs/clevis-zfs-list new file mode 100755 index 00000000..5096bd95 --- /dev/null +++ b/src/zfs/clevis-zfs-list @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="List ZFS datasets that are bound with Clevis [in dataset]" + +usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs list [-d DATASET] + + $SUMMARY: + + -d DATASET The ZFS dataset to list the labels of + + USAGE_END +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + while getopts "hd:" o; do + case "$o" in + h) usage; exit 0;; + d) dataset="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; + esac + done + + local output='name,value' + if [ -n "${dataset:-}" ]; then + output='value' + fi + + echo >&2 "The following ZFS datasets have been bound with Clevis:" + # we should not quote ${dataset:-} in case it is empty, the labels + # property is from clevis-zfs-common-functions + # shellcheck disable=SC2086,SC2154 + zfs get -H -o "${output}" -slocal "${zfs_labels_prop}" ${dataset:-} +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-list.1.adoc b/src/zfs/clevis-zfs-list.1.adoc new file mode 100644 index 00000000..8210d079 --- /dev/null +++ b/src/zfs/clevis-zfs-list.1.adoc @@ -0,0 +1,31 @@ +CLEVIS-ZFS-LIST(1) +================== +:doctype: manpage + + +== NAME + +clevis-zfs-list - Lists ZFS encryption roots bound with Clevis + +== SYNOPSIS + +*clevis zfs list* [-d DATASET] + +== OVERVIEW + +The *clevis zfs list* command lists the ZFS encryption roots bound with +Clevis together with the labels of their bindings. For example: + + $ clevis zfs list + rpool default tang2 + tank default + +== OPTIONS + +* *-d* _DATASET_ : + List only the labels of the given encryption root + +== SEE ALSO + +link:clevis-zfs-bind.1.adoc[*clevis-zfs-bind*(1)], +link:clevis-zfs-unbind.1.adoc[*clevis-zfs-unbind*(1)] diff --git a/src/zfs/clevis-zfs-unbind b/src/zfs/clevis-zfs-unbind new file mode 100755 index 00000000..1774c9d9 --- /dev/null +++ b/src/zfs/clevis-zfs-unbind @@ -0,0 +1,91 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Unbinds a label from a ZFS dataset" + +usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs unbind [-f] [-k KEY] -d DATASET [-a | -l LABEL] + + $SUMMARY: + + -f Force unbinding dataset + -d DATASET The ZFS dataset on which to perform unbinding + -a Unbind all labels + -l LABEL The label to unbind + + -k KEY Non-interactively read ZFS password from KEY file + -k - Non-interactively read ZFS password from standard input + + Without -a and -l, the "default" label is unbound. + + USAGE_END +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset= + local key= + local label= + local force_unbind='false' + local unbind_all='false' + while getopts "hafd:k:l:" o; do + case "$o" in + h) usage; exit 0;; + a) unbind_all='true';; + d) dataset="$OPTARG";; + f) force_unbind='true';; + k) key="$OPTARG";; + l) label="$OPTARG";; + *) error "unrecognized argument: -${o}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + if [[ "${unbind_all}" == 'true' ]] && [ -n "${label}" ]; then + error "cannot use -a together with -l!" + fi + label="${label:-default}" + + if [[ "${unbind_all}" == 'false' ]] && ! zfs_is_bound "${dataset}" "${label}"; then + error "label ${label} is not bound in dataset ${dataset}" + fi + + if [[ "${force_unbind}" != 'true' ]]; then + if ! zfs_is_bound "${dataset}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi + + local existing_key + echo >&2 "Loading existing key... " + existing_key="$(read_passphrase "${dataset}" "${key}")" + + if ! zfs_test_key "${dataset}" <<<"${existing_key}"; then + error "given key does not unlock ${dataset}" + fi + fi + + echo >&2 -n 'Wiping Clevis data... ' + if [[ "${unbind_all}" == 'true' ]]; then + local labels=() + read -ra labels <<< "$(zfs_get_labels "${dataset}")" + for label in "${labels[@]}"; do + zfs_unbind_clevis_label "${dataset}" "${label}" + done + else + zfs_unbind_clevis_label "${dataset}" "${label}" + fi + echo >&2 'ok' +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-unbind.1.adoc b/src/zfs/clevis-zfs-unbind.1.adoc new file mode 100644 index 00000000..77d3c9a0 --- /dev/null +++ b/src/zfs/clevis-zfs-unbind.1.adoc @@ -0,0 +1,47 @@ +CLEVIS-ZFS-UNBIND(1) +==================== +:doctype: manpage + + +== NAME + +clevis-zfs-unbind - Unbinds a label from a ZFS encryption root + +== SYNOPSIS + +*clevis zfs unbind* [-f] [-k KEY] -d DATASET [-a | -l LABEL] + +== OVERVIEW + +The *clevis zfs unbind* command removes a Clevis binding from a ZFS +encryption root. Without *-a* and *-l*, the binding with the _default_ label is +removed. For example: + + $ clevis zfs unbind -d rpool + +The existing passphrase or key is checked first, unless *-f* is given. + +== OPTIONS + +* *-d* _DATASET_ : + The bound ZFS encryption root + +* *-l* _LABEL_ : + The label of the binding to remove + +* *-a* : + Remove all bindings, cannot be combined with *-l* + +* *-f* : + Do not ask for the existing passphrase or key + +* *-k* _KEY_ : + Non-interactively read the ZFS passphrase or key from KEY file + +* *-k* - : + Non-interactively read the ZFS passphrase or key from standard input + +== SEE ALSO + +link:clevis-zfs-bind.1.adoc[*clevis-zfs-bind*(1)], +link:clevis-zfs-list.1.adoc[*clevis-zfs-list*(1)] diff --git a/src/zfs/clevis-zfs-unlock b/src/zfs/clevis-zfs-unlock new file mode 100755 index 00000000..7bc12cba --- /dev/null +++ b/src/zfs/clevis-zfs-unlock @@ -0,0 +1,63 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Unlock a ZFS dataset using the saved Clevis data" + +usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs unlock [-t] [-l LABEL] -d DATASET + + $SUMMARY: + + -t Test the Clevis configuration without unlocking + -d DATASET The ZFS dataset to unlock + -l LABEL Use only this label to unlock (defaults to trying all labels) + + USAGE_END +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local test_only='' + local label='' + while getopts "hd:l:t" o; do + case "$o" in + h) usage; exit 0;; + d) dataset="$OPTARG";; + t) test_only='yes';; + l) label="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + if ! zfs_is_bound "${dataset}" "${label}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi + + local key + if ! key="$(zfs_recover_key "${dataset}" "${label}")"; then + echo >&2 "unable to unlock ${dataset} with Clevis" + exit 1 + fi + + if [[ -n "${test_only}" ]]; then + echo >&2 "${dataset} can be unlocked with Clevis" + exit 0 + fi + + zfs_load_key "${dataset}" <<< "${key}" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-unlock.1.adoc b/src/zfs/clevis-zfs-unlock.1.adoc new file mode 100644 index 00000000..c7f3112d --- /dev/null +++ b/src/zfs/clevis-zfs-unlock.1.adoc @@ -0,0 +1,38 @@ +CLEVIS-ZFS-UNLOCK(1) +==================== +:doctype: manpage + + +== NAME + +clevis-zfs-unlock - Unlocks a ZFS encryption root bound with a Clevis policy + +== SYNOPSIS + +*clevis zfs unlock* [-t] [-l LABEL] -d DATASET + +== OVERVIEW + +The *clevis zfs unlock* command loads the key of a ZFS encryption root using +its already provisioned Clevis policy. For example: + + $ clevis zfs unlock -d rpool + +The bindings are tried one after another until one of them unlocks the +encryption root. + +== OPTIONS + +* *-d* _DATASET_ : + The ZFS encryption root to unlock + +* *-l* _LABEL_ : + Use only the binding with this label + +* *-t* : + Test the bindings without loading the key + +== SEE ALSO + +link:clevis-zfs-bind.1.adoc[*clevis-zfs-bind*(1)], +link:clevis-zfs-unlockers.7.adoc[*clevis-zfs-unlockers*(7)] diff --git a/src/zfs/clevis-zfs-unlockers.7.adoc b/src/zfs/clevis-zfs-unlockers.7.adoc new file mode 100644 index 00000000..2f1dde0b --- /dev/null +++ b/src/zfs/clevis-zfs-unlockers.7.adoc @@ -0,0 +1,73 @@ +CLEVIS-ZFS-UNLOCKERS(7) +======================= +:doctype: manpage + +== NAME + +clevis-zfs-unlockers - Overview of clevis zfs unlockers + +== OVERVIEW + +Clevis provides unlockers for ZFS encryption roots: + + * clevis-zfs-unlock - Unlocks manually using the command line. + * dracut - Unlocks the root file system automatically during early boot. + * initramfs-tools - Unlocks the root file system automatically during early boot. + * systemd - Unlocks automatically during late boot. + +Once an encryption root is bound using *clevis zfs bind*, it can be unlocked +using any of the above unlockers without using a password. The automatic +unlockers only handle encryption roots with _keylocation=prompt_ and answer the +regular password prompt of OpenZFS, so the password can still be typed in. + +== MANUAL UNLOCKING + +You can unlock an encryption root manually using the following command: + + $ sudo clevis zfs unlock -d tank + +For more information, see link:clevis-zfs-unlock.1.adoc[*clevis-zfs-unlock*(1)]. + +== EARLY BOOT UNLOCKING WITH DRACUT + +The OpenZFS dracut module has to be installed. If Clevis integration does not +already ship in your initramfs, you may need to rebuild your initramfs with +this command: + + $ sudo dracut -f + +With systemd in the initramfs, the Clevis password agent answers the password +prompt of OpenZFS. Without systemd, Clevis loads the key when OpenZFS asks for +it on the console or through plymouth. + +Dracut brings up your network for tang bindings when the initramfs is built in +host-only mode. Otherwise, add rd.neednet=1 to the kernel command line, or +specify custom network parameters as described in the dracut documentation. + +== EARLY BOOT UNLOCKING WITH INITRAMFS-TOOLS + +OpenZFS 2.2 or newer is required. Clevis installs the key loading hook +*/etc/zfs/initramfs-tools-load-key.d/clevis*, which the OpenZFS initramfs +script runs before asking for the password. You may need to rebuild your +initramfs with this command: + + $ sudo update-initramfs -u + +Clevis brings up the network for tang bindings. Use the ip= kernel parameter to +select the network interface or to configure it statically. + +To disable the unlocking, remove the key loading hook and rebuild the +initramfs. + +== LATE BOOT UNLOCKING + +The encryption roots loaded by the units of *zfs-mount-generator*(8) ask for +their password through systemd. You can let Clevis answer these prompts by +executing the following command: + + $ sudo systemctl enable clevis-luks-askpass.path + +== SEE ALSO + +link:clevis-zfs-unlock.1.adoc[*clevis-zfs-unlock*(1)], +link:clevis-zfs-bind.1.adoc[*clevis-zfs-bind*(1)] diff --git a/src/zfs/dracut/clevis-zfs-unlocker.in b/src/zfs/dracut/clevis-zfs-unlocker.in new file mode 100755 index 00000000..b0e1a1cc --- /dev/null +++ b/src/zfs/dracut/clevis-zfs-unlocker.in @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +. /lib/dracut-lib.sh + +. clevis-zfs-common-functions +. clevis-luks-common-functions + +# The mount-zfs.sh pipes the plymouth prompt into zfs load-key and waits for +# both, so end the prompt once clevis has loaded the key +end_plymouth_prompt() { + local root="$1" + local cmdline pid args i prompt + + for cmdline in /proc/[0-9]*/cmdline; do + mapfile -d '' -t args < "${cmdline}" 2>/dev/null || continue + if [ "${args[0]##*/}" != "plymouth" ] || [ "${args[1]}" != "ask-for-password" ]; then + continue + fi + + prompt= + for ((i = 2; i < ${#args[@]}; i++)); do + case "${args[i]}" in + --prompt) prompt="${args[i + 1]}";; + --prompt=*) prompt="${args[i]#--prompt=}";; + esac + done + + case "${prompt}" in + "Encrypted ZFS password for ${root}"|"Encrypted ZFS password for ${root}:"*) + pid="${cmdline#/proc/}" + kill "${pid%/cmdline}" 2>/dev/null + ;; + esac + done +} + +root="$(zfs_get_prompt_encryptionroot "$1")" || exit 1 + +# With systemd, tcsd.service provides the TCSD +pins=$(zfs_read_used_pins "${root}") +if [[ " $pins " == *" tpm1 "* ]] && [ ! -d /run/systemd/system ]; then + . /lib/dracut-clevis-lib.sh + do_configure_tpm1 +fi + +ret=1 +if key="$(zfs_recover_key "${root}")" && zfs_load_key "${root}" <<< "${key}"; then + info "ZFS: Unlocked ${root} with clevis" + end_plymouth_prompt "${root}" + ret=0 +fi + +exit "$ret" diff --git a/src/zfs/dracut/clevis-zfs-wrapper b/src/zfs/dracut/clevis-zfs-wrapper new file mode 100755 index 00000000..6ce29c13 --- /dev/null +++ b/src/zfs/dracut/clevis-zfs-wrapper @@ -0,0 +1,27 @@ +#!/bin/sh +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Try clevis first when mount-zfs.sh runs "zfs load-key DATASET" +if [ $# -eq 2 ] && [ "$1" = "load-key" ] \ + && /bin/clevis-zfs-unlocker "$2" < /dev/null; then + exit 0 +fi + +exec "$(readlink -f "$0").clevis-orig" "$@" diff --git a/src/zfs/dracut/clevis-zfs-wrapper-hook.sh b/src/zfs/dracut/clevis-zfs-wrapper-hook.sh new file mode 100755 index 00000000..df1e5332 --- /dev/null +++ b/src/zfs/dracut/clevis-zfs-wrapper-hook.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Replace zfs by the wrapper atomically, keeping the original hard linked +# as zfs.clevis-orig +zfs="$(command -v zfs)" && zfs="$(readlink -f "${zfs}")" \ + && [ ! -e "${zfs}.clevis-orig" ] \ + && cp /bin/clevis-zfs-wrapper "${zfs}.clevis-new" \ + && ln "${zfs}" "${zfs}.clevis-orig" \ + && mv -f "${zfs}.clevis-new" "${zfs}" +unset zfs diff --git a/src/zfs/dracut/meson.build b/src/zfs/dracut/meson.build new file mode 100644 index 00000000..060c162a --- /dev/null +++ b/src/zfs/dracut/meson.build @@ -0,0 +1,27 @@ +dracut = dependency('dracut', required: false) + +if dracut.found() + dracutdir = dracut.get_pkgconfig_variable('dracutmodulesdir') + '/50' + meson.project_name() + '-zfs' + + configure_file( + input: 'module-setup.sh.in', + output: 'module-setup.sh', + install_dir: dracutdir, + configuration: data, + ) + + configure_file( + input: 'clevis-zfs-unlocker.in', + output: 'clevis-zfs-unlocker', + install_dir: dracutdir, + configuration: data, + ) + + install_data( + 'clevis-zfs-wrapper', + 'clevis-zfs-wrapper-hook.sh', + install_dir: dracutdir, + ) +else + warning('Will not install dracut module clevis-zfs due to missing dependencies!') +endif diff --git a/src/zfs/dracut/module-setup.sh.in b/src/zfs/dracut/module-setup.sh.in new file mode 100755 index 00000000..3a1a83fd --- /dev/null +++ b/src/zfs/dracut/module-setup.sh.in @@ -0,0 +1,41 @@ +#!/bin/bash +# vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +check() { + require_binaries zfs zpool clevis-zfs-unlock || return 1 + return 0 +} + +depends() { + # The clevis module brings the pins and the password agent + echo zfs clevis + return 0 +} + +install() { + # Wraps zfs for mount-zfs.sh (mount 98), which has no agent protocol + # shellcheck disable=SC2154 # $moddir is a dracut variable + inst_hook mount 97 "$moddir"/clevis-zfs-wrapper-hook.sh + inst_script "$moddir"/clevis-zfs-wrapper /bin/clevis-zfs-wrapper + inst_script "$moddir"/clevis-zfs-unlocker /bin/clevis-zfs-unlocker + + inst_multiple clevis-zfs-common-functions +} diff --git a/src/zfs/initramfs-tools/clevis-zfs-hook.in b/src/zfs/initramfs-tools/clevis-zfs-hook.in new file mode 100755 index 00000000..ffd60cb8 --- /dev/null +++ b/src/zfs/initramfs-tools/clevis-zfs-hook.in @@ -0,0 +1,40 @@ +#!/bin/sh +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +PREREQ="" +prereqs() { + echo "$PREREQ" +} + +case "$1" in + prereqs) + prereqs + exit 0 + ;; +esac + +. @initramfstoolsdir@/hook-functions + +# Pins come from the clevis hook, the key loading hook from the zfs one +[ -e @initramfstoolsdir@/scripts/zfs ] || exit 0 +[ -x @bindir@/clevis-zfs-unlock ] || exit 0 + +copy_exec @bindir@/clevis-zfs-common-functions || exit 1 +copy_exec @libexecdir@/clevis-zfs-load-key || exit 1 diff --git a/src/zfs/initramfs-tools/clevis-zfs-load-key.in b/src/zfs/initramfs-tools/clevis-zfs-load-key.in new file mode 100755 index 00000000..7752ddf4 --- /dev/null +++ b/src/zfs/initramfs-tools/clevis-zfs-load-key.in @@ -0,0 +1,38 @@ +#!/bin/bash +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +. /scripts/functions +. /scripts/clevis-functions + +. clevis-zfs-common-functions +. clevis-luks-common-functions + +root="$(zfs_get_prompt_encryptionroot "$1")" || exit 1 + +pins=$(zfs_read_used_pins "${root}") +if [[ " $pins " == *" tang "* ]]; then + do_configure_networking +fi +if [[ " $pins " == *" tpm1 "* ]]; then + do_configure_tpm1 +fi + +key="$(zfs_recover_key "${root}")" && zfs_load_key "${root}" <<< "${key}" || exit 1 +log_success_msg "clevis: Unlocked ${root}" diff --git a/src/zfs/initramfs-tools/load-key.in b/src/zfs/initramfs-tools/load-key.in new file mode 100644 index 00000000..2bc2ead2 --- /dev/null +++ b/src/zfs/initramfs-tools/load-key.in @@ -0,0 +1,24 @@ +#!/bin/sh +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Sourced by the OpenZFS initramfs script before prompting for the key, the +# helper is missing without the clevis ZFS support +[ -x @libexecdir@/clevis-zfs-load-key ] || return 1 +@libexecdir@/clevis-zfs-load-key "${ENCRYPTIONROOT}" diff --git a/src/zfs/initramfs-tools/meson.build b/src/zfs/initramfs-tools/meson.build new file mode 100644 index 00000000..99319d94 --- /dev/null +++ b/src/zfs/initramfs-tools/meson.build @@ -0,0 +1,22 @@ +if initramfs_tools.found() + configure_file( + input: 'clevis-zfs-hook.in', + output: 'clevis-zfs', + install_dir: initramfs_hooks_dir, + configuration: initramfs_data, + ) + + configure_file( + input: 'clevis-zfs-load-key.in', + output: 'clevis-zfs-load-key', + install_dir: libexecdir, + configuration: initramfs_data, + ) + + configure_file( + input: 'load-key.in', + output: 'clevis', + install_dir: join_paths(sysconfdir, 'zfs', 'initramfs-tools-load-key.d'), + configuration: initramfs_data, + ) +endif diff --git a/src/zfs/meson.build b/src/zfs/meson.build new file mode 100644 index 00000000..a01168e7 --- /dev/null +++ b/src/zfs/meson.build @@ -0,0 +1,18 @@ +bins += join_paths(meson.current_source_dir(), 'clevis-zfs-common-functions') +bins += join_paths(meson.current_source_dir(), 'clevis-zfs-bind') +bins += join_paths(meson.current_source_dir(), 'clevis-zfs-list') +bins += join_paths(meson.current_source_dir(), 'clevis-zfs-unbind') +bins += join_paths(meson.current_source_dir(), 'clevis-zfs-unlock') + +mans += join_paths(meson.current_source_dir(), 'clevis-zfs-bind.1') +mans += join_paths(meson.current_source_dir(), 'clevis-zfs-list.1') +mans += join_paths(meson.current_source_dir(), 'clevis-zfs-unbind.1') +mans += join_paths(meson.current_source_dir(), 'clevis-zfs-unlock.1') +mans += join_paths(meson.current_source_dir(), 'clevis-zfs-unlockers.7') + +subdir('dracut') +subdir('initramfs-tools') + +if not meson.is_cross_build() + subdir('tests') +endif diff --git a/src/zfs/tests/bind-chunked-zfs b/src/zfs/tests/bind-chunked-zfs new file mode 100755 index 00000000..07edefb9 --- /dev/null +++ b/src/zfs/tests/bind-chunked-zfs @@ -0,0 +1,55 @@ +#!/bin/bash -ex +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +TEST=$(basename "${0}") +. zfs-common-test-functions + +zfs_check_requirements + +on_exit() { + zfs_destroy_pool "${POOL}" + [ -d "${TMP}" ] && rm -rf "${TMP}" +} + +trap 'on_exit' EXIT + +TMP="$(mktemp -d)" +POOL="$(zfs_new_pool "${TMP}")" +DS="${POOL}/enc" +P="${ZFS_TEST_PASSPHRASE}" +zfs_new_encryptionroot "${DS}" + +# Many pins make the binding exceed the ZFS property value limit +cfg=$(printf '{"t":1,"pins":{"null":[%s{}]}}' "$(printf '{},%.0s' $(seq 1 60))") +clevis zfs bind -k - -d "${DS}" -l big sss "${cfg}" <<< "${P}" \ + || error "${TEST}: Binding failed" + +chunks=$(zfs_clevis_properties "${DS}" | grep -c '^latchset\.clevis\.label:big:' || :) +[ "${chunks}" -gt 1 ] || error "${TEST}: The binding is not split into chunks" +[ "$(clevis zfs list -d "${DS}")" = "big:$((chunks - 1))" ] \ + || error "${TEST}: The chunked label is not listed" + +zfs unload-key "${DS}" || error "${TEST}: Unable to unload the key" +clevis zfs unlock -d "${DS}" || error "${TEST}: Unlocking failed" + +clevis zfs unbind -k - -d "${DS}" -l big <<< "${P}" \ + || error "${TEST}: Unbinding failed" +[ -z "$(zfs_clevis_properties "${DS}")" ] \ + || error "${TEST}: Clevis properties are left after unbinding" diff --git a/src/zfs/tests/bind-raw-zfs b/src/zfs/tests/bind-raw-zfs new file mode 100755 index 00000000..0af07c18 --- /dev/null +++ b/src/zfs/tests/bind-raw-zfs @@ -0,0 +1,44 @@ +#!/bin/bash -ex +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +TEST=$(basename "${0}") +. zfs-common-test-functions + +zfs_check_requirements + +on_exit() { + zfs_destroy_pool "${POOL}" + [ -d "${TMP}" ] && rm -rf "${TMP}" +} + +trap 'on_exit' EXIT + +TMP="$(mktemp -d)" +POOL="$(zfs_new_pool "${TMP}")" +DS="${POOL}/enc" +head -c 32 /dev/urandom > "${TMP}/key" +zfs create -o encryption=on -o keyformat=raw -o keylocation="file://${TMP}/key" \ + -o mountpoint=none "${DS}" || error "${TEST}: Unable to create ${DS}" + +if out=$(clevis zfs bind -k "${TMP}/key" -d "${DS}" null '{}' 2>&1); then + error "${TEST}: Binding a raw key succeeded" +fi +[[ "${out}" == *"raw keyformat is not supported"* ]] \ + || error "${TEST}: Unexpected error: ${out}" diff --git a/src/zfs/tests/bind-unbind-zfs b/src/zfs/tests/bind-unbind-zfs new file mode 100755 index 00000000..f33a6922 --- /dev/null +++ b/src/zfs/tests/bind-unbind-zfs @@ -0,0 +1,75 @@ +#!/bin/bash -ex +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +TEST=$(basename "${0}") +. zfs-common-test-functions + +zfs_check_requirements + +on_exit() { + zfs_destroy_pool "${POOL}" + [ -d "${TMP}" ] && rm -rf "${TMP}" +} + +trap 'on_exit' EXIT + +TMP="$(mktemp -d)" +POOL="$(zfs_new_pool "${TMP}")" +DS="${POOL}/enc" +P="${ZFS_TEST_PASSPHRASE}" +zfs_new_encryptionroot "${DS}" + +clevis zfs bind -k - -d "${DS}" null '{}' <<< "${P}" \ + || error "${TEST}: Binding the default label failed" +[ "$(clevis zfs list -d "${DS}")" = "default" ] \ + || error "${TEST}: The default label is not listed" + +if clevis zfs bind -k - -d "${DS}" null '{}' <<< "${P}"; then + error "${TEST}: Rebinding without -f succeeded" +fi +clevis zfs bind -f -k - -d "${DS}" null '{}' <<< "${P}" \ + || error "${TEST}: Rebinding with -f failed" + +clevis zfs bind -k - -d "${DS}" -l my.label-2 null '{}' <<< "${P}" \ + || error "${TEST}: Binding a label with dot and hyphen failed" +if clevis zfs bind -k - -d "${DS}" -l bad:label null '{}' <<< "${P}"; then + error "${TEST}: Binding an invalid label succeeded" +fi +if clevis zfs bind -k - -d "${DS}" -l other null '{}' <<< "wrong-passphrase"; then + error "${TEST}: Binding with a wrong passphrase succeeded" +fi +if clevis zfs bind -k - -d "${POOL}" null '{}' <<< "${P}"; then + error "${TEST}: Binding a dataset that is not an encryption root succeeded" +fi +[ "$(clevis zfs list -d "${DS}")" = "default my.label-2" ] \ + || error "${TEST}: Unexpected labels listed" + +if clevis zfs unbind -k - -d "${DS}" -a -l default <<< "${P}"; then + error "${TEST}: Unbinding with both -a and -l succeeded" +fi +clevis zfs unbind -k - -d "${DS}" <<< "${P}" \ + || error "${TEST}: Unbinding the default label failed" +[ "$(clevis zfs list -d "${DS}")" = "my.label-2" ] \ + || error "${TEST}: The default label was not unbound" + +clevis zfs unbind -k - -d "${DS}" -a <<< "${P}" \ + || error "${TEST}: Unbinding all labels failed" +[ -z "$(zfs_clevis_properties "${DS}")" ] \ + || error "${TEST}: Clevis properties are left after unbinding" diff --git a/src/zfs/tests/meson.build b/src/zfs/tests/meson.build new file mode 100644 index 00000000..d383069b --- /dev/null +++ b/src/zfs/tests/meson.build @@ -0,0 +1,15 @@ +env = environment() +env.prepend('PATH', + join_paths(meson.source_root(), 'src'), + join_paths(meson.source_root(), 'src', 'pins', 'sss'), + join_paths(meson.source_root(), 'src', 'zfs'), + join_paths(meson.source_root(), 'src', 'zfs', 'tests'), + join_paths(meson.build_root(), 'src'), + join_paths(meson.build_root(), 'src', 'pins', 'sss'), + separator: ':' +) + +test('bind-unbind-zfs', find_program('bind-unbind-zfs'), env: env) +test('unlock-zfs', find_program('unlock-zfs'), env: env) +test('bind-chunked-zfs', find_program('bind-chunked-zfs'), env: env) +test('bind-raw-zfs', find_program('bind-raw-zfs'), env: env) diff --git a/src/zfs/tests/unlock-zfs b/src/zfs/tests/unlock-zfs new file mode 100755 index 00000000..bfa84c1c --- /dev/null +++ b/src/zfs/tests/unlock-zfs @@ -0,0 +1,52 @@ +#!/bin/bash -ex +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +TEST=$(basename "${0}") +. zfs-common-test-functions + +zfs_check_requirements + +on_exit() { + zfs_destroy_pool "${POOL}" + [ -d "${TMP}" ] && rm -rf "${TMP}" +} + +trap 'on_exit' EXIT + +TMP="$(mktemp -d)" +POOL="$(zfs_new_pool "${TMP}")" +DS="${POOL}/enc" +P="${ZFS_TEST_PASSPHRASE}" +zfs_new_encryptionroot "${DS}" + +clevis zfs bind -k - -d "${DS}" null '{}' <<< "${P}" \ + || error "${TEST}: Binding failed" +zfs unload-key "${DS}" || error "${TEST}: Unable to unload the key" + +clevis zfs unlock -t -d "${DS}" || error "${TEST}: Testing the binding failed" +[ "$(zfs_keystatus "${DS}")" = "unavailable" ] \ + || error "${TEST}: Testing the binding loaded the key" +if clevis zfs unlock -l missing -d "${DS}"; then + error "${TEST}: Unlocking with a missing label succeeded" +fi + +clevis zfs unlock -d "${DS}" || error "${TEST}: Unlocking failed" +[ "$(zfs_keystatus "${DS}")" = "available" ] \ + || error "${TEST}: Unlocking did not load the key" diff --git a/src/zfs/tests/zfs-common-test-functions b/src/zfs/tests/zfs-common-test-functions new file mode 100644 index 00000000..b35f5a6e --- /dev/null +++ b/src/zfs/tests/zfs-common-test-functions @@ -0,0 +1,68 @@ +#!/bin/bash +# +# Copyright (c) 2026 Oldřich Jedlička +# +# Author: Oldřich Jedlička +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +. tests-common-functions + +ZFS_TEST_PASSPHRASE="clevis-test-passphrase" + +# Skips unless the ZFS tools work with the loaded module, as root +zfs_check_requirements() { + if ! command -v zfs >/dev/null || ! command -v zpool >/dev/null; then + skip_test "${TEST}: ZFS tools are not installed" + fi + zfs version >/dev/null 2>&1 || skip_test "${TEST}: ZFS module is not loaded" + [ "$(id -u)" -eq 0 ] || skip_test "${TEST}: ZFS tests need root" +} + +# Creates a pool on a sparse file in the directory and prints its name +zfs_new_pool() { + local dir="${1}" + local pool="clevis-test-$$-${RANDOM}" + + truncate -s 128M "${dir}/${pool}.img" + zpool create -O mountpoint=none "${pool}" "${dir}/${pool}.img" >&2 \ + || error "${TEST}: Unable to create pool ${pool}" + echo "${pool}" +} + +zfs_destroy_pool() { + [ -n "${1}" ] && zpool destroy -f "${1}" 2>/dev/null + return 0 +} + +# Creates an unmounted encryption root with the test passphrase, passing the +# additional options to zfs create +zfs_new_encryptionroot() { + local dataset="${1}" + shift + + zfs create -o encryption=on -o keyformat=passphrase \ + -o keylocation=prompt -o mountpoint=none "$@" "${dataset}" \ + <<< "${ZFS_TEST_PASSPHRASE}" || error "${TEST}: Unable to create ${dataset}" +} + +zfs_keystatus() { + zfs get -H -o value keystatus "${1}" +} + +# Prints the local clevis properties of the dataset +zfs_clevis_properties() { + zfs get -H -o property -s local all "${1}" | grep '^latchset\.clevis' || : +}