101 lines
3.4 KiB
Python
101 lines
3.4 KiB
Python
|
|
"""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)
|