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
+301 -15
View File
@@ -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<Stats>,
/// 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<Option<Arc<AtomicBool>>>,
}
pub struct AppCtx {
@@ -376,8 +398,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> 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<Arc<AtomicBool>>,
}
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<AppCtx>,
target: &Target,
pin: Option<[u8; 32]>,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
connect_with(
ctx,
target,
pin,
set_screen,
set_status,
ConnectOpts::default(),
);
}
fn connect_with(
ctx: &Arc<AppCtx>,
target: &Target,
pin: Option<[u8; 32]>,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
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<AppCtx>,
target: &Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
// 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<AppCtx>, set_screen: &AsyncSetState<Screen>) -> 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<AppCtx>, set_screen: &AsyncSetState<Screen>) -> 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<AppCtx>, set_screen: &AsyncSetState<Screen>) -> 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<Screen>) -> 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(),
])
}
+3
View File
@@ -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);
+6 -1
View File
@@ -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) => {