From 9b7a34989d570af0dad376e9f1b20acacbef4e31 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 22 Jul 2026 17:00:26 +0200 Subject: [PATCH] feat(image): distro-agnostic remote unlock via initramfs backends + Debian support Split the mkinitcpio-only remote-LUKS-unlock path into an InitramfsBackend ABC with a get_backend() dispatch, and add the initramfs-tools backend for Debian / Raspberry Pi OS. - base.py: six-step backend contract; encryption.py becomes a thin, distro-neutral sequencer (get_backend by distribution). - initramfs_tools.py: crypttab `none luks,initramfs`, cmdline rewritten to root=/dev/mapper + ip=::::host:eth0:dhcp, dropbear-initramfs authorized_keys, update-initramfs -k all (no build-host uname leak). - shipped hooks (configuration/initramfs-tools/*): single-hop non-anonymous onion, libnss DNS baking, sed-not-source DHCP, kill-tor-before-pivot. - shared offline onion keygen in keygen.py; tor.py removed (logic moved to mkinitcpio.py). - raspios added to the apt distro family (session.py, raspberry.py). Co-Authored-By: Claude Opus 4.8 --- lim/configuration/initramfs-tools/tor_bottom | 19 +++ lim/configuration/initramfs-tools/tor_hook | 35 ++++ .../initramfs-tools/tor_premount | 51 ++++++ lim/configuration/initramfs-tools/torrc | 16 ++ .../packages/server/luks-debian.txt | 5 + .../packages/server/tor-debian.txt | 3 + lim/image/encryption.py | 132 +++------------ lim/image/initramfs/__init__.py | 21 +++ lim/image/initramfs/base.py | 42 +++++ lim/image/initramfs/initramfs_tools.py | 115 +++++++++++++ lim/image/initramfs/keygen.py | 54 ++++++ lim/image/initramfs/mkinitcpio.py | 122 +++++++++++++ lim/image/raspberry.py | 10 +- lim/image/session.py | 18 +- lim/image/tor.py | 79 --------- pyproject.toml | 1 + tests/e2e/test_tor_unlock_e2e.py | 14 +- tests/unit/test_tor.py | 160 +++++++++++++----- 18 files changed, 634 insertions(+), 263 deletions(-) create mode 100644 lim/configuration/initramfs-tools/tor_bottom create mode 100644 lim/configuration/initramfs-tools/tor_hook create mode 100644 lim/configuration/initramfs-tools/tor_premount create mode 100644 lim/configuration/initramfs-tools/torrc create mode 100644 lim/configuration/packages/server/luks-debian.txt create mode 100644 lim/configuration/packages/server/tor-debian.txt create mode 100644 lim/image/initramfs/__init__.py create mode 100644 lim/image/initramfs/base.py create mode 100644 lim/image/initramfs/initramfs_tools.py create mode 100644 lim/image/initramfs/keygen.py create mode 100644 lim/image/initramfs/mkinitcpio.py delete mode 100644 lim/image/tor.py diff --git a/lim/configuration/initramfs-tools/tor_bottom b/lim/configuration/initramfs-tools/tor_bottom new file mode 100644 index 0000000..956bb77 --- /dev/null +++ b/lim/configuration/initramfs-tools/tor_bottom @@ -0,0 +1,19 @@ +#!/bin/sh +# initramfs-tools cleanup hook: stop Tor before the pivot to the real root. +# Installed to /etc/initramfs-tools/scripts/init-bottom/tor by lim. +# Nothing from the initramfs may keep running once run-init replaces it. +PREREQ="" +prereqs() { echo "$PREREQ"; } +case "$1" in + prereqs) prereqs; exit 0 ;; +esac + +# pidof/killall may be absent; scan /proc for the tor binary (no extra tools). +for pid in $(ls /proc 2>/dev/null); do + case "$pid" in + *[!0-9]*) continue ;; + esac + [ "$(readlink "/proc/$pid/exe" 2>/dev/null)" = "/usr/bin/tor" ] \ + && kill "$pid" 2>/dev/null +done +exit 0 diff --git a/lim/configuration/initramfs-tools/tor_hook b/lim/configuration/initramfs-tools/tor_hook new file mode 100644 index 0000000..6650f1e --- /dev/null +++ b/lim/configuration/initramfs-tools/tor_hook @@ -0,0 +1,35 @@ +#!/bin/sh +# initramfs-tools build hook: bake the Tor onion service into the initramfs. +# Installed to /etc/initramfs-tools/hooks/tor by linux-image-manager. +PREREQ="dropbear" +prereqs() { echo "$PREREQ"; } +case "$1" in + prereqs) prereqs; exit 0 ;; +esac + +. /usr/share/initramfs-tools/hook-functions + +copy_exec /usr/bin/tor /usr/bin + +# glibc resolves the NTP server hostname via getaddrinfo(), which dlopen()s +# these NSS modules at runtime; without them DNS silently fails and the clock +# (RTC-less boards) stays at 1970, so Tor rejects the consensus. +for nss in /usr/lib/*/libnss_dns.so.2 /usr/lib/*/libnss_files.so.2 \ + /lib/*/libnss_dns.so.2 /lib/*/libnss_files.so.2; do + [ -e "$nss" ] && copy_exec "$nss" +done + +# Full busybox for the ntpd applet (the initramfs busybox may lack it); a +# distinct path keeps the initramfs's own busybox untouched. +for bb in /bin/busybox /usr/bin/busybox; do + [ -x "$bb" ] && { copy_exec "$bb" /usr/local/bin/busybox; break; } +done + +# Onion keys + torrc, staged on the system by lim. +mkdir -p "${DESTDIR}/etc/tor/onion" +for key in hostname hs_ed25519_public_key hs_ed25519_secret_key; do + cp -a "/etc/tor/initramfs-onion/${key}" "${DESTDIR}/etc/tor/onion/${key}" +done +chmod 0700 "${DESTDIR}/etc/tor/onion" +chmod 0600 "${DESTDIR}/etc/tor/onion/hs_ed25519_secret_key" +cp -a /etc/tor/initramfs-torrc "${DESTDIR}/etc/tor/torrc" diff --git a/lim/configuration/initramfs-tools/tor_premount b/lim/configuration/initramfs-tools/tor_premount new file mode 100644 index 0000000..a5e3397 --- /dev/null +++ b/lim/configuration/initramfs-tools/tor_premount @@ -0,0 +1,51 @@ +#!/bin/sh +# initramfs-tools runtime hook: bring up networking, sync the clock and start +# the Tor onion service so the dropbear unlock shell is reachable while +# cryptsetup waits. Installed to /etc/initramfs-tools/scripts/init-premount/tor. +PREREQ="" +prereqs() { echo "$PREREQ"; } +case "$1" in + prereqs) prereqs; exit 0 ;; +esac + +. /scripts/functions + +log_begin_msg "tor: bringing up networking and starting the onion service" + +# init-premount runs BEFORE the cryptroot/dropbear networking, so Tor would +# start with no network and never publish the onion. Bring the interface up +# ourselves from the ip= cmdline (idempotent; the later dropbear setup reuses +# the lease in /run/net-*.conf). +configure_networking + +# initramfs-tools does not export arbitrary cmdline params as shell vars. +tor_ntp=$(sed -n 's/.*\btor_ntp=\([^ ]*\).*/\1/p' /proc/cmdline) + +# Best-effort DNS from the DHCP lease; extract ONLY the DNS fields with sed, +# never source the files (attacker-controllable DHCP option strings would run +# as root pre-boot). +if [ ! -s /etc/resolv.conf ]; then + for conf in /run/net-*.conf /tmp/net-*.conf; do + [ -f "$conf" ] || continue + for dns in $(sed -n 's/^IPV4DNS[01]=//p' "$conf"); do + [ -n "$dns" ] && [ "$dns" != "0.0.0.0" ] \ + && echo "nameserver $dns" >> /etc/resolv.conf + done + done +fi + +# RTC-less boards boot at 1970; Tor rejects the consensus otherwise. Bounded so +# a failed sync never blocks boot. +if [ -x /usr/local/bin/busybox ]; then + /usr/local/bin/busybox timeout 30 \ + /usr/local/bin/busybox ntpd -n -q -p "${tor_ntp:-pool.ntp.org}" \ + || log_warning_msg "tor: NTP sync failed, keeping current clock" +fi + +mkdir -p /var/lib/tor +chmod 0700 /var/lib/tor /etc/tor/onion +chmod 0600 /etc/tor/onion/hs_ed25519_secret_key +tor -f /etc/tor/torrc --RunAsDaemon 1 --Log "notice file /run/tor.log" \ + || log_warning_msg "tor: failed to start, unlock stays reachable via direct IP" + +log_end_msg diff --git a/lim/configuration/initramfs-tools/torrc b/lim/configuration/initramfs-tools/torrc new file mode 100644 index 0000000..39bd0c4 --- /dev/null +++ b/lim/configuration/initramfs-tools/torrc @@ -0,0 +1,16 @@ +# Tor configuration for the initramfs onion unlock service (initramfs-tools). +# Baked into the initramfs as /etc/tor/torrc by the "tor" hook; the onion keys +# come from /etc/tor/initramfs-onion on the system. +DataDirectory /var/lib/tor +HiddenServiceDir /etc/tor/onion +HiddenServicePort 22 127.0.0.1:22 +SocksPort 0 +# Single-hop, non-anonymous onion service. A boot-unlock onion needs +# reachability (behind NAT / a dynamic IP), NOT server-location anonymity, so +# it can skip the multi-hop, vanguards-restricted circuits that a fresh Tor +# with a limited relay view fails to build ("Giving up on launching a +# rendezvous circuit"). Single-hop builds trivial 1-hop circuits that publish +# and rendezvous reliably. Entry guards are likewise unnecessary here. +HiddenServiceNonAnonymousMode 1 +HiddenServiceSingleHopMode 1 +UseEntryGuards 0 diff --git a/lim/configuration/packages/server/luks-debian.txt b/lim/configuration/packages/server/luks-debian.txt new file mode 100644 index 0000000..986aeee --- /dev/null +++ b/lim/configuration/packages/server/luks-debian.txt @@ -0,0 +1,5 @@ +# Packages for LUKS remote unlock on Debian / Raspberry Pi OS (initramfs-tools) +cryptsetup +cryptsetup-initramfs +dropbear-initramfs +busybox diff --git a/lim/configuration/packages/server/tor-debian.txt b/lim/configuration/packages/server/tor-debian.txt new file mode 100644 index 0000000..f0de82a --- /dev/null +++ b/lim/configuration/packages/server/tor-debian.txt @@ -0,0 +1,3 @@ +# Packages for the Tor onion unlock service in the initramfs (Debian) +tor +busybox # full applet set: ntpd for clock sync on RTC-less boards diff --git a/lim/image/encryption.py b/lim/image/encryption.py index 79351be..431a476 100644 --- a/lim/image/encryption.py +++ b/lim/image/encryption.py @@ -1,129 +1,37 @@ -"""Remote-unlockable LUKS boot configuration (dropbear + mkinitcpio). +"""Remote-unlockable LUKS boot configuration. -See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and -https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994 +Distro-agnostic orchestration; the init-system-specific steps live in the +initramfs backends (mkinitcpio for Arch/Manjaro, initramfs-tools for +Debian/Raspberry Pi OS). See lim/image/initramfs/. """ from pathlib import Path -from lim import catalog, fsutil, packages, runner, ui +from lim import packages, ui +from lim.image.initramfs import get_backend from lim.image.plan import ImagePlan -from lim.image.session import ImageSession, chroot_bash, install_packages -from lim.image.tor import configure_tor_unlock - -MKINITCPIO_HOOKS_PREFIX = ( - "base udev autodetect microcode modconf kms keyboard keymap consolefont block" -) -MKINITCPIO_HOOKS_SUFFIX = "filesystems fsck" +from lim.image.session import ImageSession, install_packages -def _configure_mkinitcpio(plan: ImagePlan, root: Path) -> None: - # Concerning mkinitcpio warnings, see - # https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d - mkinitcpio_path = root / "etc/mkinitcpio.conf" - ui.info(f"Configuring {mkinitcpio_path}...") - additional_modules = catalog.mkinitcpio_modules_by_rpi().get( - plan.raspberry_pi_version - ) - if additional_modules is None: - ui.warning(f"Version {plan.raspberry_pi_version} isn't supported.") - additional_modules = "" - modules = f"g_cdc usb_f_acm usb_f_ecm {additional_modules} g_ether".replace(" ", " ") - fsutil.replace_in_file("MODULES=()", f"MODULES=({modules})", mkinitcpio_path) - fsutil.replace_in_file( - "BINARIES=()", "BINARIES=(/usr/lib/libgcc_s.so.1)", mkinitcpio_path - ) - tor_hook = "tor " if plan.tor_unlock else "" - fsutil.replace_in_file( - f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} {MKINITCPIO_HOOKS_SUFFIX})", - f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf {tor_hook}dropbear encryptssh " - f"{MKINITCPIO_HOOKS_SUFFIX})", - mkinitcpio_path, - ) - ui.info(f"Content of {mkinitcpio_path}:{mkinitcpio_path.read_text()}") - ui.info("Generating mkinitcpio...") - chroot_bash(root, "mkinitcpio -vP") - - -def _register_encrypted_root(plan: ImagePlan, session: ImageSession, root: Path) -> None: - fstab_path = root / "etc/fstab" - fstab_line = ( - f"UUID={session.root_partition_uuid} / {plan.root_filesystem}" - " defaults,noatime 0 1" - ) - ui.info(f"Configuring {fstab_path}...") - fsutil.ensure_line_in_file(fstab_line, fstab_path) - ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}") - - crypttab_path = root / "etc/crypttab" - crypttab_line = ( - f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks" - ) - ui.info(f"Configuring {crypttab_path}...") - fsutil.ensure_line_in_file(crypttab_line, crypttab_path) - ui.info(f"Content of {crypttab_path}:{crypttab_path.read_text()}") - - -def _configure_bootloader(plan: ImagePlan, session: ImageSession, root: Path) -> None: - cryptdevice = ( - f"cryptdevice=UUID={session.root_partition_uuid}:{session.root_mapper_name} " - f"root={session.root_mapper_path}" - ) - boot_txt_path = session.boot_mount_path / "boot.txt" - if boot_txt_path.is_file(): - ui.info(f"Configuring {boot_txt_path}...") - hostname = (root / "etc/hostname").read_text().strip() - fsutil.replace_in_file( - "part uuid ${devtype} ${devnum}:2 uuid", "", boot_txt_path - ) - fsutil.replace_in_file( - "setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=${uuid} " - 'rw rootwait smsc95xx.macaddr="${usbethaddr}"', - f"setenv bootargs console=ttyS1,115200 console=tty0 " - f"ip=::::{hostname}:eth0:dhcp {cryptdevice} rw rootwait " - # Concerning issues with network adapter names, see - # https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16 - 'smsc95xx.macaddr="${usbethaddr}" net.ifnames=0 biosdevname=0', - boot_txt_path, - ) - ui.info(f"Content of {boot_txt_path}:{boot_txt_path.read_text()}") - ui.info("Generating...") - chroot_bash(root, "cd /boot/ && ./mkscr || exit 1") - else: - cmdline_txt_path = session.boot_mount_path / "cmdline.txt" - ui.info(f"Configuring {cmdline_txt_path}...") - # Firmware-boot boards (e.g. RPi4 cmdline.txt) need the same early - # networking as the boot.txt branch, or the initramfs netconf hook - # brings up no interface and remote unlock (dropbear + tor) is - # unreachable — the onion never even publishes. - hostname = (root / "etc/hostname").read_text().strip() - fsutil.replace_in_file( - "root=/dev/mmcblk0p2", - f"{cryptdevice} rootfstype={plan.root_filesystem} " - f"ip=::::{hostname}:eth0:dhcp net.ifnames=0 biosdevname=0", - cmdline_txt_path, - ) - ui.info(f"Content of {cmdline_txt_path}:{cmdline_txt_path.read_text()}") - - -def configure_encryption( - plan: ImagePlan, session: ImageSession, authorized_keys: Path -) -> None: +def configure_encryption(plan: ImagePlan, session: ImageSession, authorized_keys: Path) -> None: root = session.root_mount_path + backend = get_backend(plan.distribution) ui.info("Setup encryption...") ui.info("Installing neccessary software...") install_packages( - plan.distribution, root, " ".join(packages.get_packages("server/luks")) + plan.distribution, + root, + " ".join(packages.get_packages(backend.luks_package_collection())), ) - dropbear_root_key_path = root / "etc/dropbear/root_key" - ui.info(f"Adding {authorized_keys} to dropbear...") - runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True) + backend.install_authorized_key(root, authorized_keys) if plan.tor_unlock: - # Hook files and onion keys must exist before mkinitcpio bakes the image. - configure_tor_unlock(plan, root) + # Hook files and onion keys must exist before the initramfs is baked. + backend.install_tor_unlock(plan, root) - _configure_mkinitcpio(plan, root) - _register_encrypted_root(plan, session, root) - _configure_bootloader(plan, session, root) + # crypttab must be written before the initramfs is (re)generated so the + # Debian cryptsetup hook can bake the mapping in. + backend.register_encrypted_root(plan, session, root) + backend.configure_initramfs(plan, root) + backend.configure_bootloader(plan, session, root) diff --git a/lim/image/initramfs/__init__.py b/lim/image/initramfs/__init__.py new file mode 100644 index 0000000..96e0f43 --- /dev/null +++ b/lim/image/initramfs/__init__.py @@ -0,0 +1,21 @@ +"""Distro-specific initramfs backends for encrypted remote unlock.""" + +from lim.errors import LimError +from lim.image.initramfs.base import InitramfsBackend +from lim.image.initramfs.initramfs_tools import InitramfsToolsBackend +from lim.image.initramfs.mkinitcpio import MkinitcpioBackend + +_MKINITCPIO = frozenset({"arch", "manjaro"}) +_INITRAMFS_TOOLS = frozenset({"raspios", "moode", "retropie"}) + + +def get_backend(distribution: str | None) -> InitramfsBackend: + """Pick the initramfs backend for a distribution's init system.""" + if distribution in _MKINITCPIO: + return MkinitcpioBackend() + if distribution in _INITRAMFS_TOOLS: + return InitramfsToolsBackend() + raise LimError(f"No initramfs backend for distribution {distribution!r}.") + + +__all__ = ["InitramfsBackend", "get_backend"] diff --git a/lim/image/initramfs/base.py b/lim/image/initramfs/base.py new file mode 100644 index 0000000..b50f7bd --- /dev/null +++ b/lim/image/initramfs/base.py @@ -0,0 +1,42 @@ +"""The initramfs backend interface. + +Encrypted remote-unlock images differ by init system: Arch/Manjaro use +mkinitcpio, Debian/Raspberry Pi OS use initramfs-tools. Each backend owns the +distro-specific bits (package set, crypttab syntax, initramfs generation, +bootloader cmdline, and how the Tor hook is baked in); the encryption flow +orchestrates them the same way for every distro. +""" + +from abc import ABC, abstractmethod +from pathlib import Path + +from lim.image.plan import ImagePlan +from lim.image.session import ImageSession + + +class InitramfsBackend(ABC): + """Distro-specific steps to build an encrypted, remote-unlockable image.""" + + @abstractmethod + def luks_package_collection(self) -> str: + """Name of the package collection providing the LUKS/unlock stack.""" + + @abstractmethod + def install_authorized_key(self, root: Path, authorized_keys: Path) -> None: + """Place the SSH public key where the initramfs dropbear reads it.""" + + @abstractmethod + def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str: + """Bake the Tor onion service into the initramfs; return the address.""" + + @abstractmethod + def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None: + """Write fstab and crypttab in the distro's syntax.""" + + @abstractmethod + def configure_initramfs(self, plan: ImagePlan, root: Path) -> None: + """Configure the initramfs modules/hooks and (re)generate it.""" + + @abstractmethod + def configure_bootloader(self, plan: ImagePlan, session: ImageSession, root: Path) -> None: + """Point the bootloader at the encrypted root with early networking.""" diff --git a/lim/image/initramfs/initramfs_tools.py b/lim/image/initramfs/initramfs_tools.py new file mode 100644 index 0000000..9b503d0 --- /dev/null +++ b/lim/image/initramfs/initramfs_tools.py @@ -0,0 +1,115 @@ +"""initramfs-tools backend (Debian / Raspberry Pi OS). + +Debian does not use mkinitcpio; the LUKS unlock stack is cryptsetup-initramfs +(reads /etc/crypttab, option ``initramfs``) + dropbear-initramfs (serves the +passphrase prompt over SSH). Raspberry Pi OS ships without an initramfs, so we +enable one in config.txt and regenerate with update-initramfs. + +NOTE: the exact dropbear-initramfs key path, the boot-partition mount point +(/boot vs /boot/firmware) and the initramfs enable knob vary across Raspberry +Pi OS releases; verify on the target release. This path is not exercised by the +QEMU harness yet. +""" + +import re +from pathlib import Path + +from lim import config, fsutil, packages, runner, ui +from lim.image.initramfs import keygen +from lim.image.initramfs.base import InitramfsBackend +from lim.image.plan import ImagePlan +from lim.image.session import ImageSession, chroot_bash, install_packages + + +class InitramfsToolsBackend(InitramfsBackend): + def luks_package_collection(self) -> str: + return "server/luks-debian" + + def install_authorized_key(self, root: Path, authorized_keys: Path) -> None: + # dropbear-initramfs (bookworm) reads /etc/dropbear/initramfs/authorized_keys. + target = root / "etc/dropbear/initramfs/authorized_keys" + ui.info(f"Adding {authorized_keys} to {target}...") + runner.run( + ["install", "-D", "-m", "0600", str(authorized_keys), str(target)], + sudo=True, + ) + + def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str: + ui.info("Setting up remote unlock via Tor onion service (initramfs-tools)...") + install_packages( + plan.distribution, root, " ".join(packages.get_packages("server/tor-debian")) + ) + source_dir = config.CONFIGURATION_PATH / "initramfs-tools" + for source, target, mode in ( + (source_dir / "tor_hook", root / "etc/initramfs-tools/hooks/tor", "0755"), + ( + source_dir / "tor_premount", + root / "etc/initramfs-tools/scripts/init-premount/tor", + "0755", + ), + ( + source_dir / "tor_bottom", + root / "etc/initramfs-tools/scripts/init-bottom/tor", + "0755", + ), + (source_dir / "torrc", root / keygen.TORRC_STAGING_PATH, "0644"), + ): + ui.info(f"Installing {target}...") + runner.run(["install", "-D", "-m", mode, str(source), str(target)], sudo=True) + onion_address = keygen.generate_onion_keys(root) + ui.success(f"Onion unlock address: {onion_address}") + ui.info(f"Unlock later with: torsocks ssh root@{onion_address}") + return onion_address + + def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None: + fstab_path = root / "etc/fstab" + fstab_line = ( + f"/dev/mapper/{session.root_mapper_name} / {plan.root_filesystem}" + " defaults,noatime 0 1" + ) + ui.info(f"Configuring {fstab_path}...") + fsutil.ensure_line_in_file(fstab_line, fstab_path) + + crypttab_path = root / "etc/crypttab" + # `initramfs` bakes the mapping into the initramfs so cryptsetup-initramfs + # unlocks the root before the pivot. + crypttab_line = ( + f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks,initramfs" + ) + ui.info(f"Configuring {crypttab_path}...") + fsutil.ensure_line_in_file(crypttab_line, crypttab_path) + + def configure_initramfs(self, plan: ImagePlan, root: Path) -> None: # noqa: ARG002 + ui.info("Regenerating the Debian initramfs (update-initramfs)...") + # Create for all installed kernels when none exists yet (Raspberry Pi OS + # ships without one), else update the existing one. `-k all` avoids the + # build host's `uname -r` leaking into the chroot. + chroot_bash( + root, + "update-initramfs -c -k all 2>/dev/null || update-initramfs -u -k all", + ) + + def configure_bootloader( + self, + plan: ImagePlan, # noqa: ARG002 + session: ImageSession, + root: Path, + ) -> None: + hostname = (root / "etc/hostname").read_text().strip() + boot = session.boot_mount_path + + config_txt = boot / "config.txt" + if config_txt.is_file(): + ui.info(f"Enabling an initramfs in {config_txt}...") + fsutil.ensure_line_in_file("auto_initramfs=1", config_txt) + + cmdline_txt = boot / "cmdline.txt" + ui.info(f"Configuring {cmdline_txt}...") + text = cmdline_txt.read_text().strip() + text = re.sub(r"root=\S+", f"root=/dev/mapper/{session.root_mapper_name}", text) + if "ip=" not in text: + text = f"{text} ip=::::{hostname}:eth0:dhcp" + if "net.ifnames=0" not in text: + text = f"{text} net.ifnames=0" + cmdline_txt.write_text(text + "\n") + ui.info(f"Content of {cmdline_txt}:{cmdline_txt.read_text()}") diff --git a/lim/image/initramfs/keygen.py b/lim/image/initramfs/keygen.py new file mode 100644 index 0000000..4d78f1e --- /dev/null +++ b/lim/image/initramfs/keygen.py @@ -0,0 +1,54 @@ +"""Offline Tor onion key generation, shared by every initramfs backend. + +``tor --DisableNetwork 1`` writes a v3 onion service's keys without touching +the network, so the same stable ``.onion`` address can be baked into the +initramfs whether the distro uses mkinitcpio or initramfs-tools. +""" + +from pathlib import Path + +from lim import ui +from lim.errors import LimError +from lim.image.session import chroot_bash + +# Staged relative to the mounted image root. +ONION_STAGING_DIR = "etc/tor/initramfs-onion" +TORRC_STAGING_PATH = "etc/tor/initramfs-torrc" + +# An empty -f torrc keeps the image's /etc/tor/torrc (User tor, ...) out of the +# keygen run, which would otherwise fail as non-root inside the chroot. +_KEYGEN_SCRIPT = f""" +mkdir -p /{ONION_STAGING_DIR} +chmod 0700 /{ONION_STAGING_DIR} +: > /tmp/tor-keygen-torrc +tor -f /tmp/tor-keygen-torrc --DisableNetwork 1 \\ + --DataDirectory /tmp/tor-keygen-data \\ + --HiddenServiceDir /{ONION_STAGING_DIR} \\ + --HiddenServicePort "22 127.0.0.1:22" \\ + --SocksPort 0 --RunAsDaemon 0 --Log "notice stderr" & +tor_pid=$! +for _ in $(seq 1 30); do + [ -s /{ONION_STAGING_DIR}/hostname ] && break + sleep 1 +done +kill "$tor_pid" 2>/dev/null || true +rm -rf /tmp/tor-keygen-data /tmp/tor-keygen-torrc +[ -s /{ONION_STAGING_DIR}/hostname ] +""" + + +def generate_onion_keys(root: Path) -> str: + """Generate the onion keys offline in the chroot; return the .onion address. + + Idempotent: existing keys are kept so the address stays stable across + re-runs. + """ + hostname_path = root / ONION_STAGING_DIR / "hostname" + if hostname_path.is_file(): + ui.info("Onion keys already exist, keeping the existing address.") + else: + ui.info("Generating onion service keys (offline, inside the chroot)...") + chroot_bash(root, _KEYGEN_SCRIPT, error_msg="Onion key generation failed.") + if not hostname_path.is_file(): + raise LimError(f"Onion key generation produced no {hostname_path}.") + return hostname_path.read_text().strip() diff --git a/lim/image/initramfs/mkinitcpio.py b/lim/image/initramfs/mkinitcpio.py new file mode 100644 index 0000000..36e37ac --- /dev/null +++ b/lim/image/initramfs/mkinitcpio.py @@ -0,0 +1,122 @@ +"""mkinitcpio backend (Arch Linux ARM / Manjaro ARM). + +See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and +https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994 +""" + +from pathlib import Path + +from lim import catalog, config, fsutil, packages, runner, ui +from lim.image.initramfs import keygen +from lim.image.initramfs.base import InitramfsBackend +from lim.image.plan import ImagePlan +from lim.image.session import ImageSession, chroot_bash, install_packages + +MKINITCPIO_HOOKS_PREFIX = ( + "base udev autodetect microcode modconf kms keyboard keymap consolefont block" +) +MKINITCPIO_HOOKS_SUFFIX = "filesystems fsck" + + +class MkinitcpioBackend(InitramfsBackend): + def luks_package_collection(self) -> str: + return "server/luks" + + def install_authorized_key(self, root: Path, authorized_keys: Path) -> None: + dropbear_root_key_path = root / "etc/dropbear/root_key" + ui.info(f"Adding {authorized_keys} to dropbear...") + runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True) + + def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str: + ui.info("Setting up remote unlock via Tor onion service...") + install_packages(plan.distribution, root, " ".join(packages.get_packages("server/tor"))) + source_dir = config.CONFIGURATION_PATH / "initcpio" + for source, target in ( + (source_dir / "tor_install", root / "etc/initcpio/install/tor"), + (source_dir / "tor_hook", root / "etc/initcpio/hooks/tor"), + (source_dir / "torrc", root / keygen.TORRC_STAGING_PATH), + ): + ui.info(f"Installing {target}...") + runner.run(["install", "-D", "-m", "0644", str(source), str(target)], sudo=True) + onion_address = keygen.generate_onion_keys(root) + ui.success(f"Onion unlock address: {onion_address}") + ui.info(f"Unlock later with: torsocks ssh root@{onion_address}") + return onion_address + + def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None: + fstab_path = root / "etc/fstab" + fstab_line = ( + f"UUID={session.root_partition_uuid} / {plan.root_filesystem}" + " defaults,noatime 0 1" + ) + ui.info(f"Configuring {fstab_path}...") + fsutil.ensure_line_in_file(fstab_line, fstab_path) + ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}") + + crypttab_path = root / "etc/crypttab" + crypttab_line = f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks" + ui.info(f"Configuring {crypttab_path}...") + fsutil.ensure_line_in_file(crypttab_line, crypttab_path) + ui.info(f"Content of {crypttab_path}:{crypttab_path.read_text()}") + + def configure_initramfs(self, plan: ImagePlan, root: Path) -> None: + # Concerning mkinitcpio warnings, see + # https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d + mkinitcpio_path = root / "etc/mkinitcpio.conf" + ui.info(f"Configuring {mkinitcpio_path}...") + additional_modules = catalog.mkinitcpio_modules_by_rpi().get(plan.raspberry_pi_version) + if additional_modules is None: + ui.warning(f"Version {plan.raspberry_pi_version} isn't supported.") + additional_modules = "" + modules = f"g_cdc usb_f_acm usb_f_ecm {additional_modules} g_ether".replace(" ", " ") + fsutil.replace_in_file("MODULES=()", f"MODULES=({modules})", mkinitcpio_path) + fsutil.replace_in_file("BINARIES=()", "BINARIES=(/usr/lib/libgcc_s.so.1)", mkinitcpio_path) + tor_hook = "tor " if plan.tor_unlock else "" + fsutil.replace_in_file( + f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} {MKINITCPIO_HOOKS_SUFFIX})", + f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf {tor_hook}dropbear " + f"encryptssh {MKINITCPIO_HOOKS_SUFFIX})", + mkinitcpio_path, + ) + ui.info(f"Content of {mkinitcpio_path}:{mkinitcpio_path.read_text()}") + ui.info("Generating mkinitcpio...") + chroot_bash(root, "mkinitcpio -vP") + + def configure_bootloader(self, plan: ImagePlan, session: ImageSession, root: Path) -> None: + cryptdevice = ( + f"cryptdevice=UUID={session.root_partition_uuid}:{session.root_mapper_name} " + f"root={session.root_mapper_path}" + ) + boot_txt_path = session.boot_mount_path / "boot.txt" + if boot_txt_path.is_file(): + ui.info(f"Configuring {boot_txt_path}...") + hostname = (root / "etc/hostname").read_text().strip() + fsutil.replace_in_file("part uuid ${devtype} ${devnum}:2 uuid", "", boot_txt_path) + fsutil.replace_in_file( + "setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=${uuid} " + 'rw rootwait smsc95xx.macaddr="${usbethaddr}"', + f"setenv bootargs console=ttyS1,115200 console=tty0 " + f"ip=::::{hostname}:eth0:dhcp {cryptdevice} rw rootwait " + # Concerning issues with network adapter names, see + # https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16 + 'smsc95xx.macaddr="${usbethaddr}" net.ifnames=0 biosdevname=0', + boot_txt_path, + ) + ui.info(f"Content of {boot_txt_path}:{boot_txt_path.read_text()}") + ui.info("Generating...") + chroot_bash(root, "cd /boot/ && ./mkscr || exit 1") + else: + cmdline_txt_path = session.boot_mount_path / "cmdline.txt" + ui.info(f"Configuring {cmdline_txt_path}...") + # Firmware-boot boards (e.g. RPi4 cmdline.txt) need the same early + # networking as the boot.txt branch, or the initramfs netconf hook + # brings up no interface and remote unlock (dropbear + tor) is + # unreachable — the onion never even publishes. + hostname = (root / "etc/hostname").read_text().strip() + fsutil.replace_in_file( + "root=/dev/mmcblk0p2", + f"{cryptdevice} rootfstype={plan.root_filesystem} " + f"ip=::::{hostname}:eth0:dhcp net.ifnames=0 biosdevname=0", + cmdline_txt_path, + ) + ui.info(f"Content of {cmdline_txt_path}:{cmdline_txt_path.read_text()}") diff --git a/lim/image/raspberry.py b/lim/image/raspberry.py index b460a84..e754300 100644 --- a/lim/image/raspberry.py +++ b/lim/image/raspberry.py @@ -23,9 +23,7 @@ def configure_sudoers(root_mount_path: Path, username: str) -> None: raise LimError(f"Failed to create sudoers file for {username}: {exc}") from exc -def configure_ssh_key( - public_key_path: str, target_ssh_folder: Path, authorized_keys: Path -) -> None: +def configure_ssh_key(public_key_path: str, target_ssh_folder: Path, authorized_keys: Path) -> None: source = Path(public_key_path) if not source.is_file(): raise LimError( @@ -64,9 +62,7 @@ def _seed_boot_uuid(session: ImageSession) -> None: ui.info(f"{fstab_path} does not reference /dev/mmcblk0p1. Skipping UUID seeding.") return ui.info(f"Seeding UUID to {fstab_path} to avoid path conflicts...") - fsutil.replace_in_file( - "/dev/mmcblk0p1", f"UUID={session.boot_partition_uuid}", fstab_path - ) + fsutil.replace_in_file("/dev/mmcblk0p1", f"UUID={session.boot_partition_uuid}", fstab_path) ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}") @@ -135,7 +131,7 @@ def _update_system(plan: ImagePlan, root: Path) -> None: ui.info("Updating system...") if plan.distribution in ("arch", "manjaro"): chroot_bash(root, "pacman --noconfirm -Syyu") - elif plan.distribution in ("moode", "retropie"): + elif plan.distribution in ("moode", "retropie", "raspios"): chroot_bash(root, "yes | apt update\nyes | apt upgrade\n") else: ui.warning( diff --git a/lim/image/session.py b/lim/image/session.py index 566444f..cf3981f 100644 --- a/lim/image/session.py +++ b/lim/image/session.py @@ -49,12 +49,8 @@ class ImageSession: def read_partition_uuids(self) -> None: """Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset.""" - self.root_partition_uuid = device_module.blkid_value( - self.root_partition_path, "UUID" - ) - self.boot_partition_uuid = device_module.blkid_value( - self.boot_partition_path, "UUID" - ) + self.root_partition_uuid = device_module.blkid_value(self.root_partition_path, "UUID") + self.boot_partition_uuid = device_module.blkid_value(self.boot_partition_path, "UUID") if self.root_mapper_name is None and self.root_is_luks(): # Same deterministic name decrypt_root() would have used. self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}" @@ -63,9 +59,7 @@ class ImageSession: def decrypt_root(self) -> None: if not self.root_is_luks(): return - self.root_partition_uuid = device_module.blkid_value( - self.root_partition_path, "UUID" - ) + self.root_partition_uuid = device_module.blkid_value(self.root_partition_path, "UUID") self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}" self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}" ui.info(f"Decrypting of {self.root_partition_path} is neccessary...") @@ -88,9 +82,7 @@ class ImageSession: ["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)], sudo=True, ) - runner.run( - ["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True - ) + runner.run(["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True) ui.info("Setting uuid variables...") self.read_partition_uuids() ui.info("The following mounts refering this setup exist:") @@ -184,7 +176,7 @@ def install_packages(distribution: str, root_mount_path: Path, package_names: st ui.info(f"Installing {package_names}...") if distribution in ("arch", "manjaro"): chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}") - elif distribution in ("moode", "retropie"): + elif distribution in ("moode", "retropie", "raspios"): chroot_bash(root_mount_path, f"yes | apt install {package_names}") else: raise LimError("Package manager not supported.") diff --git a/lim/image/tor.py b/lim/image/tor.py deleted file mode 100644 index 2c9a370..0000000 --- a/lim/image/tor.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Tor onion service in the initramfs for remote LUKS unlock. - -The onion keys are generated offline inside the image chroot -(``tor --DisableNetwork 1`` writes them without touching the network) -and baked into the initramfs by the "tor" mkinitcpio hook, so the -dropbear unlock shell stays reachable under a stable .onion address -even behind NAT or a dynamic IP. -""" - -from pathlib import Path - -from lim import config, packages, runner, ui -from lim.errors import LimError -from lim.image.plan import ImagePlan -from lim.image.session import chroot_bash, install_packages - -# Paths inside the image (relative to the mounted root). -ONION_DIR = "etc/tor/initramfs-onion" -TORRC_PATH = "etc/tor/initramfs-torrc" - -# An empty -f torrc keeps the image's /etc/tor/torrc (User tor, ...) out of -# the keygen run; with DisableNetwork the keys appear within a second, the -# loop only cushions slow qemu-emulated chroots. -_KEYGEN_SCRIPT = f""" -mkdir -p /{ONION_DIR} -chmod 0700 /{ONION_DIR} -: > /tmp/tor-keygen-torrc -tor -f /tmp/tor-keygen-torrc --DisableNetwork 1 \\ - --DataDirectory /tmp/tor-keygen-data \\ - --HiddenServiceDir /{ONION_DIR} \\ - --HiddenServicePort "22 127.0.0.1:22" \\ - --SocksPort 0 --RunAsDaemon 0 --Log "notice stderr" & -tor_pid=$! -for _ in $(seq 1 30); do - [ -s /{ONION_DIR}/hostname ] && break - sleep 1 -done -kill "$tor_pid" 2>/dev/null || true -rm -rf /tmp/tor-keygen-data /tmp/tor-keygen-torrc -[ -s /{ONION_DIR}/hostname ] -""" - - -def _install_initcpio_files(root: Path) -> None: - source_dir = config.CONFIGURATION_PATH / "initcpio" - for source, target in ( - (source_dir / "tor_install", root / "etc/initcpio/install/tor"), - (source_dir / "tor_hook", root / "etc/initcpio/hooks/tor"), - (source_dir / "torrc", root / TORRC_PATH), - ): - ui.info(f"Installing {target}...") - runner.run( - ["install", "-D", "-m", "0644", str(source), str(target)], sudo=True - ) - - -def _generate_onion_keys(root: Path) -> str: - hostname_path = root / ONION_DIR / "hostname" - if hostname_path.is_file(): - ui.info("Onion keys already exist, keeping the existing address.") - else: - ui.info("Generating onion service keys (offline, inside the chroot)...") - chroot_bash(root, _KEYGEN_SCRIPT, error_msg="Onion key generation failed.") - if not hostname_path.is_file(): - raise LimError(f"Onion key generation produced no {hostname_path}.") - return hostname_path.read_text().strip() - - -def configure_tor_unlock(plan: ImagePlan, root: Path) -> str: - """Install everything the "tor" mkinitcpio hook bakes in; return the onion address.""" - ui.info("Setting up remote unlock via Tor onion service...") - install_packages( - plan.distribution, root, " ".join(packages.get_packages("server/tor")) - ) - _install_initcpio_files(root) - onion_address = _generate_onion_keys(root) - ui.success(f"Onion unlock address: {onion_address}") - ui.info(f"Unlock later with: torsocks ssh root@{onion_address}") - return onion_address diff --git a/pyproject.toml b/pyproject.toml index d8d75be..12e6a32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ lim = [ "distributions.yml", "configuration/packages/**/*.txt", "configuration/initcpio/*", + "configuration/initramfs-tools/*", ] [tool.pytest.ini_options] diff --git a/tests/e2e/test_tor_unlock_e2e.py b/tests/e2e/test_tor_unlock_e2e.py index 37f39f1..e5e7a0b 100644 --- a/tests/e2e/test_tor_unlock_e2e.py +++ b/tests/e2e/test_tor_unlock_e2e.py @@ -3,7 +3,7 @@ It reproduces the operator-visible half of the decryption process that the "tor" mkinitcpio hook drives at boot: - 1. generate the v3 onion keys offline (as lim.image.tor does in the chroot), + 1. generate the v3 onion keys offline (as lim.image.initramfs.keygen does in the chroot), 2. start a real Tor onion service from a torrc mirroring the baked-in one, forwarding the virtual port 22 to a local dropbear stand-in, 3. connect to the .onion address through Tor and deliver the passphrase, @@ -24,7 +24,7 @@ import time import pytest -from lim.image import tor as tor_module +from lim.image.initramfs import keygen from tests.e2e import tor_harness # The offline checks only need the tor binary (no network) and are @@ -32,9 +32,7 @@ from tests.e2e import tor_harness # onion round-trip needs the public Tor network, so it stays opt-in behind # LIM_E2E_TOR=1 to keep an external, occasionally-flaky dependency out of the # blocking gate. -_needs_tor = pytest.mark.skipif( - shutil.which("tor") is None, reason="needs the tor binary" -) +_needs_tor = pytest.mark.skipif(shutil.which("tor") is None, reason="needs the tor binary") _needs_tor_network = pytest.mark.skipif( shutil.which("tor") is None or os.environ.get("LIM_E2E_TOR") != "1", reason="needs the tor binary and LIM_E2E_TOR=1 (live Tor network, slow)", @@ -52,7 +50,7 @@ def workdir(tmp_path): def test_offline_keygen_matches_production_flags(): """Guard: the harness keygen mirrors the flags production actually runs.""" - script = tor_module._KEYGEN_SCRIPT + script = keygen._KEYGEN_SCRIPT assert "--DisableNetwork 1" in script assert "HiddenServicePort" in script assert "22 127.0.0.1:22" in script @@ -62,9 +60,7 @@ def test_offline_keygen_matches_production_flags(): @_needs_tor def test_onion_keygen_is_deterministic_and_offline(workdir): """Keys generate without network and the .onion address is stable.""" - address = tor_harness.generate_onion_keys( - workdir / "onion", workdir / "data" - ) + address = tor_harness.generate_onion_keys(workdir / "onion", workdir / "data") assert address.endswith(".onion") assert len(address) == len("v" * 56) + len(".onion") # v3 = 56 base32 chars for name in ("hs_ed25519_secret_key", "hs_ed25519_public_key", "hostname"): diff --git a/tests/unit/test_tor.py b/tests/unit/test_tor.py index 3e69633..8513f86 100644 --- a/tests/unit/test_tor.py +++ b/tests/unit/test_tor.py @@ -3,7 +3,9 @@ import pytest from lim import config from lim.device import Device from lim.errors import LimError -from lim.image import encryption, tor +from lim.image.initramfs import get_backend, keygen, mkinitcpio +from lim.image.initramfs.initramfs_tools import InitramfsToolsBackend +from lim.image.initramfs.mkinitcpio import MkinitcpioBackend from lim.image.plan import ImagePlan from lim.image.session import ImageSession @@ -13,17 +15,45 @@ def plan(): return ImagePlan(distribution="arch", raspberry_pi_version="4", tor_unlock=True) +@pytest.fixture +def debian_plan(): + return ImagePlan(distribution="raspios", root_filesystem="ext4", tor_unlock=True) + + def _write_onion_hostname(root, address="abcdefghijklmnop.onion"): - onion_dir = root / tor.ONION_DIR + onion_dir = root / keygen.ONION_STAGING_DIR onion_dir.mkdir(parents=True) (onion_dir / "hostname").write_text(f"{address}\n") -class TestConfigureTorUnlock: +def _session(tmp_path, mapper="cryptroot"): + session = ImageSession(Device("mmcblk0")) + session.root_partition_uuid = "ROOT-UUID" + session.root_mapper_name = mapper + session.root_mapper_path = f"/dev/mapper/{mapper}" + session.boot_mount_path = tmp_path / "boot" + session.boot_mount_path.mkdir() + return session + + +class TestBackendDispatch: + def test_arch_and_manjaro_use_mkinitcpio(self): + assert isinstance(get_backend("arch"), MkinitcpioBackend) + assert isinstance(get_backend("manjaro"), MkinitcpioBackend) + + def test_raspios_uses_initramfs_tools(self): + assert isinstance(get_backend("raspios"), InitramfsToolsBackend) + + def test_unknown_distribution_raises(self): + with pytest.raises(LimError, match="No initramfs backend"): + get_backend("gentoo") + + +class TestMkinitcpioTorUnlock: def test_installs_hooks_and_returns_address(self, tmp_path, plan, fake_runner): _write_onion_hostname(tmp_path, "stableaddress.onion") - address = tor.configure_tor_unlock(plan, tmp_path) + address = MkinitcpioBackend().install_tor_unlock(plan, tmp_path) assert address == "stableaddress.onion" package_installs = [ @@ -35,74 +65,53 @@ class TestConfigureTorUnlock: assert len(fake_runner.find("install", "tor_install", "etc/initcpio/install/tor")) == 1 assert len(fake_runner.find("install", "tor_hook", "etc/initcpio/hooks/tor")) == 1 assert len(fake_runner.find("install", "torrc", "etc/tor/initramfs-torrc")) == 1 - # Existing keys must be kept: no keygen chroot run. assert fake_runner.find("chroot", "DisableNetwork") == [] def test_generates_keys_when_missing(self, tmp_path, plan, fake_runner): with pytest.raises(LimError, match="produced no"): - tor.configure_tor_unlock(plan, tmp_path) - # FakeRunner executes nothing, so the hostname file never appears — - # but the keygen chroot script must have been issued exactly once. + MkinitcpioBackend().install_tor_unlock(plan, tmp_path) keygen_calls = [ - (kind, cmd, input_text) - for kind, cmd, input_text in fake_runner.calls + input_text + for _, _, input_text in fake_runner.calls if input_text and "DisableNetwork" in input_text ] assert len(keygen_calls) == 1 - assert "HiddenServiceDir" in keygen_calls[0][2] - - def test_hook_resources_exist(self): - source_dir = config.CONFIGURATION_PATH / "initcpio" - for name in ("tor_install", "tor_hook", "torrc"): - assert (source_dir / name).is_file() + assert "HiddenServiceDir" in keygen_calls[0] class TestInitcpioHookHardening: - """Guards for review findings in the shipped initramfs hooks.""" + """Guards for review findings in the shipped mkinitcpio hooks.""" def _read(self, name): return (config.CONFIGURATION_PATH / "initcpio" / name).read_text() + def test_hook_resources_exist(self): + for name in ("tor_install", "tor_hook", "torrc"): + assert (config.CONFIGURATION_PATH / "initcpio" / name).is_file() + def test_install_bakes_the_dns_resolver(self): - # Without the NSS module the NTP hostname never resolves and the clock - # stays at 1970, so Tor rejects the consensus and never publishes. assert "libnss_dns.so.2" in self._read("tor_install") def test_hook_never_sources_dhcp_lease_files(self): - # Sourcing /tmp/net-*.conf would run attacker-controlled DHCP option - # strings as root before the LUKS root is unlocked. hook = self._read("tor_hook") assert '. "$conf"' not in hook assert "sed -n 's/^IPV4DNS" in hook def test_hook_attempts_ntp_without_gating_on_dhcp_dns(self): - # NTP must run even when tor_ntp is an IP literal (no DNS needed). - hook = self._read("tor_hook") - assert "skipping NTP sync" not in hook + assert "skipping NTP sync" not in self._read("tor_hook") -class TestBootloaderNetworking: +class TestMkinitcpioBootloader: """The cmdline.txt boot path (RPi4-class) must set ip= for remote unlock.""" - def _session(self, tmp_path): - session = ImageSession(Device("mmcblk0")) - session.root_partition_uuid = "ROOT-UUID" - session.root_mapper_name = "cryptroot" - session.root_mapper_path = "/dev/mapper/cryptroot" - session.boot_mount_path = tmp_path / "boot" - session.boot_mount_path.mkdir() - return session - def test_cmdline_txt_gets_network_params(self, tmp_path, plan): root = tmp_path / "root" (root / "etc").mkdir(parents=True) (root / "etc" / "hostname").write_text("myhost\n") - session = self._session(tmp_path) - (session.boot_mount_path / "cmdline.txt").write_text( - "root=/dev/mmcblk0p2 rw rootwait\n" - ) + session = _session(tmp_path) + (session.boot_mount_path / "cmdline.txt").write_text("root=/dev/mmcblk0p2 rw rootwait\n") - encryption._configure_bootloader(plan, session, root) + MkinitcpioBackend().configure_bootloader(plan, session, root) content = (session.boot_mount_path / "cmdline.txt").read_text() assert "ip=::::myhost:eth0:dhcp" in content @@ -118,20 +127,85 @@ class TestMkinitcpioHooksLine: path.write_text( "MODULES=()\n" "BINARIES=()\n" - f"HOOKS=({encryption.MKINITCPIO_HOOKS_PREFIX} " - f"{encryption.MKINITCPIO_HOOKS_SUFFIX})\n" + f"HOOKS=({mkinitcpio.MKINITCPIO_HOOKS_PREFIX} " + f"{mkinitcpio.MKINITCPIO_HOOKS_SUFFIX})\n" ) return path def test_tor_hook_between_netconf_and_dropbear(self, tmp_path, plan, fake_runner): path = self._mkinitcpio_conf(tmp_path) - encryption._configure_mkinitcpio(plan, tmp_path) + MkinitcpioBackend().configure_initramfs(plan, tmp_path) assert "netconf tor dropbear encryptssh" in path.read_text() def test_no_tor_hook_when_disabled(self, tmp_path, plan, fake_runner): plan.tor_unlock = False path = self._mkinitcpio_conf(tmp_path) - encryption._configure_mkinitcpio(plan, tmp_path) + MkinitcpioBackend().configure_initramfs(plan, tmp_path) content = path.read_text() assert "netconf dropbear encryptssh" in content assert " tor " not in content + + +class TestInitramfsToolsBackend: + """The Debian / Raspberry Pi OS backend.""" + + def test_crypttab_uses_initramfs_option(self, tmp_path, debian_plan): + root = tmp_path / "root" + (root / "etc").mkdir(parents=True) + session = _session(tmp_path) + InitramfsToolsBackend().register_encrypted_root(debian_plan, session, root) + crypttab = (root / "etc/crypttab").read_text() + assert "cryptroot UUID=ROOT-UUID none luks,initramfs" in crypttab + assert "/dev/mapper/cryptroot" in (root / "etc/fstab").read_text() + + def test_cmdline_points_at_mapper_with_network(self, tmp_path, debian_plan): + root = tmp_path / "root" + (root / "etc").mkdir(parents=True) + (root / "etc" / "hostname").write_text("pi\n") + session = _session(tmp_path) + (session.boot_mount_path / "cmdline.txt").write_text( + "console=serial0,115200 root=PARTUUID=abcd-02 rootfstype=ext4 rootwait\n" + ) + (session.boot_mount_path / "config.txt").write_text("dtparam=audio=on\n") + + InitramfsToolsBackend().configure_bootloader(debian_plan, session, root) + + cmdline = (session.boot_mount_path / "cmdline.txt").read_text() + assert "root=/dev/mapper/cryptroot" in cmdline + assert "root=PARTUUID=abcd-02" not in cmdline + assert "ip=::::pi:eth0:dhcp" in cmdline + assert cmdline.count("\n") == 1 # cmdline must stay a single line + assert "auto_initramfs=1" in (session.boot_mount_path / "config.txt").read_text() + + def test_install_tor_unlock_places_initramfs_tools_scripts( + self, tmp_path, debian_plan, fake_runner + ): + _write_onion_hostname(tmp_path, "debianonion.onion") + address = InitramfsToolsBackend().install_tor_unlock(debian_plan, tmp_path) + assert address == "debianonion.onion" + assert len(fake_runner.find("install", "tor_hook", "hooks/tor")) == 1 + assert len(fake_runner.find("tor_premount", "init-premount/tor")) == 1 + assert len(fake_runner.find("tor_bottom", "init-bottom/tor")) == 1 + apt = [ + text + for _, _, text in fake_runner.calls + if text and "apt install" in text and "tor busybox" in text + ] + assert len(apt) == 1 + + +class TestInitramfsToolsHookHardening: + def _read(self, name): + return (config.CONFIGURATION_PATH / "initramfs-tools" / name).read_text() + + def test_hook_files_exist(self): + for name in ("tor_hook", "tor_premount", "tor_bottom", "torrc"): + assert (config.CONFIGURATION_PATH / "initramfs-tools" / name).is_file() + + def test_hook_bakes_dns_resolver(self): + assert "libnss_dns.so.2" in self._read("tor_hook") + + def test_premount_does_not_source_lease_files(self): + premount = self._read("tor_premount") + assert '. "$conf"' not in premount + assert "sed -n 's/^IPV4DNS" in premount