From c1bccdbd95bcb0202c4b80679ade0967df643342 Mon Sep 17 00:00:00 2001 From: Michal Toman Date: Thu, 11 Jul 2024 15:47:06 +0200 Subject: [PATCH] initrdscripts: Make cryptsetup fail hard in unexpected conditions This patch adds a set of checks that make sure that the partition layout is as expected on a system with encrypted disks. Namely it * Checks that the boot partition is split * Checks that it can find the correct amount of encrypted partitions * Checks that the label on the unlocked filesystem matches the label on the encrypted partition * Checks that system is not mixing encrypted and unencrypted partitions * Checks there are no ambiguities or duplicit partitions Change-type: patch Signed-off-by: Michal Toman --- .../initrdscripts/files/cryptsetup-efi-tpm | 143 +++++++++++++++--- 1 file changed, 123 insertions(+), 20 deletions(-) diff --git a/meta-balena-common/recipes-core/initrdscripts/files/cryptsetup-efi-tpm b/meta-balena-common/recipes-core/initrdscripts/files/cryptsetup-efi-tpm index 3c1607444b..79c01f0d28 100644 --- a/meta-balena-common/recipes-core/initrdscripts/files/cryptsetup-efi-tpm +++ b/meta-balena-common/recipes-core/initrdscripts/files/cryptsetup-efi-tpm @@ -1,5 +1,36 @@ #!/bin/sh +# This script unlocks encrypted partitions on UEFI systems +# with secure boot enabled and encryption key protected by a TPM2.0 +# +# The partition layout of such system is: +# sda // Boot device +# |-sda1 // Unencrypted EFI partition, contains the bootloader and encrypted disk passphrase +# |-sda2 // Encrypted boot partition +# | `-dm-0 // Unlocked boot partition, contains balena-specifics, e.g. config.json +# |-sda3 // Encrypted rootA partition +# | `-dm-1 // Unlocked rootA partition +# |-sda4 // Encrypted rootB partition +# | `-dm-2 // Unlocked rootB partition +# |-sda5 // Encrypted state partition +# | `-dm-3 // Unlocked state partition +# `-sda6 // Encrypted data partition +# `-dm-4 // Unlocked data partition +# +# When this script executes, it should only find the encrypted partitions. +# The script unlocks the partitions using the metadata stored in the EFI +# partition in combination with secrets stored in the TPM. +# As a result the dm-0..dm-5 devices are created. +# +# After successfully unlocking a partition, the default udev rules will +# in fact create two instances of the unlocked device: +# * /dev/dm-X - the "dm-X" part is referred to as "KNAME". +# This is what the kernel uses internally to identify the device +# * /dev/mapper/luks-$UUID - the "luks-$UUID" part is referred to as "NAME". +# This is a user-defined name, we chose "luks-$UUID", the same as Fedora. +# The two devices are identical and can be used interchangeably, but this +# script tries to prefer KNAME as it seems more practical for scripting. + # shellcheck disable=SC1091 . /usr/libexec/os-helpers-logging . /usr/libexec/os-helpers-fs @@ -7,14 +38,7 @@ . /usr/libexec/os-helpers-efi . /usr/sbin/balena-config-defaults -# Give a chance to the by-state directory to appear -# We do not expect any particular device or partition to come up -# but if balenaOS is correctly configured on the device the by-state -# directory will eventually be created by the custom udev rule. -# This is useful if the rootfs is on a device that takes a while -# to initialize such as a USB disk. -wait4file "/dev/disk/by-state" "50" -EFI_DEV=$(get_state_path_from_label "${BALENA_NONENC_BOOT_LABEL}") +ENCRYPTED_PARTITIONS="boot rootA rootB state data" ensure_luks2() { LUKS_DEVICE="$1" @@ -35,16 +59,7 @@ cryptsetup_enabled() { # Ensure that secure boot is enabled and in user mode before unlocking if ! user_mode_enabled; then - return 1 - fi - - # Only run if the EFI partition is split - if [ ! -e "$EFI_DEV" ]; then - return 1 - fi - - # Check whether there are any LUKS partitions - if ! lsblk -nlo fstype | grep -q crypto_LUKS; then + info "Won't attempt to decrypt drives because secure boot is not enabled" return 1 fi @@ -55,6 +70,28 @@ cryptsetup_run() { # Die if anything fails here set -e + # Make sure there is only a single unencrypted partition. + # We will blindly mount it here with no further authentication, + # so if there are ambiguities, we prefer to bail out. + NONENC_PARTS="$(lsblk -nlo label | grep "${BALENA_NONENC_BOOT_LABEL}")" + if [ -z "${NONENC_PARTS}" ]; then + fail "Partition '${BALENA_NONENC_BOOT_LABEL}' not found" + fi + + if [ "$(echo "${NONENC_PARTS}" | wc -l)" -gt 1 ]; then + fail "Multiple '${BALENA_NONENC_BOOT_LABEL}' partitions found" + fi + + EFI_DEV=$(get_state_path_from_label "${BALENA_NONENC_BOOT_LABEL}") + if [ ! -e "$EFI_DEV" ]; then + fail "Won't attempt to decrypt drives because the EFI partition is not split" + fi + + # Check whether there are any LUKS partitions + if ! lsblk -nlo fstype | grep -q crypto_LUKS; then + fail "There are no encrypted partitions" + fi + EFI_MOUNT_DIR="/efi" mkdir "$EFI_MOUNT_DIR" mount "$EFI_DEV" "$EFI_MOUNT_DIR" @@ -86,14 +123,30 @@ cryptsetup_run() { BOOT_DEVICE=$(lsblk -nlo pkname "${EFI_DEV}") + # Check that we have the expected amount of encrypted partitions on the boot device + LUKS_PARTITIONS=$(lsblk -nlo "kname,uuid,fstype,partlabel" "/dev/${BOOT_DEVICE}" | grep "crypto_LUKS") + if [ "$(echo "${LUKS_PARTITIONS}" | wc -l)" != "$(echo "${ENCRYPTED_PARTITIONS}" | wc -w)" ]; then + fail "An unexpected amount of encrypted partitions was found" + fi + # Unlock all the partitions - cryptsetup luksOpen does not wait for udev processing # of the DM device to complete, it just kicks off the process and returns. # Since this is async, we can perform all the luksOpens here, note the device names # and wait for them in a separate loop later LUKS_UNLOCKED="" - for LUKS_UUID in $(lsblk -nlo uuid,fstype "/dev/${BOOT_DEVICE}" | grep crypto_LUKS | cut -d " " -f 1); do + for PART_NAME in ${ENCRYPTED_PARTITIONS}; do + LUKS_UUID=$(echo "${LUKS_PARTITIONS}" | grep " \(balena\|resin\)-${PART_NAME}$" | cut -d " " -f 2) + + if [ -z "${LUKS_UUID}" ]; then + fail "Partition '${PART_NAME}' not found" + fi + + if [ "$(echo "${LUKS_UUID}" | wc -l)" -gt 1 ]; then + fail "More than one '${PART_NAME}' partition found" + fi + ensure_luks2 "/dev/disk/by-uuid/${LUKS_UUID}" - cryptsetup luksOpen --key-file $PASSPHRASE_FILE UUID="${LUKS_UUID}" luks-"${LUKS_UUID}" + cryptsetup luksOpen --key-file "${PASSPHRASE_FILE}" "UUID=${LUKS_UUID}" "luks-${LUKS_UUID}" LUKS_UNLOCKED="${LUKS_UNLOCKED} luks-${LUKS_UUID}" done @@ -102,6 +155,56 @@ cryptsetup_run() { wait4udev "/dev/mapper/${DM_NAME}" done + # Perform sanity checks after unlocking. + # We know what the system should look like after the partitions are unlocked. + # We want to make sure that the newly unlocked partitions are the ones + # we are going to actually use, there is nothing missing and nothing extra. + for PART_NAME in ${ENCRYPTED_PARTITIONS}; do + LUKS_LINE=$(echo "${LUKS_PARTITIONS}" | grep " \(balena\|resin\)-${PART_NAME}$") + + # Effectively the same checks have been done in the previous loop + # We want to be defensive, so we don't mind double-checking + if [ -z "${LUKS_LINE}" ]; then + fail "Partition '${PART_NAME}' not found" + fi + + if [ "$(echo "${LUKS_LINE}" | wc -l)" -gt 1 ]; then + fail "More than one '${PART_NAME}' partition found" + fi + + LUKS_KNAME=$(echo "${PART_LINE}" | cut -d " " -f 1) + LUKS_LABEL=$(echo "${PART_LINE}" | cut -d " " -f 4) + + # Even though the following lsblk could be restricted to just /dev/${LUKS_KNAME} + # we intentionally do not want to do that. If there are multiple filesystems with + # the correct label, even though we know which one to use, we prefer to bail out, + # as this should never happen. + FS_LINE=$(lsblk -nlo "kname,pkname,label" | grep "${LUKS_LABEL}") + if [ -z "${FS_LINE}" ]; then + fail "Filesystem '${LUKS_LABEL}' not found after decryption" + fi + + if [ "$(echo "${FS_LINE}" | wc -l)" -gt 1 ]; then + fail "More than one '${LUKS_LABEL}' filesystems found after decryption" + fi + + FS_KNAME=$(echo "${FS_LINE}" | cut -d " " -f 1) + FS_PKNAME=$(echo "${FS_LINE}" | cut -d " " -f 2) + + # We have found an encrypted partition with partlabel=${LUKS_LABEL} and a filesystem + # with label=${LUKS_LABEL}. Check that these are parent/child. + if [ "${FS_PKNAME}" != "${LUKS_KNAME}" ]; then + fail "LUKS partition '${LUKS_LABEL}' does not provide the expected filesystem" + fi + + # The original idea was to check the /dev/disk/by-state symlinks here, + # but they do not all exist at this point. Because resin_update_state_probe + # only creates the symlinks for partitions that are on the same physical drive + # as the root partition, no by-state symlinks are created until rootA or rootB + # is unlocked. This is not an issue here and all further scripts that actually + # want to use the symlinks will trigger udev and get them created. + done + rm -f "$PASSPHRASE_FILE" umount "$EFI_MOUNT_DIR" rmdir "$EFI_MOUNT_DIR"