From 8ab262f8f88085d2a9bd09d4a8e00c3c4396c3f2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 13:27:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(trust):=20host-gated=20trust-on-first-use?= =?UTF-8?q?=20=E2=80=94=20PIN=20pairing=20mandatory=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 25 ++++--- README.md | 4 +- .../kotlin/io/unom/punktfunk/MainActivity.kt | 70 +++++++++---------- .../Sources/PunktfunkClient/ContentView.swift | 31 ++++++-- .../Sources/PunktfunkClient/HostCards.swift | 4 +- .../Sources/PunktfunkClient/HostStore.swift | 5 +- .../PunktfunkClient/SessionModel.swift | 25 ++++++- .../Sources/PunktfunkKit/HostDiscovery.swift | 7 +- crates/punktfunk-client-linux/src/app.rs | 64 +++++++++++++---- crates/punktfunk-client-linux/src/session.rs | 14 +++- crates/punktfunk-client-linux/src/ui_hosts.rs | 19 +++-- crates/punktfunk-host/src/main.rs | 16 +++-- docs-site/content/docs/pairing.md | 34 ++++++--- 13 files changed, 221 insertions(+), 97 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8b745c..8f8cd4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,12 +37,16 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc 0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:** host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream - pairing) and logs the SHA-256 fingerprint; clients pin it (TOFU on first connect — - `endpoint::client_pinned`), and a **SPAKE2 PIN pairing ceremony** (host arms pairing and displays a - 4-digit PIN; a PAKE binds both cert fingerprints so an attacker gets one online guess, - no offline dictionary attack) establishes mutual trust: - clients present persistent identities via QUIC client auth, the host stores paired - fingerprints (`punktfunk1-paired.json`) and can gate sessions with `--require-pairing`. + pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing + ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an + attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new + hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in + (`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs; + clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every + other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing + (no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores + paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the + default; `--allow-tofu`/`--open` accept unpaired clients). **LAN auto-discovery**: both `serve --native` and `m3-host` advertise the native service over mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to pin)/`pair`(required|optional)/`id`; `punktfunk-client-rs --discover` lists hosts, Apple clients @@ -114,9 +118,12 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc 3. **punktfunk/1 protocol growth**: concurrent sessions (today: one at a time, extras wait in the accept queue). **Done:** unified host (`serve --native` runs GameStream + the punktfunk/1 QUIC host in one process) with native pairing driven over the mgmt API / - web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list). Next - (see roadmap): **mandatory PIN pairing by default** (TOFU-without-pairing is insecure on a - LAN) + **delegated pairing approval** (an already-paired device approves a new one). + web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list). + **Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises + `pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts + unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a + fingerprint change. Next (see roadmap): **delegated pairing approval** (an already-paired device + approves a new one). 4. **M2 polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc -highbitdepth 1` already encodes Main10 from 8-bit input on this box), reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented diff --git a/README.md b/README.md index e7077e6..fac3409 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ catalog, RTSP/ENet/audio, and **video at the client's exact resolution and refre per-session virtual output (KWin, gamescope, Mutter, Sway backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan → NVENC) at up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a GF(2¹⁶) Leopard-FEC + AES-GCM data -plane (p50 ~0.8 ms capture→reassembled at 720p120), with a SPAKE2 PIN pairing ceremony. Both +plane (p50 ~0.8 ms capture→reassembled at 720p120). Its trust model is **SPAKE2 PIN pairing by +default** — a new host requires the PIN ceremony; trust-on-first-use is an explicit host opt-in +(`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs. Both run from **one process** (`serve --native`), managed through a REST API + web console. Builds against FFmpeg 7 or 8; deployed live on Bazzite. Full status: [`CLAUDE.md`](CLAUDE.md); roadmap, setup guides & progress: the docs site ([`docs-site/`](docs-site) — Fumadocs; diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index a823ec2..7a69e77 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -145,13 +145,17 @@ private sealed interface Screen { data class Stream(val handle: Long) : Screen } -/** A trust decision awaiting the user before a connect proceeds. [hostId] is the PinStore key. */ +/** + * A trust decision awaiting the user before a connect proceeds. [hostId] is the PinStore key. + * Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED pair=optional; + * a pair=required host or a manually-typed/unknown-policy host goes straight to PIN pairing + * ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust shortcut. + */ private data class PendingTrust( val host: String, val port: Int, val hostId: String, val advertisedFp: String?, - val pairingRequired: Boolean, val kind: Kind, ) { enum class Kind { TRUST_NEW, FP_CHANGED, PAIR } @@ -237,32 +241,28 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) { } } - // Decide TOFU vs pinned vs pairing before connecting. + // Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust-on- + // first-use is permitted ONLY when the host advertised pair=optional (dh.pairingRequired == + // false); a pair=required host, or a manually-typed/unknown-policy host (dh == null), must pair + // by PIN — we never trust an unverified cert on faith. fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) { val hostId = hostIdFor(targetHost, targetPort, dh) val stored = pinStore.get(hostId) - val pairingReq = dh?.pairingRequired ?: false + val adv = dh?.fingerprint?.lowercase() when { - stored != null -> { - val adv = dh?.fingerprint?.lowercase() - if (adv != null && adv != stored) { - // Advertised fp no longer matches the pin — host reinstall, or an impostor. - pendingTrust = PendingTrust( - targetHost, targetPort, hostId, adv, pairingReq, PendingTrust.Kind.FP_CHANGED, - ) - } else { - doConnect(targetHost, targetPort, hostId, stored) - } - } - // Never trusted + host requires pairing → TOFU can't pass the gate; go straight to PIN. - pairingReq -> pendingTrust = PendingTrust( - // pairingReq true ⇒ dh != null (smart-cast), so the fp is the advertised one. - targetHost, targetPort, hostId, dh.fingerprint, true, PendingTrust.Kind.PAIR, - ) - // Never trusted, TOFU allowed → confirm trust first. - else -> pendingTrust = PendingTrust( - targetHost, targetPort, hostId, dh?.fingerprint, false, PendingTrust.Kind.TRUST_NEW, - ) + // Known host whose advertised fp still matches the pin → silent pinned reconnect. + stored != null && (adv == null || adv == stored) -> + doConnect(targetHost, targetPort, hostId, stored) + // Known host whose fp changed → force re-pairing (no silent re-trust shortcut). + stored != null -> pendingTrust = + PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.FP_CHANGED) + // Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it, + // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null. + dh?.pairingRequired == false -> pendingTrust = + PendingTrust(targetHost, targetPort, hostId, dh.fingerprint, PendingTrust.Kind.TRUST_NEW) + // pair=required, or a manual/unknown-policy host → PIN pairing is mandatory. + else -> pendingTrust = + PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.PAIR) } } @@ -328,7 +328,10 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) { Column { Text("First connection to ${pt.host}:${pt.port}.") pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}…") } - Text("Pairing with a PIN is stronger — it verifies both sides.") + Text( + "This host allows trust-on-first-use, but that can't tell an impostor " + + "from the real host. Pairing with a PIN is stronger — it proves both sides.", + ) } }, confirmButton = { @@ -351,22 +354,15 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) { text = { Text( "The pinned fingerprint for ${pt.host} no longer matches what it now " + - "advertises. This can mean a host reinstall — or an impostor. Re-pair, " + - "or forget the saved fingerprint to trust the new one.", + "advertises. This can mean a host reinstall — or an impostor. Re-pair " + + "with the host's PIN to continue.", ) }, confirmButton = { TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") } }, dismissButton = { - Row { - TextButton({ - pinStore.remove(pt.hostId) - pendingTrust = null - doConnect(pt.host, pt.port, pt.hostId, null) - }) { Text("Forget & re-TOFU") } - TextButton({ pendingTrust = null }) { Text("Cancel") } - } + TextButton({ pendingTrust = null }) { Text("Cancel") } }, ) PendingTrust.Kind.PAIR -> { @@ -442,8 +438,8 @@ private fun DiscoveredHostRow(dh: DiscoveredHost, enabled: Boolean, onTap: () -> ) { Column(Modifier.padding(12.dp)) { Text(dh.name, style = MaterialTheme.typography.bodyLarge) - val pairing = if (dh.pairingRequired) "pairing required" else "TOFU" - Text("${dh.host}:${dh.port} · $pairing", style = MaterialTheme.typography.bodySmall) + val trust = if (dh.pairingRequired) "PIN pairing" else "PIN or trust-on-first-use" + Text("${dh.host}:${dh.port} · $trust", style = MaterialTheme.typography.bodySmall) dh.fingerprint?.let { fp -> Text("fp ${fp.take(16)}…", style = MaterialTheme.typography.labelSmall) } diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c0a66dd..74ddeb0 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -181,7 +181,21 @@ struct ContentView: View { // MARK: - Connect - private func connect(_ host: StoredHost, launchID: String? = nil) { + private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { + // A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when + // the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already + // know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set: + // an unpinned host with no matching `pair=optional` advert routes to PIN pairing instead + // of silently entering the trust prompt (rules 3b + 4). A pinned host ignores all of this. + if host.pinnedSHA256 == nil { + let tofuOK = allowTofu ?? discovery.hosts.contains { + host.matches($0) && $0.allowsTofu + } + if !tofuOK { + pairingTarget = host + return + } + } // The gamepad-type setting resolves NOW (Automatic → match the active physical // controller): the host's virtual pad backend is fixed per session. model.connect( @@ -194,7 +208,8 @@ struct ContentView: View { setting: PunktfunkConnection.GamepadType( rawValue: UInt32(clamping: gamepadType)) ?? .auto), bitrateKbps: UInt32(clamping: bitrateKbps), - launchID: launchID) + launchID: launchID, + allowTofu: host.pinnedSHA256 == nil) } /// Picked a title in the (experimental) library: dismiss the browser and start a session that @@ -205,16 +220,18 @@ struct ContentView: View { } /// Tap a discovered host: save it (so the session has a stored identity and the trust pin - /// persists), then connect — TOFU shows the fingerprint, which should match the advertised - /// `fp`. A `pair=required` host goes straight to the pairing ceremony instead. + /// persists), then connect or pair per the host's advertised policy. The host is the policy + /// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a); + /// a `pair=required` host, or one with no/unknown `pair` field, goes straight to the PIN + /// pairing ceremony (rule 3b). (A pinned discovered host connects silently inside `connect`.) private func connectDiscovered(_ d: DiscoveredHost) { guard !model.isBusy else { return } let host = StoredHost(name: d.name, address: d.host, port: d.port) store.add(host) - if d.requiresPairing { - pairingTarget = host + if d.allowsTofu { + connect(host, allowTofu: true) } else { - connect(host) + pairingTarget = host } } diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/HostCards.swift index f381bcd..8b3ba71 100644 --- a/clients/apple/Sources/PunktfunkClient/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/HostCards.swift @@ -110,7 +110,9 @@ struct HostCardView: View { Button("Browse Library…", action: onBrowseLibrary) } if host.pinnedSHA256 != nil { - Button("Forget Identity", action: onForget) + // Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via + // PIN (unless the host advertises pair=optional). Wording reflects that. + Button("Forget Identity (re-pair to reconnect)", action: onForget) } Button("Remove", role: .destructive, action: onRemove) } diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/HostStore.swift index cd2975b..4698d56 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/HostStore.swift @@ -86,8 +86,9 @@ final class HostStore: ObservableObject { hosts[i].pinnedSHA256 = fingerprint } - /// Drop the pinned identity (e.g. after a legitimate host reinstall) — the next - /// connect goes through the trust prompt again. + /// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade + /// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises + /// `pair=optional` (the only case the connect path still offers the trust prompt). func forgetIdentity(_ host: StoredHost) { guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return } hosts[i].pinnedSHA256 = nil diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index f22b8ee..5ba2042 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -83,11 +83,18 @@ final class SessionModel: ObservableObject { var isBusy: Bool { phase != .idle } + /// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true + /// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host + /// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair` + /// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and + /// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its + /// stored fingerprint is the trust decision.) func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, compositor: PunktfunkConnection.Compositor = .auto, gamepad: PunktfunkConnection.GamepadType = .auto, bitrateKbps: UInt32 = 0, launchID: String? = nil, + allowTofu: Bool = false, autoTrust: Bool = false) { guard phase == .idle else { return } phase = .connecting @@ -118,12 +125,24 @@ final class SessionModel: ObservableObject { } switch result { case .success(let conn): - self.connection = conn - self.startStatsTimer() if pin != nil || autoTrust { + self.connection = conn + self.startStatsTimer() self.beginStreaming() - } else { + } else if allowTofu { + // Host advertised pair=optional — offer the reduced-security TOFU prompt + // over the live (blurred) stream (rule 3a). + self.connection = conn + self.startStatsTimer() self.phase = .awaitingTrust(fingerprint: conn.hostFingerprint) + } else { + // Unpinned and TOFU not permitted (rule 3b): never let this silently + // become trustable. Drop the connection; the caller routes to pairing. + Task.detached { conn.close() } // joins Rust threads — off-main + self.phase = .idle + self.activeHost = nil + self.errorMessage = "\(host.displayName) is not paired yet. " + + "Pair with its PIN before streaming." } case .failure: self.phase = .idle diff --git a/clients/apple/Sources/PunktfunkKit/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/HostDiscovery.swift index e7083b3..71f4dd4 100644 --- a/clients/apple/Sources/PunktfunkKit/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/HostDiscovery.swift @@ -27,6 +27,10 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { public let fingerprintHex: String? /// The host advertised `pair=required` — a client must pair before it can stream. public let requiresPairing: Bool + /// The host EXPLICITLY advertised `pair=optional` — only then may the client offer the + /// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional: + /// pairing is mandatory unless this is true (the policy authority is the host's advert). + public let allowsTofu: Bool } @MainActor @@ -124,7 +128,8 @@ public final class HostDiscovery: ObservableObject { self.resolved[key] = DiscoveredHost( id: (id?.isEmpty == false) ? id! : name, name: name, host: address, port: port.rawValue, - fingerprintHex: fp, requiresPairing: pair == "required") + fingerprintHex: fp, requiresPairing: pair == "required", + allowsTofu: pair == "optional") self.publish() } conn.cancel() diff --git a/crates/punktfunk-client-linux/src/app.rs b/crates/punktfunk-client-linux/src/app.rs index 5d64da0..265dc5b 100644 --- a/crates/punktfunk-client-linux/src/app.rs +++ b/crates/punktfunk-client-linux/src/app.rs @@ -44,7 +44,10 @@ pub fn run() -> glib::ExitCode { } /// `--connect host[:port]` — skip the hosts page and start a session immediately -/// (scripting + headless testing; trust follows the same known-hosts/TOFU rules). +/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host +/// already pinned at this address connects silently on its stored pin; an unknown host is +/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are +/// unset, so `initiate_connect`'s manual arm mandates pairing). fn cli_connect_request() -> Option { let args: Vec = std::env::args().collect(); let target = args @@ -61,7 +64,7 @@ fn cli_connect_request() -> Option { addr, port, fp_hex: None, - pair_required: false, + pair_optional: false, }) } @@ -119,10 +122,18 @@ fn build_ui(gtk_app: &adw::Application) { } } -/// The trust gate in front of every connect. Discovered hosts carry their fingerprint in -/// the mDNS advert, so trust is decided *before* any traffic: known → pinned connect; -/// unknown → TOFU prompt (or straight to pairing when the host requires it). Manual -/// entries have no advance fingerprint: trust on first use, pin from then on. +/// The trust gate in front of every connect. The host is the policy authority (it +/// advertises `pair=optional` only when it accepts unpaired clients); the client renders +/// its trust UI from that: +/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently. +/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer +/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of +/// the advertised policy. +/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a); +/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is +/// mandatory (rule 3b). +/// +/// A new host is never auto-connected without a stored pin or an explicit trust decision. fn initiate_connect(app: Rc, req: ConnectRequest) { if app.busy.get() { return; @@ -131,19 +142,31 @@ fn initiate_connect(app: Rc, req: ConnectRequest) { match &req.fp_hex { Some(fp_hex) => { if known.find_by_fp(fp_hex).is_some() { + // Rule 1: pinned fingerprint matches — silent connect. start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex)); - } else if req.pair_required { - // TOFU alone won't pass the host's gate — go straight to the ceremony. + } else if known.find_by_addr(&req.addr, req.port).is_some() { + // Rule 2: we trust a host at this address but the fingerprint changed — + // the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut). + app.toast("Host fingerprint changed — re-pair with a PIN to continue"); pin_dialog(app, req); - } else { + } else if req.pair_optional { + // Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN. tofu_dialog(app, req); + } else { + // Rule 3b: pair=required or unknown policy — PIN pairing is mandatory. + pin_dialog(app, req); } } None => { - let pin = known + // Manual entry (no advertised fingerprint). A known address connects silently + // on its stored pin (rule 1); an unknown one must pair — never silent TOFU. + match known .find_by_addr(&req.addr, req.port) - .and_then(|k| crate::trust::parse_hex32(&k.fp_hex)); - start_session(app, req, pin); + .and_then(|k| crate::trust::parse_hex32(&k.fp_hex)) + { + Some(pin) => start_session(app, req, Some(pin)), + None => pin_dialog(app, req), // rule 3b + } } } } @@ -457,10 +480,21 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { p.update_stats(s); } } - SessionEvent::Failed(msg) => { - tracing::warn!(%msg, "connect failed"); - app.toast(&msg); + SessionEvent::Failed { + msg, + trust_rejected, + } => { + tracing::warn!(%msg, trust_rejected, "connect failed"); app.busy.set(false); + // A pinned connect rejected on trust grounds means the host's cert no + // longer matches the stored pin (rotated cert or impostor) — route to + // the PIN ceremony to re-establish trust rather than dead-ending. + if trust_rejected && !tofu { + app.toast("Host fingerprint changed — re-pair with a PIN to continue"); + pin_dialog(app.clone(), req.clone()); + } else { + app.toast(&msg); + } break; } SessionEvent::Ended(err) => { diff --git a/crates/punktfunk-client-linux/src/session.rs b/crates/punktfunk-client-linux/src/session.rs index 32d2788..e3cb8cc 100644 --- a/crates/punktfunk-client-linux/src/session.rs +++ b/crates/punktfunk-client-linux/src/session.rs @@ -42,7 +42,13 @@ pub enum SessionEvent { mode: Mode, fingerprint: [u8; 32], }, - Failed(String), + /// `trust_rejected` is set when the connect failed the TLS trust check (a `Crypto` + /// error): for a pinned connect this is the fingerprint-changed signal, so the UI can + /// offer a re-pair (PIN) path rather than a dead-end error. + Failed { + msg: String, + trust_rejected: bool, + }, Ended(Option), Stats(Stats), } @@ -97,6 +103,7 @@ fn pump( ) { Ok(c) => Arc::new(c), Err(e) => { + let trust_rejected = matches!(e, PunktfunkError::Crypto); let msg = match e { PunktfunkError::Crypto => { "Host identity rejected — wrong fingerprint, or the host requires pairing" @@ -105,7 +112,10 @@ fn pump( PunktfunkError::Timeout => "Connection timed out".to_string(), other => format!("Connect failed: {other:?}"), }; - let _ = ev_tx.send_blocking(SessionEvent::Failed(msg)); + let _ = ev_tx.send_blocking(SessionEvent::Failed { + msg, + trust_rejected, + }); return; } }; diff --git a/crates/punktfunk-client-linux/src/ui_hosts.rs b/crates/punktfunk-client-linux/src/ui_hosts.rs index f5f4454..6c508f2 100644 --- a/crates/punktfunk-client-linux/src/ui_hosts.rs +++ b/crates/punktfunk-client-linux/src/ui_hosts.rs @@ -9,15 +9,17 @@ use std::collections::HashMap; use std::rc::Rc; /// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the -/// host was discovered (drives the TOFU prompt *before* connecting); manual entries have -/// none and trust on first use. +/// host was discovered (drives the trust decision *before* connecting); manual entries have +/// none. `pair_optional` is true ONLY when a discovered host advertised `pair=optional`, +/// which is the sole case in which the reduced-security TOFU path may be offered — every +/// other case (pair=required, unknown/empty policy, manual entry) mandates PIN pairing. #[derive(Clone, Debug)] pub struct ConnectRequest { pub name: String, pub addr: String, pub port: u16, pub fp_hex: Option, - pub pair_required: bool, + pub pair_optional: bool, } pub fn new( @@ -80,7 +82,9 @@ pub fn new( addr: h.addr.clone(), port: h.port, fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), - pair_required: h.pair == "required", + // TOFU is offered only when the host explicitly opts in + // with pair=optional; required/empty means mandatory PIN. + pair_optional: h.pair == "optional", }); } }); @@ -119,7 +123,8 @@ pub fn new( addr, port, fp_hex: None, - pair_required: false, + // Manual entry carries no advertised policy — never eligible for TOFU. + pair_optional: false, }); } }; @@ -172,7 +177,9 @@ pub fn new( addr: k.addr.clone(), port: k.port, fp_hex: Some(k.fp_hex.clone()), - pair_required: false, + // Saved host: its fp is already pinned, so this routes to a silent + // pinned connect; TOFU eligibility is irrelevant. + pair_optional: false, }; let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic"); speed_btn.set_tooltip_text(Some("Test network speed")); diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index d0849e4..cf571c0 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -184,8 +184,13 @@ fn real_main() -> Result<()> { max_concurrent: get("--max-concurrent") .and_then(|s| s.parse().ok()) .unwrap_or(m3::DEFAULT_MAX_CONCURRENT), - require_pairing: args.iter().any(|a| a == "--require-pairing"), - allow_pairing: args.iter().any(|a| a == "--allow-pairing"), + // Secure by default: REQUIRE PIN pairing (reject unpaired clients) unless + // --allow-tofu opts into trust-on-first-use — the host then accepts unpaired + // clients and advertises pair=optional. Pairing is always armed so a PIN is + // available (logged at startup); `--require-pairing`/`--allow-pairing` are now + // the default and accepted as no-ops for back-compat. + require_pairing: !args.iter().any(|a| a == "--allow-tofu"), + allow_pairing: true, pairing_pin: None, paired_store: None, }) @@ -446,9 +451,10 @@ M3-HOST OPTIONS: --max-sessions exit after N sessions; 0 = serve forever (default: 0) --max-concurrent stream at most N sessions at once (NVENC bound); overflow waits in the accept queue; 0 = unlimited (default: 4) - --allow-pairing accept PIN pairing ceremonies (arm pairing mode) - --require-pairing only serve PIN-paired clients (implies --allow-pairing; - the host logs a 4-digit PIN when a client starts pairing) + --allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise + pair=optional. Default: pairing REQUIRED — the host rejects + unpaired clients and logs a 4-digit pairing PIN at startup; + TOFU without pairing is insecure on a LAN M0 OPTIONS: --source diff --git a/docs-site/content/docs/pairing.md b/docs-site/content/docs/pairing.md index 799401c..f3343e6 100644 --- a/docs-site/content/docs/pairing.md +++ b/docs-site/content/docs/pairing.md @@ -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