From 1e711f5c968c9929998556fa4f7865a1b1ff13d1 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 21 Jul 2026 18:48:56 +0200 Subject: [PATCH] feat(image): remote LUKS unlock via a Tor onion service in the initramfs Encrypted image setups can now bake a Tor onion service into the initramfs so the dropbear unlock shell stays reachable behind NAT or a dynamic IP. When the user opts in, configure_encryption installs tor + busybox, drops the mkinitcpio hooks (ordered `netconf tor dropbear encryptssh`), generates the v3 onion keys offline in the image chroot, and prints the stable .onion address. Unlock with `torsocks ssh root@`. The runtime hook syncs the clock via NTP first (RTC-less boards boot at 1970, which Tor's consensus checks reject) and starts the onion service pointing at dropbear on 127.0.0.1:22. Hardening baked in from an adversarial review of the shipped path: - cmdline.txt boot path (RPi4-class firmware boot) now sets the same ip=:::::eth0:dhcp net.ifnames=0 params as the boot.txt path, so the initramfs actually gets a network and the onion can publish. - the initramfs bakes in libnss_dns.so.2 so the NTP hostname resolves. - the hook extracts DHCP DNS with sed instead of sourcing the lease files, which would run attacker-controlled DHCP option strings as root pre-boot. - NTP is attempted unconditionally (bounded), not gated on DHCP-provided DNS. Co-Authored-By: Claude Opus 4.8 --- lim/configuration/initcpio/tor_hook | 47 ++++++++ lim/configuration/initcpio/tor_install | 37 ++++++ lim/configuration/initcpio/torrc | 7 ++ lim/configuration/packages/server/tor.txt | 3 + lim/image/encryption.py | 16 ++- lim/image/plan.py | 1 + lim/image/setup.py | 4 + lim/image/tor.py | 79 +++++++++++++ pyproject.toml | 6 +- tests/unit/test_tor.py | 137 ++++++++++++++++++++++ 10 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 lim/configuration/initcpio/tor_hook create mode 100644 lim/configuration/initcpio/tor_install create mode 100644 lim/configuration/initcpio/torrc create mode 100644 lim/configuration/packages/server/tor.txt create mode 100644 lim/image/tor.py create mode 100644 tests/unit/test_tor.py diff --git a/lim/configuration/initcpio/tor_hook b/lim/configuration/initcpio/tor_hook new file mode 100644 index 0000000..193c567 --- /dev/null +++ b/lim/configuration/initcpio/tor_hook @@ -0,0 +1,47 @@ +#!/usr/bin/ash +# mkinitcpio runtime hook: start Tor so the dropbear unlock shell is +# reachable as an onion service while encryptssh waits for the passphrase. +# Installed to /etc/initcpio/hooks/tor by linux-image-manager. + +# netconf's ipconfig drops its DHCP lease data (incl. DNS) into +# /tmp/net-*.conf; busybox's resolver only reads /etc/resolv.conf. +_tor_write_resolv_conf() { + [ -s /etc/resolv.conf ] && return 0 + local conf dns + for conf in /tmp/net-*.conf; do + [ -f "$conf" ] || continue + # Extract ONLY the DNS fields with sed; never source these files — + # they hold attacker-controllable DHCP option strings (hostname, + # domain, rootpath), and sourcing would run them as root pre-boot. + 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 + [ -s /etc/resolv.conf ] +} + +run_hook() { + # Tor rejects consensus documents when the clock is far off; boards + # without an RTC boot in 1970, so sync before starting Tor. Bounded: + # a failed sync must never block the boot. + msg "tor: syncing clock via NTP..." + # Best-effort DNS; a tor_ntp IP literal needs none, so never gate on it. + _tor_write_resolv_conf || msg "tor: no DNS from DHCP (fine if tor_ntp is an IP)" + /usr/local/bin/busybox timeout 30 \ + /usr/local/bin/busybox ntpd -n -q -p "${tor_ntp:-pool.ntp.org}" \ + || msg "tor: NTP sync failed, keeping current clock" + + msg "tor: starting onion service for remote unlock..." + 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 /tmp/tor.log" \ + || msg "tor: failed to start, unlock stays reachable via direct IP" +} + +run_cleanuphook() { + # Nothing from the initramfs may keep running after the pivot. + /usr/local/bin/busybox killall tor 2>/dev/null + return 0 +} diff --git a/lim/configuration/initcpio/tor_install b/lim/configuration/initcpio/tor_install new file mode 100644 index 0000000..955d70c --- /dev/null +++ b/lim/configuration/initcpio/tor_install @@ -0,0 +1,37 @@ +#!/bin/bash +# mkinitcpio install hook: Tor onion service for remote LUKS unlock. +# Installed to /etc/initcpio/install/tor by linux-image-manager. + +build() { + add_binary /usr/bin/tor + # Full busybox for the ntpd applet: boards without an RTC (e.g. most + # Raspberry Pis) wake up in 1970, which Tor's consensus checks reject. + # A different target path keeps mkinitcpio's own busybox untouched. + add_binary /usr/bin/busybox /usr/local/bin/busybox + # glibc resolves the NTP server hostname via getaddrinfo(), which dlopen()s + # these NSS modules at runtime — add_binary only follows NEEDED libs, so + # without them DNS silently fails, ntpd never syncs, and the clock stays + # at 1970. glibc's built-in default (no nsswitch.conf) is "dns ... files". + add_binary /usr/lib/libnss_dns.so.2 + add_binary /usr/lib/libnss_files.so.2 + add_file /etc/tor/initramfs-torrc /etc/tor/torrc + add_file /etc/tor/initramfs-onion/hostname /etc/tor/onion/hostname + add_file /etc/tor/initramfs-onion/hs_ed25519_public_key /etc/tor/onion/hs_ed25519_public_key + add_file /etc/tor/initramfs-onion/hs_ed25519_secret_key /etc/tor/onion/hs_ed25519_secret_key + add_runscript +} + +help() { + cat < NTP server used to set the clock before Tor starts + (default: pool.ntp.org) +HELPEOF +} diff --git a/lim/configuration/initcpio/torrc b/lim/configuration/initcpio/torrc new file mode 100644 index 0000000..7f888f8 --- /dev/null +++ b/lim/configuration/initcpio/torrc @@ -0,0 +1,7 @@ +# Tor configuration for the initramfs onion unlock service. +# Baked into the initramfs as /etc/tor/torrc by the "tor" mkinitcpio 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 diff --git a/lim/configuration/packages/server/tor.txt b/lim/configuration/packages/server/tor.txt new file mode 100644 index 0000000..6e2f116 --- /dev/null +++ b/lim/configuration/packages/server/tor.txt @@ -0,0 +1,3 @@ +# Packages for the Tor onion unlock service in the initramfs +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 c57df9f..79351be 100644 --- a/lim/image/encryption.py +++ b/lim/image/encryption.py @@ -9,6 +9,7 @@ from pathlib import Path from lim import catalog, fsutil, packages, runner, ui 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" @@ -32,9 +33,10 @@ def _configure_mkinitcpio(plan: ImagePlan, root: Path) -> None: 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 dropbear encryptssh " + f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf {tor_hook}dropbear encryptssh " f"{MKINITCPIO_HOOKS_SUFFIX})", mkinitcpio_path, ) @@ -90,9 +92,15 @@ def _configure_bootloader(plan: ImagePlan, session: ImageSession, root: Path) -> 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"{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()}") @@ -112,6 +120,10 @@ def configure_encryption( ui.info(f"Adding {authorized_keys} to dropbear...") runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True) + if plan.tor_unlock: + # Hook files and onion keys must exist before mkinitcpio bakes the image. + configure_tor_unlock(plan, root) + _configure_mkinitcpio(plan, root) _register_encrypted_root(plan, session, root) _configure_bootloader(plan, session, root) diff --git a/lim/image/plan.py b/lim/image/plan.py index 4d75259..5653d71 100644 --- a/lim/image/plan.py +++ b/lim/image/plan.py @@ -17,6 +17,7 @@ class ImagePlan: luks_memory_cost: str | None = None raspberry_pi_version: str | None = None encrypt_system: bool = False + tor_unlock: bool = False root_filesystem: str = "" image_folder: Path = field(default_factory=Path) diff --git a/lim/image/setup.py b/lim/image/setup.py index 469653a..6defd89 100644 --- a/lim/image/setup.py +++ b/lim/image/setup.py @@ -49,6 +49,10 @@ def _choose_and_verify_image(plan: ImagePlan) -> None: if plan.operation_system == "linux": choosers.choose_linux_image(plan) plan.encrypt_system = ui.confirm("Should the system be encrypted?") + if plan.encrypt_system: + plan.tor_unlock = ui.confirm( + "Should the system be remotely unlockable via a Tor onion service?" + ) ui.info("Generating os-image...") transfer.download_image(plan) else: diff --git a/lim/image/tor.py b/lim/image/tor.py new file mode 100644 index 0000000..2c9a370 --- /dev/null +++ b/lim/image/tor.py @@ -0,0 +1,79 @@ +"""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 0d5b931..c460cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,11 @@ lim = "lim.cli:main" include = ["lim*"] [tool.setuptools.package-data] -lim = ["distributions.yml", "configuration/packages/**/*.txt"] +lim = [ + "distributions.yml", + "configuration/packages/**/*.txt", + "configuration/initcpio/*", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/unit/test_tor.py b/tests/unit/test_tor.py new file mode 100644 index 0000000..3e69633 --- /dev/null +++ b/tests/unit/test_tor.py @@ -0,0 +1,137 @@ +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.plan import ImagePlan +from lim.image.session import ImageSession + + +@pytest.fixture +def plan(): + return ImagePlan(distribution="arch", raspberry_pi_version="4", tor_unlock=True) + + +def _write_onion_hostname(root, address="abcdefghijklmnop.onion"): + onion_dir = root / tor.ONION_DIR + onion_dir.mkdir(parents=True) + (onion_dir / "hostname").write_text(f"{address}\n") + + +class TestConfigureTorUnlock: + 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) + + assert address == "stableaddress.onion" + package_installs = [ + input_text + for _, _, input_text in fake_runner.calls + if input_text and "pacman" in input_text and "tor busybox" in input_text + ] + assert len(package_installs) == 1 + 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. + keygen_calls = [ + (kind, cmd, input_text) + for kind, cmd, 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() + + +class TestInitcpioHookHardening: + """Guards for review findings in the shipped initramfs hooks.""" + + def _read(self, name): + return (config.CONFIGURATION_PATH / "initcpio" / name).read_text() + + 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 + + +class TestBootloaderNetworking: + """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" + ) + + encryption._configure_bootloader(plan, session, root) + + content = (session.boot_mount_path / "cmdline.txt").read_text() + assert "ip=::::myhost:eth0:dhcp" in content + assert "net.ifnames=0" in content + assert "cryptdevice=UUID=ROOT-UUID:cryptroot" in content + assert "root=/dev/mmcblk0p2" not in content + + +class TestMkinitcpioHooksLine: + def _mkinitcpio_conf(self, root): + path = root / "etc/mkinitcpio.conf" + path.parent.mkdir(parents=True) + path.write_text( + "MODULES=()\n" + "BINARIES=()\n" + f"HOOKS=({encryption.MKINITCPIO_HOOKS_PREFIX} " + f"{encryption.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) + 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) + content = path.read_text() + assert "netconf dropbear encryptssh" in content + assert " tor " not in content