Installing a Minimal, Encrypted Debian Rootfs on a Raspberry Pi 3 using debootstrap

Instead of trusting a binary rootfs downloaded from the internet, why not build your own? In this tutorial, we see how the pieces of the Debian GNU/Linux distribution are put together. Building a rootfs for a non-native architecture like that of the Raspberry Pi 3 can be tricky, so we use qemu’s user-mode emulation to work around this. We can also use cryptsetup to encrypt the root partition with LUKS and unlock with a password at boot.

Finding a fast SD card

The main bottleneck when operating a Raspberry Pi has always been disk I/O. To make your experience a pleasant one, I recommend considering the benchmarks here when buying your next microSD card.

Getting debootstrap and other system prerequisites

The following utilities are available on most Linux distributions but my host system was Debian:

apt install debootstrap binfmt-support qemu-user-static

If you don’t have a qemu-user-static in your distribution, this Gentoo guide has some hints for compiling your own.

Preparing the root and firmware partitions

Identify the SD card in dmesg or ls /dev/sd* before and after insertion. Also choose a mount point:

export SD_CARD=/dev/sdX
export LUKS_MAPPER_ALIAS=pi01
export ROOT_MOUNT=/mnt/rpi

Use the partitioning utility of your choice to create an msdos disklabel and two partitions on the card:

I used the cfdisk utility from the util-linux collection:

cfdisk -z ${SD_CARD}

After partitioning, my cfdisk screen looked like:

#     Device        Boot         Start             End         Sectors         Size        Id Type
#     /dev/sdd1                   2048          411647          409600         200M         b W95 FAT32
# >>  /dev/sdd2                 411648        60751871        60340224        28.8G        83 Linux

Take note of the new partition numbers:

export SD_CARD_FW=${SD_CARD}1
export SD_CARD_LUKS=${SD_CARD}2

Encrypt the root partition with LUKS and unlock it for modification:

cryptsetup luksFormat -c aes-xts-plain -s 512 -i 100 --label PILUKS ${SD_CARD_LUKS}
cryptsetup luksOpen ${SD_CARD_LUKS} ${LUKS_MAPPER_ALIAS}

Note: review the parameters available for setting up an encrypted container and make a decision here that’s right for your threat profile.

Create and mount the real rootfs and the unencrypted firmware partition:

mkfs.vfat -F 32 -n PIBOOT ${SD_CARD_FW}
mkfs.ext4 -L PIROOT /dev/mapper/${LUKS_MAPPER_ALIAS}
mkdir -pv ${ROOT_MOUNT}
mount -v /dev/mapper/${LUKS_MAPPER_ALIAS} ${ROOT_MOUNT}
mkdir -pv ${ROOT_MOUNT}/boot/firmware
mount -v ${SD_CARD_FW} ${ROOT_MOUNT}/boot/firmware

Install the rootfs

Begin the bootstrapping:

debootstrap --arch=arm64 \
            --foreign \
            --components=main,non-free \
            --variant=minbase \
            --include=linux-image-arm64,systemd-sysv,raspi3-firmware,cryptsetup,console-setup \
            --force-check-gpg \
            buster \
            ${ROOT_MOUNT} \
            http://cdn-fastly.deb.debian.org/debian

We use this mirror because it is served from a commercial CDN at no cost to the Debian organization. The minbase collection doesn’t even install a kernel or an init system, so we include those. A few other utilities are appended to allow an initramfs with LUKS to be smoothly created later.

Running debootstrap with the --foreign option causes it to stop after the first stage. The second stage can be immediately run if the qemu-user-static package (see above) is configured to detect and interpret the aarch64 binaries:

chroot ${ROOT_MOUNT} /debootstrap/debootstrap --second-stage

Prepare the rootfs for booting

Move to the rootfs mountpoint for the rest of this tutorial:

cd ${ROOT_MOUNT}

Tell the initramfs how to unlock the encrypted container and mount the filesystems:

echo "${LUKS_MAPPER_ALIAS} LABEL=PILUKS none luks" > etc/crypttab
cat <<EOF > etc/fstab
LABEL=PIROOT / ext4 noatime 0 1
LABEL=PIBOOT /boot/firmware vfat defaults 0 2
EOF

