Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0016 — Web UI for unlock + BE selection (in the keyboot env)

Status: accepted — Phase 1 + Phase 2 BUILT (2026-06-25; proposed 2026-06-24)

Context

Today the operator unlocks the pool and (eventually) picks a boot environment by SSHing into the pre-boot dropbear (ADR 0002 fallback path / the interactive prompt), or at the TTY. SSH is a strong default: its security rests on public/private-key auth plus server host-key verification, all in one small audited daemon already in the image. The downside is convenience — it needs an SSH client and a typed command; a browser-based unlock (“open a page, type the passphrase, click the BE”) would be friendlier, especially for less CLI-comfortable operators.

Prior art is essentially nonexistent. Network/early-boot unlock today is: SSH-in-initramfs (dropbear; what we do), automated key-fetch over HTTPS (tqdev’s LUKS-over-HTTPS keyscript — machine automation, not a UI), network-bound auto-unlock (Clevis/Tang), or Mandos. No project ships an interactive browser UI to unlock + select a BE at boot. So this is novel — exciting, and a reason for extra caution: no one has hardened this pattern for us.

This ADR records the design thinking so we don’t trade away SSH’s security for UX. No code is committed by this ADR.

The constraint that shapes the whole design

Browsers do not expose the TLS channel/cert to JavaScript. Therefore the appealing “the page itself cryptographically proves there’s no MITM” approach (PAKE / TLS channel-binding, RFC 5929) cannot be done in a plain browser — the page can’t read its own TLS cert, and a MITM can rewrite anything the page displays. “Prove no MITM” must come from either (a) the browser’s own cert validation or (b) a human comparing a fingerprint out-of-band. There is no fully-automatic in-page version.

What SSH gives for free (the bar to clear)

  1. Strong mutual auth — client key and server host-key (TOFU / known_hosts).
  2. Encrypted transport before any secret crosses the wire.
  3. A tiny, audited, already-present server (dropbear).

