From 7109269917de86c8c9fb08360d5d49338f051853 Mon Sep 17 00:00:00 2001 From: Vince van Oosten Date: Tue, 24 May 2022 21:40:54 +0200 Subject: [PATCH 01/11] zfs: implement ZFS integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind ZFS native encryption roots by storing the clevis encrypt output in user properties of the encryption root, split into chunks when it exceeds the property value size limit, and unlock them with it. Co-authored-by: Joel Low Co-authored-by: Oldřich Jedlička Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/meson.build | 1 + src/zfs/clevis-zfs-bind | 95 ++++++++++++++++ src/zfs/clevis-zfs-common-functions | 166 ++++++++++++++++++++++++++++ src/zfs/clevis-zfs-list | 47 ++++++++ src/zfs/clevis-zfs-unbind | 60 ++++++++++ src/zfs/clevis-zfs-unlock | 66 +++++++++++ src/zfs/meson.build | 5 + 7 files changed, 440 insertions(+) create mode 100755 src/zfs/clevis-zfs-bind create mode 100644 src/zfs/clevis-zfs-common-functions create mode 100755 src/zfs/clevis-zfs-list create mode 100755 src/zfs/clevis-zfs-unbind create mode 100755 src/zfs/clevis-zfs-unlock create mode 100644 src/zfs/meson.build 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..63d66cd6 --- /dev/null +++ b/src/zfs/clevis-zfs-bind @@ -0,0 +1,95 @@ +#!/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 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 + + -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 pin="${2}" + local cfg="${3}" + local key="${4}" + local overwrite="${5:-}" + + local existing_key clevis_data + + if [[ -z "${overwrite}" ]] && zfs_is_bound "${dataset}"; then + error "given dataset already has a Clevis binding, not overwriting: ${dataset}." + fi + + existing_key="$(load_key "${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}" <<<"${existing_key}" )" + echo >&2 'ok' + + [[ -n "${overwrite}" ]] && zfs_wipe_clevis_data "${dataset}" && echo >&2 'wiped old clevis data' + + zfs_bind_clevis_data "${dataset}" "${clevis_data}" +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local pin + local cfg + local key + local overwrite + while getopts ":hyfd:k:" o; do + case "$o" in + h) usage; exit 0;; + y) ;; + f) overwrite='yes' ;; + d) dataset="$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 ! pin=${@:$((OPTIND++)):1} || [ -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 + + if ! cfg=${@:$((OPTIND++)):1} || [ -z "$cfg" ]; then + error "did not specify a pin config!" + fi + + bind_zfs_dataset "${dataset}" "${pin}" "${cfg}" "${key}" "${overwrite}" + echo >&2 "dataset ${dataset} is successfully bound" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-common-functions b/src/zfs/clevis-zfs-common-functions new file mode 100644 index 00000000..3240b1e2 --- /dev/null +++ b/src/zfs/clevis-zfs-common-functions @@ -0,0 +1,166 @@ +#!/bin/bash + +# zfs user properties are limited to 8192 bytes +zfs_userprop_max_size=8000 + +zfs_userprop_prefix='latchset.clevis' +zfs_data_prop="${zfs_userprop_prefix}:data" +zfs_status_prop="${zfs_userprop_prefix}:status" + +# defaults to getting just the value of the given property and only when it is set directly on the dataset ("local") +zfs_get_prop() { + local dataset="${1}" + local prop="${2}" + shift 2 + zfs get "${prop}" "${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_is_bound() { + local dataset="${1}" + [[ "$(zfs_get_prop "${dataset}" "${zfs_status_prop}" )" == 'bound' ]] +} + +# 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_prop "${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_prop "${dataset}" 'keyformat' -snone)" == 'raw' ]]; then + error "raw keyformat is not supported: ${dataset}" + fi +} + + +# functions to deal with I/O to the user +load_key() { + 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_max_size}" +} + +zero_pad() { + local width="${1}"; shift + printf "%0${width}d " "${@}" +} + + +# functions to add/remove a Clevis binding +######################################### +zfs_bind_clevis_data() { + local dataset="${1}" + local clevis_data="${2}" + + echo >&2 -n 'binding new Clevis data... ' + clevis_chunks=( $(cut_into_chunks <<<"${clevis_data}") ) + last_index="$(( "${#clevis_chunks[@]}" - 1 ))" + width="${#last_index}" + + local chunk chunk_num + for i in $(seq 0 "${last_index}" ); do + chunk="${clevis_chunks[${i}]}" + # we add zero-padding so the props sort nicely when we want to combine + # them when we unlock + chunk_num="$(zero_pad "${i}" "${width}" )" + + # e.g. latchset.clevis:pin-01=chunk_data + zfs set "${zfs_data_prop}-${chunk_num}=${chunk}" "${dataset}" + done + echo >&2 'ok' + + # check if unlocking works + echo >&2 -n 'testing new Clevis data... ' + if ! (zfs_get_clevis_data "${dataset}" | clevis decrypt | zfs_test_key "${dataset}"); then + zfs_wipe_clevis_data "${dataset}" + error "could not unlock dataset with Clevis configuration: ${dataset}" + fi + echo >&2 'ok' + + zfs set "${zfs_status_prop}=bound" "${dataset}" +} + +zfs_get_data_props() { + local dataset="${1}" + + #property HAS to be set, otherwise the grep doesn't work + local outputs="${2:-property}" + + zfs_get_prop "${dataset}" 'all' -o "${outputs}" | grep -F "${zfs_data_prop}" | sort +} + +zfs_wipe_clevis_data() { + local dataset="${1}" + + for prop in $(zfs_get_prop "${dataset}" 'all' -o property | grep -F "${zfs_userprop_prefix}" ); do + zfs inherit "${prop}" "${dataset}" + done +} + +zfs_get_clevis_data() { + local dataset="${1:?}" + zfs_get_data_props "${dataset}" 'property,value' | sort | awk '{print $2}' | tr -d '\n' +} 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-unbind b/src/zfs/clevis-zfs-unbind new file mode 100755 index 00000000..0a6c8157 --- /dev/null +++ b/src/zfs/clevis-zfs-unbind @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Unbinds a ZFS dataset (remove clevis)" + +usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs unbind [-f] [-k KEY] -d DATASET [-a] + + $SUMMARY: + + -f Force unbinding dataset + -d DATASET The ZFS dataset on which to perform unbinding + + -k KEY Non-interactively read ZFS password from KEY file + -k - Non-interactively read ZFS password from standard input + + USAGE_END +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local key + while getopts ":hfd:k:" o; do + case "$o" in + d) dataset="$OPTARG";; + k) key="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + if ! zfs_is_bound "${dataset}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi + + echo >&2 "Loading existing key... " + local existing_key="$(load_key "${dataset}" "${key}")" + + if ! zfs_test_key "${dataset}" <<<"${existing_key}"; then + error "given key does not unlock ${dataset}" + fi + + echo >&2 -n 'Wiping Clevis data... ' + zfs_wipe_clevis_data "${dataset}" + echo >&2 'ok' +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-unlock b/src/zfs/clevis-zfs-unlock new file mode 100755 index 00000000..4930feb4 --- /dev/null +++ b/src/zfs/clevis-zfs-unlock @@ -0,0 +1,66 @@ +#!/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] -d DATASET + + $SUMMARY: + + -t Test the Clevis configuration without unlocking + -d DATASET The ZFS dataset to unlock + + USAGE_END +} + +main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local test_only='false' + while getopts ":d:t" o; do + case "$o" in + d) dataset="$OPTARG" ;; + t) test_only='true' ;; + *) error "unrecognized argument: -${OPTARG}" ;; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + if ! zfs_is_bound "${dataset}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi + + local clevis_data password + + echo >&2 -n "loading clevis data from ${dataset}... " + clevis_data="$(zfs_get_clevis_data "${dataset}")" + password="$(clevis decrypt <<<"${clevis_data}")" + echo >&2 'ok' + + if [[ "${test_only}" == 'true' ]]; then + echo >&2 -n "testing key for ${dataset}... " + if ! zfs_test_key "${dataset}" <<<"${password}"; then + error "testing key for ${dataset} failed" + fi + else + echo >&2 -n "unlocking ${dataset}... " + if ! zfs_load_key "${dataset}" <<<"${password}"; then + error "could not load key for ${dataset}" + fi + fi + echo >&2 'ok' +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-common-functions + main "${@}" +fi diff --git a/src/zfs/meson.build b/src/zfs/meson.build new file mode 100644 index 00000000..d7d3420e --- /dev/null +++ b/src/zfs/meson.build @@ -0,0 +1,5 @@ +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') From 4f89ac7e4e448d5a19301f03a27ebd707324e21b Mon Sep 17 00:00:00 2001 From: Vince van Oosten Date: Wed, 25 May 2022 21:48:09 +0200 Subject: [PATCH 02/11] zfs: add support for multiple unlock slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store each binding under its own label, "default" unless given, and keep the list of labels in a well-known property, so that different policies can unlock the same encryption root. The recovered key is verified before it is loaded, and -y is passed on to the pin. Co-authored-by: Joel Low Co-authored-by: Oldřich Jedlička Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/zfs/clevis-zfs-bind | 55 ++++-- src/zfs/clevis-zfs-common-functions | 282 +++++++++++++++++++++++----- src/zfs/clevis-zfs-unbind | 57 ++++-- src/zfs/clevis-zfs-unlock | 45 +++-- 4 files changed, 336 insertions(+), 103 deletions(-) diff --git a/src/zfs/clevis-zfs-bind b/src/zfs/clevis-zfs-bind index 63d66cd6..235c0de7 100755 --- a/src/zfs/clevis-zfs-bind +++ b/src/zfs/clevis-zfs-bind @@ -5,13 +5,14 @@ SUMMARY="Binds a ZFS dataset using the specified policy" usage() { cat >&2 <<-USAGE_END - Usage: clevis zfs bind [-f] [-y] [-k KEY] -d DATASET PIN CFG + 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 @@ -21,30 +22,35 @@ usage() { bind_zfs_dataset() { local dataset="${1}" - local pin="${2}" - local cfg="${3}" - local key="${4}" - local overwrite="${5:-}" + 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}"; then - error "given dataset already has a Clevis binding, not overwriting: ${dataset}." + 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="$(load_key "${dataset}" "${key}")" + 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}" <<<"${existing_key}" )" + clevis_data="$(clevis encrypt "${pin}" "${cfg}" ${yes:+"${yes}"} <<<"${existing_key}")" echo >&2 'ok' - [[ -n "${overwrite}" ]] && zfs_wipe_clevis_data "${dataset}" && echo >&2 'wiped old clevis data' + 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_data "${dataset}" "${clevis_data}" + zfs_bind_clevis_label "${dataset}" "${label}" "${clevis_data}" } main() { @@ -56,14 +62,17 @@ main() { local dataset local pin local cfg - local key - local overwrite - while getopts ":hyfd:k:" o; do + local label='default' + local key='' + local overwrite='' + local yes='' + while getopts ":hyfd:l:k:" o; do case "$o" in h) usage; exit 0;; - y) ;; - f) overwrite='yes' ;; + y) yes='-y';; + f) overwrite='yes';; d) dataset="$OPTARG";; + l) label="$OPTARG";; k) key="$OPTARG";; *) error "unrecognized argument: -${OPTARG}";; esac @@ -75,18 +84,24 @@ main() { check_valid_dataset "${dataset}" - if ! pin=${@:$((OPTIND++)):1} || [ -z "$pin" ]; then + 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 - if ! cfg=${@:$((OPTIND++)):1} || [ -z "$cfg" ]; then + cfg="${*:$((OPTIND++)):1}" + if [ -z "$cfg" ]; then error "did not specify a pin config!" fi - bind_zfs_dataset "${dataset}" "${pin}" "${cfg}" "${key}" "${overwrite}" - echo >&2 "dataset ${dataset} is successfully bound" + 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 diff --git a/src/zfs/clevis-zfs-common-functions b/src/zfs/clevis-zfs-common-functions index 3240b1e2..97cfffdc 100644 --- a/src/zfs/clevis-zfs-common-functions +++ b/src/zfs/clevis-zfs-common-functions @@ -1,18 +1,51 @@ #!/bin/bash # zfs user properties are limited to 8192 bytes -zfs_userprop_max_size=8000 +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' -zfs_data_prop="${zfs_userprop_prefix}:data" -zfs_status_prop="${zfs_userprop_prefix}:status" + +# 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_prop() { +zfs_get_property() { local dataset="${1}" - local prop="${2}" + local property="${2}" shift 2 - zfs get "${prop}" "${dataset}" -H -o value -slocal "${@}" + zfs get "${property}" "${dataset}" -H -o value -slocal "${@}" } zfs_load_key() { @@ -28,16 +61,110 @@ 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}" - [[ "$(zfs_get_prop "${dataset}" "${zfs_status_prop}" )" == 'bound' ]] + 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_prop "${dataset}" 'encryptionroot' -snone )" == "${dataset}" ]] + [[ "$(zfs_get_property "${dataset}" 'encryptionroot' -snone )" == "${dataset}" ]] } # does it even exist? @@ -58,14 +185,14 @@ check_valid_dataset() { fi # The key is passed through shell variables, which cannot hold raw bytes - if [[ "$(zfs_get_prop "${dataset}" 'keyformat' -snone)" == 'raw' ]]; then + 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 -load_key() { +read_passphrase() { local dataset="${1}" local key="${2?need keyinput argument}" @@ -100,67 +227,130 @@ error() { ######################################################################## cut_into_chunks() { - fold -w "${zfs_userprop_max_size}" + fold -w "${zfs_userprop_value_limit}" } -zero_pad() { - local width="${1}"; shift - printf "%0${width}d " "${@}" +# 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 ######################################### -zfs_bind_clevis_data() { + +# 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 clevis_data="${2}" + local label="${2}" + local clevis_data="${3}" + + local zfs_label_prop="${zfs_label_prefix}:${label}" echo >&2 -n 'binding new Clevis data... ' - clevis_chunks=( $(cut_into_chunks <<<"${clevis_data}") ) - last_index="$(( "${#clevis_chunks[@]}" - 1 ))" - width="${#last_index}" - - local chunk chunk_num - for i in $(seq 0 "${last_index}" ); do - chunk="${clevis_chunks[${i}]}" - # we add zero-padding so the props sort nicely when we want to combine - # them when we unlock - chunk_num="$(zero_pad "${i}" "${width}" )" - - # e.g. latchset.clevis:pin-01=chunk_data - zfs set "${zfs_data_prop}-${chunk_num}=${chunk}" "${dataset}" - done + # 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_get_clevis_data "${dataset}" | clevis decrypt | zfs_test_key "${dataset}"); then - zfs_wipe_clevis_data "${dataset}" - error "could not unlock dataset with Clevis configuration: ${dataset}" + 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 set "${zfs_status_prop}=bound" "${dataset}" + zfs_add_label "${dataset}" "${label}" } -zfs_get_data_props() { +zfs_unbind_clevis_label() { local dataset="${1}" + local label - #property HAS to be set, otherwise the grep doesn't work - local outputs="${2:-property}" + label="$(zfs_get_label "${dataset}" "${2}")" || return 0 - zfs_get_prop "${dataset}" 'all' -o "${outputs}" | grep -F "${zfs_data_prop}" | sort + zfs_remove_clevis_label_data "${dataset}" "${label}" + zfs_remove_label "${dataset}" "${label}" } -zfs_wipe_clevis_data() { +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 +} - for prop in $(zfs_get_prop "${dataset}" 'all' -o property | grep -F "${zfs_userprop_prefix}" ); do - zfs inherit "${prop}" "${dataset}" - done +# 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}" } -zfs_get_clevis_data() { - local dataset="${1:?}" - zfs_get_data_props "${dataset}" 'property,value' | sort | awk '{print $2}' | tr -d '\n' +# 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 } diff --git a/src/zfs/clevis-zfs-unbind b/src/zfs/clevis-zfs-unbind index 0a6c8157..1774c9d9 100755 --- a/src/zfs/clevis-zfs-unbind +++ b/src/zfs/clevis-zfs-unbind @@ -1,20 +1,24 @@ #!/bin/bash set -euo pipefail -SUMMARY="Unbinds a ZFS dataset (remove clevis)" +SUMMARY="Unbinds a label from a ZFS dataset" usage() { cat >&2 <<-USAGE_END - Usage: clevis zfs unbind [-f] [-k KEY] -d DATASET [-a] + 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 } @@ -24,13 +28,20 @@ main() { exit 0 fi - local dataset - local key - while getopts ":hfd:k:" o; do + 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";; - *) error "unrecognized argument: -${OPTARG}";; + l) label="$OPTARG";; + *) error "unrecognized argument: -${o}";; esac done @@ -38,19 +49,39 @@ main() { error "did not specify a dataset!" fi - if ! zfs_is_bound "${dataset}"; then - error "dataset is not bound with Clevis: ${dataset}" + 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 - echo >&2 "Loading existing key... " - local existing_key="$(load_key "${dataset}" "${key}")" + if [[ "${force_unbind}" != 'true' ]]; then + if ! zfs_is_bound "${dataset}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi - if ! zfs_test_key "${dataset}" <<<"${existing_key}"; then - error "given key does not unlock ${dataset}" + 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... ' - zfs_wipe_clevis_data "${dataset}" + 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' } diff --git a/src/zfs/clevis-zfs-unlock b/src/zfs/clevis-zfs-unlock index 4930feb4..7bc12cba 100755 --- a/src/zfs/clevis-zfs-unlock +++ b/src/zfs/clevis-zfs-unlock @@ -5,12 +5,13 @@ SUMMARY="Unlock a ZFS dataset using the saved Clevis data" usage() { cat >&2 <<-USAGE_END - Usage: clevis zfs unlock [-t] -d DATASET + 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 } @@ -22,12 +23,15 @@ main() { fi local dataset - local test_only='false' - while getopts ":d:t" o; do + local test_only='' + local label='' + while getopts "hd:l:t" o; do case "$o" in - d) dataset="$OPTARG" ;; - t) test_only='true' ;; - *) error "unrecognized argument: -${OPTARG}" ;; + h) usage; exit 0;; + d) dataset="$OPTARG";; + t) test_only='yes';; + l) label="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; esac done @@ -35,29 +39,22 @@ main() { error "did not specify a dataset!" fi - if ! zfs_is_bound "${dataset}"; then + if ! zfs_is_bound "${dataset}" "${label}"; then error "dataset is not bound with Clevis: ${dataset}" fi - local clevis_data password - - echo >&2 -n "loading clevis data from ${dataset}... " - clevis_data="$(zfs_get_clevis_data "${dataset}")" - password="$(clevis decrypt <<<"${clevis_data}")" - echo >&2 'ok' + local key + if ! key="$(zfs_recover_key "${dataset}" "${label}")"; then + echo >&2 "unable to unlock ${dataset} with Clevis" + exit 1 + fi - if [[ "${test_only}" == 'true' ]]; then - echo >&2 -n "testing key for ${dataset}... " - if ! zfs_test_key "${dataset}" <<<"${password}"; then - error "testing key for ${dataset} failed" - fi - else - echo >&2 -n "unlocking ${dataset}... " - if ! zfs_load_key "${dataset}" <<<"${password}"; then - error "could not load key for ${dataset}" - fi + if [[ -n "${test_only}" ]]; then + echo >&2 "${dataset} can be unlocked with Clevis" + exit 0 fi - echo >&2 'ok' + + zfs_load_key "${dataset}" <<< "${key}" } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then From 06e5ac01b15a151bfa88f88c3c3e91bc6689329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Sat, 26 Sep 2026 14:01:43 +0200 Subject: [PATCH 03/11] systemd: answer ZFS key prompts in clevis-luks-askpass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlock ZFS encryption roots bound by clevis zfs bind through the regular password prompts of the OpenZFS initramfs integration and of the zfs-mount-generator units, when the ZFS support is installed. Only locked encryption roots with keylocation=prompt are answered. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/luks/systemd/clevis-luks-askpass.in | 20 ++++++++++++++++++++ src/zfs/clevis-zfs-common-functions | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) 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/zfs/clevis-zfs-common-functions b/src/zfs/clevis-zfs-common-functions index 97cfffdc..d3fc9893 100644 --- a/src/zfs/clevis-zfs-common-functions +++ b/src/zfs/clevis-zfs-common-functions @@ -354,3 +354,24 @@ zfs_recover_key() { 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}" +} From cf1a839e3344d8ad652b010d868ba7a7a5d9d167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Mon, 28 Sep 2026 01:21:12 +0200 Subject: [PATCH 04/11] dracut: move TCSD setup into shared functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let other clevis unlockers reuse it. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/luks/dracut/clevis/clevis-lib.sh.in | 42 +++++++++++++++++++ .../dracut/clevis/clevis-password-unlocker.in | 19 +-------- src/luks/dracut/clevis/meson.build | 7 ++++ src/luks/dracut/clevis/module-setup.sh.in | 1 + 4 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 src/luks/dracut/clevis/clevis-lib.sh.in 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..de585bd9 --- /dev/null +++ b/src/luks/dracut/clevis/clevis-lib.sh.in @@ -0,0 +1,42 @@ +#!/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 + +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 +} 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 \ From 0e5020b1f9d6ead7e790f149c961bb16d8d80584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Sat, 26 Sep 2026 14:02:35 +0200 Subject: [PATCH 05/11] zfs: add dracut module unlocking the ZFS root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With systemd, the clevis module's password agent answers the key prompt of zfs-load-key.sh. The mount-zfs.sh has no agent protocol, so a mount hook right before it replaces zfs by a wrapper hooking its zfs load-key, which ends the matching plymouth prompt. With systemd, the wrapper leaves the TCSD to tcsd.service. Tang bindings on ZFS enable networking. The TCSD start is attempted once per boot for all unlockers, and it is stopped at cleanup also without the LUKS unlocker. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- .../dracut/clevis-pin-tang/module-setup.sh.in | 2 + src/luks/dracut/clevis/clevis-cleanup.in | 9 +-- src/luks/dracut/clevis/clevis-lib.sh.in | 15 +++- src/zfs/clevis-zfs-common-functions | 27 +++++++ src/zfs/dracut/clevis-zfs-unlocker.in | 71 +++++++++++++++++++ src/zfs/dracut/clevis-zfs-wrapper | 27 +++++++ src/zfs/dracut/clevis-zfs-wrapper-hook.sh | 28 ++++++++ src/zfs/dracut/meson.build | 27 +++++++ src/zfs/dracut/module-setup.sh.in | 41 +++++++++++ src/zfs/meson.build | 2 + 10 files changed, 242 insertions(+), 7 deletions(-) create mode 100755 src/zfs/dracut/clevis-zfs-unlocker.in create mode 100755 src/zfs/dracut/clevis-zfs-wrapper create mode 100755 src/zfs/dracut/clevis-zfs-wrapper-hook.sh create mode 100644 src/zfs/dracut/meson.build create mode 100755 src/zfs/dracut/module-setup.sh.in 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 index de585bd9..57b3c981 100644 --- a/src/luks/dracut/clevis/clevis-lib.sh.in +++ b/src/luks/dracut/clevis/clevis-lib.sh.in @@ -23,20 +23,29 @@ command -v getarg > /dev/null || . /lib/dracut-lib.sh -do_configure_tpm1() { +# 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" - if ! tcsd_output=$(TCSD_NO_PRIVILEGE_DROP=0 start_tcsd 2>&1); then + # 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/zfs/clevis-zfs-common-functions b/src/zfs/clevis-zfs-common-functions index d3fc9893..5a66b014 100644 --- a/src/zfs/clevis-zfs-common-functions +++ b/src/zfs/clevis-zfs-common-functions @@ -375,3 +375,30 @@ zfs_recover_prompt_key() { 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/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/meson.build b/src/zfs/meson.build index d7d3420e..aacf726a 100644 --- a/src/zfs/meson.build +++ b/src/zfs/meson.build @@ -3,3 +3,5 @@ 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') + +subdir('dracut') From 049852fb9fe0fc316723b6fd7fea1c3f4f1be82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Sun, 27 Sep 2026 01:26:18 +0200 Subject: [PATCH 06/11] initramfs: move networking and TCSD setup into shared functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let other clevis unlockers reuse them. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- .../scripts/clevis-functions.in | 153 ++++++++++++++++++ .../scripts/local-top/clevis.in | 128 +-------------- src/initramfs-tools/scripts/meson.build | 7 + 3 files changed, 161 insertions(+), 127 deletions(-) create mode 100644 src/initramfs-tools/scripts/clevis-functions.in diff --git a/src/initramfs-tools/scripts/clevis-functions.in b/src/initramfs-tools/scripts/clevis-functions.in new file mode 100644 index 00000000..02a6c322 --- /dev/null +++ b/src/initramfs-tools/scripts/clevis-functions.in @@ -0,0 +1,153 @@ +#!/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="$(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 +} diff --git a/src/initramfs-tools/scripts/local-top/clevis.in b/src/initramfs-tools/scripts/local-top/clevis.in index 8143544b..5cef81b6 100755 --- a/src/initramfs-tools/scripts/local-top/clevis.in +++ b/src/initramfs-tools/scripts/local-top/clevis.in @@ -175,135 +175,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, +) From 8c490a88e86277fcb08d8c2772bc8943c6489360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Sun, 27 Sep 2026 01:28:18 +0200 Subject: [PATCH 07/11] zfs: add initramfs-tools support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load the key through the initramfs-tools-load-key.d hook of OpenZFS 2.2, its standard way for third-party tools, before the password prompt. The pins come from the clevis hook, networking and TCSD from the shared functions, which now run once per boot for all unlockers, under flock. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/initramfs-tools/hooks/clevis.in | 2 + .../scripts/clevis-functions.in | 36 ++++++++++++++--- .../scripts/local-top/clevis.in | 8 +--- src/zfs/initramfs-tools/clevis-zfs-hook.in | 40 +++++++++++++++++++ .../initramfs-tools/clevis-zfs-load-key.in | 38 ++++++++++++++++++ src/zfs/initramfs-tools/load-key.in | 24 +++++++++++ src/zfs/initramfs-tools/meson.build | 22 ++++++++++ src/zfs/meson.build | 1 + 8 files changed, 159 insertions(+), 12 deletions(-) create mode 100755 src/zfs/initramfs-tools/clevis-zfs-hook.in create mode 100755 src/zfs/initramfs-tools/clevis-zfs-load-key.in create mode 100644 src/zfs/initramfs-tools/load-key.in create mode 100644 src/zfs/initramfs-tools/meson.build 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 index 02a6c322..cb26a732 100644 --- a/src/initramfs-tools/scripts/clevis-functions.in +++ b/src/initramfs-tools/scripts/clevis-functions.in @@ -97,7 +97,21 @@ wait_for_device() { return $ret } -do_configure_networking() { +# 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) @@ -105,7 +119,8 @@ do_configure_networking() { echo "" echo "clevis: Warning: multiple network interfaces available but no ip= parameter provided." fi - configure_networking + # 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 @@ -124,13 +139,21 @@ do_configure_networking() { done fi fi -} +) -do_configure_tpm1() { +# 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" @@ -141,7 +164,8 @@ do_configure_tpm1() { TCSD_NO_PRIVILEGE_DROP=0 [ -f /conf/conf.d/clevis ] && . /conf/conf.d/clevis - if ! tcsd_output=$(start_tcsd 2>&1); then + # 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 @@ -150,4 +174,4 @@ do_configure_tpm1() { 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 5cef81b6..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 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 index aacf726a..1c89e5b7 100644 --- a/src/zfs/meson.build +++ b/src/zfs/meson.build @@ -5,3 +5,4 @@ bins += join_paths(meson.current_source_dir(), 'clevis-zfs-unbind') bins += join_paths(meson.current_source_dir(), 'clevis-zfs-unlock') subdir('dracut') +subdir('initramfs-tools') From cfe82099ce1532226ab1db5bb067dc502271a9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Sun, 27 Sep 2026 16:43:08 +0200 Subject: [PATCH 08/11] zfs: add documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the ZFS commands and unlockers in man pages and in the README, the same way as the LUKS ones. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- README.md | 78 ++++++++++++++++++++++++++++- src/clevis.1.adoc | 13 +++++ src/zfs/clevis-zfs-bind.1.adoc | 75 +++++++++++++++++++++++++++ src/zfs/clevis-zfs-list.1.adoc | 31 ++++++++++++ src/zfs/clevis-zfs-unbind.1.adoc | 47 +++++++++++++++++ src/zfs/clevis-zfs-unlock.1.adoc | 38 ++++++++++++++ src/zfs/clevis-zfs-unlockers.7.adoc | 73 +++++++++++++++++++++++++++ src/zfs/meson.build | 6 +++ 8 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/zfs/clevis-zfs-bind.1.adoc create mode 100644 src/zfs/clevis-zfs-list.1.adoc create mode 100644 src/zfs/clevis-zfs-unbind.1.adoc create mode 100644 src/zfs/clevis-zfs-unlock.1.adoc create mode 100644 src/zfs/clevis-zfs-unlockers.7.adoc 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/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-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.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.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/meson.build b/src/zfs/meson.build index 1c89e5b7..17a788ce 100644 --- a/src/zfs/meson.build +++ b/src/zfs/meson.build @@ -4,5 +4,11 @@ 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') From ff7c3b5faf400c4371eb43e61226003f35993fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Mon, 28 Sep 2026 17:55:50 +0200 Subject: [PATCH 09/11] zfs: add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test the ZFS commands on an encryption root in a pool on a file. They need root and the ZFS tools with the loaded module, and are skipped otherwise. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/zfs/meson.build | 4 ++ src/zfs/tests/bind-chunked-zfs | 55 ++++++++++++++++++ src/zfs/tests/bind-raw-zfs | 44 +++++++++++++++ src/zfs/tests/bind-unbind-zfs | 75 +++++++++++++++++++++++++ src/zfs/tests/meson.build | 15 +++++ src/zfs/tests/unlock-zfs | 52 +++++++++++++++++ src/zfs/tests/zfs-common-test-functions | 68 ++++++++++++++++++++++ 7 files changed, 313 insertions(+) create mode 100755 src/zfs/tests/bind-chunked-zfs create mode 100755 src/zfs/tests/bind-raw-zfs create mode 100755 src/zfs/tests/bind-unbind-zfs create mode 100644 src/zfs/tests/meson.build create mode 100755 src/zfs/tests/unlock-zfs create mode 100644 src/zfs/tests/zfs-common-test-functions diff --git a/src/zfs/meson.build b/src/zfs/meson.build index 17a788ce..a01168e7 100644 --- a/src/zfs/meson.build +++ b/src/zfs/meson.build @@ -12,3 +12,7 @@ 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' || : +} From c0a9720d54e5dfaf6f688a361ad6dc96c5d37bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Mon, 28 Sep 2026 21:01:28 +0200 Subject: [PATCH 10/11] initramfs: fix the NFS boot check in the shared functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The init script sets BOOT from boot=, never boot, so the networking was configured again on NFS boot. Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/initramfs-tools/scripts/clevis-functions.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/initramfs-tools/scripts/clevis-functions.in b/src/initramfs-tools/scripts/clevis-functions.in index cb26a732..71124760 100644 --- a/src/initramfs-tools/scripts/clevis-functions.in +++ b/src/initramfs-tools/scripts/clevis-functions.in @@ -113,7 +113,7 @@ do_configure_networking() ( done # Make sure networking is set up: if booting via nfs, it already is - if [ "$boot" != nfs ] && wait_for_device; then + 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 "" From 6dbfa4932d171b8ee6bf16016667987105466b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Old=C5=99ich=20Jedli=C4=8Dka?= Date: Mon, 28 Sep 2026 21:01:28 +0200 Subject: [PATCH 11/11] initramfs: fix shellcheck warnings in the shared functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 (1M context) Signed-off-by: Oldřich Jedlička --- src/initramfs-tools/scripts/clevis-functions.in | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/initramfs-tools/scripts/clevis-functions.in b/src/initramfs-tools/scripts/clevis-functions.in index 71124760..881f5230 100644 --- a/src/initramfs-tools/scripts/clevis-functions.in +++ b/src/initramfs-tools/scripts/clevis-functions.in @@ -62,7 +62,8 @@ clevis_all_netbootable_devices() { } get_specified_device() { - local dev="$(echo $IP | awk -F: '{ print $6 }')" + local dev + dev="$(echo $IP | awk -F: '{ print $6 }')" [ -z "$dev" ] || echo $dev } @@ -126,6 +127,7 @@ do_configure_networking() ( 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