Skip to content

Commit

Permalink
Merge pull request #3486 from balena-os/mtoman/cryptsetup-fail-hard
Browse files Browse the repository at this point in the history
initrdscripts: Make cryptsetup fail hard in unexpected conditions
  • Loading branch information
flowzone-app[bot] authored Sep 13, 2024
2 parents 99684cd + c1bccdb commit e086acb
Showing 1 changed file with 123 additions and 20 deletions.
143 changes: 123 additions & 20 deletions meta-balena-common/recipes-core/initrdscripts/files/cryptsetup-efi-tpm
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
#!/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
. /usr/libexec/os-helpers-tpm2
. /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"
Expand All @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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"
Expand Down

0 comments on commit e086acb

Please sign in to comment.