A web unlock must re-solve all three, and the convenient version (“browse to https://host, type passphrase”) is exactly where each is hardest.

Threats specific to a web unlock

  • Passphrase capture (catastrophic). The LUKS passphrase unlocks every disk. Plain HTTP, or HTTPS the user was trained to click through, → an on-path attacker captures it. SSH never reveals the passphrase until the channel and server identity are proven.
  • Server impersonation / phishing — a fake unlock page harvests the passphrase.
  • Pre-boot RCE — an HTTP+TLS stack in the initramfs runs as root, holding the keys; a parser bug is game-over. Dropbear is one small audited thing; a web stack is more surface.
  • Browser-trust UX — a raw self-signed cert trains users to click through the exact “not secure” warning that would otherwise catch a MITM.

Decision (proposed)

Pursue it in layers, none of which weaken the SSH default; the web UI is always additive and opt-in, and dropbear remains a supported gate.

Trust model: bake the TLS cert like an SSH host key

The middle path that fits keyboot’s existing model: keyboot already bakes persistent SSH host keys into the image (SSH_DIR). Do the same for TLS — bake a persistent self-signed server cert + key into the image. The operator imports that cert into their browser/OS trust store once (from the image they built — the same already-trusted source as the SSH keys). Thereafter: no warning, and the browser performs MITM detection automatically every boot (cert mismatch = hard block). Its leak profile is identical to the baked SSH host key (image leak → key leak), a trade already accepted for SSH — so this is consistent, not a regression.

Bootstrap / no-import-yet fallback: keyboot prints the cert’s SHA-256 fingerprint to the console (serial/KVM/IPMI — already written there); the operator compares it to the browser’s cert viewer on first connect (TLS equivalent of verifying an SSH host key). Manual, but a real proof of no-MITM.

Authorization (who may unlock)

A trusted channel still must gate who can unlock (don’t expose the unlock UI to every host on the subnet). The authorized_keys analog, in preference order: mTLS client cert (issued from the same baked trust — cleanest model, clunky browser enrollment) → WebAuthn/passkey (nicest touch-to-auth, more moving parts) → baked bearer token (simplest, weakest).

Rollout

  • Phase 1 (recommended first): web server bound to 127.0.0.1 only, reached via an SSH tunnel (ssh -L). Security is identical to today (dropbear is still the only thing on the network); the win is a real UI (BE grid, pool/disk status, click-to-unlock) at near-zero new risk. Validates the UX and the server skeleton.
  • Phase 2 (opt-in, non-default): network-exposed HTTPS using the bake-and-trust-once cert + client-cert/passkey auth above, behind its own cmdline/config flag, documented threat model, and never replacing dropbear.

Implementation invariants

  • Server in Rust + rustls, minimal hand-rolled routes — no general web framework; keep the pre-boot-root attack surface tiny.
  • Drives the same keyboot unlock / BE-discovery code paths — no parallel unlock logic.
  • The passphrase is handled with the same care as elsewhere (never logged, never to non-volatile storage; zeroized after use).

Alternatives considered (rejected)

  • Plain HTTP / network-exposed self-signed with clickthrough — passphrase capture via MITM/sniff; trains clickthrough. Strictly worse than SSH. No.
  • Automated HTTPS key-fetch (tqdev) / Clevis-Tang — these are automatic unlock (key from a server / network presence). Different feature; doesn’t give an interactive operator UI and changes the threat model (a network service holds/gates the key). Out of scope here.
  • In-page PAKE / channel-binding for automatic MITM proof — impossible in a plain browser (no JS access to the TLS channel; see constraint above).

Consequences

  • A genuinely novel, friendlier unlock path without lowering the SSH baseline (it stays; the web UI is additive/opt-in).
  • New code on a pre-boot-root surface — mitigated by Rust/rustls + minimal routes
    • Phase-1 localhost-only start.
  • A new baked secret (TLS cert/key) with the same lifecycle/leak handling as the SSH host key (rotation story should mirror it).
  • Operator one-time cert import for the warning-free path; console-fingerprint fallback otherwise.

Status — what was built (2026-06-25)

Both phases are implemented, CI-gated, and Phase 2 was validated on real hardware.

  • Phase 1 (keyboot web, loopback + ssh -L): tools/keyboot/src/cli/web.rs (--features web, tiny_http). Shared router web::dispatch (GET /, /api/status; POST /api/unlock, /api/select-be). /api/unlock writes the passphrase to the stage-4 FIFO exactly like keyboot-askpass (one unlock path); BE selection writes /run/keyboot/be.selected validated against the candidates. CI: clippy:web + qemu:web (boots keyboot.web=1, tunnels, asserts "phase":1).
  • Phase 2 (network HTTPS + mTLS): tools/keyboot/src/cli/web_tls.rs (--features tls, rustls with the ring provider — no aws-lc/cmake, so it builds into the static musl binary). The shipped keyboot-x86_64-musl is built --features web,tls (build:static-be), so the release binary is Phase-2-capable out of the box. Server cert baked like the SSH host key; WebPkiClientVerifier requires a client cert signed by the baked client CA (the authorized_keys analog). Same dispatch router over a hand-rolled HTTP/1.1 read on the TLS stream.
  • Boot wiring: init/stage-3-ssh.sh launches the server when keyboot.web=1 (init/lib/cmdline.sh) — HTTPS+mTLS on 0.0.0.0:8443 if /etc/keyboot/web/{server-cert,server-key,client-ca}.pem are baked, else loopback Phase 1. no-port-forwarding on the boot key is relaxed only under keyboot.web=1 so the ssh -L tunnel works. ci/build-image.sh bakes the cert set from WEB_TLS_DIR; ci/gen-web-certs.sh generates server/CA/client material.
  • Real-hardware validation (.11, 88.99.137.11, 2026-06-25): the static --features tls binary served HTTPS+mTLS on :8443 over the public internet — no client cert ⇒ TLS handshake rejected (certificate required); the CA-signed operator client cert ⇒ /api/status JSON. (Ran in userspace on the installed OS; the in-pre-boot launch is the same QEMU-gated stage-3 path.)

Open questions

  • mTLS client cert vs passkey vs token for authorizationresolved: mTLS client cert (Phase 2, built). Passkey/token remain possible future additions.
  • Cert rotation/management UX (parallel to SSH host-key rotation).
  • Whether Phase 2 is worth building at all, or Phase 1 (localhost + tunnel) already captures most of the value at a fraction of the risk.