6 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
69779919e5 Release version 2.2.0
Some checks failed
tests / lint (push) Has been cancelled
tests / pytest (push) Has been cancelled
tests / tor-network-e2e (push) Has been cancelled
2026-07-21 19:09:25 +02:00
Kevin Veen-Birkenbach
05e9129f4e ci: run the offline Tor keygen in CI, add a non-blocking network e2e job
Split the rootless Tor e2e skip so the deterministic offline checks (the
real offline onion keygen and the production-flag guard) run whenever the
tor binary is present, while only the live onion round-trip stays gated on
LIM_E2E_TOR=1. The pytest job now installs tor so the keygen — which
validates the exact production keygen path — runs on every push. A separate
continue-on-error tor-network-e2e job exercises the full round-trip without
making the public Tor network a blocking merge gate.

The full QEMU build/boot/unlock e2e still needs root + KVM + Arch tooling
and stays local/opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:00:12 +02:00
Kevin Veen-Birkenbach
963f0a8da6 docs: document Tor unlock, e2e tests, and make targets
README: Tor onion unlock feature + security note, the rootless and QEMU
end-to-end tests and how to run them, project-structure entries, and a
correction that PyYAML>=6 is required (not stdlib-only) on the
package-manager/symlink install path. Makefile: test-tor, test-qemu and
test-all targets, with an overridable PYTHON interpreter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:53:17 +02:00
Kevin Veen-Birkenbach
853a503a3f test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.

The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.

The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:50:55 +02:00
Kevin Veen-Birkenbach
b4f6ec4424 test(e2e): rootless Tor onion unlock end-to-end test
Opt-in via LIM_E2E_TOR=1. Generates the v3 onion keys offline exactly as
the image build does, stands up a real Tor onion service from a torrc
mirroring the baked-in one, and delivers a passphrase through Tor to a
dropbear stand-in — asserting it arrives and the endpoint "unlocks".
Needs the real tor binary and network, so it is skipped otherwise; the
offline keygen and production-flag guard run without network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:49:40 +02:00
Kevin Veen-Birkenbach
b88878efec 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>
2026-07-21 18:48:56 +02:00
18 changed files with 263 additions and 634 deletions

View File

@@ -1,19 +0,0 @@
#!/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

View File

@@ -1,35 +0,0 @@
#!/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"

View File

@@ -1,51 +0,0 @@
#!/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

View File

@@ -1,16 +0,0 @@
# 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

View File

@@ -1,5 +0,0 @@
# Packages for LUKS remote unlock on Debian / Raspberry Pi OS (initramfs-tools)
cryptsetup
cryptsetup-initramfs
dropbear-initramfs
busybox

View File

@@ -1,3 +0,0 @@
# Packages for the Tor onion unlock service in the initramfs (Debian)
tor
busybox # full applet set: ntpd for clock sync on RTC-less boards

View File

@@ -1,37 +1,129 @@
"""Remote-unlockable LUKS boot configuration.
"""Remote-unlockable LUKS boot configuration (dropbear + mkinitcpio).
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/.
See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and
https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994
"""
from pathlib import Path
from lim import packages, ui
from lim.image.initramfs import get_backend
from lim import catalog, fsutil, packages, runner, ui
from lim.image.plan import ImagePlan
from lim.image.session import ImageSession, install_packages
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"
def configure_encryption(plan: ImagePlan, session: ImageSession, authorized_keys: Path) -> None:
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:
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(backend.luks_package_collection())),
plan.distribution, root, " ".join(packages.get_packages("server/luks"))
)
backend.install_authorized_key(root, authorized_keys)
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)
if plan.tor_unlock:
# Hook files and onion keys must exist before the initramfs is baked.
backend.install_tor_unlock(plan, root)
# Hook files and onion keys must exist before mkinitcpio bakes the image.
configure_tor_unlock(plan, 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)
_configure_mkinitcpio(plan, root)
_register_encrypted_root(plan, session, root)
_configure_bootloader(plan, session, root)

View File

@@ -1,21 +0,0 @@
"""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"]

View File

@@ -1,42 +0,0 @@
"""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."""

View File

@@ -1,115 +0,0 @@
"""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()}")

View File

@@ -1,54 +0,0 @@
"""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()

