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