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 <noreply@anthropic.com>
This commit is contained in:
19
lim/configuration/initramfs-tools/tor_bottom
Normal file
19
lim/configuration/initramfs-tools/tor_bottom
Normal file
@@ -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
|
||||||
35
lim/configuration/initramfs-tools/tor_hook
Normal file
35
lim/configuration/initramfs-tools/tor_hook
Normal file
@@ -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"
|
||||||
51
lim/configuration/initramfs-tools/tor_premount
Normal file
51
lim/configuration/initramfs-tools/tor_premount
Normal file
@@ -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
|
||||||
16
lim/configuration/initramfs-tools/torrc
Normal file
16
lim/configuration/initramfs-tools/torrc
Normal file
@@ -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
|
||||||
5
lim/configuration/packages/server/luks-debian.txt
Normal file
5
lim/configuration/packages/server/luks-debian.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Packages for LUKS remote unlock on Debian / Raspberry Pi OS (initramfs-tools)
|
||||||
|
cryptsetup
|
||||||
|
cryptsetup-initramfs
|
||||||
|
dropbear-initramfs
|
||||||
|
busybox
|
||||||
3
lim/configuration/packages/server/tor-debian.txt
Normal file
3
lim/configuration/packages/server/tor-debian.txt
Normal file
@@ -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
|
||||||
@@ -1,129 +1,37 @@
|
|||||||
"""Remote-unlockable LUKS boot configuration (dropbear + mkinitcpio).
|
"""Remote-unlockable LUKS boot configuration.
|
||||||
|
|
||||||
See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and
|
Distro-agnostic orchestration; the init-system-specific steps live in the
|
||||||
https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994
|
initramfs backends (mkinitcpio for Arch/Manjaro, initramfs-tools for
|
||||||
|
Debian/Raspberry Pi OS). See lim/image/initramfs/.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
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.plan import ImagePlan
|
||||||
from lim.image.session import ImageSession, chroot_bash, install_packages
|
from lim.image.session import ImageSession, 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_mkinitcpio(plan: ImagePlan, root: Path) -> None:
|
def configure_encryption(plan: ImagePlan, session: ImageSession, authorized_keys: 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
|
root = session.root_mount_path
|
||||||
|
backend = get_backend(plan.distribution)
|
||||||
ui.info("Setup encryption...")
|
ui.info("Setup encryption...")
|
||||||
ui.info("Installing neccessary software...")
|
ui.info("Installing neccessary software...")
|
||||||
install_packages(
|
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"
|
backend.install_authorized_key(root, authorized_keys)
|
||||||
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:
|
if plan.tor_unlock:
|
||||||
# Hook files and onion keys must exist before mkinitcpio bakes the image.
|
# Hook files and onion keys must exist before the initramfs is baked.
|
||||||
configure_tor_unlock(plan, root)
|
backend.install_tor_unlock(plan, root)
|
||||||
|
|
||||||
_configure_mkinitcpio(plan, root)
|
# crypttab must be written before the initramfs is (re)generated so the
|
||||||
_register_encrypted_root(plan, session, root)
|
# Debian cryptsetup hook can bake the mapping in.
|
||||||
_configure_bootloader(plan, session, root)
|
backend.register_encrypted_root(plan, session, root)
|
||||||
|
backend.configure_initramfs(plan, root)
|
||||||
|
backend.configure_bootloader(plan, session, root)
|
||||||
|
|||||||
21
lim/image/initramfs/__init__.py
Normal file
21
lim/image/initramfs/__init__.py
Normal file
@@ -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"]
|
||||||
42
lim/image/initramfs/base.py
Normal file
42
lim/image/initramfs/base.py
Normal file
@@ -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."""
|
||||||
115
lim/image/initramfs/initramfs_tools.py
Normal file
115
lim/image/initramfs/initramfs_tools.py
Normal file
@@ -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()}")
|
||||||
54
lim/image/initramfs/keygen.py
Normal file
54
lim/image/initramfs/keygen.py
Normal file
@@ -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()
|
||||||
122
lim/image/initramfs/mkinitcpio.py
Normal file
122
lim/image/initramfs/mkinitcpio.py
Normal file
@@ -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()}")
|
||||||
@@ -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
|
raise LimError(f"Failed to create sudoers file for {username}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def configure_ssh_key(
|
def configure_ssh_key(public_key_path: str, target_ssh_folder: Path, authorized_keys: Path) -> None:
|
||||||
public_key_path: str, target_ssh_folder: Path, authorized_keys: Path
|
|
||||||
) -> None:
|
|
||||||
source = Path(public_key_path)
|
source = Path(public_key_path)
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
raise LimError(
|
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.")
|
ui.info(f"{fstab_path} does not reference /dev/mmcblk0p1. Skipping UUID seeding.")
|
||||||
return
|
return
|
||||||
ui.info(f"Seeding UUID to {fstab_path} to avoid path conflicts...")
|
ui.info(f"Seeding UUID to {fstab_path} to avoid path conflicts...")
|
||||||
fsutil.replace_in_file(
|
fsutil.replace_in_file("/dev/mmcblk0p1", f"UUID={session.boot_partition_uuid}", fstab_path)
|
||||||
"/dev/mmcblk0p1", f"UUID={session.boot_partition_uuid}", fstab_path
|
|
||||||
)
|
|
||||||
ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}")
|
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...")
|
ui.info("Updating system...")
|
||||||
if plan.distribution in ("arch", "manjaro"):
|
if plan.distribution in ("arch", "manjaro"):
|
||||||
chroot_bash(root, "pacman --noconfirm -Syyu")
|
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")
|
chroot_bash(root, "yes | apt update\nyes | apt upgrade\n")
|
||||||
else:
|
else:
|
||||||
ui.warning(
|
ui.warning(
|
||||||
|
|||||||
@@ -49,12 +49,8 @@ class ImageSession:
|
|||||||
|
|
||||||
def read_partition_uuids(self) -> None:
|
def read_partition_uuids(self) -> None:
|
||||||
"""Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset."""
|
"""Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset."""
|
||||||
self.root_partition_uuid = device_module.blkid_value(
|
self.root_partition_uuid = device_module.blkid_value(self.root_partition_path, "UUID")
|
||||||
self.root_partition_path, "UUID"
|
self.boot_partition_uuid = device_module.blkid_value(self.boot_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():
|
if self.root_mapper_name is None and self.root_is_luks():
|
||||||
# Same deterministic name decrypt_root() would have used.
|
# Same deterministic name decrypt_root() would have used.
|
||||||
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
|
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
|
||||||
@@ -63,9 +59,7 @@ class ImageSession:
|
|||||||
def decrypt_root(self) -> None:
|
def decrypt_root(self) -> None:
|
||||||
if not self.root_is_luks():
|
if not self.root_is_luks():
|
||||||
return
|
return
|
||||||
self.root_partition_uuid = device_module.blkid_value(
|
self.root_partition_uuid = device_module.blkid_value(self.root_partition_path, "UUID")
|
||||||
self.root_partition_path, "UUID"
|
|
||||||
)
|
|
||||||
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
|
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
|
||||||
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
|
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
|
||||||
ui.info(f"Decrypting of {self.root_partition_path} is neccessary...")
|
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)],
|
["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)],
|
||||||
sudo=True,
|
sudo=True,
|
||||||
)
|
)
|
||||||
runner.run(
|
runner.run(["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True)
|
||||||
["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True
|
|
||||||
)
|
|
||||||
ui.info("Setting uuid variables...")
|
ui.info("Setting uuid variables...")
|
||||||
self.read_partition_uuids()
|
self.read_partition_uuids()
|
||||||
ui.info("The following mounts refering this setup exist:")
|
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}...")
|
ui.info(f"Installing {package_names}...")
|
||||||
if distribution in ("arch", "manjaro"):
|
if distribution in ("arch", "manjaro"):
|
||||||
chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}")
|
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}")
|
chroot_bash(root_mount_path, f"yes | apt install {package_names}")
|
||||||
else:
|
else:
|
||||||
raise LimError("Package manager not supported.")
|
raise LimError("Package manager not supported.")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -32,6 +32,7 @@ lim = [
|
|||||||
"distributions.yml",
|
"distributions.yml",
|
||||||
"configuration/packages/**/*.txt",
|
"configuration/packages/**/*.txt",
|
||||||
"configuration/initcpio/*",
|
"configuration/initcpio/*",
|
||||||
|
"configuration/initramfs-tools/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
It reproduces the operator-visible half of the decryption process that the
|
It reproduces the operator-visible half of the decryption process that the
|
||||||
"tor" mkinitcpio hook drives at boot:
|
"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,
|
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,
|
forwarding the virtual port 22 to a local dropbear stand-in,
|
||||||
3. connect to the .onion address through Tor and deliver the passphrase,
|
3. connect to the .onion address through Tor and deliver the passphrase,
|
||||||
@@ -24,7 +24,7 @@ import time
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lim.image import tor as tor_module
|
from lim.image.initramfs import keygen
|
||||||
from tests.e2e import tor_harness
|
from tests.e2e import tor_harness
|
||||||
|
|
||||||
# The offline checks only need the tor binary (no network) and are
|
# 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
|
# 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
|
# LIM_E2E_TOR=1 to keep an external, occasionally-flaky dependency out of the
|
||||||
# blocking gate.
|
# blocking gate.
|
||||||
_needs_tor = pytest.mark.skipif(
|
_needs_tor = pytest.mark.skipif(shutil.which("tor") is None, reason="needs the tor binary")
|
||||||
shutil.which("tor") is None, reason="needs the tor binary"
|
|
||||||
)
|
|
||||||
_needs_tor_network = pytest.mark.skipif(
|
_needs_tor_network = pytest.mark.skipif(
|
||||||
shutil.which("tor") is None or os.environ.get("LIM_E2E_TOR") != "1",
|
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)",
|
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():
|
def test_offline_keygen_matches_production_flags():
|
||||||
"""Guard: the harness keygen mirrors the flags production actually runs."""
|
"""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 "--DisableNetwork 1" in script
|
||||||
assert "HiddenServicePort" in script
|
assert "HiddenServicePort" in script
|
||||||
assert "22 127.0.0.1:22" in script
|
assert "22 127.0.0.1:22" in script
|
||||||
@@ -62,9 +60,7 @@ def test_offline_keygen_matches_production_flags():
|
|||||||
@_needs_tor
|
@_needs_tor
|
||||||
def test_onion_keygen_is_deterministic_and_offline(workdir):
|
def test_onion_keygen_is_deterministic_and_offline(workdir):
|
||||||
"""Keys generate without network and the .onion address is stable."""
|
"""Keys generate without network and the .onion address is stable."""
|
||||||
address = tor_harness.generate_onion_keys(
|
address = tor_harness.generate_onion_keys(workdir / "onion", workdir / "data")
|
||||||
workdir / "onion", workdir / "data"
|
|
||||||
)
|
|
||||||
assert address.endswith(".onion")
|
assert address.endswith(".onion")
|
||||||
assert len(address) == len("v" * 56) + len(".onion") # v3 = 56 base32 chars
|
assert len(address) == len("v" * 56) + len(".onion") # v3 = 56 base32 chars
|
||||||
for name in ("hs_ed25519_secret_key", "hs_ed25519_public_key", "hostname"):
|
for name in ("hs_ed25519_secret_key", "hs_ed25519_public_key", "hostname"):
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import pytest
|
|||||||
from lim import config
|
from lim import config
|
||||||
from lim.device import Device
|
from lim.device import Device
|
||||||
from lim.errors import LimError
|
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.plan import ImagePlan
|
||||||
from lim.image.session import ImageSession
|
from lim.image.session import ImageSession
|
||||||
|
|
||||||
@@ -13,17 +15,45 @@ def plan():
|
|||||||
return ImagePlan(distribution="arch", raspberry_pi_version="4", tor_unlock=True)
|
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"):
|
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.mkdir(parents=True)
|
||||||
(onion_dir / "hostname").write_text(f"{address}\n")
|
(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):
|
def test_installs_hooks_and_returns_address(self, tmp_path, plan, fake_runner):
|
||||||
_write_onion_hostname(tmp_path, "stableaddress.onion")
|
_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"
|
assert address == "stableaddress.onion"
|
||||||
package_installs = [
|
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_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", "tor_hook", "etc/initcpio/hooks/tor")) == 1
|
||||||
assert len(fake_runner.find("install", "torrc", "etc/tor/initramfs-torrc")) == 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") == []
|
assert fake_runner.find("chroot", "DisableNetwork") == []
|
||||||
|
|
||||||
def test_generates_keys_when_missing(self, tmp_path, plan, fake_runner):
|
def test_generates_keys_when_missing(self, tmp_path, plan, fake_runner):
|
||||||
with pytest.raises(LimError, match="produced no"):
|
with pytest.raises(LimError, match="produced no"):
|
||||||
tor.configure_tor_unlock(plan, tmp_path)
|
MkinitcpioBackend().install_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 = [
|
keygen_calls = [
|
||||||
(kind, cmd, input_text)
|
input_text
|
||||||
for kind, cmd, input_text in fake_runner.calls
|
for _, _, input_text in fake_runner.calls
|
||||||
if input_text and "DisableNetwork" in input_text
|
if input_text and "DisableNetwork" in input_text
|
||||||
]
|
]
|
||||||
assert len(keygen_calls) == 1
|
assert len(keygen_calls) == 1
|
||||||
assert "HiddenServiceDir" in keygen_calls[0][2]
|
assert "HiddenServiceDir" in keygen_calls[0]
|
||||||
|
|
||||||
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:
|
class TestInitcpioHookHardening:
|
||||||
"""Guards for review findings in the shipped initramfs hooks."""
|
"""Guards for review findings in the shipped mkinitcpio hooks."""
|
||||||
|
|
||||||
def _read(self, name):
|
def _read(self, name):
|
||||||
return (config.CONFIGURATION_PATH / "initcpio" / name).read_text()
|
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):
|
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")
|
assert "libnss_dns.so.2" in self._read("tor_install")
|
||||||
|
|
||||||
def test_hook_never_sources_dhcp_lease_files(self):
|
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")
|
hook = self._read("tor_hook")
|
||||||
assert '. "$conf"' not in hook
|
assert '. "$conf"' not in hook
|
||||||
assert "sed -n 's/^IPV4DNS" in hook
|
assert "sed -n 's/^IPV4DNS" in hook
|
||||||
|
|
||||||
def test_hook_attempts_ntp_without_gating_on_dhcp_dns(self):
|
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).
|
assert "skipping NTP sync" not in self._read("tor_hook")
|
||||||
hook = self._read("tor_hook")
|
|
||||||
assert "skipping NTP sync" not in hook
|
|
||||||
|
|
||||||
|
|
||||||
class TestBootloaderNetworking:
|
class TestMkinitcpioBootloader:
|
||||||
"""The cmdline.txt boot path (RPi4-class) must set ip= for remote unlock."""
|
"""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):
|
def test_cmdline_txt_gets_network_params(self, tmp_path, plan):
|
||||||
root = tmp_path / "root"
|
root = tmp_path / "root"
|
||||||
(root / "etc").mkdir(parents=True)
|
(root / "etc").mkdir(parents=True)
|
||||||
(root / "etc" / "hostname").write_text("myhost\n")
|
(root / "etc" / "hostname").write_text("myhost\n")
|
||||||
session = self._session(tmp_path)
|
session = _session(tmp_path)
|
||||||
(session.boot_mount_path / "cmdline.txt").write_text(
|
(session.boot_mount_path / "cmdline.txt").write_text("root=/dev/mmcblk0p2 rw rootwait\n")
|
||||||
"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()
|
content = (session.boot_mount_path / "cmdline.txt").read_text()
|
||||||
assert "ip=::::myhost:eth0:dhcp" in content
|
assert "ip=::::myhost:eth0:dhcp" in content
|
||||||
@@ -118,20 +127,85 @@ class TestMkinitcpioHooksLine:
|
|||||||
path.write_text(
|
path.write_text(
|
||||||
"MODULES=()\n"
|
"MODULES=()\n"
|
||||||
"BINARIES=()\n"
|
"BINARIES=()\n"
|
||||||
f"HOOKS=({encryption.MKINITCPIO_HOOKS_PREFIX} "
|
f"HOOKS=({mkinitcpio.MKINITCPIO_HOOKS_PREFIX} "
|
||||||
f"{encryption.MKINITCPIO_HOOKS_SUFFIX})\n"
|
f"{mkinitcpio.MKINITCPIO_HOOKS_SUFFIX})\n"
|
||||||
)
|
)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def test_tor_hook_between_netconf_and_dropbear(self, tmp_path, plan, fake_runner):
|
def test_tor_hook_between_netconf_and_dropbear(self, tmp_path, plan, fake_runner):
|
||||||
path = self._mkinitcpio_conf(tmp_path)
|
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()
|
assert "netconf tor dropbear encryptssh" in path.read_text()
|
||||||
|
|
||||||
def test_no_tor_hook_when_disabled(self, tmp_path, plan, fake_runner):
|
def test_no_tor_hook_when_disabled(self, tmp_path, plan, fake_runner):
|
||||||
plan.tor_unlock = False
|
plan.tor_unlock = False
|
||||||
path = self._mkinitcpio_conf(tmp_path)
|
path = self._mkinitcpio_conf(tmp_path)
|
||||||
encryption._configure_mkinitcpio(plan, tmp_path)
|
MkinitcpioBackend().configure_initramfs(plan, tmp_path)
|
||||||
content = path.read_text()
|
content = path.read_text()
|
||||||
assert "netconf dropbear encryptssh" in content
|
assert "netconf dropbear encryptssh" in content
|
||||||
assert " tor " not 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
|
||||||
|
|||||||
Reference in New Issue
Block a user