View File

@@ -1,122 +0,0 @@
"""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()}")

View File

@@ -23,7 +23,9 @@ 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(
@@ -62,7 +64,9 @@ 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()}")
@@ -131,7 +135,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", "raspios"):
elif plan.distribution in ("moode", "retropie"):
chroot_bash(root, "yes | apt update\nyes | apt upgrade\n")
else:
ui.warning(

View File

@@ -49,8 +49,12 @@ 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}"
@@ -59,7 +63,9 @@ 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...")
@@ -82,7 +88,9 @@ 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:")
@@ -176,7 +184,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", "raspios"):
elif distribution in ("moode", "retropie"):
chroot_bash(root_mount_path, f"yes | apt install {package_names}")
else:
raise LimError("Package manager not supported.")

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

View File

@@ -32,7 +32,6 @@ lim = [
"distributions.yml",
"configuration/packages/**/*.txt",
"configuration/initcpio/*",
"configuration/initramfs-tools/*",
]
[tool.pytest.ini_options]

View File

@@ -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.initramfs.keygen does in the chroot),
1. generate the v3 onion keys offline (as lim.image.tor 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.initramfs import keygen
from lim.image import tor as tor_module
from tests.e2e import tor_harness
# The offline checks only need the tor binary (no network) and are
@@ -32,7 +32,9 @@ 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)",
@@ -50,7 +52,7 @@ def workdir(tmp_path):
def test_offline_keygen_matches_production_flags():
"""Guard: the harness keygen mirrors the flags production actually runs."""
script = keygen._KEYGEN_SCRIPT
script = tor_module._KEYGEN_SCRIPT
assert "--DisableNetwork 1" in script
assert "HiddenServicePort" in script
assert "22 127.0.0.1:22" in script
@@ -60,7 +62,9 @@ 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"):

View File

@@ -3,9 +3,7 @@ import pytest
from lim import config
from lim.device import Device
from lim.errors import LimError
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 import encryption, tor
from lim.image.plan import ImagePlan
from lim.image.session import ImageSession
@@ -15,45 +13,17 @@ 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 / keygen.ONION_STAGING_DIR
onion_dir = root / tor.ONION_DIR
onion_dir.mkdir(parents=True)
(onion_dir / "hostname").write_text(f"{address}\n")
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:
class TestConfigureTorUnlock:
def test_installs_hooks_and_returns_address(self, tmp_path, plan, fake_runner):
_write_onion_hostname(tmp_path, "stableaddress.onion")
address = MkinitcpioBackend().install_tor_unlock(plan, tmp_path)
address = tor.configure_tor_unlock(plan, tmp_path)
assert address == "stableaddress.onion"
package_installs = [
@@ -65,53 +35,74 @@ class TestMkinitcpioTorUnlock:
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"):
MkinitcpioBackend().install_tor_unlock(plan, tmp_path)
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 = [
input_text
for _, _, input_text in fake_runner.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]
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 mkinitcpio hooks."""
"""Guards for review findings in the shipped initramfs 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):
assert "skipping NTP sync" not in self._read("tor_hook")
# 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 TestMkinitcpioBootloader:
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 = _session(tmp_path)
(session.boot_mount_path / "cmdline.txt").write_text("root=/dev/mmcblk0p2 rw rootwait\n")
session = self._session(tmp_path)
(session.boot_mount_path / "cmdline.txt").write_text(
"root=/dev/mmcblk0p2 rw rootwait\n"
)
MkinitcpioBackend().configure_bootloader(plan, session, root)
encryption._configure_bootloader(plan, session, root)
content = (session.boot_mount_path / "cmdline.txt").read_text()
assert "ip=::::myhost:eth0:dhcp" in content
@@ -127,85 +118,20 @@ class TestMkinitcpioHooksLine:
path.write_text(
"MODULES=()\n"
"BINARIES=()\n"
f"HOOKS=({mkinitcpio.MKINITCPIO_HOOKS_PREFIX} "
f"{mkinitcpio.MKINITCPIO_HOOKS_SUFFIX})\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)
MkinitcpioBackend().configure_initramfs(plan, 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)
MkinitcpioBackend().configure_initramfs(plan, tmp_path)
encryption._configure_mkinitcpio(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