feat(trust): host-gated trust-on-first-use — PIN pairing mandatory by default
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 1m12s
ci / web (push) Successful in 29s
android / android (push) Failing after 1m49s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m48s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
flatpak / build-publish (push) Failing after 3s
deb / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m20s

TOFU let anyone who could reach the host click "Trust" and stream, which defeats the point
on a LAN. Make SPAKE2 PIN pairing the default and only way to trust a NEW host; TOFU survives
as an explicit HOST opt-in (for fully trusted networks), advertised over mDNS so clients render
their trust UI from the host's policy rather than offering trust on faith.

Contract:
- Host advertises pair=required (default) or pair=optional. pair=required rejects unpaired
  clients at the handshake; pair=optional accepts them (TOFU).
- Clients: a pinned host whose fingerprint matches connects silently; a pinned host whose
  fingerprint CHANGED forces re-pairing via PIN (no re-trust shortcut); a NEW host is offered
  TOFU only if it advertised pair=optional, otherwise PIN pairing is mandatory; a manually-typed
  or unknown-policy host is always PIN.

Host (crates/punktfunk-host/src/main.rs):
- m3-host now REQUIRES pairing by default (was open by default). New --allow-tofu opts into
  accepting unpaired clients + advertising pair=optional; pairing is always armed (PIN logged at
  startup). serve --native was already secure-by-default (serve --open). The mDNS advert and the
  accept loop already mapped require_pairing -> pair=required + reject; only the m3-host CLI
  default + help text changed.

Clients honor the advertised policy:
- Android (MainActivity.kt): TOFU only for a discovered pair=optional host; manual/unknown -> PIN;
  fp-change -> re-pair only (dropped the "Forget & re-TOFU" shortcut).
- Apple (HostDiscovery/SessionModel/ContentView/HostCards/HostStore): new allowsTofu
  (pair==optional, distinct from unknown); connect() gates .awaitingTrust on it; unpinned
  non-optional hosts route to the PIN sheet; "Forget Identity" re-pairs rather than re-TOFUs.
- Linux (app.rs/ui_hosts.rs/session.rs): ConnectRequest.pair_required -> pair_optional;
  initiate_connect routes pinned/fp-changed/optional/else; manual + --connect unknown -> PIN; a
  pinned connect rejected on trust grounds re-pairs.

Docs (CLAUDE.md, README.md, docs-site/content/docs/pairing.md): describe the gated model — PIN is
the default, TOFU an explicit opt-in with an impostor warning.

Verified: host cargo check/clippy/fmt clean; Android built + live (emulator -> home-worker-2):
a manual connect now opens the PIN dialog (no Trust button) and the PIN ceremony streams; Apple
swift build clean; Linux clippy -D warnings + fmt clean on the Linux box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 13:27:09 +02:00
parent 1fd4c97139
commit 8ab262f8f8
13 changed files with 221 additions and 97 deletions
+26 -8
View File
@@ -34,8 +34,10 @@ with console access can admit a device.
## Pairing with a PIN
The PIN ceremony is the other path — useful for the *first* device (before the console has admitted
anything) or when you're at the client and the console isn't handy.
PIN pairing is the **default and required** path for any new host: unless the host has explicitly
opted into trust-on-first-use (see below), a client connecting to an unknown host must complete the
PIN ceremony before it can stream. It's the right path for the *first* device (before the console has
admitted anything) or when you're at the client and the console isn't handy.
Pairing has to be **armed** on the host before a client can pair (so a random device can't pair
itself). On the production host (`serve --native`), this is done from the **web console**: open the
@@ -57,14 +59,30 @@ By default, the native host **requires** pairing — only devices that have pair
the right setting on a shared network: a device has to complete the PIN ceremony once before it can
connect.
If you're on a fully trusted single-user network and want to skip pairing, start the host with
`serve --native --open` — but requiring pairing is strongly recommended.
If you're on a fully trusted single-user network and want to skip pairing, run the host open with
`serve --native --open` (or `m3-host --allow-tofu` for the standalone host) — it then advertises
`pair=optional` and accepts unpaired clients. Requiring pairing is strongly recommended.
## Trust-on-first-use
## Trust-on-first-use (host opt-in)
If a host *isn't* requiring pairing, a client connecting for the first time will show the host's
fingerprint and ask you to confirm it (trust-on-first-use), then pin it. Pairing is the stronger path
and the default; trust-on-first-use is a convenience for trusted setups.
Trust-on-first-use (TOFU) is **off by default** and is an explicit *host* opt-in for fully trusted
networks. A host enables it by running open — `m3-host --allow-tofu` or `serve --open` — which makes
it advertise `pair=optional` over mDNS and accept unpaired clients. Only then does a client offer the
TOFU path: connecting to such a host for the first time shows the host's fingerprint and asks you to
confirm it (compare it with the one the host logged at startup), then pins it. The client presents
this clearly as the reduced-security option, alongside **Pair with PIN**.
> **Warning:** TOFU cannot detect an impostor on the first connection — if someone is impersonating
> the host the very first time you connect, you'll pin the attacker's fingerprint. PIN pairing closes
> that gap (the SPAKE2 ceremony binds both identities), which is why it's the default. Use TOFU only
> on a network you fully trust.
For every other case — a host advertising `pair=required` (the default), a host you typed in by hand,
or a discovered host whose pair policy is unknown — TOFU is not offered and the client routes straight
to the PIN ceremony.
Once a host is pinned, a fingerprint change is treated as the impostor signal: the client forces
re-pairing through the PIN ceremony rather than offering to re-trust the new identity.
## Managing paired devices