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@<onion-address>`.

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=::::<host>: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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-Birkenbach
2026-07-21 18:48:56 +02:00
parent b38303b72f
commit 1e711f5c96
10 changed files with 334 additions and 3 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

79
lim/image/tor.py Normal file
View File

@@ -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