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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user