feat(pairing): seamless no-PIN delegated approval (host parks the knock, clients add "Request access")

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 06:41:09 +00:00
parent 705a8fa94e
commit d1d2ca293d
13 changed files with 1149 additions and 161 deletions
@@ -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."
}
}
}
}