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
+16 -9
View File
@@ -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 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:** 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 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 — pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
`endpoint::client_pinned`), and a **SPAKE2 PIN pairing ceremony** (host arms pairing and displays a ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
4-digit PIN; a PAKE binds both cert fingerprints so an attacker gets one online guess, attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
no offline dictionary attack) establishes mutual trust: hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
clients present persistent identities via QUIC client auth, the host stores paired (`m3-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
fingerprints (`punktfunk1-paired.json`) and can gate sessions with `--require-pairing`. 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 **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 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 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 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 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 / 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 web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
(see roadmap): **mandatory PIN pairing by default** (TOFU-without-pairing is insecure on a **Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
LAN) + **delegated pairing approval** (an already-paired device approves a new one). `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 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), -highbitdepth 1` already encodes Main10 from 8-bit input on this box),
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
+3 -1
View File
@@ -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 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 **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 **`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 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); 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; roadmap, setup guides & progress: the docs site ([`docs-site/`](docs-site) — Fumadocs;
@@ -145,13 +145,17 @@ private sealed interface Screen {
data class Stream(val handle: Long) : 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( private data class PendingTrust(
val host: String, val host: String,
val port: Int, val port: Int,
val hostId: String, val hostId: String,
val advertisedFp: String?, val advertisedFp: String?,
val pairingRequired: Boolean,
val kind: Kind, val kind: Kind,
) { ) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR } 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) { fun connect(targetHost: String, targetPort: Int, dh: DiscoveredHost? = null) {
val hostId = hostIdFor(targetHost, targetPort, dh) val hostId = hostIdFor(targetHost, targetPort, dh)
val stored = pinStore.get(hostId) val stored = pinStore.get(hostId)
val pairingReq = dh?.pairingRequired ?: false val adv = dh?.fingerprint?.lowercase()
when { when {
stored != null -> { // Known host whose advertised fp still matches the pin → silent pinned reconnect.
val adv = dh?.fingerprint?.lowercase() stored != null && (adv == null || adv == stored) ->
if (adv != null && adv != stored) { doConnect(targetHost, targetPort, hostId, stored)
// Advertised fp no longer matches the pin — host reinstall, or an impostor. // Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
pendingTrust = PendingTrust( stored != null -> pendingTrust =
targetHost, targetPort, hostId, adv, pairingReq, PendingTrust.Kind.FP_CHANGED, PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.FP_CHANGED)
) // Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
} else { // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
doConnect(targetHost, targetPort, hostId, stored) 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.
// Never trusted + host requires pairing → TOFU can't pass the gate; go straight to PIN. else -> pendingTrust =
pairingReq -> pendingTrust = PendingTrust( PendingTrust(targetHost, targetPort, hostId, adv, PendingTrust.Kind.PAIR)
// 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,
)
} }
} }
@@ -328,7 +328,10 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
Column { Column {
Text("First connection to ${pt.host}:${pt.port}.") Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") } 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 = { confirmButton = {
@@ -351,22 +354,15 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
text = { text = {
Text( Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " + "The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair, " + "advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"or forget the saved fingerprint to trust the new one.", "with the host's PIN to continue.",
) )
}, },
confirmButton = { confirmButton = {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") } TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
}, },
dismissButton = { dismissButton = {
Row { TextButton({ pendingTrust = null }) { Text("Cancel") }
TextButton({
pinStore.remove(pt.hostId)
pendingTrust = null
doConnect(pt.host, pt.port, pt.hostId, null)
}) { Text("Forget & re-TOFU") }
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
}, },
) )
PendingTrust.Kind.PAIR -> { PendingTrust.Kind.PAIR -> {
@@ -442,8 +438,8 @@ private fun DiscoveredHostRow(dh: DiscoveredHost, enabled: Boolean, onTap: () ->
) { ) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(dh.name, style = MaterialTheme.typography.bodyLarge) Text(dh.name, style = MaterialTheme.typography.bodyLarge)
val pairing = if (dh.pairingRequired) "pairing required" else "TOFU" val trust = if (dh.pairingRequired) "PIN pairing" else "PIN or trust-on-first-use"
Text("${dh.host}:${dh.port} · $pairing", style = MaterialTheme.typography.bodySmall) Text("${dh.host}:${dh.port} · $trust", style = MaterialTheme.typography.bodySmall)
dh.fingerprint?.let { fp -> dh.fingerprint?.let { fp ->
Text("fp ${fp.take(16)}", style = MaterialTheme.typography.labelSmall) Text("fp ${fp.take(16)}", style = MaterialTheme.typography.labelSmall)
} }
@@ -181,7 +181,21 @@ struct ContentView: View {
// MARK: - Connect // 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 // The gamepad-type setting resolves NOW (Automatic match the active physical
// controller): the host's virtual pad backend is fixed per session. // controller): the host's virtual pad backend is fixed per session.
model.connect( model.connect(
@@ -194,7 +208,8 @@ struct ContentView: View {
setting: PunktfunkConnection.GamepadType( setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto), rawValue: UInt32(clamping: gamepadType)) ?? .auto),
bitrateKbps: UInt32(clamping: bitrateKbps), 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 /// 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 /// 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 /// persists), then connect or pair per the host's advertised policy. The host is the policy
/// `fp`. A `pair=required` host goes straight to the pairing ceremony instead. /// 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) { private func connectDiscovered(_ d: DiscoveredHost) {
guard !model.isBusy else { return } guard !model.isBusy else { return }
let host = StoredHost(name: d.name, address: d.host, port: d.port) let host = StoredHost(name: d.name, address: d.host, port: d.port)
store.add(host) store.add(host)
if d.requiresPairing { if d.allowsTofu {
pairingTarget = host connect(host, allowTofu: true)
} else { } else {
connect(host) pairingTarget = host
} }
} }
@@ -110,7 +110,9 @@ struct HostCardView: View {
Button("Browse Library…", action: onBrowseLibrary) Button("Browse Library…", action: onBrowseLibrary)
} }
if host.pinnedSHA256 != nil { 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) Button("Remove", role: .destructive, action: onRemove)
} }
@@ -86,8 +86,9 @@ final class HostStore: ObservableObject {
hosts[i].pinnedSHA256 = fingerprint hosts[i].pinnedSHA256 = fingerprint
} }
/// Drop the pinned identity (e.g. after a legitimate host reinstall) the next /// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade
/// connect goes through the trust prompt again. /// 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) { func forgetIdentity(_ host: StoredHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return } guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i].pinnedSHA256 = nil hosts[i].pinnedSHA256 = nil
@@ -83,11 +83,18 @@ final class SessionModel: ObservableObject {
var isBusy: Bool { phase != .idle } 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, func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto, compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto, gamepad: PunktfunkConnection.GamepadType = .auto,
bitrateKbps: UInt32 = 0, bitrateKbps: UInt32 = 0,
launchID: String? = nil, launchID: String? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false) { autoTrust: Bool = false) {
guard phase == .idle else { return } guard phase == .idle else { return }
phase = .connecting phase = .connecting
@@ -118,12 +125,24 @@ final class SessionModel: ObservableObject {
} }
switch result { switch result {
case .success(let conn): case .success(let conn):
self.connection = conn
self.startStatsTimer()
if pin != nil || autoTrust { if pin != nil || autoTrust {
self.connection = conn
self.startStatsTimer()
self.beginStreaming() 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) 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: case .failure:
self.phase = .idle self.phase = .idle
@@ -27,6 +27,10 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
public let fingerprintHex: String? public let fingerprintHex: String?
/// The host advertised `pair=required` a client must pair before it can stream. /// The host advertised `pair=required` a client must pair before it can stream.
public let requiresPairing: Bool 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 @MainActor
@@ -124,7 +128,8 @@ public final class HostDiscovery: ObservableObject {
self.resolved[key] = DiscoveredHost( self.resolved[key] = DiscoveredHost(
id: (id?.isEmpty == false) ? id! : name, id: (id?.isEmpty == false) ? id! : name,
name: name, host: address, port: port.rawValue, name: name, host: address, port: port.rawValue,
fingerprintHex: fp, requiresPairing: pair == "required") fingerprintHex: fp, requiresPairing: pair == "required",
allowsTofu: pair == "optional")
self.publish() self.publish()
} }
conn.cancel() conn.cancel()
+49 -15
View File
@@ -44,7 +44,10 @@ pub fn run() -> glib::ExitCode {
} }
/// `--connect host[:port]` — skip the hosts page and start a session immediately /// `--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<ConnectRequest> { fn cli_connect_request() -> Option<ConnectRequest> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let target = args let target = args
@@ -61,7 +64,7 @@ fn cli_connect_request() -> Option<ConnectRequest> {
addr, addr,
port, port,
fp_hex: None, 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 trust gate in front of every connect. The host is the policy authority (it
/// the mDNS advert, so trust is decided *before* any traffic: known → pinned connect; /// advertises `pair=optional` only when it accepts unpaired clients); the client renders
/// unknown → TOFU prompt (or straight to pairing when the host requires it). Manual /// its trust UI from that:
/// entries have no advance fingerprint: trust on first use, pin from then on. /// 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<App>, req: ConnectRequest) { fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
if app.busy.get() { if app.busy.get() {
return; return;
@@ -131,19 +142,31 @@ fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
match &req.fp_hex { match &req.fp_hex {
Some(fp_hex) => { Some(fp_hex) => {
if known.find_by_fp(fp_hex).is_some() { 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)); start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex));
} else if req.pair_required { } else if known.find_by_addr(&req.addr, req.port).is_some() {
// TOFU alone won't pass the host's gate — go straight to the ceremony. // 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); 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); tofu_dialog(app, req);
} else {
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
pin_dialog(app, req);
} }
} }
None => { 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) .find_by_addr(&req.addr, req.port)
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex)); .and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
start_session(app, req, pin); {
Some(pin) => start_session(app, req, Some(pin)),
None => pin_dialog(app, req), // rule 3b
}
} }
} }
} }
@@ -457,10 +480,21 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
p.update_stats(s); p.update_stats(s);
} }
} }
SessionEvent::Failed(msg) => { SessionEvent::Failed {
tracing::warn!(%msg, "connect failed"); msg,
app.toast(&msg); trust_rejected,
} => {
tracing::warn!(%msg, trust_rejected, "connect failed");
app.busy.set(false); 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; break;
} }
SessionEvent::Ended(err) => { SessionEvent::Ended(err) => {
+12 -2
View File
@@ -42,7 +42,13 @@ pub enum SessionEvent {
mode: Mode, mode: Mode,
fingerprint: [u8; 32], 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<String>), Ended(Option<String>),
Stats(Stats), Stats(Stats),
} }
@@ -97,6 +103,7 @@ fn pump(
) { ) {
Ok(c) => Arc::new(c), Ok(c) => Arc::new(c),
Err(e) => { Err(e) => {
let trust_rejected = matches!(e, PunktfunkError::Crypto);
let msg = match e { let msg = match e {
PunktfunkError::Crypto => { PunktfunkError::Crypto => {
"Host identity rejected — wrong fingerprint, or the host requires pairing" "Host identity rejected — wrong fingerprint, or the host requires pairing"
@@ -105,7 +112,10 @@ fn pump(
PunktfunkError::Timeout => "Connection timed out".to_string(), PunktfunkError::Timeout => "Connection timed out".to_string(),
other => format!("Connect failed: {other:?}"), other => format!("Connect failed: {other:?}"),
}; };
let _ = ev_tx.send_blocking(SessionEvent::Failed(msg)); let _ = ev_tx.send_blocking(SessionEvent::Failed {
msg,
trust_rejected,
});
return; return;
} }
}; };
+13 -6
View File
@@ -9,15 +9,17 @@ use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the /// 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 /// host was discovered (drives the trust decision *before* connecting); manual entries have
/// none and trust on first use. /// 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)] #[derive(Clone, Debug)]
pub struct ConnectRequest { pub struct ConnectRequest {
pub name: String, pub name: String,
pub addr: String, pub addr: String,
pub port: u16, pub port: u16,
pub fp_hex: Option<String>, pub fp_hex: Option<String>,
pub pair_required: bool, pub pair_optional: bool,
} }
pub fn new( pub fn new(
@@ -80,7 +82,9 @@ pub fn new(
addr: h.addr.clone(), addr: h.addr.clone(),
port: h.port, port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), 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, addr,
port, port,
fp_hex: None, 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(), addr: k.addr.clone(),
port: k.port, port: k.port,
fp_hex: Some(k.fp_hex.clone()), 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"); let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
speed_btn.set_tooltip_text(Some("Test network speed")); speed_btn.set_tooltip_text(Some("Test network speed"));
+11 -5
View File
@@ -184,8 +184,13 @@ fn real_main() -> Result<()> {
max_concurrent: get("--max-concurrent") max_concurrent: get("--max-concurrent")
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(m3::DEFAULT_MAX_CONCURRENT), .unwrap_or(m3::DEFAULT_MAX_CONCURRENT),
require_pairing: args.iter().any(|a| a == "--require-pairing"), // Secure by default: REQUIRE PIN pairing (reject unpaired clients) unless
allow_pairing: args.iter().any(|a| a == "--allow-pairing"), // --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, pairing_pin: None,
paired_store: None, paired_store: None,
}) })
@@ -446,9 +451,10 @@ M3-HOST OPTIONS:
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0) --max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits --max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
in the accept queue; 0 = unlimited (default: 4) in the accept queue; 0 = unlimited (default: 4)
--allow-pairing accept PIN pairing ceremonies (arm pairing mode) --allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
--require-pairing only serve PIN-paired clients (implies --allow-pairing; pair=optional. Default: pairing REQUIRED — the host rejects
the host logs a 4-digit PIN when a client starts pairing) unpaired clients and logs a 4-digit pairing PIN at startup;
TOFU without pairing is insecure on a LAN
M0 OPTIONS: M0 OPTIONS:
--source <synthetic|portal|kwin-virtual> --source <synthetic|portal|kwin-virtual>
+26 -8
View File
@@ -34,8 +34,10 @@ with console access can admit a device.
## Pairing with a PIN ## Pairing with a PIN
The PIN ceremony is the other path — useful for the *first* device (before the console has admitted PIN pairing is the **default and required** path for any new host: unless the host has explicitly
anything) or when you're at the client and the console isn't handy. 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 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 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 the right setting on a shared network: a device has to complete the PIN ceremony once before it can
connect. connect.
If you're on a fully trusted single-user network and want to skip pairing, start the host with If you're on a fully trusted single-user network and want to skip pairing, run the host open with
`serve --native --open` — but requiring pairing is strongly recommended. `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 Trust-on-first-use (TOFU) is **off by default** and is an explicit *host* opt-in for fully trusted
fingerprint and ask you to confirm it (trust-on-first-use), then pin it. Pairing is the stronger path networks. A host enables it by running open — `m3-host --allow-tofu` or `serve --open` — which makes
and the default; trust-on-first-use is a convenience for trusted setups. 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 ## Managing paired devices