Files
linux-image-manager/lim/image/unlock.py

101 lines
3.4 KiB
Python
Raw Normal View History

"""Remote LUKS boot unlock over the initramfs SSH/dropbear session.
Reaches the waiting initramfs either through a Tor onion address (wrapped in
torsocks) or a plain host/IP, then either runs cryptroot-unlock (initramfs-tools
targets) or drops into the passphrase prompt (mkinitcpio encryptssh).
"""
import json
import os
from pathlib import Path
from lim import runner, ui
from lim.errors import LimError
RECORDS_SUBPATH = ".config/lim/unlocks"
def records_dir(home: Path | None = None) -> Path:
return (home or Path.home()) / RECORDS_SUBPATH
def build_unlock_argv(target: str, key: str, unlock_command: str) -> list[str]:
"""SSH invocation for the unlock; torsocks-wrapped for .onion targets.
The initramfs dropbear key differs from the booted host key, so host-key
checking is disabled; a v3 onion address authenticates the endpoint itself.
"""
ssh = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
if key:
ssh += ["-i", str(Path(key).expanduser())]
if unlock_command:
ssh += ["-t"] # PTY so the remote passphrase prompt is interactive
ssh += [f"root@{target}"]
if unlock_command:
ssh += [unlock_command]
if target.endswith(".onion"):
return ["torsocks", *ssh]
return ssh
def save_record(home: Path, uid: int, gid: int, name: str, record: dict) -> None:
"""Persist an unlock target under the target user's home, owned by them."""
directory = records_dir(home)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{name}.json"
path.write_text(json.dumps(record, indent=2) + "\n")
try:
for entry in (path, directory, directory.parent, directory.parent.parent):
os.chown(entry, uid, gid)
except OSError:
pass
def _read_record(path: Path) -> dict | None:
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return None
def _load_records() -> list[tuple[str, dict]]:
directory = records_dir()
if not directory.is_dir():
return []
loaded = ((path.stem, _read_record(path)) for path in sorted(directory.glob("*.json")))
return [(name, record) for name, record in loaded if record is not None]
def _select_record() -> dict | None:
records = _load_records()
if not records:
return None
ui.info("Saved unlock targets:")
for index, (name, record) in enumerate(records, 1):
ui.info(f" {index}) {name} -> {record.get('target')}")
answer = ui.ask("Pick a number, or Enter for manual entry:")
if answer.isdigit() and 1 <= int(answer) <= len(records):
return records[int(answer) - 1][1]
return None
def remote_unlock() -> None:
record = _select_record()
if record:
target = record["target"]
key = record.get("key", "")
unlock_command = record.get("unlock_command", "")
else:
target = ui.ask("Onion address or host/IP to unlock:")
if not target:
raise LimError("No target given.")
key = ui.ask("Path to the SSH private key (empty for ssh defaults):")
unlock_command = (
"cryptroot-unlock"
if ui.confirm("Debian / Raspberry Pi OS target (run cryptroot-unlock)?")
else ""
)
if target.endswith(".onion"):
ui.info("Routing through Tor (torsocks); a local tor must be running.")
runner.run(build_unlock_argv(target, key, unlock_command), check=False)