In this tutorial we’re allowing systemd to manage our resolv.conf, but we’re disabling both its stub resolver and its multicast DNS responder. Additionally, we’re trusting systemd’s DHCP client and network management scripts to bring online any interface whose device name starts with the letter “e”:

rm -v etc/resolv.conf
ln -sv ../run/systemd/resolve/resolv.conf etc/resolv.conf
cat <<EOF >> etc/systemd/resolved.conf
DNS=1.1.1.1
LLMNR=no
EOF
cat <<EOF > etc/systemd/network/50-dhcp.network
[Match]
Name=e*
[Network]
DHCP=ipv4
[DHCP]
UseDNS=no
EOF

iproute2’s ip command and dhcpcd were not installed by the debootstrap command above and will not be immediately available on boot.

The new host needs a hostname and a hosts file. debootstrap strangely copies the host computer’s hostname yet skips creating a hosts file at all. Handle both of these quirks:

export NEW_HOSTNAME=new-hostname
echo "${NEW_HOSTNAME}" > etc/hostname
cat <<EOF > etc/hosts
127.0.0.1 localhost
::1 localhost
127.0.1.1 ${NEW_HOSTNAME} ${NEW_HOSTNAME}.example.com
EOF

Replace the sources.list laid down by debootstrap with a more complete one, adding buster-updates:

cp -v usr/share/doc/apt/examples/sources.list etc/apt/sources.list
echo "deb http://deb.debian.org/debian/ buster-updates main contrib non-free" >> etc/apt/sources.list

Prepare the rootfs for chrooting

Get ready to enter the chroot by making available filesystem information that will be necessary to build the initramfs:

mount -o bind /dev ${ROOT_MOUNT}/dev
mount -o bind /proc ${ROOT_MOUNT}/proc
mount -o bind /sys ${ROOT_MOUNT}/sys

Use the chroot:

LANG=C chroot ${ROOT_MOUNT} /bin/bash
update-initramfs -u
systemctl enable systemd-networkd
systemctl enable systemd-resolved
passwd root
exit

Prepare the firmware partition for booting

If the firmware partition was mounted properly at ${ROOT_MOUNT}/boot/firmware, the Debian installer placed the non-free raspi3-firmware files and the freshly-built initramfs into the root directory of the firmware partition. Everything is ready to boot except for the cmdline.txt.

Because our rootfs is encrypted, the existing kernel root argument /dev/mmcblkp0s2 needs to be replaced with the label of the / partition (see above):

sed -i -e 's,/dev/mmcblk0p2,LABEL=PIROOT,' boot/firmware/cmdline.txt

If you desire the encrypted volume password prompt to appear on the graphical console (as opposed to the serial console), strike the console=ttyS1,115200 argument from the same file:

sed -i -e 's/console=ttyS1,115200 //' boot/firmware/cmdline.txt

Move away from the rootfs mountpoint and unmount the SD card:

cd /
umount -Rv ${ROOT_MOUNT}
cryptsetup luksClose ${LUKS_MAPPER_ALIAS}

Booting on the Pi

The rootfs is now at the point where it will be able to boot, get a network connection, and run apt to install anything further. Some suggestions:

apt install --no-install-recommends dbus locales sudo \
    openssh-server iproute2 ca-certificates man-db less

In case there’s an issue with verifying packages downloaded from the mirror, manually set tomorrow’s date until dbus can be installed:

date +%Y%m%d -s 20200101

Bonus: powerpc64le architecture variant

These instructions can also be used to bootstrap a rootfs for a Talos II POWER9 workstation. Only the first-stage debootstrap command would be different:

debootstrap --arch=ppc64el \
            --foreign \
            --components=main \
            --variant=minbase \
            --include=linux-image-powerpc64le,systemd-sysv,grub2-common,cryptsetup,console-setup \
            --force-check-gpg \
            buster \
            ${ROOT_MOUNT} \
            http://cdn-fastly.deb.debian.org/debian

Post-install, a couple of packages are nice to have:

apt install --no-install-recommends lm-sensors powerpc-ibm-utils

Further reading