Skip to content

Instantly share code, notes, and snippets.

@tvdstaaij
Last active March 2, 2025 00:26
Show Gist options
  • Save tvdstaaij/53de6743eab05c979a7831c539382632 to your computer and use it in GitHub Desktop.
Save tvdstaaij/53de6743eab05c979a7831c539382632 to your computer and use it in GitHub Desktop.
Raspberry Pi headless full disk encryption with remote unlock
#!/bin/bash
# Based on guide: https://github.com/ViRb3/pi-encrypted-boot-ssh (rev cac7ac5)
#
# Some usage notes:
# - Definitions: "host" means the machine executing this script, "source" means the prebuilt Raspberry Pi OS image used as a base, "target" means the image or disk being provisioned
# - Assumes Debian host system, and execution as root. Running in a VM is highly recommended for safety reasons
# - Only tested with https://raspi.debian.net/tested-images/ Bullseye/Bookworm RPi4 images as source (should also work for RPi3)
# - Assumes target with boot and root partition on one disk
# - Assumes Raspberry Pi is connected with Ethernet and DHCP (i.e. no special network configuration in initramfs)
# - After booting for the first time, you can ssh as `root` using one of the keys authorized for Dropbear
# - During execution of this script Dropbear will complain about an invalid authorized_keys file, this is ok because the file will be copied later in the script
#
# Notable changes/additions/removals compared to ViRb3/pi-encrypted-boot-ssh:
# - Adapted to work with Debian instead of Ubuntu
# - Add discard flags to crypttab and cmdline.txt (for SSDs)
# - Customize cryptsetup and dropbear options
# Some settings you may want to customize. In particular, for RPi3 and/or better performance, consider:
# - Using xchacha12 instead of xchacha20
# - Reducing --pbkdf-parallel to 1
# - Reducing --pbkdf-force-iterations
[[ -z ${LUKS_OPTIONS+x} ]] && LUKS_OPTIONS="-c xchacha20,aes-adiantum-plain64 -s 256 --pbkdf argon2id --pbkdf-parallel 4 --pbkdf-force-iterations 4 --pbkdf-memory 524288"
[[ -z ${DROPBEAR_OPTIONS+x} ]] && DROPBEAR_OPTIONS="-j -k -s -p 22222 -c cryptroot-unlock"
[[ -z ${CRYPTSETUP_TIMEOUT+x} ]] && CRYPTSETUP_TIMEOUT=30
# Override to 0 to inspect incomplete output when script fails
[[ -z ${ERROR_CLEANUP+x} ]] && ERROR_CLEANUP=1
set -Eeuoo pipefail functrace
cleanup() {
umount chroot/boot/firmware
umount chroot/sys
umount chroot/proc
umount chroot/dev/pts
umount chroot/dev
umount chroot
umount rootimg
cryptsetup close cryptroot
[[ -n "${SOURCE_IMAGE}" ]] && kpartx -d "${SOURCE_IMAGE}"
[[ -z "${TARGET_DEV}" ]] && [[ -n "${TARGET_IMAGE}" ]] && kpartx -d "${TARGET_IMAGE}"
rmdir chroot
rmdir rootimg
}
failure() {
set +Eeuo pipefail
local lineno=$2
local fn=$3
local exitstatus=$4
local msg=$5
local lineno_fns=${1% 0}
if [[ "$lineno_fns" != "0" ]] ; then
lineno="${lineno} ${lineno_fns}"
fi
echo "${BASH_SOURCE[1]}:${fn}[${lineno}] Failed with status ${exitstatus}: $msg"
echo "Attempting cleanup..."
[[ ${ERROR_CLEANUP} -eq 1 ]] && cleanup
exit 1
}
trap 'failure "${BASH_LINENO[*]}" "$LINENO" "${FUNCNAME[*]:-script}" "$?" "$BASH_COMMAND"' ERR
[[ -z ${TARGET_DEV+x} ]] && read -p 'Target device (/dev/xyz) (empty to write image): ' TARGET_DEV
[[ -z ${SOURCE_IMAGE+x} ]] && read -p 'Absolute path to OS image: ' SOURCE_IMAGE
[[ -z ${AUTHORIZED_KEYS_FILE+x} ]] && read -p 'Path to authorized_keys file: ' AUTHORIZED_KEYS_FILE
apt update
apt install -y kpartx cryptsetup-bin qemu-user-static rsync
if [[ "${SOURCE_IMAGE}" != /* ]]; then
echo Error: image path must be absolute
exit 1
fi
if [[ ! -f "${SOURCE_IMAGE}" ]]; then
echo Error: ${SOURCE_IMAGE} does not exist
exit 1
fi
if [[ ! -f "${AUTHORIZED_KEYS_FILE}" ]]; then
echo Error: ${AUTHORIZED_KEYS_FILE} does not exist
exit 1
fi
if [[ -z "${TARGET_DEV}" ]]; then
TARGET_IMAGE="${SOURCE_IMAGE}.crypt"
cp "${SOURCE_IMAGE}" "${TARGET_IMAGE}"
TARGET_IMAGE_MAP=$(kpartx -va "${TARGET_IMAGE}")
TARGET_ROOT_DEV="/dev/mapper/$(echo "${TARGET_IMAGE_MAP}" | tail -1 | cut -d ' ' -f 3)"
TARGET_BOOT_DEV="/dev/mapper/$(echo "${TARGET_IMAGE_MAP}" | tail -2 | head -1 | cut -d ' ' -f 3)"
else
read -p "Flashing ${SOURCE_IMAGE} to ${TARGET_DEV}. DATA ON ${TARGET_DEV} WILL BE LOST. Press any key to continue or Ctrl+C to abort."
dd if="${SOURCE_IMAGE}" of="${TARGET_DEV}" bs=4M conv=fdatasync status=progress
sync
TARGET_BOOT_DEV="/dev/$(lsblk -lno name ${TARGET_DEV} | tail -n 2 | head -n 1)"
TARGET_ROOT_DEV="/dev/$(lsblk -lno name ${TARGET_DEV} | tail -n 1)"
read -p "Detected partitions ${TARGET_BOOT_DEV} for boot and ${TARGET_ROOT_DEV} for root. Press any key to continue or Ctrl+C if this is incorrect."
fdisk -l
echo -e "d\n2\nw" | fdisk "${TARGET_DEV}" # Delete partition number 2
read -p "Deleted root partition. Please use fdisk to create a new root partition with number 2. Press any key to start fdisk."
fdisk -l
fdisk "${TARGET_DEV}"
fi
echo "Please initialize encryption:"
cryptsetup luksFormat ${LUKS_OPTIONS} "${TARGET_ROOT_DEV}"
echo "Please unlock the encrypted volume:"
cryptsetup open "${TARGET_ROOT_DEV}" cryptroot
mkfs.ext4 /dev/mapper/cryptroot
mkdir -p chroot
mount /dev/mapper/cryptroot chroot
SOURCE_IMAGE_ROOT_DEV="/dev/mapper/$(kpartx -var "${SOURCE_IMAGE}" | tail -n 1 | cut -d ' ' -f 3)"
mkdir -p rootimg
mount "${SOURCE_IMAGE_ROOT_DEV}" rootimg
rsync --archive --hard-links --acls --xattrs --one-file-system --numeric-ids --info="progress2" rootimg/* chroot/
mkdir -p chroot/boot
mount "${TARGET_BOOT_DEV}" chroot/boot/firmware
mount -t proc none chroot/proc
mount -t sysfs none chroot/sys
mount -o bind /dev chroot/dev
mount -o bind /dev/pts chroot/dev/pts
cp "${AUTHORIZED_KEYS_FILE}" chroot/root/authorized_keys.tmp
cat << EOF | chroot chroot
set -Eeuo pipefail
# Create temporary DNS config
[[ -f /etc/resolv.conf ]] || [[ -L /etc/resolv.conf ]] && mv /etc/resolv.conf /etc/resolv.conf.bak
echo "nameserver 1.1.1.1" > /etc/resolv.conf
# Modify system configuration
PARTITION_REF=\$(blkid -o export ${TARGET_ROOT_DEV} | grep ^UUID)
echo "cryptroot \${PARTITION_REF} none luks,initramfs,discard" > /etc/crypttab
echo "cryptdevice=\${PARTITION_REF}:cryptroot:allow-discards" > /etc/default/raspi-extra-cmdline
sed -i -E 's|ROOTPART=\\S+|ROOTPART=/dev/mapper/cryptroot|' /etc/default/raspi-firmware
sed -i -E 's|root=\\S+|root=/dev/mapper/cryptroot cryptdevice='"\${PARTITION_REF}"':cryptroot:allow-discards|' /boot/firmware/cmdline.txt
sed -i -E 's|rootfstype=\\S+|rootfstype=ext4|' /boot/firmware/cmdline.txt
sed -i -E 's|^\\S+\\s+/\\s+\\S+\\s+\\S+|/dev/mapper/cryptroot / ext4 defaults,relatime,discard,errors=remount-ro|' /etc/fstab
# Install dependencies
apt update
apt install -y busybox cryptsetup dropbear-initramfs openssh-server patch
# Patch cryptsetup unlock timeout
sed -i 's/^TIMEOUT=.*/TIMEOUT=${CRYPTSETUP_TIMEOUT}/g' /usr/share/cryptsetup/initramfs/bin/cryptroot-unlock
# Patch cryptroot hook to prevent incorrect UUID resolution
patch --no-backup-if-mismatch /usr/share/initramfs-tools/hooks/cryptroot << 'CRYPTHOOK'
--- cryptroot
+++ cryptroot
@@ -33,7 +33,7 @@
printf '%s\0' "\$target" >>"\$DESTDIR/cryptroot/targets"
crypttab_find_entry "\$target" || return 1
crypttab_parse_options --missing-path=warn || return 1
- crypttab_print_entry
+ printf '%s %s %s %s\n' "\$_CRYPTTAB_NAME" "\$_CRYPTTAB_SOURCE" "\$_CRYPTTAB_KEY" "\$_CRYPTTAB_OPTIONS" >&3
fi
}
CRYPTHOOK
# Detect Dropbear configuration paths
if [[ -d /etc/dropbear-initramfs ]]; then
# Debian 11
DROPBEAR_CONFIG_FILE=/etc/dropbear-initramfs/config
DROPBEAR_AUTHORIZED_KEYS_FILE=/etc/dropbear-initramfs/authorized_keys
elif [[ -d /etc/dropbear ]]; then
# Debian 12
DROPBEAR_CONFIG_FILE=/etc/dropbear/initramfs/dropbear.conf
DROPBEAR_AUTHORIZED_KEYS_FILE=/etc/dropbear/initramfs/authorized_keys
else
echo Error: dropbear-initramfs configuration directory not found
exit 1
fi
# Configure initramfs
echo >> /etc/cryptsetup-initramfs/conf-hook
echo "CRYPTSETUP=y" >> /etc/cryptsetup-initramfs/conf-hook
echo >> \${DROPBEAR_CONFIG_FILE}
echo "DROPBEAR_OPTIONS=\\"${DROPBEAR_OPTIONS}\\"" >> \${DROPBEAR_CONFIG_FILE}
mv /root/authorized_keys.tmp \${DROPBEAR_AUTHORIZED_KEYS_FILE}
chmod 0600 \${DROPBEAR_AUTHORIZED_KEYS_FILE}
# Update existing initramfs
update-initramfs -u -k all
# Restore DNS config
rm -f /etc/resolv.conf
[[ -f /etc/resolv.conf.bak ]] && mv /etc/resolv.conf.bak /etc/resolv.conf
# Bootstrap SSH (allow initial login as root using the dropbear authorized keys)
mkdir -p /root/.ssh
chmod 700 /root/.ssh
cp \${DROPBEAR_AUTHORIZED_KEYS_FILE} /root/.ssh/authorized_keys
# For some reason, some of the Debian "tested" RPi images have both ssh.service and ssh.socket
# enabled, which are in conflict. Enable only the service.
systemctl is-enabled ssh.socket && systemctl disable ssh.socket
systemctl enable ssh.service
# From https://raspi.debian.net/defaults-and-settings/
# "The image is configured to process the /boot/firmware/sysconf.txt file at boot time; this file documents this functionality can be disabled by issuing:"
systemctl disable rpi-set-sysconf
sync
history -c
EOF
echo "Successfully finished. Cleaning up..."
cleanup
# Note: if image output was used, after flashing the image,
# resize the root partition on the Pi to full size:
# echo -e "d\n2\nn\np\n2\n\n\nw" | fdisk /dev/<maindisk>
# cryptsetup resize cryptroot
# systemctl reboot
@abe-101
Copy link

abe-101 commented Aug 19, 2022

Really neat!
Thanks for sharing

@ronandalton
Copy link

If anyone runs into the following error while running the script,

mount: /home/user/Downloads/rootimg: unknown filesystem type 'crypto_LUKS'.
       dmesg(1) may have more information after failed mount system call.
./rpi-install-cryptroot.sh:script[114] Failed with status 32: mount "${SOURCE_IMAGE_ROOT_DEV}" rootimg

try using a shorter name for the input image instead of the longer default name of 2024-11-19-raspios-bookworm-arm64-lite.img. When writing to an image, the second kpartx command seems to behave as if the given file name is the same as what was passed to the first command, and returns the same mappings as the first. This doesn't happen when a shorter name is used or the order of the two invocations is swapped (i.e. calling kpartx with SOURCE_IMAGE much earlier), strangely enough. This happened on Debian Bookworm.

@ronandalton
Copy link

ronandalton commented Feb 26, 2025

A few more gotchas:

If you are trying to set this up on the Pi 5 and get this error message when trying to unlock the disk at boot

No usable keyslot is available

swap xchacha20,aes-adiantum-plain64 for aes-xts-plain64 in the LUKS_OPTIONS definition. The Pi 5 has hardware accelerated cryptography extensions so Adiantum isn't needed anyway.

Also, when SSHing in to unlock the disk, note that the daemon is running on a non-standard port (22222), so be sure to modify the SSH command to use that port. e.g. ssh -p 22222 root@IPADDR.

It's probably also a good idea to scrub the target root partition before encrypting it, otherwise the previous unencrypted data may remain. I did this with shred -n 1 -v "${TARGET_ROOT_DEV}" before the cryptsetup luksFormat line.

Since the script also patches some installed files, it's probably a good idea to hold the associated package back from being updated lest the changes be lost. This can be done with sudo apt-mark hold cryptsetup-initramfs, since both patches apply to files owned by this package.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment