From d1d2ca293d6f04ce030707874b671e940be88381 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 29 Jun 2026 06:41:09 +0000 Subject: [PATCH] feat(pairing): seamless no-PIN delegated approval (host parks the knock, clients add "Request access") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web-console "Approve" (delegated pairing, roadmap §8b-1) was unreachable: every client routed a fresh pair=required host straight to the SPAKE2 PIN ceremony, so no "knock" was ever recorded; and an unpaired connect was rejected+closed with no way to resume after approval. The backend + console were complete but had no client-side trigger and no post-approval admit path. Host (native_pairing.rs, punktfunk1.rs): an unpaired identified knock is now PARKED instead of rejected — it releases its NVENC session permit, awaits an operator decision (NativePairing::wait_for_decision, woken by a Notify on approve/deny), and on approval re-acquires a slot and admits the SAME connection with no reconnect. QUIC keep-alive (4s/8s) holds the parked connection warm. The pairing gate moves out of the HANDSHAKE_TIMEOUT-bounded handshake future; approve_pending is reordered read-then-add and wait_for_decision double-checks is_paired to close a "neither pending nor paired" race. New PENDING_APPROVAL_WAIT (180s). Tests: delegated_approval_admits_after_knock now approves mid-park (no reconnect) + new wait_for_decision_approve_deny_timeout unit test (108 host tests green). Clients (Linux/Apple/Windows/Android): a fresh pair=required host now offers "Request access" alongside the PIN ceremony — a plain identified connect with a ~185s handshake budget and a cancelable "waiting for approval" UI; on success the host is saved as paired, and cancel returns the UI immediately while a late- resolving connect is torn down silently via a per-attempt flag. Apple reuses the existing C-ABI timeout_ms (no ABI change); Windows adds SessionParams.connect_timeout + a RequestAccess screen; Android adds a timeoutMs arg to the nativeConnect JNI seam (both sides + both callers). Linux built + clippy + fmt clean; Apple/Windows/ Android pending their CI/on-device compiles. SPAKE2 ceremony reviewed end-to-end against the spake2 0.4 contract — correct, no changes needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 158 ++++++++- .../io/unom/punktfunk/models/UiModels.kt | 8 +- .../io/unom/punktfunk/kit/NativeBridge.kt | 7 +- clients/android/native/src/session.rs | 15 +- .../Sources/PunktfunkClient/ContentView.swift | 181 +++++++++- .../PunktfunkClient/SessionModel.swift | 49 ++- clients/linux/src/app.rs | 163 ++++++++- clients/linux/src/session.rs | 7 +- clients/windows/src/app.rs | 316 +++++++++++++++++- clients/windows/src/main.rs | 3 + clients/windows/src/session.rs | 7 +- crates/punktfunk-host/src/native_pairing.rs | 178 +++++++++- crates/punktfunk-host/src/punktfunk1.rs | 218 +++++++----- 13 files changed, 1149 insertions(+), 161 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 4ee6e8a..d49f989 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -74,10 +74,31 @@ import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.kit.security.obtainIdentity import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.PendingTrust +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */ +private const val CONNECT_TIMEOUT_MS = 10_000 + +/** + * Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park + * window (~180 s) so a slow operator approval still lands on this same parked connection rather than + * timing the client out first. Mirrors the Linux client's 185 s. + */ +private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000 + +/** + * A no-PIN "request access" connect in flight — the host being requested (drives the cancelable + * "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a + * blocking call with no abort, so Cancel returns the UI immediately and a late result checks + * [cancelled] and tears the (possibly just-approved) session down silently rather than navigating. + */ +private class RequestAccessState(val target: PendingTrust) { + val cancelled = AtomicBoolean(false) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { @@ -128,8 +149,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { .onSuccess { identity = it } .onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" } } - // A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing). + // A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the + // request-access-or-PIN choice). var pendingTrust by remember { mutableStateOf(null) } + // A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog). + var awaiting by remember { mutableStateOf(null) } // A saved host whose label is being edited (the Rename dialog). var renameTarget by remember { mutableStateOf(null) } @@ -163,7 +187,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { targetHost, targetPort, w, h, hz, id.certPem, id.privateKeyPem, pinHex ?: "", settings.bitrateKbps, settings.compositor, gamepadPref, - hdrEnabled, settings.audioChannels, + hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS, ) } connecting = false @@ -182,10 +206,66 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } } - // Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is + // The no-PIN "request access" path (delegated approval): open a normal identified connect that + // the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable + // "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no + // reconnect), so on success we record the host as PAIRED — the operator's approval IS the pairing. + // The connect can't be aborted, so Cancel returns the UI immediately and a late result is torn + // down silently via the per-attempt flag (mirrors the Linux client's request-access flow). + fun requestAccess(target: PendingTrust) { + val id = identity + if (id == null) { + status = "Identity not ready yet — try again in a moment" + return + } + val req = RequestAccessState(target) + awaiting = req + connecting = true + status = null + discovery.stop() // free the Wi-Fi radio before the (parked) stream session + scope.launch { + val hdrEnabled = displaySupportsHdr(context) + val gamepadPref = Gamepad.resolvePref(settings.gamepad) + // Pin the advertised fingerprint for a discovered host (defence against an impostor while + // we wait); a manually-typed host has none, so trust-on-first-use. + val pinHex = target.advertisedFp ?: "" + val handle = withContext(Dispatchers.IO) { + NativeBridge.nativeConnect( + target.host, target.port, w, h, hz, + id.certPem, id.privateKeyPem, pinHex, + settings.bitrateKbps, settings.compositor, gamepadPref, + hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS, + ) + } + // Cancelled while we were parked: tear the (possibly just-approved) session down and + // don't touch UI a fresh action may now own. + if (req.cancelled.get()) { + if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) } + return@launch + } + awaiting = null + connecting = false + if (handle != 0L) { + // Approved — save the host as PAIRED, pinning the fingerprint it presented, so + // future connects are silent (exactly like after a PIN ceremony). + val fp = NativeBridge.nativeHostFingerprint(handle) + if (fp.isNotEmpty()) { + knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true)) + savedHosts = knownHostStore.all() + } + onConnected(handle) + } else { + status = "Request timed out — approve this device in the host's console, then retry." + discovery.start() + } + } + } + + // Decide pinned-reconnect vs fp-changed vs TOFU vs pairing before connecting. Trust state is // keyed by address:port, so a discovered and a manually-typed connection to the same host share // one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a - // pair=required host, or a manual/unknown-policy host, must pair by PIN. + // pair=required host, or a manual/unknown-policy host, must pair — either by no-PIN request + // access (approve in the console) or by the SPAKE2 PIN ceremony. fun connect( targetHost: String, targetPort: Int, @@ -208,9 +288,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null. dh?.pairingRequired == false -> pendingTrust = PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW) - // pair=required, or a manual/unknown-policy host → PIN pairing is mandatory. + // pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN + // "request access" (approve in the console) or the SPAKE2 PIN ceremony. else -> pendingTrust = - PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR) + PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS) } } @@ -471,6 +552,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { TextButton({ pendingTrust = null }) { Text("Cancel") } }, ) + // A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request + // access" is the no-PIN path — connect and wait for the operator to click Approve in the + // host's console; "Use a PIN…" switches to the SPAKE2 ceremony. + PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog( + onDismissRequest = { pendingTrust = null }, + title = { Text("Pairing required") }, + text = { + Column { + Text("${pt.host}:${pt.port} requires pairing before it will stream.") + Text( + "Request access and approve this device in the host's console (or web " + + "UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.", + ) + } + }, + confirmButton = { + TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") } + }, + dismissButton = { + Row { + TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { + Text("Use a PIN…") + } + TextButton({ pendingTrust = null }) { Text("Cancel") } + } + }, + ) PendingTrust.Kind.PAIR -> { var pin by remember(pt) { mutableStateOf("") } var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") } @@ -537,6 +645,44 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } } + // The no-PIN "request access" wait: the connect is parked on the host until the operator + // approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a + // late approval is torn down silently (see requestAccess) and resumes discovery. + awaiting?.let { req -> + fun cancel() { + req.cancelled.set(true) + awaiting = null + connecting = false + discovery.start() // the request may still be pending on the host; keep scanning + } + AlertDialog( + onDismissRequest = { cancel() }, + title = { Text("Waiting for approval") }, + text = { + val deviceName = Build.MODEL ?: "this device" + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Text("Approve this device on ${req.target.name}.") + } + Text( + "Open the host's console (or web UI) and approve “$deviceName”. It connects " + + "automatically once you approve — no PIN needed.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { cancel() }) { Text("Cancel") } + }, + ) + } + // Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a // friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field. renameTarget?.let { kh -> diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index b568bda..c456a8d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -14,8 +14,10 @@ enum class Tab(val label: String, val icon: ImageVector) { /** * A trust decision awaiting the user before a connect proceeds. [name] is the label to save the * host under. 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. + * pair=optional; a pair=required host or a manually-typed/unknown-policy host is offered the + * two ways in ([Kind.REQUEST_ACCESS]): a no-PIN "request access" connect the operator approves in + * the host's console, or the SPAKE2 PIN ceremony ([Kind.PAIR]). A changed fingerprint forces + * re-pairing by PIN ([Kind.FP_CHANGED]) — never a silent re-trust. */ data class PendingTrust( val host: String, @@ -24,7 +26,7 @@ data class PendingTrust( val advertisedFp: String?, val kind: Kind, ) { - enum class Kind { TRUST_NEW, FP_CHANGED, PAIR } + enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS } } /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 898f3de..1c18e19 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -29,8 +29,10 @@ object NativeBridge { * trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch → * `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at * exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the - * `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0` - * on failure. Pair with exactly one [nativeClose]. + * `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). [timeoutMs] is the handshake budget — the + * normal path passes a short value, the no-PIN "request access" path a long one (≥ the host's + * approval-park window) so a slow operator approval lands on this same parked connection. Returns + * an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose]. */ external fun nativeConnect( host: String, @@ -46,6 +48,7 @@ object NativeBridge { gamepadPref: Int, hdrEnabled: Boolean, audioChannels: Int, + timeoutMs: Int, ): Long /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ diff --git a/clients/android/native/src/session.rs b/clients/android/native/src/session.rs index b77f9d3..a274daa 100644 --- a/clients/android/native/src/session.rs +++ b/clients/android/native/src/session.rs @@ -140,13 +140,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde } /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, -/// compositorPref, gamepadPref, hdrEnabled, audioChannels): Long`. `certPem`/`keyPem` empty = -/// anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read +/// compositorPref, gamepadPref, hdrEnabled, audioChannels, timeoutMs): Long`. `certPem`/`keyPem` +/// empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read /// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps` /// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes /// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized, -/// anything else → stereo) — the host clamps it and the resolved count drives playback. -/// Returns an opaque handle, or 0 on failure (logged). +/// anything else → stereo) — the host clamps it and the resolved count drives playback. `timeoutMs` +/// is the handshake budget: the normal path passes a short value, the no-PIN "request access" path a +/// long one (≥ the host's approval-park window) so a slow operator approval lands on this same parked +/// connection rather than timing the client out first. Returns an opaque handle, or 0 on failure. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>( @@ -165,6 +167,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo gamepad_pref: jint, hdr_enabled: jboolean, audio_channels: jint, + timeout_ms: jint, ) -> jlong { let host: String = match env.get_string(&host) { Ok(s) => s.into(), @@ -224,7 +227,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo None, // launch: default app pin, // Some → Crypto on host-fp mismatch identity, // owned (cert, key) PEM, or None (anonymous) - Duration::from_secs(10), + // Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access" + // (the host parks the connection until the operator approves the device — see ConnectScreen). + Duration::from_millis(timeout_ms.max(0) as u64), ) { Ok(client) => { let handle = SessionHandle { diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index a3ef40a..0a18ba6 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -4,10 +4,12 @@ // (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in // their own files. // -// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the -// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing -// verifies both sides at once and is the only way into hosts running --require-pairing. Once -// pinned, reconnects are silent and a changed host identity refuses to connect. +// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the +// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional), +// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing — +// delegated approval ("Request Access": a plain identified connect the host parks until the operator +// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed +// host identity refuses to connect. #if os(macOS) import AppKit @@ -31,6 +33,12 @@ struct ContentView: View { @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @State private var showAddHost = false @State private var pairingTarget: StoredHost? + /// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN + /// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b). + @State private var approvalChoice: ApprovalRequest? + /// A delegated-approval connect is in flight (host parks it until the operator approves): + /// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success. + @State private var awaitingApproval: ApprovalRequest? @State private var speedTestTarget: StoredHost? @State private var libraryTarget: StoredHost? #if !os(macOS) @@ -55,10 +63,27 @@ struct ContentView: View { autoConnectIfAsked() } .onChange(of: model.phase) { _, phase in - // A session actually started — remember it on the card ("Connected … ago" - // plus the accent ring on the most recent host). - if case .streaming = phase, let host = model.activeHost { + switch phase { + case .streaming: + // A session actually started — remember it on the card ("Connected … ago" + // plus the accent ring on the most recent host). + guard let host = model.activeHost else { break } store.markConnected(host.id) + // Delegated approval just succeeded: the operator let this device in, so pin the + // host's observed fingerprint and remember it as paired — future connects are then + // silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt. + if awaitingApproval?.host.id == host.id { + if let fp = model.connection?.hostFingerprint { + store.pin(host.id, fingerprint: fp) + } + awaitingApproval = nil + } + case .idle: + // The delegated-approval connect failed, timed out, or was cancelled — drop the + // wait prompt (SessionModel surfaces any error via `errorMessage`). + if awaitingApproval != nil { awaitingApproval = nil } + default: + break } } .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) @@ -90,6 +115,47 @@ struct ContentView: View { } } #endif + // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an + // alert) so it never collides with the wait alert below. "Request Access" is the no-PIN + // delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on + // presentation is deferred a tick so this dialog is fully dismissed first. + .confirmationDialog( + "Pairing required", + isPresented: Binding( + get: { approvalChoice != nil }, + set: { if !$0 { approvalChoice = nil } }), + titleVisibility: .visible, + presenting: approvalChoice + ) { req in + Button("Request Access") { + DispatchQueue.main.async { requestAccess(req) } + } + Button("Pair with PIN…") { + DispatchQueue.main.async { pairingTarget = req.host } + } + Button("Cancel", role: .cancel) {} + } message: { req in + Text("\(req.host.displayName) requires pairing. Request access and approve this " + + "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or " + + "pair with the 4-digit PIN it can display.") + } + // The delegated-approval wait: the host holds the connection open until the operator + // approves it. Cancel returns the UI at once; the in-flight connect is left to time out + // and its late result is discarded by SessionModel's connect guard (disconnect resets the + // phase/host it checks). + .alert( + "Waiting for approval", + isPresented: Binding( + get: { awaitingApproval != nil }, + set: { if !$0 { awaitingApproval = nil } }), + presenting: awaitingApproval + ) { _ in + Button("Cancel", role: .cancel) { model.disconnect() } + } message: { req in + Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web " + + "console (port 3000 → Pairing). This device connects automatically once you " + + "approve it — no need to reconnect.") + } } private var home: some View { @@ -230,19 +296,32 @@ struct ContentView: View { // 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. + // an unpinned host with no matching `pair=optional` advert routes to the approval choice + // (request access / pair with PIN) 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 + // pair=required / unknown policy / manual entry (rule 3b): never a silent + // connect — offer no-PIN delegated approval or the PIN ceremony. + approvalChoice = ApprovalRequest( + host: host, advertisedFingerprint: advertisedFingerprint(for: host)) return } } - // The gamepad-type setting resolves NOW (Automatic → match the active physical - // controller): the host's virtual pad backend is fixed per session. + startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil) + } + + /// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The + /// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the + /// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN + /// delegated-approval connect (host parks it until the operator approves). + private func startSession( + _ host: StoredHost, launchID: String? = nil, + allowTofu: Bool, requestAccess: Bool = false + ) { model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), @@ -255,7 +334,22 @@ struct ContentView: View { bitrateKbps: UInt32(clamping: bitrateKbps), audioChannels: UInt8(clamping: audioChannels), launchID: launchID, - allowTofu: host.pinnedSHA256 == nil) + allowTofu: allowTofu, + requestAccess: requestAccess) + } + + /// The no-PIN delegated-approval flow: open an identified connect the host parks until the + /// operator approves it in the console, showing the cancelable "Waiting for approval" prompt + /// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned + /// as paired (see the `.streaming` branch of `onChange`). + private func requestAccess(_ req: ApprovalRequest) { + guard !model.isBusy else { return } + awaitingApproval = req + // Pin the advertised certificate for a discovered host (impostor defence during the long + // wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. + var host = req.host + host.pinnedSHA256 = req.advertisedFingerprint + startSession(host, allowTofu: false, requestAccess: true) } /// Picked a title in the (experimental) library: dismiss the browser and start a session that @@ -268,8 +362,9 @@ struct ContentView: View { /// Tap a discovered host: save it (so the session has a stored identity and the trust pin /// 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`.) + /// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice + /// (request access / pair with PIN) (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) @@ -277,7 +372,9 @@ struct ContentView: View { if d.allowsTofu { connect(host, allowTofu: true) } else { - pairingTarget = host + // pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN. + approvalChoice = ApprovalRequest( + host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex)) } } @@ -291,6 +388,30 @@ struct ContentView: View { connect(pinned) } + /// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see + /// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently + /// advertising or advertised no/invalid `fp`. + private func advertisedFingerprint(for host: StoredHost) -> Data? { + pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex) + } + + /// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect + /// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls + /// back to trust-on-first-use rather than failing the connect closed. + private func pinFingerprint(_ hex: String?) -> Data? { + guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil } + return data + } + + /// How the host lists this device in its approval prompt (matches PairSheet's client name). + private var localDeviceName: String { + #if os(macOS) + Host.current().localizedName ?? "Mac" + #else + UIDevice.current.name + #endif + } + // MARK: - First-run + dev hooks /// First run on iOS: default the stream mode to this device's native screen so the @@ -378,3 +499,31 @@ private struct FullscreenController: NSViewRepresentable { } } #endif + +/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access +/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the +/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use). +private struct ApprovalRequest { + let host: StoredHost + let advertisedFingerprint: Data? +} + +private extension Data { + /// Parse an even-length hex string into bytes; nil on any non-hex character or odd length. + /// Used to turn an mDNS-advertised cert fingerprint into a connect pin. + init?(hexString: String) { + let chars = Array(hexString) + guard chars.count.isMultiple(of: 2) else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(chars.count / 2) + var i = 0 + while i < chars.count { + guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else { + return nil + } + bytes.append(UInt8(hi << 4 | lo)) + i += 2 + } + self = Data(bytes) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index f01f9a5..07d35ba 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -95,6 +95,13 @@ final class SessionModel: ObservableObject { /// 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.) + /// + /// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host + /// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no + /// reconnect). The handshake budget is widened to exceed the host's park window, and a + /// successful connect streams directly (the approval IS the trust decision) — the caller pins + /// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert + /// for the wait; nil = trust-on-first-use. func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, compositor: PunktfunkConnection.Compositor = .auto, gamepad: PunktfunkConnection.GamepadType = .auto, @@ -103,7 +110,8 @@ final class SessionModel: ObservableObject { hdrEnabled: Bool = true, launchID: String? = nil, allowTofu: Bool = false, - autoTrust: Bool = false) { + autoTrust: Bool = false, + requestAccess: Bool = false) { guard phase == .idle else { return } phase = .connecting activeHost = host @@ -138,7 +146,11 @@ final class SessionModel: ObservableObject { width: width, height: height, refreshHz: hz, pinSHA256: pin, identity: identity, compositor: compositor, gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps, - audioChannels: audioChannels, launchID: launchID) } + audioChannels: audioChannels, launchID: launchID, + // Delegated approval: the host holds this connect open until the operator approves + // it (~180 s) — outwait that window so a slow approval still lands here. Normal + // connects keep the snappy default. + timeoutMs: requestAccess ? 185_000 : 10_000) } await MainActor.run { [weak self] in guard let self else { return } // The user may have abandoned this attempt (window closed, another host @@ -152,7 +164,9 @@ final class SessionModel: ObservableObject { } switch result { case .success(let conn): - if pin != nil || autoTrust { + if pin != nil || autoTrust || requestAccess { + // requestAccess: the operator approved this device on the host, so the + // session is trusted — stream directly (the caller pins it as paired). self.connection = conn self.startStatsTimer() self.beginStreaming() @@ -174,16 +188,25 @@ final class SessionModel: ObservableObject { case .failure: self.phase = .idle self.activeHost = nil - self.errorMessage = pin != nil - ? "Could not connect to \(host.displayName) — host unreachable, " - + "not running, its identity no longer matches the pinned " - + "fingerprint, or it requires pairing and no longer " - + "recognizes this Mac (right-click the host card to pair " - + "again)." - : "Could not connect to \(host.displayName) — is punktfunk-host " - + "running on \(host.address):\(host.port)? If it requires " - + "pairing, right-click the host card and pair with its PIN " - + "first." + if requestAccess { + // The delegated-approval connect ended without being admitted: the + // operator didn't approve it before the host's park window elapsed (or + // the host was unreachable). + self.errorMessage = "\(host.displayName) didn't let this device in. " + + "Approve it in the host's web console (port 3000 → Pairing), then " + + "request access again — the request expires after a few minutes." + } else { + self.errorMessage = pin != nil + ? "Could not connect to \(host.displayName) — host unreachable, " + + "not running, its identity no longer matches the pinned " + + "fingerprint, or it requires pairing and no longer " + + "recognizes this Mac (right-click the host card to pair " + + "again)." + : "Could not connect to \(host.displayName) — is punktfunk-host " + + "running on \(host.address):\(host.port)? If it requires " + + "pairing, right-click the host card and pair with its PIN " + + "first." + } } } } diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index a3277f9..a32db88 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -295,19 +295,21 @@ fn initiate_connect(app: Rc, req: ConnectRequest) { // 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); + // Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval + // (request access → approve in the console) or the PIN ceremony. + approval_dialog(app, req); } } None => { // Manual entry (no advertised fingerprint). A known address connects silently - // on its stored pin (rule 1); an unknown one must pair — never silent TOFU. + // on its stored pin (rule 1); an unknown one must pair — request access (approve in + // the console) or use a PIN; never silent TOFU. match known .find_by_addr(&req.addr, req.port) .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 + None => approval_dialog(app, req), // rule 3b } } } @@ -418,6 +420,83 @@ fn pin_dialog(app: Rc, req: ConnectRequest) { dialog.present(Some(&parent)); } +/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN +/// path — connect and wait for the operator to click Approve in the host's console/web UI +/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony. +fn approval_dialog(app: Rc, req: ConnectRequest) { + let dialog = adw::AlertDialog::new( + Some("Pairing Required"), + Some(&format!( + "{} requires pairing.\n\nRequest access and approve this device in the host's console \ + (or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.", + req.name + )), + ); + dialog.add_responses(&[ + ("cancel", "Cancel"), + ("pin", "Use a PIN instead…"), + ("request", "Request Access"), + ]); + dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("request")); + dialog.set_close_response("cancel"); + let parent = app.window.clone(); + dialog.connect_response(None, move |_, response| match response { + "request" => request_access(app.clone(), req.clone()), + "pin" => pin_dialog(app.clone(), req.clone()), + _ => {} + }); + dialog.present(Some(&parent)); +} + +/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the +/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On +/// approval the same connection is admitted (no reconnect) and the host is saved as paired. +fn request_access(app: Rc, req: ConnectRequest) { + // Pin the advertised certificate for a discovered host (defence against a host impostor while + // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. + let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32); + let cancel = Rc::new(std::cell::Cell::new(false)); + + let waiting = adw::AlertDialog::new( + Some("Waiting for Approval"), + Some(&format!( + "Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \ + connects automatically once you approve it.", + glib::host_name(), + req.name + )), + ); + waiting.add_responses(&[("cancel", "Cancel")]); + waiting.set_close_response("cancel"); + { + let app = app.clone(); + let cancel = cancel.clone(); + waiting.connect_response(Some("cancel"), move |_, _| { + // Return the UI immediately; the in-flight connect is left to time out and is torn + // down silently by the event loop (see StartOpts::cancel). + cancel.set(true); + app.busy.set(false); + app.toast("Cancelled — the request may still be pending on the host."); + }); + } + waiting.present(Some(&app.window)); + + start_session_with( + app, + req, + pin, + StartOpts { + // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator + // approval still lands on this connection rather than timing the client out first. + connect_timeout: std::time::Duration::from_secs(185), + persist_paired: true, + waiting: Some(waiting), + cancel: Some(cancel), + }, + ); +} + /// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"): /// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report /// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap. @@ -556,7 +635,42 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode { mode } +/// Tunables for a session start that differ between the normal connect and the "request access" +/// (delegated-approval) flow. `Default` is the normal connect. +struct StartOpts { + /// Handshake budget. The request-access flow uses a long one because the host PARKS the + /// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`). + connect_timeout: std::time::Duration, + /// Persist the host as *paired* on a successful connect. Set for request-access, where the + /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU + /// persists the host *unpaired* (pinned, but not PIN/approval-verified). + persist_paired: bool, + /// A "waiting for approval" dialog to dismiss on the first session event (request-access only). + waiting: Option, + /// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with + /// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves + /// the in-flight connect to time out; when it finally resolves, the event loop sees this flag + /// and tears down silently (drops the connector → closes the connection) without touching the + /// UI a new session may already own. + cancel: Option>>, +} + +impl Default for StartOpts { + fn default() -> Self { + Self { + connect_timeout: std::time::Duration::from_secs(15), + persist_paired: false, + waiting: None, + cancel: None, + } + } +} + fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { + start_session_with(app, req, pin, StartOpts::default()); +} + +fn start_session_with(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) { if app.busy.replace(true) { return; } @@ -577,10 +691,14 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { audio_channels: s.audio_channels, pin, identity: app.identity.clone(), + connect_timeout: opts.connect_timeout, }; let inhibit = s.inhibit_shortcuts; drop(s); let tofu = pin.is_none(); + let persist_paired = opts.persist_paired; + let mut waiting = opts.waiting; + let cancel = opts.cancel; let mut handle = crate::session::start(params); let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1); @@ -588,14 +706,41 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { let mut frames = Some(frames); let mut page: Option = None; while let Ok(event) = handle.events.recv().await { + // A cancelled request-access connect resolved late: tear down silently. Don't touch + // app.busy — Cancel already cleared it, and a fresh session may now own it. + if cancel.as_ref().is_some_and(|c| c.get()) { + if let Some(w) = waiting.take() { + w.close(); + } + break; + } match event { SessionEvent::Connected { connector, mode, fingerprint, } => { - // A TOFU connect just observed the real fingerprint — pin it from now on. - if tofu { + // Dismiss the "waiting for approval" dialog (request-access flow), if any. + if let Some(w) = waiting.take() { + w.close(); + } + if persist_paired { + // Request-access: the operator approved this device, so record the host as + // a trusted PAIRED host (pinning the fingerprint we observed) — future + // connects are then silent (rule 1), exactly like after a PIN ceremony. + let fp_hex = crate::trust::hex(&fingerprint); + let mut known = KnownHosts::load(); + known.upsert(KnownHost { + name: req.name.clone(), + addr: req.addr.clone(), + port: req.port, + fp_hex, + paired: true, + }); + let _ = known.save(); + app.toast("Approved — connecting…"); + } else if tofu { + // A TOFU connect just observed the real fingerprint — pin it from now on. let fp_hex = crate::trust::hex(&fingerprint); let mut known = KnownHosts::load(); known.upsert(KnownHost { @@ -644,6 +789,9 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { msg, trust_rejected, } => { + if let Some(w) = waiting.take() { + w.close(); + } tracing::warn!(%msg, trust_rejected, "connect failed"); app.busy.set(false); // A pinned connect rejected on trust grounds means the host's cert no @@ -658,6 +806,9 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { break; } SessionEvent::Ended(err) => { + if let Some(w) = waiting.take() { + w.close(); + } app.gamepad.detach(); app.nav.pop_to_tag("hosts"); if let Some(e) = err { diff --git a/clients/linux/src/session.rs b/clients/linux/src/session.rs index a32eee7..10f1056 100644 --- a/clients/linux/src/session.rs +++ b/clients/linux/src/session.rs @@ -27,6 +27,11 @@ pub struct SessionParams { /// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one). pub pin: Option<[u8; 32]>, pub identity: (String, String), + /// How long to wait for the handshake. The normal path uses a short budget; the + /// "request access" (delegated-approval) path uses a long one, because the host PARKS the + /// connection until the operator clicks Approve in its console (so this must exceed the + /// host's approval window — see `PENDING_APPROVAL_WAIT`). + pub connect_timeout: Duration, } #[derive(Clone, Copy, Default)] @@ -139,7 +144,7 @@ fn pump( None, // launch: the Linux client has no library picker yet params.pin, Some(params.identity), - Duration::from_secs(15), + params.connect_timeout, ) { Ok(c) => Arc::new(c), Err(e) => { diff --git a/clients/windows/src/app.rs b/clients/windows/src/app.rs index 6bdb855..b95629b 100644 --- a/clients/windows/src/app.rs +++ b/clients/windows/src/app.rs @@ -20,7 +20,9 @@ use crate::video::{DecodedFrame, DecoderPref}; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use windows_reactor::*; const RESOLUTIONS: &[(u32, u32)] = &[ @@ -43,12 +45,27 @@ const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150]; /// capture; the resolved count drives the decoder + WASAPI render layout. const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")]; +/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen. +const APP_LICENSE: &str = concat!( + include_str!("../../../LICENSE-MIT"), + "\n\n================================ Apache-2.0 ================================\n\n", + include_str!("../../../LICENSE-APACHE"), +); +/// Third-party software notices for the linked Rust crates (generated by +/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/). +const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt"); + #[derive(Clone, PartialEq)] enum Screen { Hosts, Connecting, + /// The no-PIN "request access" wait: an identified connect is in flight, parked by the host + /// until the operator approves this device in its console. Cancelable. + RequestAccess, Stream, Settings, + /// Open-source / third-party license notices (reached from Settings). + Licenses, Pair, } @@ -132,6 +149,11 @@ struct Shared { /// Latest stream stats, written by the session's event loop and mirrored into reactor state /// by the stream page's HUD poll thread to drive the overlay. stats: Mutex, + /// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per + /// request: the waiting screen's Cancel button reads it back from here and sets it, and that + /// request's event loop (which captured the same `Arc` at spawn) then tears down silently when + /// the parked connect finally resolves. `None` outside a request-access flow. + cancel: Mutex>>, } pub struct AppCtx { @@ -376,8 +398,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { .vertical_alignment(VerticalAlignment::Center) .into() } + // request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline + // is sound — it only wires a Cancel button to the shared cancel flag + navigation. + Screen::RequestAccess => request_access_page(ctx, &set_screen), // settings_page uses no hooks (it never touches `cx`), so calling it inline is sound. Screen::Settings => settings_page(ctx, &set_screen), + // licenses_page is a static text screen (no hooks), so inline is sound. + Screen::Licenses => licenses_page(&set_screen), Screen::Pair => component(pair_page, svc), Screen::Stream => component(stream_page, StreamProps { svc, stats }), } @@ -569,12 +596,61 @@ fn initiate( } } +/// Tunables that differ between the normal connect and the no-PIN "request access" flow. +/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the +/// plain "Connecting" screen. +struct ConnectOpts { + /// Handshake budget. Request-access uses a long one because the host PARKS the connection + /// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`). + connect_timeout: Duration, + /// Persist the host as *paired* on a successful connect. Set for request-access, where the + /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU + /// persists the host *unpaired* (pinned, but not PIN/approval-verified). + persist_paired: bool, + /// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access). + awaiting_approval: bool, + /// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no + /// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time + /// out; this request's event loop then sees the flag and tears down silently (drops the + /// connector → closes the connection) without touching a screen a new session may already own. + cancel: Option>, +} + +impl Default for ConnectOpts { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(15), + persist_paired: false, + awaiting_approval: false, + cancel: None, + } + } +} + fn connect( ctx: &Arc, target: &Target, pin: Option<[u8; 32]>, set_screen: &AsyncSetState, set_status: &AsyncSetState, +) { + connect_with( + ctx, + target, + pin, + set_screen, + set_status, + ConnectOpts::default(), + ); +} + +fn connect_with( + ctx: &Arc, + target: &Target, + pin: Option<[u8; 32]>, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, + opts: ConnectOpts, ) { let s = ctx.settings.lock().unwrap().clone(); let mode = if s.width != 0 && s.refresh_hz != 0 { @@ -607,29 +683,54 @@ fn connect( decoder: DecoderPref::from_name(&s.decoder), pin, identity: ctx.identity.clone(), + connect_timeout: opts.connect_timeout, }); set_status.call(String::new()); - set_screen.call(Screen::Connecting); + set_screen.call(if opts.awaiting_approval { + Screen::RequestAccess + } else { + Screen::Connecting + }); let tofu = pin.is_none(); + let persist_paired = opts.persist_paired; + let cancel = opts.cancel; let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone()); let (ss, st) = (set_screen.clone(), set_status.clone()); let target = target.clone(); std::thread::spawn(move || loop { - match handle.events.recv_blocking() { - Ok(SessionEvent::Connected { + let event = match handle.events.recv_blocking() { + Ok(e) => e, + Err(_) => { + gamepad.detach(); + ss.call(Screen::Hosts); + break; + } + }; + // A cancelled request-access connect that resolved late (the host approved or the park + // timed out after the user walked away): tear down silently. Cancel already returned the + // UI to the host list; dropping `event` (and with it any connector) closes the connection + // without popping a stream or a stray error over the screen a new session may own. + if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { + break; + } + match event { + SessionEvent::Connected { connector, fingerprint, .. - }) => { - if tofu { + } => { + if persist_paired || tofu { + // Request-access: the operator approved this device, so record the host as a + // trusted PAIRED host — future connects are then silent (rule 1), exactly like + // after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned). let mut k = KnownHosts::load(); k.upsert(KnownHost { name: target.name.clone(), addr: target.addr.clone(), port: target.port, fp_hex: trust::hex(&fingerprint), - paired: false, + paired: persist_paired, }); let _ = k.save(); } @@ -638,10 +739,10 @@ fn connect( *shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone())); ss.call(Screen::Stream); } - Ok(SessionEvent::Failed { + SessionEvent::Failed { msg, trust_rejected, - }) => { + } => { st.call(msg); gamepad.detach(); if trust_rejected { @@ -653,22 +754,100 @@ fn connect( } break; } - Ok(SessionEvent::Ended(err)) => { + SessionEvent::Ended(err) => { st.call(err.unwrap_or_else(|| "Session ended".into())); gamepad.detach(); ss.call(Screen::Hosts); break; } - Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s, - Err(_) => { - gamepad.detach(); - ss.call(Screen::Hosts); - break; - } + SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s, } }); } +/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the +/// operator approves this device in its console (or web UI), showing a cancelable "waiting" +/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is +/// saved as paired, so later connects are silent. +fn request_access( + ctx: &Arc, + target: &Target, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, +) { + // Pin the advertised certificate for a discovered host (defence against a host impostor while + // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. + let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32); + // A fresh cancel flag per request, installed where the waiting screen's Cancel button can read + // it back; this request's event loop captures the same `Arc` (via ConnectOpts) below. + let cancel = Arc::new(AtomicBool::new(false)); + *ctx.shared.cancel.lock().unwrap() = Some(cancel.clone()); + connect_with( + ctx, + target, + pin, + set_screen, + set_status, + ConnectOpts { + // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator + // approval still lands on this connection rather than timing the client out first. + connect_timeout: Duration::from_secs(185), + persist_paired: true, + awaiting_approval: true, + cancel: Some(cancel), + }, + ); +} + +/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while +/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and +/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the +/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks. +fn request_access_page(ctx: &Arc, set_screen: &AsyncSetState) -> Element { + let target_name = ctx.shared.target.lock().unwrap().name.clone(); + let headline = if target_name.is_empty() { + "Waiting for approval\u{2026}".to_string() + } else { + format!("Waiting for {target_name} to approve\u{2026}") + }; + let cancel_btn = { + let (ctx, ss) = (ctx.clone(), set_screen.clone()); + button("Cancel") + .icon(SymbolGlyph::Cancel) + .on_click(move || { + // Return the UI immediately; the parked connect is blocking with no abort, so trip + // the flag this request's event loop captured — it then tears down silently when + // the connect finally resolves (see ConnectOpts::cancel). + if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() { + c.store(true, Ordering::SeqCst); + } + ss.call(Screen::Hosts); + }) + .horizontal_alignment(HorizontalAlignment::Center) + }; + vstack(( + ProgressRing::indeterminate() + .width(48.0) + .height(48.0) + .horizontal_alignment(HorizontalAlignment::Center), + text_block(headline) + .font_size(18.0) + .semibold() + .horizontal_alignment(HorizontalAlignment::Center), + text_block( + "Approve this device in the host's console or web UI \u{2014} it connects automatically \ + once you approve it. No PIN needed.", + ) + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center), + cancel_btn, + )) + .spacing(16.0) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center) + .into() +} + fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { let ctx = &props.ctx; let set_screen = &props.set_screen; @@ -728,6 +907,20 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { .icon(SymbolGlyph::Cancel) .on_click(move || ss.call(Screen::Hosts)) }; + // The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that + // the host parks until the operator approves this device in its console (delegated approval). + let request_btn = { + let (ctx2, ss, st, target2) = ( + ctx.clone(), + set_screen.clone(), + set_status.clone(), + target.clone(), + ); + button("Request access without a PIN") + .icon(SymbolGlyph::Send) + .on_click(move || request_access(&ctx2, &target2, &ss, &st)) + .horizontal_alignment(HorizontalAlignment::Stretch) + }; let content = card(vstack(( grid(( @@ -760,6 +953,13 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { .font_size(28.0) .on_changed(move |s| set_code.call(s)), hstack((pair_btn, cancel_btn)).spacing(8.0), + text_block( + "Don\u{2019}t have a PIN? Request access instead and approve this device on the host \ + (its console or web UI) \u{2014} no PIN needed.", + ) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + request_btn, )) .spacing(16.0)) .max_width(480.0) @@ -967,6 +1167,21 @@ fn settings_page(ctx: &Arc, set_screen: &AsyncSetState) -> Eleme .spacing(10.0), ); + let licenses_button = { + let ss = set_screen.clone(); + button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) + }; + let about_card = card( + vstack(( + text_block("About").font_size(15.0).semibold(), + text_block("punktfunk is licensed under MIT OR Apache-2.0.") + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + licenses_button, + )) + .spacing(10.0), + ); + page(vec![ header.into(), section("DISPLAY"), @@ -975,6 +1190,77 @@ fn settings_page(ctx: &Arc, set_screen: &AsyncSetState) -> Eleme video_card.into(), section("AUDIO"), audio_card.into(), + section("ABOUT"), + about_card.into(), + ]) +} + +/// Static screen: the app's own license + the third-party software notices (reached from Settings). +fn licenses_page(set_screen: &AsyncSetState) -> Element { + let header = grid(( + text_block("Third-party licenses") + .font_size(30.0) + .bold() + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + button("Back") + .accent() + .icon(SymbolGlyph::Back) + .on_click({ + let ss = set_screen.clone(); + move || ss.call(Screen::Settings) + }) + .grid_column(1) + .vertical_alignment(VerticalAlignment::Center), + )) + .columns([GridLength::Star(1.0), GridLength::Auto]) + .margin(edges(0.0, 0.0, 0.0, 6.0)); + + let app_card = card( + vstack(( + text_block("punktfunk").font_size(15.0).semibold(), + text_block("Licensed under MIT OR Apache-2.0, at your option.") + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + text_block(APP_LICENSE) + .font_size(11.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + let natives_card = card( + vstack(( + text_block("Bundled components").font_size(15.0).semibold(), + text_block( + "FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \ + license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \ + Windows App SDK (Microsoft) are also linked.", + ) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + let notices_card = card( + vstack(( + text_block("Rust crates").font_size(15.0).semibold(), + text_block(THIRD_PARTY_NOTICES) + .font_size(11.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + page(vec![ + header.into(), + section("PUNKTFUNK"), + app_card.into(), + section("BUNDLED"), + natives_card.into(), + section("OPEN SOURCE"), + notices_card.into(), ]) } diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 4b91c57..da2f239 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -184,6 +184,9 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { decoder, pin, identity, + // Headless CLI uses the normal (short) handshake budget; the long request-access wait is a + // GUI-only flow. + connect_timeout: Duration::from_secs(15), }); let deadline = Instant::now() + Duration::from_secs(60); diff --git a/clients/windows/src/session.rs b/clients/windows/src/session.rs index 02aecf9..e9e2052 100644 --- a/clients/windows/src/session.rs +++ b/clients/windows/src/session.rs @@ -34,6 +34,11 @@ pub struct SessionParams { /// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one). pub pin: Option<[u8; 32]>, pub identity: (String, String), + /// How long to wait for the handshake. The normal path uses a short budget; the + /// "request access" (delegated-approval) path uses a long one, because the host PARKS the + /// connection until the operator clicks Approve in its console (so this must exceed the + /// host's approval window — see `PENDING_APPROVAL_WAIT`). + pub connect_timeout: Duration, } #[derive(Clone, Copy, Default, PartialEq)] @@ -164,7 +169,7 @@ fn pump( None, // launch: the Windows client has no library picker yet params.pin, Some(params.identity), - Duration::from_secs(15), + params.connect_timeout, ) { Ok(c) => Arc::new(c), Err(e) => { diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs index 04ae152..06962dc 100644 --- a/crates/punktfunk-host/src/native_pairing.rs +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -11,6 +11,7 @@ use anyhow::Result; use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant}; +use tokio::sync::Notify; /// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`. /// (Separate from GameStream pairing, which has its own store and ceremony.) @@ -76,6 +77,18 @@ pub struct PendingRequest { pub age_secs: u64, } +/// The outcome of [`NativePairing::wait_for_decision`] — what an operator did with a parked, +/// unpaired knock (delegated approval, roadmap §8b-1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PairingDecision { + /// The operator clicked Approve (the fingerprint is now paired) — admit the session. + Approved, + /// The operator denied, or the pending entry was otherwise dropped without pairing — reject. + Denied, + /// No decision within the wait window — reject; the device can knock again. + TimedOut, +} + /// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be /// approvable days later when the operator no longer remembers the context). const PENDING_TTL: Duration = Duration::from_secs(10 * 60); @@ -88,6 +101,11 @@ pub struct NativePairing { arm: Mutex, paired: Mutex, pending: Mutex, + /// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock + /// denied/dropped), so a QUIC connection parked in [`NativePairing::wait_for_decision`] wakes + /// the instant an operator acts in the console — the substrate for delegated approval admitting + /// a session with no client reconnect. + changed: Notify, } /// A snapshot for the management API / web console. @@ -199,6 +217,7 @@ impl NativePairing { arm: Mutex::new(arm), paired: Mutex::new(PairedState { path, clients }), pending: Mutex::new(PendingState::default()), + changed: Notify::new(), }) } @@ -276,10 +295,17 @@ impl NativePairing { } } // A device that knocked and is now paired shouldn't linger in the approval list. - let mut pending = self.pending.lock().unwrap(); - pending - .items - .retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex)); + { + let mut pending = self.pending.lock().unwrap(); + pending + .items + .retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex)); + } + // Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just + // completed (console approve or the PIN ceremony), so it can admit the session with no + // reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the + // fully settled state (paired = true, no longer pending) — see `wait_for_decision`. + self.changed.notify_waiters(); Ok(()) } @@ -372,6 +398,17 @@ impl NativePairing { .collect() } + /// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped + /// first, so this also reports whether a parked knock is still live.) + pub fn pending_contains(&self, fp_hex: &str) -> bool { + let mut pending = self.pending.lock().unwrap(); + Self::expire_pending(&mut pending); + pending + .items + .iter() + .any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex)) + } + /// Approve a pending knock: pair its fingerprint (under `name_override` if the operator /// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such /// (or expired) id. @@ -380,29 +417,78 @@ impl NativePairing { id: u32, name_override: Option<&str>, ) -> Result> { - let entry = { + // Read (do NOT pre-remove) the entry: `add()` pins the fingerprint and THEN clears its + // pending entry — an order `wait_for_decision` relies on so a parked waiter never observes + // the device as "neither pending nor paired" (which would read as a denial). Removing here + // first would open exactly that window. + let (knock_name, fp_hex) = { let mut pending = self.pending.lock().unwrap(); Self::expire_pending(&mut pending); - let Some(at) = pending.items.iter().position(|p| p.id == id) else { - return Ok(None); - }; - pending.items.remove(at) - }; // pending lock released — add() takes the paired lock - let name = name_override.unwrap_or(&entry.name); - self.add(name, &entry.fp_hex)?; + match pending.items.iter().find(|p| p.id == id) { + Some(p) => (p.name.clone(), p.fp_hex.clone()), + None => return Ok(None), + } + }; // pending lock released — add() takes the paired then pending locks + let name = name_override.unwrap_or(&knock_name).to_string(); + self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters Ok(Some(PairedClient { - name: name.to_string(), - fingerprint: entry.fp_hex, + name, + fingerprint: fp_hex, })) } /// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock /// re-creates an entry — deny is "not now", not a blocklist. pub fn deny_pending(&self, id: u32) -> bool { - let mut pending = self.pending.lock().unwrap(); - let before = pending.items.len(); - pending.items.retain(|p| p.id != id); - pending.items.len() != before + let removed = { + let mut pending = self.pending.lock().unwrap(); + let before = pending.items.len(); + pending.items.retain(|p| p.id != id); + pending.items.len() != before + }; + if removed { + // Wake a parked waiter so it returns `Denied` at once instead of holding the + // connection open until the approval window lapses. + self.changed.notify_waiters(); + } + removed + } + + /// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`. + /// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console + /// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is + /// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no + /// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`] + /// to keep the knocking connection open until a human clicks Approve — so the device pairs and + /// streams with no reconnect (delegated approval, roadmap §8b-1). + pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision { + let deadline = tokio::time::Instant::now() + timeout; + loop { + // Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that + // lands between the state check and the await still wakes us (no lost notification). + let notified = self.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + if self.is_paired(fp_hex) { + return PairingDecision::Approved; + } + if !self.pending_contains(fp_hex) { + // Neither pending nor paired. This is almost always a denial — but it can also be + // the tiny interval inside `add()` between pinning and clearing the pending entry. + // Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a + // cleared-pending observation that is really an approval will now read as paired. + if self.is_paired(fp_hex) { + return PairingDecision::Approved; + } + return PairingDecision::Denied; + } + + tokio::select! { + _ = &mut notified => {} + _ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut, + } + } } } @@ -561,4 +647,60 @@ mod tests { assert!(np.current_pin().is_none()); let _ = std::fs::remove_file(&p); } + + #[tokio::test] + async fn wait_for_decision_approve_deny_timeout() { + use std::sync::Arc; + let p = temp(); + let _ = std::fs::remove_file(&p); + let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap()); + + // TimedOut: a parked knock with no decision returns TimedOut; the entry survives. + np.note_pending("Knocker", "ab01"); + let d = np + .wait_for_decision("ab01", Duration::from_millis(80)) + .await; + assert_eq!(d, PairingDecision::TimedOut); + assert!(np.pending_contains("ab01")); + + // Approved: approving WHILE parked wakes the waiter with Approved. + let np2 = np.clone(); + let waiter = + tokio::spawn( + async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await }, + ); + tokio::time::sleep(Duration::from_millis(30)).await; + let id = np + .pending() + .into_iter() + .find(|x| x.fingerprint == "ab01") + .unwrap() + .id; + np.approve_pending(id, Some("Approved")).unwrap().unwrap(); + assert_eq!(waiter.await.unwrap(), PairingDecision::Approved); + assert!(np.is_paired("ab01")); + + // Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout). + np.note_pending("Knock2", "cd02"); + let np3 = np.clone(); + let waiter = + tokio::spawn( + async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await }, + ); + tokio::time::sleep(Duration::from_millis(30)).await; + let id = np + .pending() + .into_iter() + .find(|x| x.fingerprint == "cd02") + .unwrap() + .id; + assert!(np.deny_pending(id)); + assert_eq!(waiter.await.unwrap(), PairingDecision::Denied); + assert!(!np.is_paired("cd02")); + + // Already paired before the call → immediate Approved (no waiting). + let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await; + assert_eq!(d, PairingDecision::Approved); + let _ = std::fs::remove_file(&p); + } } diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 7de9d73..a066222 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -78,7 +78,7 @@ pub struct Punktfunk1Options { } /// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API. -use crate::native_pairing::NativePairing; +use crate::native_pairing::{NativePairing, PairingDecision}; /// The shared streaming-stats recorder (web-console capture/graph), shared with the management API /// and the GameStream loop; threaded into each session's `SessionContext`. use crate::stats_recorder::StatsRecorder; @@ -290,8 +290,11 @@ pub(crate) async fn serve( let stats = stats.clone(); let inj_tx = injector.sender(); let mic_tx = mic_service.sender(); + // The session permit + the pool it came from are handed to serve_session, which owns the + // permit's lifetime: it's released while a knock is parked for delegated approval and + // re-acquired on approval, so the hold is no longer a simple closure-scoped binding. + let sem_session = sem.clone(); sessions.spawn(async move { - let _permit = permit; // held for the session's lifetime; frees a slot on completion match serve_session( conn, &opts, @@ -302,6 +305,8 @@ pub(crate) async fn serve( &np, &last_pairing, stats, + permit, + sem_session, ) .await { @@ -410,6 +415,14 @@ type AudioCapSlot = Arc>, stats: Arc, + // The session slot. Owned here (not just held by the spawning task) because an unpaired knock + // RELEASES it while parked for delegated approval, then RE-ACQUIRES one on approval — so a + // parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from. + mut permit: tokio::sync::OwnedSemaphorePermit, + sem: Arc, ) -> Result<()> { let peer = conn.remote_address(); @@ -531,6 +549,79 @@ async fn serve_session( return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await; } + // Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the + // `handshake` future below for two reasons: (1) the approval wait must not be bound by the + // short HANDSHAKE_TIMEOUT — a human reads the console and clicks Approve; (2) the NVENC session + // permit is released while parked, so a knock awaiting approval can't hold a streaming slot. + // On approval the device is now paired, so the handshake proceeds and the session starts with + // NO client reconnect (delegated approval, roadmap §8b-1). + if opts.require_pairing { + // Decode just enough to gate (the Hello carries the device name for the pending label); + // the `handshake` future re-decodes for the real session — a few dozen bytes, negligible. + let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; + anyhow::ensure!( + gate_hello.abi_version == punktfunk_core::ABI_VERSION, + "ABI mismatch: client {} host {}", + gate_hello.abi_version, + punktfunk_core::ABI_VERSION + ); + let fp = endpoint::peer_fingerprint(&conn); + let known = fp + .as_ref() + .map(|fp| np.is_paired(&fingerprint_hex(fp))) + .unwrap_or(false); + if !known { + // An anonymous client (no certificate) has no identity to approve — reject outright + // (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks. + let Some(fp) = fp else { + anyhow::bail!( + "unpaired anonymous client rejected (this host requires pairing — present a \ + client identity and approve it in the console, or run the PIN ceremony)" + ); + }; + let fp_hex = fingerprint_hex(&fp); + // Sanitize the wire-supplied name before it reaches the log / console (untrusted: an + // unpaired device could embed terminal escapes / bidi overrides); note_pending stores + // the same sanitized form and derives a fingerprint label when empty. + let label = crate::native_pairing::sanitize_device_name( + gate_hello.name.as_deref().unwrap_or(""), + &fp_hex, + ); + tracing::info!(name = %label, fingerprint = %fp_hex, + "unpaired device knocked — parking connection for delegated approval in the console"); + np.note_pending(&label, &fp_hex); + // Free the session slot while a human decides — a parked knock must not hold an NVENC + // permit (a handful of parked knocks would otherwise block every real session). + drop(permit); + let decision = tokio::select! { + d = np.wait_for_decision(&fp_hex, PENDING_APPROVAL_WAIT) => d, + // The client gave up (closed the connection) before a decision — stop waiting. + _ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"), + }; + match decision { + PairingDecision::Approved => { + tracing::info!(name = %label, fingerprint = %fp_hex, + "device approved in console — admitting session (no reconnect)"); + } + PairingDecision::Denied => anyhow::bail!("pairing request denied in the console"), + PairingDecision::TimedOut => anyhow::bail!( + "pairing request not approved within {PENDING_APPROVAL_WAIT:?} \ + — the device can knock again" + ), + } + // Re-acquire a session slot for the now-approved session (waits if all slots are busy, + // exactly like any freshly accepted client). + permit = sem + .clone() + .acquire_owned() + .await + .expect("session semaphore is never closed"); + } + } + // Held for the rest of the session (RAII frees the slot on return). For an already-paired + // client this is the original permit; for a just-approved knock it's the re-acquired one. + let _permit = permit; + let source = opts.source; let frames = opts.frames; let handshake = async { @@ -541,36 +632,8 @@ async fn serve_session( hello.abi_version, punktfunk_core::ABI_VERSION ); - if opts.require_pairing { - let fp = endpoint::peer_fingerprint(&conn); - let known = fp - .as_ref() - .map(|fp| np.is_paired(&fingerprint_hex(fp))) - .unwrap_or(false); - if !known { - // Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending - // request the operator can approve from the console — no PIN fetched out of band. - // The label is the client's Hello name, else fingerprint-derived. An anonymous - // client (no certificate) has no identity to approve, so nothing is recorded. - if let Some(fp) = &fp { - let fp_hex = fingerprint_hex(fp); - // Sanitize the wire-supplied name before it reaches the log (untrusted: an - // unpaired device could embed terminal escapes / bidi overrides); note_pending - // stores the same sanitized form and derives a fingerprint label when empty. - let label = crate::native_pairing::sanitize_device_name( - hello.name.as_deref().unwrap_or(""), - &fp_hex, - ); - tracing::info!(name = %label, fingerprint = %fp_hex, - "unpaired device knocked — held for approval in the console"); - np.note_pending(&label, &fp_hex); - } - anyhow::bail!( - "unpaired client rejected (this host requires pairing — approve the device \ - in the console, or run the PIN ceremony)" - ); - } - } + // The pairing gate (require_pairing → paired? else park for delegated approval) ran above, + // before this future, so a client reaching here is paired (or the host is `--open`). crate::encode::validate_dimensions( crate::encode::Codec::H265, hello.mode.width, @@ -4110,10 +4173,11 @@ mod tests { std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id())) } - /// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's - /// knock on a pairing-required host is held as a pending request (fingerprint-derived label — - /// the connector sends no Hello name); approving it pairs the fingerprint, and the same - /// identity then gets a session with no PIN ceremony. + /// Delegated approval (§8b-1) end to end in-process, the SEAMLESS flow: an + /// identified-but-unpaired client's knock on a pairing-required host is PARKED (connection held + /// open) and shows up as a pending request (fingerprint-derived label — the connector sends no + /// Hello name); the operator approves it WHILE the client waits, and the SAME connection is + /// admitted to a session with no PIN and no reconnect. #[test] fn delegated_approval_admits_after_knock() { use punktfunk_core::client::NativeClient; @@ -4136,7 +4200,7 @@ mod tests { source: Punktfunk1Source::Synthetic, seconds: 0, frames: 25, - max_sessions: 2, // the knock + the post-approval session + max_sessions: 1, // the single parked-then-approved session (no reconnect) max_concurrent: 1, require_pairing: true, allow_pairing: false, @@ -4150,49 +4214,47 @@ mod tests { )) }); std::thread::sleep(std::time::Duration::from_millis(500)); - let timeout = std::time::Duration::from_secs(10); let (cert, key) = endpoint::generate_identity().unwrap(); + let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap()); let mode = punktfunk_core::Mode { width: 1280, height: 720, refresh_hz: 60, }; - // 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending. - assert!( - NativeClient::connect( - "127.0.0.1", - 19779, - mode, - CompositorPref::Auto, - GamepadPref::Auto, - 0, - 0, // video_caps - 2, // audio_channels (stereo) - None, // launch - None, - Some((cert.clone(), key.clone())), - timeout - ) - .is_err(), - "unpaired knock must still be rejected" - ); - let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap()); - let pend = np.pending(); - assert_eq!(pend.len(), 1, "the knock must be held for approval"); - assert_eq!(pend[0].fingerprint, expected_fp); - assert!( - pend[0].name.starts_with("device "), - "no Hello name → fingerprint-derived label, got {:?}", - pend[0].name - ); + // Approver thread: wait for the parked knock to register, assert its label, then APPROVE it + // WHILE the client is still parked — the console "click accept" flow. + let np_approve = np.clone(); + let expect_fp = expected_fp.clone(); + let approver = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); + let pend = loop { + if let Some(p) = np_approve + .pending() + .into_iter() + .find(|p| p.fingerprint == expect_fp) + { + break p; + } + assert!( + std::time::Instant::now() < deadline, + "the knock must register while the client is parked" + ); + std::thread::sleep(std::time::Duration::from_millis(40)); + }; + assert!( + pend.name.starts_with("device "), + "no Hello name → fingerprint-derived label, got {:?}", + pend.name + ); + np_approve + .approve_pending(pend.id, Some("Approved Device")) + .unwrap() + .expect("pending id must approve"); + }); - // 2: approve (with an operator label) → the same identity now gets a session, no PIN. - let approved = np - .approve_pending(pend[0].id, Some("Approved Device")) - .unwrap() - .expect("pending id must approve"); - assert_eq!(approved.fingerprint, expected_fp); + // The knock: a SINGLE connect that parks until approved, then streams — no reconnect. The + // timeout is generous (it covers the park + the approver's poll latency). let client = NativeClient::connect( "127.0.0.1", 19779, @@ -4203,11 +4265,17 @@ mod tests { 0, // video_caps 2, // audio_channels (stereo) None, // launch - None, + None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client Some((cert, key)), - timeout, + std::time::Duration::from_secs(15), ) - .expect("approved identity gets a session"); + .expect("approved mid-park → session admitted with no reconnect"); + approver.join().unwrap(); + assert!( + np.is_paired(&expected_fp), + "approval must pin the knocking fingerprint" + ); + assert_eq!(np.list()[0].name, "Approved Device"); drop(client); let _ = std::fs::remove_file(&store); host.join().unwrap().unwrap();