feat(image): remote LUKS unlock via a Tor onion service in the initramfs
Encrypted image setups can now bake a Tor onion service into the initramfs so the dropbear unlock shell stays reachable behind NAT or a dynamic IP. When the user opts in, configure_encryption installs tor + busybox, drops the mkinitcpio hooks (ordered `netconf tor dropbear encryptssh`), generates the v3 onion keys offline in the image chroot, and prints the stable .onion address. Unlock with `torsocks ssh root@<onion-address>`. The runtime hook syncs the clock via NTP first (RTC-less boards boot at 1970, which Tor's consensus checks reject) and starts the onion service pointing at dropbear on 127.0.0.1:22. Hardening baked in from an adversarial review of the shipped path: - cmdline.txt boot path (RPi4-class firmware boot) now sets the same ip=::::<host>:eth0:dhcp net.ifnames=0 params as the boot.txt path, so the initramfs actually gets a network and the onion can publish. - the initramfs bakes in libnss_dns.so.2 so the NTP hostname resolves. - the hook extracts DHCP DNS with sed instead of sourcing the lease files, which would run attacker-controlled DHCP option strings as root pre-boot. - NTP is attempted unconditionally (bounded), not gated on DHCP-provided DNS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
47
lim/configuration/initcpio/tor_hook
Normal file
47
lim/configuration/initcpio/tor_hook
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/ash
|
||||
# mkinitcpio runtime hook: start Tor so the dropbear unlock shell is
|
||||
# reachable as an onion service while encryptssh waits for the passphrase.
|
||||
# Installed to /etc/initcpio/hooks/tor by linux-image-manager.
|
||||
|
||||
# netconf's ipconfig drops its DHCP lease data (incl. DNS) into
|
||||
# /tmp/net-*.conf; busybox's resolver only reads /etc/resolv.conf.
|
||||
_tor_write_resolv_conf() {
|
||||
[ -s /etc/resolv.conf ] && return 0
|
||||
local conf dns
|
||||
for conf in /tmp/net-*.conf; do
|
||||
[ -f "$conf" ] || continue
|
||||
# Extract ONLY the DNS fields with sed; never source these files —
|
||||
# they hold attacker-controllable DHCP option strings (hostname,
|
||||
# domain, rootpath), and sourcing would run them as root pre-boot.
|
||||
for dns in $(sed -n 's/^IPV4DNS[01]=//p' "$conf"); do
|
||||
[ -n "$dns" ] && [ "$dns" != "0.0.0.0" ] \
|
||||
&& echo "nameserver $dns" >> /etc/resolv.conf
|
||||
done
|
||||
done
|
||||
[ -s /etc/resolv.conf ]
|
||||
}
|
||||
|
||||
run_hook() {
|
||||
# Tor rejects consensus documents when the clock is far off; boards
|
||||
# without an RTC boot in 1970, so sync before starting Tor. Bounded:
|
||||
# a failed sync must never block the boot.
|
||||
msg "tor: syncing clock via NTP..."
|
||||
# Best-effort DNS; a tor_ntp IP literal needs none, so never gate on it.
|
||||
_tor_write_resolv_conf || msg "tor: no DNS from DHCP (fine if tor_ntp is an IP)"
|
||||
/usr/local/bin/busybox timeout 30 \
|
||||
/usr/local/bin/busybox ntpd -n -q -p "${tor_ntp:-pool.ntp.org}" \
|
||||
|| msg "tor: NTP sync failed, keeping current clock"
|
||||
|
||||
msg "tor: starting onion service for remote unlock..."
|
||||
mkdir -p /var/lib/tor
|
||||
chmod 0700 /var/lib/tor /etc/tor/onion
|
||||
chmod 0600 /etc/tor/onion/hs_ed25519_secret_key
|
||||
tor -f /etc/tor/torrc --RunAsDaemon 1 --Log "notice file /tmp/tor.log" \
|
||||
|| msg "tor: failed to start, unlock stays reachable via direct IP"
|
||||
}
|
||||
|
||||
run_cleanuphook() {
|
||||
# Nothing from the initramfs may keep running after the pivot.
|
||||
/usr/local/bin/busybox killall tor 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
37
lim/configuration/initcpio/tor_install
Normal file
37
lim/configuration/initcpio/tor_install
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# mkinitcpio install hook: Tor onion service for remote LUKS unlock.
|
||||
# Installed to /etc/initcpio/install/tor by linux-image-manager.
|
||||
|
||||
build() {
|
||||
add_binary /usr/bin/tor
|
||||
# Full busybox for the ntpd applet: boards without an RTC (e.g. most
|
||||
# Raspberry Pis) wake up in 1970, which Tor's consensus checks reject.
|
||||
# A different target path keeps mkinitcpio's own busybox untouched.
|
||||
add_binary /usr/bin/busybox /usr/local/bin/busybox
|
||||
# glibc resolves the NTP server hostname via getaddrinfo(), which dlopen()s
|
||||
# these NSS modules at runtime — add_binary only follows NEEDED libs, so
|
||||
# without them DNS silently fails, ntpd never syncs, and the clock stays
|
||||
# at 1970. glibc's built-in default (no nsswitch.conf) is "dns ... files".
|
||||
add_binary /usr/lib/libnss_dns.so.2
|
||||
add_binary /usr/lib/libnss_files.so.2
|
||||
add_file /etc/tor/initramfs-torrc /etc/tor/torrc
|
||||
add_file /etc/tor/initramfs-onion/hostname /etc/tor/onion/hostname
|
||||
add_file /etc/tor/initramfs-onion/hs_ed25519_public_key /etc/tor/onion/hs_ed25519_public_key
|
||||
add_file /etc/tor/initramfs-onion/hs_ed25519_secret_key /etc/tor/onion/hs_ed25519_secret_key
|
||||
add_runscript
|
||||
}
|
||||
|
||||
help() {
|
||||
cat <<HELPEOF
|
||||
Starts a Tor onion service in early userspace so the dropbear unlock
|
||||
shell stays reachable under the .onion address baked into the image,
|
||||
even behind NAT or a dynamic IP.
|
||||
|
||||
Place it between netconf and dropbear:
|
||||
HOOKS=(... netconf tor dropbear encryptssh ...)
|
||||
|
||||
Optional kernel parameter:
|
||||
tor_ntp=<server> NTP server used to set the clock before Tor starts
|
||||
(default: pool.ntp.org)
|
||||
HELPEOF
|
||||
}
|
||||
7
lim/configuration/initcpio/torrc
Normal file
7
lim/configuration/initcpio/torrc
Normal file
@@ -0,0 +1,7 @@
|
||||
# Tor configuration for the initramfs onion unlock service.
|
||||
# Baked into the initramfs as /etc/tor/torrc by the "tor" mkinitcpio hook;
|
||||
# the onion keys come from /etc/tor/initramfs-onion on the system.
|
||||
DataDirectory /var/lib/tor
|
||||
HiddenServiceDir /etc/tor/onion
|
||||
HiddenServicePort 22 127.0.0.1:22
|
||||
SocksPort 0
|
||||
3
lim/configuration/packages/server/tor.txt
Normal file
3
lim/configuration/packages/server/tor.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Packages for the Tor onion unlock service in the initramfs
|
||||
tor
|
||||
busybox # full applet set: ntpd for clock sync on RTC-less boards
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from lim import catalog, fsutil, packages, runner, ui
|
||||
from lim.image.plan import ImagePlan
|
||||
from lim.image.session import ImageSession, chroot_bash, install_packages
|
||||
from lim.image.tor import configure_tor_unlock
|
||||
|
||||
MKINITCPIO_HOOKS_PREFIX = (
|
||||
"base udev autodetect microcode modconf kms keyboard keymap consolefont block"
|
||||
@@ -32,9 +33,10 @@ def _configure_mkinitcpio(plan: ImagePlan, root: Path) -> None:
|
||||
fsutil.replace_in_file(
|
||||
"BINARIES=()", "BINARIES=(/usr/lib/libgcc_s.so.1)", mkinitcpio_path
|
||||
)
|
||||
tor_hook = "tor " if plan.tor_unlock else ""
|
||||
fsutil.replace_in_file(
|
||||
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} {MKINITCPIO_HOOKS_SUFFIX})",
|
||||
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf dropbear encryptssh "
|
||||
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf {tor_hook}dropbear encryptssh "
|
||||
f"{MKINITCPIO_HOOKS_SUFFIX})",
|
||||
mkinitcpio_path,
|
||||
)
|
||||
@@ -90,9 +92,15 @@ def _configure_bootloader(plan: ImagePlan, session: ImageSession, root: Path) ->
|
||||
else:
|
||||
cmdline_txt_path = session.boot_mount_path / "cmdline.txt"
|
||||
ui.info(f"Configuring {cmdline_txt_path}...")
|
||||
# Firmware-boot boards (e.g. RPi4 cmdline.txt) need the same early
|
||||
# networking as the boot.txt branch, or the initramfs netconf hook
|
||||
# brings up no interface and remote unlock (dropbear + tor) is
|
||||
# unreachable — the onion never even publishes.
|
||||
hostname = (root / "etc/hostname").read_text().strip()
|
||||
fsutil.replace_in_file(
|
||||
"root=/dev/mmcblk0p2",
|
||||
f"{cryptdevice} rootfstype={plan.root_filesystem}",
|
||||
f"{cryptdevice} rootfstype={plan.root_filesystem} "
|
||||
f"ip=::::{hostname}:eth0:dhcp net.ifnames=0 biosdevname=0",
|
||||
cmdline_txt_path,
|
||||
)
|
||||
ui.info(f"Content of {cmdline_txt_path}:{cmdline_txt_path.read_text()}")
|
||||
@@ -112,6 +120,10 @@ def configure_encryption(
|
||||
ui.info(f"Adding {authorized_keys} to dropbear...")
|
||||
runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True)
|
||||
|
||||
if plan.tor_unlock:
|
||||
# Hook files and onion keys must exist before mkinitcpio bakes the image.
|
||||
configure_tor_unlock(plan, root)
|
||||
|
||||
_configure_mkinitcpio(plan, root)
|
||||
_register_encrypted_root(plan, session, root)
|
||||
_configure_bootloader(plan, session, root)
|
||||
|
||||
@@ -17,6 +17,7 @@ class ImagePlan:
|
||||
luks_memory_cost: str | None = None
|
||||
raspberry_pi_version: str | None = None
|
||||
encrypt_system: bool = False
|
||||
tor_unlock: bool = False
|
||||
root_filesystem: str = ""
|
||||
image_folder: Path = field(default_factory=Path)
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ def _choose_and_verify_image(plan: ImagePlan) -> None:
|
||||
if plan.operation_system == "linux":
|
||||
choosers.choose_linux_image(plan)
|
||||
plan.encrypt_system = ui.confirm("Should the system be encrypted?")
|
||||
if plan.encrypt_system:
|
||||
plan.tor_unlock = ui.confirm(
|
||||
"Should the system be remotely unlockable via a Tor onion service?"
|
||||
)
|
||||
ui.info("Generating os-image...")
|
||||
transfer.download_image(plan)
|
||||
else:
|
||||
|
||||
79
lim/image/tor.py
Normal file
79
lim/image/tor.py
Normal 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
|
||||
@@ -28,7 +28,11 @@ lim = "lim.cli:main"
|
||||
include = ["lim*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
lim = ["distributions.yml", "configuration/packages/**/*.txt"]
|
||||
lim = [
|
||||
"distributions.yml",
|
||||
"configuration/packages/**/*.txt",
|
||||
"configuration/initcpio/*",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
137
tests/unit/test_tor.py
Normal file
137
tests/unit/test_tor.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import pytest
|
||||
|
||||
from lim import config
|
||||
from lim.device import Device
|
||||
from lim.errors import LimError
|
||||
from lim.image import encryption, tor
|
||||
from lim.image.plan import ImagePlan
|
||||
from lim.image.session import ImageSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan():
|
||||
return ImagePlan(distribution="arch", raspberry_pi_version="4", tor_unlock=True)
|
||||
|
||||
|
||||
def _write_onion_hostname(root, address="abcdefghijklmnop.onion"):
|
||||
onion_dir = root / tor.ONION_DIR
|
||||
onion_dir.mkdir(parents=True)
|
||||
(onion_dir / "hostname").write_text(f"{address}\n")
|
||||
|
||||
|
||||
class TestConfigureTorUnlock:
|
||||
def test_installs_hooks_and_returns_address(self, tmp_path, plan, fake_runner):
|
||||
_write_onion_hostname(tmp_path, "stableaddress.onion")
|
||||
|
||||
address = tor.configure_tor_unlock(plan, tmp_path)
|
||||
|
||||
assert address == "stableaddress.onion"
|
||||
package_installs = [
|
||||
input_text
|
||||
for _, _, input_text in fake_runner.calls
|
||||
if input_text and "pacman" in input_text and "tor busybox" in input_text
|
||||
]
|
||||
assert len(package_installs) == 1
|
||||
assert len(fake_runner.find("install", "tor_install", "etc/initcpio/install/tor")) == 1
|
||||
assert len(fake_runner.find("install", "tor_hook", "etc/initcpio/hooks/tor")) == 1
|
||||
assert len(fake_runner.find("install", "torrc", "etc/tor/initramfs-torrc")) == 1
|
||||
# Existing keys must be kept: no keygen chroot run.
|
||||
assert fake_runner.find("chroot", "DisableNetwork") == []
|
||||
|
||||
def test_generates_keys_when_missing(self, tmp_path, plan, fake_runner):
|
||||
with pytest.raises(LimError, match="produced no"):
|
||||
tor.configure_tor_unlock(plan, tmp_path)
|
||||
# FakeRunner executes nothing, so the hostname file never appears —
|
||||
# but the keygen chroot script must have been issued exactly once.
|
||||
keygen_calls = [
|
||||
(kind, cmd, input_text)
|
||||
for kind, cmd, input_text in fake_runner.calls
|
||||
if input_text and "DisableNetwork" in input_text
|
||||
]
|
||||
assert len(keygen_calls) == 1
|
||||
assert "HiddenServiceDir" in keygen_calls[0][2]
|
||||
|
||||
def test_hook_resources_exist(self):
|
||||
source_dir = config.CONFIGURATION_PATH / "initcpio"
|
||||
for name in ("tor_install", "tor_hook", "torrc"):
|
||||
assert (source_dir / name).is_file()
|
||||
|
||||
|
||||
class TestInitcpioHookHardening:
|
||||
"""Guards for review findings in the shipped initramfs hooks."""
|
||||
|
||||
def _read(self, name):
|
||||
return (config.CONFIGURATION_PATH / "initcpio" / name).read_text()
|
||||
|
||||
def test_install_bakes_the_dns_resolver(self):
|
||||
# Without the NSS module the NTP hostname never resolves and the clock
|
||||
# stays at 1970, so Tor rejects the consensus and never publishes.
|
||||
assert "libnss_dns.so.2" in self._read("tor_install")
|
||||
|
||||
def test_hook_never_sources_dhcp_lease_files(self):
|
||||
# Sourcing /tmp/net-*.conf would run attacker-controlled DHCP option
|
||||
# strings as root before the LUKS root is unlocked.
|
||||
hook = self._read("tor_hook")
|
||||
assert '. "$conf"' not in hook
|
||||
assert "sed -n 's/^IPV4DNS" in hook
|
||||
|
||||
def test_hook_attempts_ntp_without_gating_on_dhcp_dns(self):
|
||||
# NTP must run even when tor_ntp is an IP literal (no DNS needed).
|
||||
hook = self._read("tor_hook")
|
||||
assert "skipping NTP sync" not in hook
|
||||
|
||||
|
||||
class TestBootloaderNetworking:
|
||||
"""The cmdline.txt boot path (RPi4-class) must set ip= for remote unlock."""
|
||||
|
||||
def _session(self, tmp_path):
|
||||
session = ImageSession(Device("mmcblk0"))
|
||||
session.root_partition_uuid = "ROOT-UUID"
|
||||
session.root_mapper_name = "cryptroot"
|
||||
session.root_mapper_path = "/dev/mapper/cryptroot"
|
||||
session.boot_mount_path = tmp_path / "boot"
|
||||
session.boot_mount_path.mkdir()
|
||||
return session
|
||||
|
||||
def test_cmdline_txt_gets_network_params(self, tmp_path, plan):
|
||||
root = tmp_path / "root"
|
||||
(root / "etc").mkdir(parents=True)
|
||||
(root / "etc" / "hostname").write_text("myhost\n")
|
||||
session = self._session(tmp_path)
|
||||
(session.boot_mount_path / "cmdline.txt").write_text(
|
||||
"root=/dev/mmcblk0p2 rw rootwait\n"
|
||||
)
|
||||
|
||||
encryption._configure_bootloader(plan, session, root)
|
||||
|
||||
content = (session.boot_mount_path / "cmdline.txt").read_text()
|
||||
assert "ip=::::myhost:eth0:dhcp" in content
|
||||
assert "net.ifnames=0" in content
|
||||
assert "cryptdevice=UUID=ROOT-UUID:cryptroot" in content
|
||||
assert "root=/dev/mmcblk0p2" not in content
|
||||
|
||||
|
||||
class TestMkinitcpioHooksLine:
|
||||
def _mkinitcpio_conf(self, root):
|
||||
path = root / "etc/mkinitcpio.conf"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(
|
||||
"MODULES=()\n"
|
||||
"BINARIES=()\n"
|
||||
f"HOOKS=({encryption.MKINITCPIO_HOOKS_PREFIX} "
|
||||
f"{encryption.MKINITCPIO_HOOKS_SUFFIX})\n"
|
||||
)
|
||||
return path
|
||||
|
||||
def test_tor_hook_between_netconf_and_dropbear(self, tmp_path, plan, fake_runner):
|
||||
path = self._mkinitcpio_conf(tmp_path)
|
||||
encryption._configure_mkinitcpio(plan, tmp_path)
|
||||
assert "netconf tor dropbear encryptssh" in path.read_text()
|
||||
|
||||
def test_no_tor_hook_when_disabled(self, tmp_path, plan, fake_runner):
|
||||
plan.tor_unlock = False
|
||||
path = self._mkinitcpio_conf(tmp_path)
|
||||
encryption._configure_mkinitcpio(plan, tmp_path)
|
||||
content = path.read_text()
|
||||
assert "netconf dropbear encryptssh" in content
|
||||
assert " tor " not in content
|
||||
Reference in New Issue
Block a user