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
+160 -18
View File
@@ -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<Armed>,
paired: Mutex<PairedState>,
pending: Mutex<PendingState>,
/// 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<Option<PairedClient>> {
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);
}
}
+143 -75
View File
@@ -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<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
/// client), so its budget is far larger than the machine-speed session handshake.
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// How long the host keeps an unpaired knock PARKED — connection held open — waiting for the
/// operator to click Approve in the console (delegated approval, roadmap §8b-1). The QUIC
/// keep-alive (4 s, under the 8 s idle timeout) holds the path warm meanwhile, so on approval the
/// device pairs and streams with NO reconnect. Bounded well under the pending entry's TTL (10 min);
/// the client uses a comparable connect timeout, and a client that gives up first closes the
/// connection (the host stops waiting at once).
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
/// client's key-confirmation MAC (its single online guess), and persist the client's
@@ -502,6 +515,11 @@ async fn serve_session(
np: &NativePairing,
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
stats: Arc<StatsRecorder>,
// 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<tokio::sync::Semaphore>,
) -> 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();