2026-07-21 18:49:40 +02:00
|
|
|
"""End-to-end test of the Tor onion LUKS-unlock path (rootless).
|
|
|
|
|
|
|
|
|
|
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.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,
|
|
|
|
|
4. assert the passphrase reached the unlock endpoint and it "unlocked".
|
|
|
|
|
|
|
|
|
|
The physical flashing half (loop device, cryptsetup, mount, chroot,
|
|
|
|
|
mkinitcpio) needs root and is out of scope here by design.
|
|
|
|
|
|
|
|
|
|
Requires the real `tor` binary and live Tor network access, so it is
|
|
|
|
|
opt-in and skipped unless LIM_E2E_TOR=1:
|
|
|
|
|
|
|
|
|
|
LIM_E2E_TOR=1 pytest tests/e2e/test_tor_unlock_e2e.py -v
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import shutil
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from lim.image import tor as tor_module
|
|
|
|
|
from tests.e2e import tor_harness
|
|
|
|
|
|
2026-07-21 19:00:12 +02:00
|
|
|
# The offline checks only need the tor binary (no network) and are
|
|
|
|
|
# deterministic, so they run in CI whenever tor is installed. Only the live
|
|
|
|
|
# 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_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)",
|
2026-07-21 18:49:40 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
PASSPHRASE = b"correct horse battery staple"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def workdir(tmp_path):
|
|
|
|
|
(tmp_path / "onion").mkdir()
|
|
|
|
|
(tmp_path / "data").mkdir()
|
|
|
|
|
return tmp_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_offline_keygen_matches_production_flags():
|
|
|
|
|
"""Guard: the harness keygen mirrors the flags production actually runs."""
|
|
|
|
|
script = tor_module._KEYGEN_SCRIPT
|
|
|
|
|
assert "--DisableNetwork 1" in script
|
|
|
|
|
assert "HiddenServicePort" in script
|
|
|
|
|
assert "22 127.0.0.1:22" in script
|
|
|
|
|
assert "--SocksPort 0" in script
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 19:00:12 +02:00
|
|
|
@_needs_tor
|
2026-07-21 18:49:40 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
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"):
|
|
|
|
|
assert (workdir / "onion" / name).is_file()
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 19:00:12 +02:00
|
|
|
@_needs_tor_network
|
2026-07-21 18:49:40 +02:00
|
|
|
def test_unlock_passphrase_travels_over_onion(workdir):
|
|
|
|
|
"""Full rootless round-trip: client -> Tor -> onion -> dropbear stand-in."""
|
|
|
|
|
onion_dir = workdir / "onion"
|
|
|
|
|
data_dir = workdir / "data"
|
|
|
|
|
onion_address = tor_harness.generate_onion_keys(onion_dir, data_dir)
|
|
|
|
|
|
|
|
|
|
backend_port = tor_harness.free_port()
|
|
|
|
|
socks_port = tor_harness.free_port()
|
|
|
|
|
torrc = workdir / "torrc"
|
|
|
|
|
log_path = workdir / "tor.log"
|
|
|
|
|
tor_harness.write_test_torrc(torrc, data_dir, onion_dir, backend_port)
|
|
|
|
|
|
|
|
|
|
dropbear = tor_harness.FakeDropbear(backend_port)
|
|
|
|
|
dropbear.start()
|
|
|
|
|
service = tor_harness.start_onion_service(torrc, socks_port, log_path)
|
|
|
|
|
try:
|
|
|
|
|
tor_harness.wait_bootstrapped(log_path, service)
|
|
|
|
|
|
|
|
|
|
# Onion descriptors need a moment to publish after bootstrap; retry.
|
|
|
|
|
last_error = None
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
try:
|
|
|
|
|
stream = tor_harness.socks5_connect(socks_port, onion_address, 22)
|
|
|
|
|
break
|
|
|
|
|
except (OSError, RuntimeError) as exc:
|
|
|
|
|
last_error = exc
|
|
|
|
|
time.sleep(2)
|
|
|
|
|
else:
|
|
|
|
|
pytest.fail(f"Could not reach {onion_address} via Tor: {last_error}")
|
|
|
|
|
|
|
|
|
|
with stream:
|
|
|
|
|
stream.sendall(PASSPHRASE + b"\n")
|
|
|
|
|
reply = stream.recv(64)
|
|
|
|
|
finally:
|
|
|
|
|
service.terminate()
|
|
|
|
|
service.wait(timeout=15)
|
|
|
|
|
dropbear.stop()
|
|
|
|
|
|
|
|
|
|
assert reply.strip() == b"UNLOCKED"
|
|
|
|
|
assert dropbear.received == PASSPHRASE
|