diff --git a/dracut/60clevis-zfs/clevis-zfs-hook.sh b/dracut/60clevis-zfs/clevis-zfs-hook.sh new file mode 100755 index 00000000..ab3b0f5f --- /dev/null +++ b/dracut/60clevis-zfs/clevis-zfs-hook.sh @@ -0,0 +1,53 @@ +#!/bin/bash + + +# import the libs now that we know the pool imported +[ -f /lib/dracut-lib.sh ] && dracutlib=/lib/dracut-lib.sh +[ -f /usr/lib/dracut/modules.d/99base/dracut-lib.sh ] && dracutlib=/usr/lib/dracut/modules.d/99base/dracut-lib.sh +# shellcheck source=./lib-zfs.sh.in +. "$dracutlib" + +# load the kernel command line vars +[ -z "$root" ] && root="$(getarg root=)" +# If root is not ZFS= or zfs: or rootfstype is not zfs then we are not supposed to handle it. +[ "${root##zfs:}" = "${root}" ] && [ "${root##ZFS=}" = "${root}" ] && [ "$rootfstype" != "zfs" ] && exit 0 + +# There is a race between the zpool import and the pre-mount hooks, so we wait for a pool to be imported +while true; do + zpool list -H | grep -q -v '^$' && break + [ "$(systemctl is-failed zfs-import-cache.service)" = 'failed' ] && exit 1 + [ "$(systemctl is-failed zfs-import-scan.service)" = 'failed' ] && exit 1 + sleep 0.1s +done + +# run this after import as zfs-import-cache/scan service is confirmed good +# we do not overwrite the ${root} variable, but create a new one, BOOTFS, to hold the dataset +if [ "${root}" = "zfs:AUTO" ] ; then + BOOTFS="$(zpool list -H -o bootfs | awk '$1 != "-" {print; exit}')" +else + BOOTFS="${root##zfs:}" + BOOTFS="${BOOTFS##ZFS=}" +fi + +# if pool encryption is active and the zfs command understands '-o encryption' +if [ "$(zpool list -H -o feature@encryption $(echo "${BOOTFS}" | awk -F\/ '{print $1}'))" = 'active' ]; then + # if the root dataset has encryption enabled + ENCRYPTIONROOT=$(zfs get -H -o value encryptionroot "${BOOTFS}") + # where the key is stored (in a file or loaded via prompt) + KEYLOCATION=$(zfs get -H -o value keylocation "${ENCRYPTIONROOT}") + if ! [ "${ENCRYPTIONROOT}" = "-" ]; then + KEYSTATUS="$(zfs get -H -o value keystatus "${ENCRYPTIONROOT}")" + # continue only if the key needs to be loaded + [ "$KEYSTATUS" = "unavailable" ] || exit 0 + # decrypt them + TRY_COUNT=5 + while [ $TRY_COUNT -gt 0 ]; do + echo >&2 "Attempting to unlock with clevis-zfs-unlock; ${TRY_COUNT} attempts left..." + clevis-zfs-unlock -d "${ENCRYPTIONROOT}" && break + TRY_COUNT=$((TRY_COUNT - 1)) + done + fi +fi + + + diff --git a/dracut/60clevis-zfs/module-setup.sh b/dracut/60clevis-zfs/module-setup.sh new file mode 100755 index 00000000..4212c4ea --- /dev/null +++ b/dracut/60clevis-zfs/module-setup.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: +# +# Copyright (c) 2016 Red Hat, Inc. +# Author: Nathaniel McCallum +# +# 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 . +# + +depends() { + # do we have a hard dependency on systemd? + echo zfs systemd + return 255 +} + +install() { + inst_multiple \ + /etc/services \ + grep sed cut \ + clevis-decrypt \ + clevis-zfs-common \ + clevis-zfs-unlock \ + clevis-zfs-list \ + clevis \ + mktemp \ + jose + + inst_hook pre-mount 90 "${moddir}/clevis-zfs-hook.sh" + + dracut_need_initqueue +} diff --git a/src/initramfs-tools/hooks/clevis-zfs.in b/src/initramfs-tools/hooks/clevis-zfs.in new file mode 100755 index 00000000..9327cee5 --- /dev/null +++ b/src/initramfs-tools/hooks/clevis-zfs.in @@ -0,0 +1,96 @@ +#!/bin/bash +# +# Copyright (c) 2017 Shawn Rose +# Copyright (c) 2024 Joel Low +# Author: Shawn Rose +# +# 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 + +die() { + code="$1" + msg="$2" + echo " (ERROR): $msg" >&2 + exit $1 +} + +find_binary() { + bin_name="$1" + resolved=$(command -v ${bin_name}) + [ -z "$resolved" ] && die 1 "Unable to find ${bin_name}" + echo "$resolved" +} + + +copy_exec @bindir@/clevis-decrypt-tang || die 1 "@bindir@/clevis-decrypt-tang not found" +copy_exec @bindir@/clevis-decrypt-sss || die 1 "@bindir@/clevis-decrypt-sss not found" +copy_exec @bindir@/clevis-decrypt-null || die 1 "@bindir@/clevis-decrypt-null not found" +copy_exec @bindir@/clevis-decrypt || die 1 "@bindir@/clevis-decrypt not found" +copy_exec @libexecdir@/clevis-zfs-common || die 1 "@libexecdir@/clevis-zfs-common not found" +copy_exec @bindir@/clevis-zfs-unlock || die 1 "@bindir@/clevis-zfs-unlock not found" +if [ -x @bindir@/clevis-decrypt-tpm2 ]; then + copy_exec @bindir@/clevis-decrypt-tpm2 || die 1 "@bindir@/clevis-decrypt-tpm2 not found" + tpm2_creatprimary_bin=$(find_binary "tpm2_createprimary") + tpm2_unseal_bin=$(find_binary "tpm2_unseal") + tpm2_load_bin=$(find_binary "tpm2_load") + tpm2_flushcontext=$(find_binary "tpm2_flushcontext") + copy_exec "${tpm2_creatprimary_bin}" || die 1 "Unable to copy ${tpm2_creatprimary_bin}" + copy_exec "${tpm2_unseal_bin}" || die 1 "Unable to copy ${tpm2_unseal_bin}" + copy_exec "${tpm2_load_bin}" || die 1 "Unable to copy ${tpm2_load_bin}" + copy_exec "${tpm2_flushcontext}" || die 1 "Unable to copy ${tpm2_flushcontext}" + for _LIBRARY in @libdir@/libtss2-tcti-device.so*; do + if [ -e "${_LIBRARY}" ]; then + copy_exec "${_LIBRARY}" || die 2 "Unable to copy ${_LIBRARY}" + fi + done + manual_add_modules tpm_crb + manual_add_modules tpm_tis +fi + + +jose_bin=$(find_binary "jose") +copy_exec "${jose_bin}" || die 2 "Unable to copy ${jose_bin}" + + +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") +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 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 + copy_exec "${so}" +done +for so in $(ldconfig -p | sed -nr 's/^\s*libnss_dns\.so\.[0-9]+\s.*=>\s*//p'); do + copy_exec "${so}" +done diff --git a/src/initramfs-tools/hooks/meson.build b/src/initramfs-tools/hooks/meson.build index 9ac06c77..4eb52d5f 100644 --- a/src/initramfs-tools/hooks/meson.build +++ b/src/initramfs-tools/hooks/meson.build @@ -1,6 +1,12 @@ configure_file( input: 'clevis.in', - output: 'clevis', + output: 'clevis-luks', + install_dir: initramfs_hooks_dir, + configuration: initramfs_data, +) +configure_file( + input: 'clevis-zfs.in', + output: 'clevis-zfs', install_dir: initramfs_hooks_dir, configuration: initramfs_data, ) diff --git a/src/initramfs-tools/meson.build b/src/initramfs-tools/meson.build index a4661c90..ce02209f 100644 --- a/src/initramfs-tools/meson.build +++ b/src/initramfs-tools/meson.build @@ -4,6 +4,7 @@ if initramfs_tools.found() initramfstools_dir = '/usr/share/initramfs-tools' initramfs_hooks_dir = '/usr/share/initramfs-tools/hooks' initramfs_scripts_dir = '/usr/share/initramfs-tools/scripts' + zfs_initramfs_load_key_scripts_dir = '/etc/zfs/initramfs-tools-load-key.d' initramfs_data = configuration_data() initramfs_data.merge_from(data) initramfs_data.set('initramfstoolsdir', initramfstools_dir) diff --git a/src/initramfs-tools/scripts/meson.build b/src/initramfs-tools/scripts/meson.build index 2e7678b2..7d9fecd2 100644 --- a/src/initramfs-tools/scripts/meson.build +++ b/src/initramfs-tools/scripts/meson.build @@ -1,2 +1,3 @@ subdir('local-top') subdir('local-bottom') +subdir('zfs-load-key') diff --git a/src/initramfs-tools/scripts/zfs-load-key/clevis-zfs.in b/src/initramfs-tools/scripts/zfs-load-key/clevis-zfs.in new file mode 100644 index 00000000..168229b7 --- /dev/null +++ b/src/initramfs-tools/scripts/zfs-load-key/clevis-zfs.in @@ -0,0 +1,22 @@ +#!/bin/bash +# +# Copyright (c) 2024 Joel Low +# +# Author: Joel Low +# +# 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 . +# +set -eu + +clevis zfs unlock -d "${ENCRYPTIONROOT}" diff --git a/src/initramfs-tools/scripts/zfs-load-key/meson.build b/src/initramfs-tools/scripts/zfs-load-key/meson.build new file mode 100644 index 00000000..e730df65 --- /dev/null +++ b/src/initramfs-tools/scripts/zfs-load-key/meson.build @@ -0,0 +1,6 @@ +configure_file( + input: 'clevis-zfs.in', + output: 'clevis-zfs', + install_dir: zfs_initramfs_load_key_scripts_dir, + configuration: initramfs_data, +) 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..9e999478 --- /dev/null +++ b/src/zfs/clevis-zfs-bind @@ -0,0 +1,111 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Binds a ZFS dataset using the specified policy" + +function usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs bind [-f] [-k KEY] -d DATASET PIN CFG + + $SUMMARY: + + -f Do not prompt when overwriting configuration + -d DATASET The ZFS dataset on which to perform binding + -l LABEL The label to use for this binding. Valid characters: letters, numbers and underscores + + -k KEY Non-interactively read ZFS password from KEY file + -k - Non-interactively read ZFS password from standard input + + USAGE_END +} + +function findexe() { + [ $# -eq 1 ] || return 1 + while read -r -d: path; do + [ -f "$path/$1" ] && [ -x "$path/$1" ] && echo "$path/$1" && return 0 + done <<< "$PATH:" + return 1 +} + +function bind_zfs_dataset() { + local dataset="${1}" + local label="${2}" + local pin="${3}" + local cfg="${4}" + local key="${5}" + local overwrite="${6:-}" + + local existing_key clevis_data + + if [[ -z "${overwrite}" ]] && zfs_is_bound "${dataset}" "${label}"; then + error "given label ${label} in dataset ${dataset} already has a Clevis binding, not overwriting." + fi + + existing_key="$(read_passphrase "${dataset}" "${key}")" + + if ! zfs_test_key "${dataset}" <<<"${existing_key}"; then + error "given key does not unlock ${dataset}" + fi + + echo >&2 -n 'creating new Clevis data... ' + clevis_data="$(clevis encrypt "${pin}" "${cfg}" <<<"${existing_key}")" + echo >&2 'ok' + + [[ -n "${overwrite}" ]] && zfs_unbind_clevis_label "${dataset}" "${label}" && echo >&2 'unbound old clevis data' + + zfs_bind_clevis_label "${dataset}" "${label}" "${clevis_data}" +} + +function main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local pin + local cfg + local label + local key='' + local overwrite='' + while getopts ":hfd:l:k:" o; do + case "$o" in + h) usage; exit 0;; + f) overwrite='yes';; + d) dataset="$OPTARG";; + l) label="$OPTARG";; + k) key="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + check_valid_dataset "${dataset}" + + if [ -z "${label:-}" ]; then + error "did not specify a label!" + fi + + pin="${*:$((OPTIND++)):1}" + if [ -z "$pin" ]; then + error "did not specify a pin!" + elif ! eval "findexe clevis-encrypt-${pin}" &>/dev/null; then + error "'$pin' is not a valid pin!" + fi + + cfg="${*:$((OPTIND++)):1}" + if [ -z "$cfg" ]; then + error "did not specify a pin config!" + fi + + bind_zfs_dataset "${dataset}" "${label}" "${pin}" "${cfg}" "${key}" "${overwrite}" + echo >&2 "label ${label} on dataset ${dataset} is succesfully bound" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . @libexecdir@/clevis-zfs-common + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-common b/src/zfs/clevis-zfs-common new file mode 100755 index 00000000..c77fabcf --- /dev/null +++ b/src/zfs/clevis-zfs-common @@ -0,0 +1,346 @@ +#!/bin/bash +set -euo pipefail + +# zfs user properties are limited to 8192 bytes +zfs_userprop_value_limit=8000 +zfs_userprop_name_limit=256 + +# all clevis userprops will be prefixed with "latchset.clevis:" as suggested by +# the User Properties section in zfsprops(8) +zfs_userprop_prefix='latchset.clevis' + +# This contains the space-separated list of labels that have been bound with clevis +zfs_labels_prop="${zfs_userprop_prefix}:labels" + +# The data for each label is saved into one or more zfs properties. +# E.g. the label 'mybinding' with data of 20k bytes and label 'other' with 4k bytes +# we suffix the label in the :labels property so we can easily find all numbered parts +# - latchset.clevis:labels = "mybinding:2 other" +# - latchset.clevis.label:mybinding-0 = "[clevis data first 8k]" +# - latchset.clevis.label:mybinding-1 = "[clevis data second 8k]" +# - latchset.clevis.label:mybinding-2 = "[clevis data final 4k]" +# - latchset.clevis.label:other = [clevis data 4k] +zfs_label_prefix="${zfs_userprop_prefix}.label" + + +# Interfacing functions with ZFS +################################ +function 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) ) +function 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") +function zfs_get_property() { + local dataset="${1}" + local property="${2}" + shift 2 + zfs get "${property}" "${dataset}" -H -o value -slocal "${@}" +} + +function zfs_load_key() { + local dataset="${1}" + local args=( "-L" "prompt" ) + if [[ -n "${2:-}" ]]; then + args+=( "-n" ) + fi + zfs load-key "${args[@]}" "${dataset}" >/dev/null +} + +function zfs_test_key() { + zfs_load_key "${1}" 'dry_run' +} + +function zfs_unload_key() { + local dataset="${1}" + zfs unload-key "${dataset}" >/dev/null +} + +# ZFS properties functions to deal with Clevis labels +############################################## + +# valid characters of clevis-zfs labels are: [0-9a-z_] +function is_valid_label() { + local label="${1}" + # This length limit is quite arbitrary; (as is the removal of [:.-] ) + # 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)" + return 1 + fi +} + +# get a list of all labels, including possible number suffixes +function zfs_get_labels() { + local dataset="${1}" + zfs_get_property "${dataset}" "${zfs_labels_prop}" +} + +# get a single label from the list of all labels, +# including possible number suffix +function zfs_get_label() { + local dataset="${1}" + local label="${2%%:*}" + zfs_get_labels "${dataset}" | tr ' ' '\n' | grep -E "^${label}(:|$)" +} + +# set the list of labels to the given value +function 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 +function 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 +function zfs_remove_label() { + local dataset="${1}" + local old_label="${2}" + local labels + read -ra labels <<< "$(zfs_get_labels "${dataset}")" + local new_labels=( "${labels[@]/${old_label}}" ) + 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) +function zfs_is_bound() { + local dataset="${1}" + local label="${2:-}" + + if [[ -z "${label}" ]] && [[ -n "$(zfs_get_labels "${dataset}")" ]]; then + return 0 + elif [[ -n "$(zfs_get_label "${dataset}" "${label}")" ]]; then + return 0 + else + return 1 + fi +} + +# we can only load keys on encryptionroots +# it does not make sense to add a binding elsewhere +function zfs_is_encryptionroot() { + local dataset="${1}" + [[ "$(zfs_get_property "${dataset}" 'encryptionroot' -snone )" == "${dataset}" ]] +} + +# does it even exist? +function zfs_is_dataset() { + local dataset="${1}" + zfs_get_property "${dataset}" 'name' -snone &>/dev/null +} + + +function 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 +} + + +# functions to deal with I/O to the user +function read_passphrase() { + local dataset="${1}" + local key="${2?need keyinput argument}" + + # Get the existing passphrase/keyfile. + local existing_key + local keyfile + + case "${key}" in + "") IFS= read -r -s -p "Enter existing ZFS password for ${dataset}: " existing_key; + echo >&2 + ;; + -) IFS= read -r -s -p "" existing_key;; + *) keyfile="${key}" + if [ -r "${keyfile}" ]; then + existing_key="$(< "${keyfile}")" + else + error "cannot read key file '${keyfile}'" + fi + ;; + esac + echo "${existing_key}" +} + +function error() { + echo >&2 -e "ERROR: ${*}" + usage + exit 1 +} + + +# functions to deal with too large Clevis data for a single ZFS property +######################################################################## + +function cut_into_chunks() { + fold -w "${zfs_userprop_value_limit}" +} + +function zero_pad() { + local width="${1}"; shift + printf "%0${width}d " "${@}" +} + +function num_list() { + local last_index="${1}" + local indices=() + read -ra indices <<< "$(eval "echo {0..${last_index}}")" + zero_pad "${#last_index}" "${indices[@]}" +} + + + +# functions to add/remove a Clevis binding +######################################### +function zfs_bind_clevis_label() { + local dataset="${1}" + local label="${2}" + local clevis_data="${3}" + + local zfs_label_prop="${zfs_label_prefix}:${label}" + + echo >&2 -n 'binding new Clevis data... ' + # use a single prop without number suffix if it will fit in one prop + if [[ "${#clevis_data}" -lt "${zfs_userprop_value_limit}" ]]; then + zfs_set_property "${dataset}" "${zfs_label_prop}" "${clevis_data}" + else + local clevis_chunks=() + read -ra clevis_chunks <<< "$(cut_into_chunks <<<"${clevis_data}")" + last_index="$(( "${#clevis_chunks[@]}" - 1 ))" + width="${#last_index}" + + local chunk chunk_num + for chunk_num in $(num_list "${last_index}"); do + # bash assumes numbers are octal when prefixed with a 0, so we + # remove it + i="${chunk_num##0}" + i="${i:-0}" # if we removed all zeroes, we are at the start + chunk="${clevis_chunks[${i}]}" + # e.g. latchset.clevis.label:${label}-01=chunk_data + zfs_set_property "${dataset}" "${zfs_label_prop}-${chunk_num}" "${chunk}" + done + + label="${label}:${last_index}" + fi + echo >&2 'ok' + + # check if unlocking works + echo >&2 -n 'testing new Clevis data... ' + + # somehow clevis-decrypt exits with a non-zero code, but still outputs the + # correct data, so we ignore the exit code. zfs_test_key will fail anyway + # if something goes wrong + if ! unlock_with_label "${dataset}" "${label}" 'dry_run'; then + zfs_unbind_clevis_label "${dataset}" "${label}" + error "could not unlock dataset with clevis configuration: ${dataset}" + fi + + echo >&2 'ok' + zfs_add_label "${dataset}" "${label}" +} + +function zfs_unbind_clevis_label() { + local dataset="${1}" + local label="${2%:*}" + local last_index + + label="$(zfs_get_label "${dataset}" "${label}")" + last_index="${label#*:}" + label="${label%:*}" + + local zfs_label_prop="${zfs_label_prefix}:${label}" + + if [[ "${label}" == "${last_index}" ]]; 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_remove_label "${dataset}" "${label}" +} + + +function zfs_get_clevis_label() { + local dataset="${1}" + local label="${2%:*}" + local last_index="${2#*:}" + + local zfs_label_prop="${zfs_label_prefix}:${label}" + if [[ "${label}" == "${last_index}" ]]; then + zfs_get_property "${dataset}" "${zfs_label_prop}" + else + local clevis_data=() + 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 +} + + +function unlock_with_label() { + local dataset="${1}" + local label="${2}" + local test_only="${3:-}" + + # somehow clevis-decrypt exits with a non-zero code, but still outputs the + # correct data, so we ignore the exit code. zfs_load_key will fail anyway + # if something goes wrong + zfs_get_clevis_label "${dataset}" "${label}" \ + | (clevis decrypt || true) \ + | zfs_load_key "${dataset}" "${test_only}" +} + + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . clevis-zfs-test + _test "$@" +fi diff --git a/src/zfs/clevis-zfs-list b/src/zfs/clevis-zfs-list new file mode 100755 index 00000000..c7efb371 --- /dev/null +++ b/src/zfs/clevis-zfs-list @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="List ZFS datasets that are bound with Clevis [in dataset]" + +function usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs list -d DATASET + + $SUMMARY: + + -d DATASET The ZFS dataset on which to perform unbinding + + 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 + # shellcheck disable=SC2086 + zfs get -H -o "${output}" -slocal "${zfs_labels_prop}" ${dataset:-} +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . @libexecdir@/clevis-zfs-common + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-test b/src/zfs/clevis-zfs-test new file mode 100755 index 00000000..4f1e262a --- /dev/null +++ b/src/zfs/clevis-zfs-test @@ -0,0 +1,242 @@ +#!/bin/bash +set -euo pipefail + +zpool='clevis-zfs-pool' +root_dataset="${zpool}/clevis" +test_dataset="${root_dataset}/test" + +tang_host='127.0.0.1' +tang_thp='TANG_THP' + +tang_host="192.168.178.26:8565" +tang_thp="NHt3FdBvUX0AiHZycp2emEzfYIc" + +shutup='/dev/null' + +# simple tang config +tang_config='{"url": "http://'${tang_host}'", "thp": "'${tang_thp}'"}' +# config that will go over the 8k limit twice (i.e. >16k) +sss_config='{ + "t": 4, + "pins": { + "tang": ['"${tang_config}","${tang_config}","${tang_config}"'], + "sss": { + "t": 4, + "pins": { + "tang": ['"${tang_config}","${tang_config}","${tang_config}"'], + "sss": { + "t": 3, + "pins": { + "tang": ['"${tang_config}","${tang_config}","${tang_config}"'] + } + } + } + } + } +}' + +function zfs_usage() { + local permissions='create,destroy,mount,load-key,change-key,userprop,encryption,keyformat,keylocation' + cat >&2 <<-EOF + + To test the zfs functions you will need to do the following as root: + + 1) make sure the zfs kernel module is loaded: + modprobe zfs + + 2) create a new backing file: + dd if=/dev/zero bs=10M count=20 conv=fdatasync of=/tmp/clevis-zfs-pool + + 3) create an unencrypted zfs pool using the backing file: + zpool create ${zpool} /tmp/clevis-zfs-pool + + 4) create an unencrypted child dataset so we can grant access to a non-root user: + zpool create ${root_dataset} + + 4) give the user running the test script permissions to make changes to this pool: + zfs allow ${USER} ${permissions} ${root_dataset} + + 5) set tang_host and tang_thp to an existing tang server in ${BASH_SOURCE[0]} + EOF +} + +exit_code=0 +testing_password="paaaassssswoooooorrrrddd" + +function testing() { + echo >&2 -en "${FUNCNAME[1]}: ${*}... " +} +function success() { + echo >&2 'ok' +} +function failed() { + echo >&2 "failed!" + echo >&2 "${BASH_SOURCE[0]}:${BASH_LINENO[0]} EXPECTED: '${expected}' GOT: '${result}' ${*}" + declare -g exit_code=1 +} + +function _test() { + _test_is_valid_label + _test_read_passphrase + _test_zero_pad + _test_num_list + _test_zfs_functions + [[ "${exit_code}" -gt 0 ]] && echo >&2 "SOME TESTS HAVE FAILED" + exit "${exit_code}" +} + +function _test_is_valid_label() { + local expected + local result + valid_labels=( + 2 # single digit + 0 # single 0 + a # single letter + 3a # double + a4 # double + 1024 # all digits + abcd # all letters + 0a0ab # alphanum + a_bc # with underscore + ) + + invalid_labels=( + a-bc # with dash + a.bc # with dot + a:bc # with colon + a.1_c-2:e # with all + '' # empty string + '.' # just a dot + '-' # just a dash + AteA_ # capitals + aa@a # @-symbol + aa/a # /-symbol + aa/a # /-symbol + aa?a # ?-symbol + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # 101-chars long + ) + + testing 'testing valid labels' + expected=0 + result=1 + for l in "${valid_labels[@]}"; do + if ! is_valid_label "${l}" &>${shutup}; then + failed "for is_valid_label '${l}'" + fi + done + success + + testing 'testing invalid labels' + expected=1 + result=0 + for l in "${invalid_labels[@]}"; do + if is_valid_label "${l}" &>${shutup}; then + failed "for \`is_valid_label '${l}'\`" + fi + done + success +} + +function _test_read_passphrase() { + # test with reading from stdin + local expected + local result + + testing 'key test: no key argument (stdin)' + expected='no-argument-password' + result="$(read_passphrase "mydataset" '' <<<"${expected}" 2>/dev/null)" + [[ "${expected}" == "${result}" ]] && success || failed + + testing 'key test: dash argument (stdin)' + expected='dash-argument-password' + result="$(read_passphrase "mydataset" '-' <<<"${expected}" 2>/dev/null)" + [[ "${expected}" == "${result}" ]] && success || failed + + + testing 'key arg: filename' + expected='filename-argument-password' + result="$(read_passphrase "mydataset" <(echo "${expected}") 2>/dev/null)" + [[ "${expected}" == "${result}" ]] && success || failed +} + +function _test_zero_pad() { + testing 'pad with 4 zeroes' + local expected='000051 ' + local result + result="$(zero_pad 6 51)" + [[ "${expected}" == "${result}" ]] && success || failed +} + +function _test_num_list() { + testing 'list with zero padding' + local expected='00 01 02 03 04 05 06 07 08 09 10 ' + local result + result="$(num_list 10)" + [[ "${expected}" == "${result}" ]] && success || failed +} + +function _zfs_test_teardown() { + testing "removing zfs testing dataset: ${test_dataset}" + ! zfs list "${test_dataset}" &>${shutup} && success && return 0 + zfs destroy -f -r "${test_dataset}" + success +} + +function _zfs_create_encrypted_dataset() { + local dataset="${1}" + testing "creating encrypted test dataset: ${dataset}" + # zfs create will work, assuming we have the permissions as described in + # zfs_usage, but will exit with an error code because only the root user + # can mount the dataset + zfs create "${dataset}" -o encryption=on -o keyformat=passphrase <<<"${testing_password}" &>${shutup} || true + # we need to make sure the dataset is created because of the error-code shenanigans + if zfs list "${dataset}" &>${shutup}; then + success + return 0 + else + return 1 + fi +} + +function _test_zfs_functions() { + testing "checking if zfs testing dataset exists: ${root_dataset}" + zfs list "${root_dataset}" &>${shutup} && success || (zfs_usage; return 1) + trap _zfs_test_teardown EXIT + _zfs_create_encrypted_dataset "${test_dataset}" + + testing 'binding zfs dataset tang' + echo "${testing_password}" | ./clevis-zfs-bind -d "${test_dataset}" -k - -l 'tang_testlabel' tang "${tang_config}" &>${shutup} && success || failed + testing 'binding zfs dataset sss' + echo "${testing_password}" | ./clevis-zfs-bind -d "${test_dataset}" -k - -l 'sss_testlabel' sss "${sss_config}" &>${shutup} && success || failed + + testing 'listing labels' + # the tang_testlabel should be way under the 8k limit + # the sss_testlabel should be over 16k + expected="${test_dataset}"$'\ttang_testlabel sss_testlabel:2' + result="$(./clevis-zfs-list -d "${test_dataset}" 2>/dev/null)" + [[ "${expected}" == "${result}" ]] && success || failed + + expected='unlock test success' + result='unlock test failure' + testing 'testing unlocking with bindings tang' + ./clevis-zfs-unlock -t -d "${test_dataset}" -l 'tang_testlabel' &>${shutup} && success || failed + + testing 'testing unlocking with bindings sss' + ./clevis-zfs-unlock -t -d "${test_dataset}" -l 'sss_testlabel' &>${shutup} && success || failed + testing 'testing unlocking with bindings either' + ./clevis-zfs-unlock -t -d "${test_dataset}" &>${shutup} && success || failed + + testing 'unlocking with binding' + expected='unlock success' + zfs unload-key "${test_dataset}" || result='unload key failed' failed + [[ "$(zfs_get_property "${test_dataset}" 'keystatus' -snone)" == 'unavailable' ]] || result='unload key failed' failed + ./clevis-zfs-unlock -d "${test_dataset}" &>${shutup} || result='unlocking failed' failed + success + + expected='unbinding success' + result='unbinding failed' + testing 'unbinding dataset tang' + echo "${testing_password}" | ./clevis-zfs-unbind -d "${test_dataset}" -l 'tang_testlabel' -k - &>${shutup} && success || failed + testing 'unbinding dataset sss' + echo "${testing_password}" | ./clevis-zfs-unbind -d "${test_dataset}" -l 'sss_testlabel' -k - &>${shutup} && success || failed +} diff --git a/src/zfs/clevis-zfs-unbind b/src/zfs/clevis-zfs-unbind new file mode 100755 index 00000000..5a542b7a --- /dev/null +++ b/src/zfs/clevis-zfs-unbind @@ -0,0 +1,86 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Unbinds a label from a ZFS dataset" + +function usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs unbind [-f] [-k KEY] -d DATASET [-a] -l LABEL + + $SUMMARY: + + -f Force unbinding dataset + -d DATASET The ZFS dataset on which to perform unbinding + -a Unbind all labels + -l LABEL The label to unbind + + -k KEY Non-interactively read ZFS password from KEY file + -k - Non-interactively read ZFS password from standard input + + USAGE_END +} + + +function main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset= + local key= + local label= + local force_unbind='false' + local unbind_all='false' + while getopts "hafd:k:l:" o; do + case "$o" in + h) usage; exit 0;; + a) unbind_all='true';; + d) dataset="$OPTARG";; + f) force_unbind='true';; + k) key="$OPTARG";; + l) label="$OPTARG";; + *) error "unrecognized argument: -${o}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + if [ -z "${label}" ] && [ "${unbind_all}" == 'false' ]; then + error "did not specify a label!" + fi + + if [[ "${force_unbind}" != 'true' ]]; then + if ! zfs_is_bound "${dataset}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi + + local existing_key + echo >&2 "Loading existing key... " + existing_key="$(read_passphrase "${dataset}" "${key}")" + + if ! zfs_test_key "${dataset}" <<<"${existing_key}"; then + error "given key does not unlock ${dataset}" + fi + fi + + echo >&2 -n 'Wiping Clevis data... ' + if [[ "${unbind_all}" == 'true' ]]; then + local labels=() + read -ra labels <<< "$(zfs_get_labels "${dataset}")" + for label in "${labels[@]}"; do + zfs_unbind_clevis_label "${dataset}" "${label}" + done + else + zfs_unbind_clevis_label "${dataset}" "${label}" + fi + + echo >&2 'ok' +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . @libexecdir@/clevis-zfs-common + main "${@}" +fi diff --git a/src/zfs/clevis-zfs-unlock b/src/zfs/clevis-zfs-unlock new file mode 100755 index 00000000..bf978ac4 --- /dev/null +++ b/src/zfs/clevis-zfs-unlock @@ -0,0 +1,86 @@ +#!/bin/bash +set -euo pipefail + +SUMMARY="Unlock a ZFS dataset using the saved Clevis data" + +function usage() { + cat >&2 <<-USAGE_END + Usage: clevis zfs unlock [-t] [-l LABEL] -d DATASET + + $SUMMARY: + + -t Test the Clevis configuration without unlocking + -d DATASET The ZFS dataset to unlock + -l LABEL Use only this label to unlock (defaults to trying all labels) + + USAGE_END +} + +function main() { + if [ $# -eq 1 ] && [ "${1:-}" == "--summary" ]; then + echo "$SUMMARY" + exit 0 + fi + + local dataset + local test_only='' + local label='' + while getopts "h:d:l:t" o; do + case "$o" in + h) usage; exit 0;; + d) dataset="$OPTARG";; + t) test_only=' (test)';; + l) label="$OPTARG";; + *) error "unrecognized argument: -${OPTARG}";; + esac + done + + if [ -z "${dataset:-""}" ]; then + error "did not specify a dataset!" + fi + + if ! zfs_is_bound "${dataset}"; then + error "dataset is not bound with Clevis: ${dataset}" + fi + + if [[ -n "${label}" ]]; then + label="$(zfs_get_label "${dataset}" "${label}")" + testing -n "unlocking ${dataset} with ${label} ${test_only}... " + if unlock_with_label "${dataset}" "${label}" "${test_only}"; then + testing 'ok' + exit 0 + else + testing 'failed' + exit 1 + fi + else + local labels + labels="$(zfs_get_labels "${dataset}")" + for label in ${labels}; do + testing -n "unlocking ${dataset} with ${label} ${test_only}... " + if unlock_with_label "${dataset}" "${label}" "${test_only}"; then + testing 'ok' + [[ -z "${test_only}" ]] && exit 0 || true + else + testing "failed" + continue + fi + done + fi + + if [[ -n "${test_only}" ]]; then + exit 0 + else + exit 1 + fi +} + + +function testing() { + [[ -n "${test_only}" ]] && echo >&2 "${@}" || true +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + . @libexecdir@/clevis-zfs-common + main "${@}" +fi diff --git a/src/zfs/meson.build b/src/zfs/meson.build new file mode 100644 index 00000000..0aa37a31 --- /dev/null +++ b/src/zfs/meson.build @@ -0,0 +1,21 @@ +install_data( + [join_paths(meson.current_source_dir(), 'clevis-zfs-common')], + install_dir: libexecdir +) + +zfs_bins = [ + 'clevis-zfs-bind', + 'clevis-zfs-list', + 'clevis-zfs-unbind', + 'clevis-zfs-unlock', +] +foreach b : zfs_bins + configure_file( + input: b, + output: b, + install_dir: bindir, + configuration: data + ) +endforeach + +test('clevis-zfs-test', find_program(join_paths(meson.current_source_dir(), 'clevis-zfs-test')))