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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user