From 99b4de32ee9c1a811d3d207e9ed31e37d99009d0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 12 Jun 2026 19:14:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(pairing):=20delegated=20approval=20(=C2=A7?= =?UTF-8?q?8b-1)=20=E2=80=94=20approve=20an=20unpaired=20device=20from=20t?= =?UTF-8?q?he=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An identified-but-unpaired device that knocks on a pairing-required host is now held as a pending request the operator approves from the web console — pairing it with no PIN fetched out of band — instead of a flat reject. - core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64, same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs --name sends it; the connector sends None (fingerprint-derived label). - native_pairing: in-memory pending queue (note_pending dedups by fingerprint, evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/ bidi — untrusted wire input); add()/remove() roll back in-memory on a persist failure; pairing clears any stale pending knock. - m3: the require_pairing gate records the knock (sanitized label) before rejecting; anonymous (certless) clients record nothing. - mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name}) and /deny; OpenAPI + tests; docs/api/openapi.json regenerated. - web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/ Deny, error-surfaced via QueryState); en+de strings. - Also completes an in-progress NativeClient Sync refactor (receivers behind per-plane mutexes) that was left half-applied in the tree. Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are fixed here. Validated live on the GNOME box: knock (with a wire name, and a malicious ANSI/bidi name that got neutralized) → pending → approve → the same identity streams real video. Full workspace tests + clippy + fmt green; web tsc clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client follow-up. See docs-site pairing page. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-client-rs/src/main.rs | 3 + crates/punktfunk-core/src/client.rs | 31 +- crates/punktfunk-core/src/quic.rs | 86 +++++- crates/punktfunk-host/src/m3.rs | 127 +++++++- crates/punktfunk-host/src/mgmt.rs | 224 ++++++++++++++ crates/punktfunk-host/src/native_pairing.rs | 318 +++++++++++++++++++- docs-site/content/docs/pairing.md | 26 +- docs-site/content/docs/roadmap.md | 37 ++- docs-site/content/docs/status.md | 7 + docs/api/openapi.json | 225 ++++++++++++++ include/punktfunk_core.h | 6 + web/messages/de.json | 8 + web/messages/en.json | 8 + web/src/routes/pairing.tsx | 253 ++++++++++++---- 14 files changed, 1250 insertions(+), 109 deletions(-) diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 32aecff..c795eb5 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -371,6 +371,9 @@ async fn session(args: Args) -> Result<()> { compositor: args.compositor, gamepad: args.gamepad, bitrate_kbps: args.bitrate_kbps, + // `--name` (also the pairing label) — shown in the host's pending-approval list when + // this client knocks on a pairing-required host. + name: Some(args.name.clone()), } .encode(), ) diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index e80dda0..d86a808 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -108,11 +108,15 @@ pub struct AudioPacket { } pub struct NativeClient { - frames: Receiver, - audio: Receiver, - rumble: Receiver<(u16, u16, u16)>, + // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust + // embedders can share one `Arc` across their plane threads (the same + // one-thread-per-plane contract the C ABI documents — the lock is uncontended there, + // and two threads racing one plane now serialize instead of being undefined). + frames: Mutex>, + audio: Mutex>, + rumble: Mutex>, /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. - hidout: Receiver, + hidout: Mutex>, input_tx: tokio::sync::mpsc::UnboundedSender, /// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker. mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec)>, @@ -234,10 +238,10 @@ impl NativeClient { }; *mode_slot.lock().unwrap() = negotiated; Ok(NativeClient { - frames: frame_rx, - audio: audio_rx, - rumble: rumble_rx, - hidout: hidout_rx, + frames: Mutex::new(frame_rx), + audio: Mutex::new(audio_rx), + rumble: Mutex::new(rumble_rx), + hidout: Mutex::new(hidout_rx), input_tx, mic_tx, rich_input_tx, @@ -419,7 +423,7 @@ impl NativeClient { /// (`&self` here supports the cross-plane sharing; a plane's queue is still /// single-consumer by contract). pub fn next_frame(&self, timeout: Duration) -> Result { - match self.frames.recv_timeout(timeout) { + match self.frames.lock().unwrap().recv_timeout(timeout) { Ok(f) => Ok(f), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), @@ -430,7 +434,7 @@ impl NativeClient { /// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread — /// packets arrive every 5 ms. pub fn next_audio(&self, timeout: Duration) -> Result { - match self.audio.recv_timeout(timeout) { + match self.audio.lock().unwrap().recv_timeout(timeout) { Ok(p) => Ok(p), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), @@ -440,7 +444,7 @@ impl NativeClient { /// Pull the next rumble update `(pad, low, high)`; same semantics as /// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> { - match self.rumble.recv_timeout(timeout) { + match self.rumble.lock().unwrap().recv_timeout(timeout) { Ok(r) => Ok(r), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), @@ -452,7 +456,7 @@ impl NativeClient { /// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's /// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these. pub fn next_hidout(&self, timeout: Duration) -> Result { - match self.hidout.recv_timeout(timeout) { + match self.hidout.lock().unwrap().recv_timeout(timeout) { Ok(h) => Ok(h), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), @@ -579,6 +583,9 @@ async fn worker_main(args: WorkerArgs) { compositor, gamepad, bitrate_kbps, + // No device name yet: the connect ABI has no name parameter (pairing does). The + // host falls back to a fingerprint-derived label in its pending-approval list. + name: None, } .encode(), ) diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index a4d0757..fe0df30 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -38,7 +38,7 @@ pub const CTL_MAGIC: &[u8; 4] = b"PKFc"; /// `client → host`: open the session, requesting a display mode (the host creates its /// virtual output at exactly this size/refresh — native resolution end to end). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Hello { pub abi_version: u32, pub mode: Mode, @@ -57,8 +57,17 @@ pub struct Hello { /// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form — /// omitted by older clients (decodes to `0`, i.e. host default). pub bitrate_kbps: u32, + /// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks + /// on a pairing-required host (the delegated-approval pending list) and stored on approval. + /// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by + /// older clients (decodes to `None`; the host falls back to a fingerprint-derived label). + pub name: Option, } +/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on +/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway). +pub const HELLO_NAME_MAX: usize = 64; + /// `host → client`: the complete session offer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Welcome { @@ -463,6 +472,22 @@ impl Hello { b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26 + if let Some(name) = &self.name { + // Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None` + // emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which + // means a *future* field can't simply follow `name` at a fixed offset; it would need + // its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX. + let mut n = name.as_str(); + while n.len() > HELLO_NAME_MAX { + let mut cut = HELLO_NAME_MAX; + while !n.is_char_boundary(cut) { + cut -= 1; + } + n = &n[..cut]; + } + b.push(n.len() as u8); + b.extend_from_slice(n.as_bytes()); + } b } @@ -492,6 +517,17 @@ impl Hello { .get(22..26) .map(|s| u32::from_le_bytes(s.try_into().unwrap())) .unwrap_or(0), + // Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 → + // `None` (never fail the handshake over a label). + name: b.get(26).and_then(|&len| { + let len = len as usize; + if len == 0 || len > HELLO_NAME_MAX { + return None; + } + b.get(27..27 + len) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(String::from) + }), }) } } @@ -1406,6 +1442,7 @@ mod tests { compositor: CompositorPref::Kwin, gamepad: GamepadPref::DualSense, bitrate_kbps: 25_000, + name: Some("Test Device".into()), }; assert_eq!(Hello::decode(&h.encode()).unwrap(), h); let s = Start { @@ -1470,6 +1507,7 @@ mod tests { compositor: CompositorPref::Mutter, gamepad: GamepadPref::DualSense, bitrate_kbps: 80_000, + name: None, }; let enc = h.encode(); assert_eq!(enc.len(), 26); @@ -1526,6 +1564,51 @@ mod tests { assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000); } + #[test] + fn hello_name_roundtrip_and_back_compat() { + let base = Hello { + abi_version: 2, + mode: Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, + bitrate_kbps: 0, + name: Some("Enrico's MacBook".into()), + }; + let enc = base.encode(); + assert_eq!( + Hello::decode(&enc).unwrap().name.as_deref(), + Some("Enrico's MacBook") + ); + // A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named + // host reading a bitrate-era Hello decodes name = None. + assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None); + // No name → wire form is byte-identical to the bitrate-era message (26 bytes). + let unnamed = Hello { + name: None, + ..base.clone() + }; + assert_eq!(unnamed.encode().len(), 26); + // Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode. + let long = Hello { + name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap + ..base.clone() + }; + let dec = Hello::decode(&long.encode()).unwrap(); + let n = dec.name.expect("truncated name still present"); + assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x')); + // A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err. + let mut bad_len = unnamed.encode(); + bad_len.push(40); // claims 40 name bytes, none follow + assert_eq!(Hello::decode(&bad_len).unwrap().name, None); + let mut bad_utf8 = unnamed.encode(); + bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]); + assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None); + } + #[test] fn reconfigure_roundtrip() { let rq = Reconfigure { @@ -1632,6 +1715,7 @@ mod tests { compositor: CompositorPref::Auto, gamepad: GamepadPref::Auto, bitrate_kbps: 0, + name: None, } .encode(); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 45dc987..37e2a28 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -454,13 +454,34 @@ async fn serve_session( punktfunk_core::ABI_VERSION ); if opts.require_pairing { - let known = endpoint::peer_fingerprint(&conn) - .map(|fp| np.is_paired(&fingerprint_hex(&fp))) + let fp = endpoint::peer_fingerprint(&conn); + let known = fp + .as_ref() + .map(|fp| np.is_paired(&fingerprint_hex(fp))) .unwrap_or(false); - anyhow::ensure!( - known, - "unpaired client rejected (this host requires pairing — run the PIN ceremony first)" - ); + 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)" + ); + } } crate::encode::validate_dimensions( crate::encode::Codec::H265, @@ -2206,6 +2227,100 @@ 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. + #[test] + fn delegated_approval_admits_after_knock() { + use punktfunk_core::client::NativeClient; + use punktfunk_core::quic::endpoint; + + let store = + std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id())); + let _ = std::fs::remove_file(&store); + let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap()); + let np_host = np.clone(); + let host = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + rt.block_on(serve( + M3Options { + port: 19779, + source: M3Source::Synthetic, + seconds: 0, + frames: 25, + max_sessions: 2, // the knock + the post-approval session + max_concurrent: 1, + require_pairing: true, + allow_pairing: false, + pairing_pin: None, + paired_store: None, // unused: the shared `np` IS the store handle + }, + np_host, + )) + }); + 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 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, + 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 + ); + + // 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); + let client = NativeClient::connect( + "127.0.0.1", + 19779, + mode, + CompositorPref::Auto, + GamepadPref::Auto, + 0, + None, + Some((cert, key)), + timeout, + ) + .expect("approved identity gets a session"); + drop(client); + let _ = std::fs::remove_file(&store); + host.join().unwrap().unwrap(); + } + /// The PIN pairing ceremony + the --require-pairing gate, end to end in-process: /// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired /// identity gets a session on a pairing-required host; an anonymous client does not. diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 42c3789..f4ac8de 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -145,6 +145,9 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(disarm_native_pairing)) .routes(routes!(list_native_clients)) .routes(routes!(unpair_native_client)) + .routes(routes!(list_pending_devices)) + .routes(routes!(approve_pending_device)) + .routes(routes!(deny_pending_device)) .routes(routes!(stop_session)) .routes(routes!(request_idr)), ) @@ -379,6 +382,29 @@ struct NativeClient { fingerprint: String, } +/// An unpaired device that tried to connect while the host requires pairing — awaiting +/// **delegated approval** (approve it here instead of fetching the host PIN out of band). +#[derive(Serialize, ToSchema)] +struct PendingDevice { + /// Id to address approve/deny (per-process; entries expire after ~10 minutes). + id: u32, + /// Best-effort device label (the client's own name, else fingerprint-derived). + #[schema(example = "Enrico's MacBook")] + name: String, + /// Hex SHA-256 of the device's certificate — what approval pins. + fingerprint: String, + /// Seconds since the device last knocked. + age_secs: u64, +} + +/// Approve-pending-device request body. Send `{}` to keep the device's own name. +#[derive(Deserialize, ToSchema)] +struct ApprovePending { + /// Operator-chosen label for the device (defaults to the name it knocked with). + #[schema(example = "Living Room TV")] + name: Option, +} + /// Error envelope for every non-2xx response. #[derive(Serialize, Deserialize, ToSchema)] struct ApiError { @@ -885,6 +911,116 @@ async fn unpair_native_client( } } +/// List devices awaiting pairing approval +/// +/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair +/// it without a PIN (delegated approval); entries expire after ~10 minutes. +#[utoipa::path( + get, + path = "/native/pending", + tag = "native", + operation_id = "listPendingDevices", + responses( + (status = OK, description = "Devices awaiting approval (empty when none, or when the \ + native host is not enabled)", body = Vec), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn list_pending_devices(State(st): State>) -> Json> { + let pending = st + .native + .as_ref() + .map(|np| np.pending()) + .unwrap_or_default(); + Json( + pending + .into_iter() + .map(|p| PendingDevice { + id: p.id, + name: p.name, + fingerprint: p.fingerprint, + age_secs: p.age_secs, + }) + .collect(), + ) +} + +/// Approve a pending device +/// +/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally +/// relabel it via the body; send `{}` to keep the name it knocked with. +#[utoipa::path( + post, + path = "/native/pending/{id}/approve", + tag = "native", + operation_id = "approvePendingDevice", + params(("id" = u32, Path, description = "Pending-request id from the pending list")), + request_body = ApprovePending, + responses( + (status = OK, description = "Device paired", body = NativeClient), + (status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError), + (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn approve_pending_device( + State(st): State>, + Path(id): Path, + ApiJson(req): ApiJson, +) -> Response { + let Some(np) = &st.native else { + return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled"); + }; + match np.approve_pending(id, req.name.as_deref()) { + Ok(Some(client)) => { + tracing::info!(name = %client.name, fingerprint = %client.fingerprint, + "management API: pending device approved (delegated pairing)"); + Json(NativeClient { + name: client.name, + fingerprint: client.fingerprint, + }) + .into_response() + } + Ok(None) => api_error( + StatusCode::NOT_FOUND, + "no pending request with that id (it may have expired — have the device retry)", + ), + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not persist trust store: {e}"), + ), + } +} + +/// Deny a pending device +/// +/// Drops the request. Not a blocklist — the device's next attempt knocks again. +#[utoipa::path( + post, + path = "/native/pending/{id}/deny", + tag = "native", + operation_id = "denyPendingDevice", + params(("id" = u32, Path, description = "Pending-request id from the pending list")), + responses( + (status = NO_CONTENT, description = "Request dropped"), + (status = NOT_FOUND, description = "No pending request with that id", body = ApiError), + (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn deny_pending_device(State(st): State>, Path(id): Path) -> Response { + let Some(np) = &st.native else { + return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled"); + }; + if np.deny_pending(id) { + tracing::info!(id, "management API: pending device denied"); + StatusCode::NO_CONTENT.into_response() + } else { + api_error(StatusCode::NOT_FOUND, "no pending request with that id") + } +} + /// Stop the active session /// /// Kicks the connected client: stops the video/audio stream threads and clears the launch @@ -1344,6 +1480,77 @@ mod tests { assert_eq!(b["armed"], false); } + #[tokio::test] + async fn pending_devices_approve_and_deny() { + let np = Arc::new( + crate::native_pairing::NativePairing::load_with( + Some( + std::env::temp_dir() + .join(format!("pf-mgmt-pending-{}.json", std::process::id())), + ), + None, + false, + ) + .unwrap(), + ); + let app = test_app_native(test_state(), np.clone()); + + // Empty queue. + let (s, b) = send(&app, get_req("/api/v1/native/pending")).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b.as_array().unwrap().len(), 0); + + // Two devices knock (what the QUIC gate records); they appear in the list. + np.note_pending("Enrico's MacBook", "aa11"); + np.note_pending("device bb22cc33", "bb22"); + let (_, b) = send(&app, get_req("/api/v1/native/pending")).await; + assert_eq!(b.as_array().unwrap().len(), 2); + assert_eq!(b[0]["name"], "Enrico's MacBook"); + let approve_id = b[0]["id"].as_u64().unwrap(); + let deny_id = b[1]["id"].as_u64().unwrap(); + + // Approve the first with an operator label → paired under that name, gone from pending. + let (s, b) = send( + &app, + post_json( + &format!("/api/v1/native/pending/{approve_id}/approve"), + serde_json::json!({"name": "Office MacBook"}), + ), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b["name"], "Office MacBook"); + assert_eq!(b["fingerprint"], "aa11"); + assert!(np.is_paired("AA11"), "approval pins the fingerprint"); + + // Deny the second → dropped, not paired; a re-deny is 404. + let deny = post_json( + &format!("/api/v1/native/pending/{deny_id}/deny"), + serde_json::json!({}), + ); + assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT); + assert!(!np.is_paired("bb22")); + let (s, _) = send( + &app, + post_json( + &format!("/api/v1/native/pending/{deny_id}/deny"), + serde_json::json!({}), + ), + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND); + + // Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name). + let (_, b) = send(&app, get_req("/api/v1/native/pending")).await; + assert_eq!(b.as_array().unwrap().len(), 0); + let (s, _) = send( + &app, + post_json("/api/v1/native/pending/123/approve", serde_json::json!({})), + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND); + } + #[tokio::test] async fn native_endpoints_report_disabled_without_native_host() { let app = test_app(test_state(), None); @@ -1357,5 +1564,22 @@ mod tests { ) .await; assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); + // Pending list reads as an empty array (like /native/clients), not a 503. + let (s, b) = send(&app, get_req("/api/v1/native/pending")).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b.as_array().unwrap().len(), 0); + // Approve/deny without a native host are 503. + let (s, _) = send( + &app, + post_json("/api/v1/native/pending/0/approve", serde_json::json!({})), + ) + .await; + assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); + let (s, _) = send( + &app, + post_json("/api/v1/native/pending/0/deny", serde_json::json!({})), + ) + .await; + assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); } } diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs index 3403aea..4fcbdd7 100644 --- a/crates/punktfunk-host/src/native_pairing.rs +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -47,10 +47,47 @@ struct Armed { expires_at: Option, } -/// Shared native-pairing state: the arming PIN window + the persistent trust store. +/// An unpaired (but identified) device that knocked on a pairing-required host — held for +/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently +/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks +/// again), and they expire after [`PENDING_TTL`]. +struct Pending { + id: u32, + name: String, + fp_hex: String, + requested_at: Instant, +} + +#[derive(Default)] +struct PendingState { + next_id: u32, + items: Vec, +} + +/// A pending-approval snapshot for the management API / web console. +pub struct PendingRequest { + /// Per-process id used to address approve/deny (stable for the entry's lifetime). + pub id: u32, + /// Best-effort device label (the client's `Hello` name, else fingerprint-derived). + pub name: String, + /// Hex SHA-256 of the knocking client's certificate — what approval pins. + pub fingerprint: String, + /// Seconds since the (most recent) knock. + pub age_secs: u64, +} + +/// 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); +/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop. +const PENDING_CAP: usize = 32; + +/// Shared native-pairing state: the arming PIN window + the persistent trust store + the +/// pending-approval queue. pub struct NativePairing { arm: Mutex, paired: Mutex, + pending: Mutex, } /// A snapshot for the management API / web console. @@ -92,6 +129,48 @@ fn random_pin() -> String { format!("{:04}", rand::thread_rng().gen_range(0..10_000u32)) } +/// Sanitize a client-supplied device name before it's stored, listed, or logged. The name comes +/// straight off the wire (the `Hello`/`PairRequest` of an *unpaired* device), so it's untrusted: a +/// hostile LAN device could embed terminal escapes / control characters (log + console injection) or +/// bidi overrides (`U+202E` etc.) to make a malicious device *look* like a trusted one in the +/// approval UI. Strip C0/C1 controls and Unicode bidi/format controls, collapse whitespace, trim, and +/// cap the length; an empty/all-control name falls back to a fingerprint-derived label. +pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| if c == '\t' || c == '\n' { ' ' } else { c }) + .filter(|&c| { + !c.is_control() + // Bidi/format controls that could spoof or reorder the displayed name. + && !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF + && !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI + && c != '\u{200E}' // LRM + && c != '\u{200F}' // RLM + && c != '\u{061C}' // ALM + && c != '\u{FEFF}' // BOM / zero-width no-break space + }) + .collect(); + // Collapse internal whitespace runs, trim, cap at the wire limit. + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + let mut trimmed = collapsed.as_str(); + while trimmed.len() > NAME_MAX { + let mut cut = NAME_MAX; + while !trimmed.is_char_boundary(cut) { + cut -= 1; + } + trimmed = &trimmed[..cut]; + } + let trimmed = trimmed.trim(); + if trimmed.is_empty() { + format!("device {}", &fp_hex[..8.min(fp_hex.len())]) + } else { + trimmed.to_string() + } +} + +/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`). +const NAME_MAX: usize = 64; + impl NativePairing { /// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start` /// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin` @@ -117,6 +196,7 @@ impl NativePairing { Ok(NativePairing { arm: Mutex::new(arm), paired: Mutex::new(PairedState { path, clients }), + pending: Mutex::new(PendingState::default()), }) } @@ -172,15 +252,33 @@ impl NativePairing { self.paired.lock().unwrap().clients.contains(fp_hex) } - /// Record a successful pairing (re-pairing the same fingerprint just updates the name). + /// Record a successful pairing (re-pairing the same fingerprint just updates the name — + /// matched case-insensitively, like every other fingerprint comparison here). The name is + /// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never + /// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired). pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> { - let mut p = self.paired.lock().unwrap(); - p.clients.clients.retain(|c| c.fingerprint != fp_hex); - p.clients.clients.push(PairedClient { - name: name.to_string(), - fingerprint: fp_hex.to_string(), - }); - save(&p) + let name = sanitize_device_name(name, fp_hex); + { + let mut p = self.paired.lock().unwrap(); + let snapshot = p.clients.clients.clone(); // restore on a failed save + p.clients + .clients + .retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex)); + p.clients.clients.push(PairedClient { + name, + fingerprint: fp_hex.to_string(), + }); + if let Err(e) = save(&p) { + p.clients.clients = snapshot; + return Err(e); + } + } + // 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)); + Ok(()) } /// The paired clients (for the management API's device list). @@ -188,19 +286,122 @@ impl NativePairing { self.paired.lock().unwrap().clients.clients.clone() } - /// Remove a paired client by fingerprint. Returns whether one was removed. + /// Remove a paired client by fingerprint. Returns whether one was removed. On a persist + /// failure the in-memory store is rolled back (it never diverges from disk). pub fn remove(&self, fp_hex: &str) -> Result { let mut p = self.paired.lock().unwrap(); let before = p.clients.clients.len(); + let snapshot = p.clients.clients.clone(); p.clients .clients .retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex)); let removed = p.clients.clients.len() != before; if removed { - save(&p)?; + if let Err(e) = save(&p) { + p.clients.clients = snapshot; + return Err(e); + } } Ok(removed) } + + // -- Delegated approval (roadmap §8b-1) -------------------------------- + + /// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). + fn expire_pending(pending: &mut PendingState) { + pending + .items + .retain(|p| p.requested_at.elapsed() < PENDING_TTL); + } + + /// Record an unpaired device's knock for delegated approval. Re-knocks from the same + /// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam + /// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry + /// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]). + pub fn note_pending(&self, name: &str, fp_hex: &str) { + let name = sanitize_device_name(name, fp_hex); + let mut pending = self.pending.lock().unwrap(); + Self::expire_pending(&mut pending); + if let Some(p) = pending + .items + .iter_mut() + .find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex)) + { + p.requested_at = Instant::now(); + p.name = name; + return; + } + if pending.items.len() >= PENDING_CAP { + // Evict the least-recently-active entry. NOT index 0: the in-place refresh above means + // Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly. + if let Some(at) = pending + .items + .iter() + .enumerate() + .min_by_key(|(_, p)| p.requested_at) + .map(|(i, _)| i) + { + pending.items.remove(at); + } + } + let id = pending.next_id; + pending.next_id = pending.next_id.wrapping_add(1); + pending.items.push(Pending { + id, + name, + fp_hex: fp_hex.to_string(), + requested_at: Instant::now(), + }); + } + + /// The devices currently awaiting approval (for the management API). + pub fn pending(&self) -> Vec { + let mut pending = self.pending.lock().unwrap(); + Self::expire_pending(&mut pending); + pending + .items + .iter() + .map(|p| PendingRequest { + id: p.id, + name: p.name.clone(), + fingerprint: p.fp_hex.clone(), + age_secs: p.requested_at.elapsed().as_secs(), + }) + .collect() + } + + /// 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. + pub fn approve_pending( + &self, + id: u32, + name_override: Option<&str>, + ) -> Result> { + let entry = { + 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)?; + Ok(Some(PairedClient { + name: name.to_string(), + fingerprint: entry.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 + } } #[cfg(test)] @@ -250,6 +451,101 @@ mod tests { let _ = std::fs::remove_file(&p); } + #[test] + fn pending_knock_approve_and_deny() { + let p = temp(); + let _ = std::fs::remove_file(&p); + let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); + assert!(np.pending().is_empty()); + + // A knock appears; a re-knock from the same fingerprint refreshes (same id, new name) + // instead of duplicating. + np.note_pending("device aa11", "AA11"); + np.note_pending("Bedroom TV", "aa11"); + let pend = np.pending(); + assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint"); + assert_eq!(pend[0].name, "Bedroom TV"); + let id = pend[0].id; + + // Deny drops it without pairing; the next knock gets a fresh id. + assert!(np.deny_pending(id)); + assert!(!np.deny_pending(id)); + assert!(np.pending().is_empty()); + assert!(!np.is_paired("aa11")); + + // Approve pairs the fingerprint (operator label wins) and clears the entry. + np.note_pending("device bb22", "BB22"); + let id = np.pending()[0].id; + assert!( + np.approve_pending(9999, None).unwrap().is_none(), + "unknown id" + ); + let client = np + .approve_pending(id, Some("Living Room")) + .unwrap() + .unwrap(); + assert_eq!(client.name, "Living Room"); + assert!(np.is_paired("bb22"), "approval pins the fingerprint"); + assert!(np.pending().is_empty()); + assert_eq!(np.list()[0].name, "Living Room"); + + // The cap evicts the oldest knock. + for i in 0..(PENDING_CAP + 3) { + np.note_pending("flood", &format!("f{i:03}")); + } + let pend = np.pending(); + assert_eq!(pend.len(), PENDING_CAP); + assert_eq!(pend[0].fingerprint, "f003", "oldest entries evicted first"); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn sanitize_strips_control_and_bidi() { + // ANSI escape + newline + a bidi override that could spoof the displayed name. + let dirty = "\u{1b}]0;evil\u{07}Good\nDevice\u{202E}xfp"; + let clean = sanitize_device_name(dirty, "deadbeef00"); + assert!(!clean.contains('\u{1b}') && !clean.contains('\n') && !clean.contains('\u{202E}')); + // ESC dropped (']' survives), BEL dropped, '\n'→space (Good Device), RLO dropped (no space). + assert_eq!(clean, "]0;evilGood Devicexfp"); + // All-control / empty → fingerprint-derived fallback. + assert_eq!( + sanitize_device_name("\u{1b}\u{07}", "deadbeef00"), + "device deadbeef" + ); + assert_eq!(sanitize_device_name(" ", "abc"), "device abc"); + // Over-long names cap at a char boundary. + assert!(sanitize_device_name(&"x".repeat(200), "ab").len() <= 64); + } + + #[test] + fn pairing_clears_a_pending_knock() { + let p = temp(); + let _ = std::fs::remove_file(&p); + let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); + np.note_pending("Knocker", "cc44"); + assert_eq!(np.pending().len(), 1); + // Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry. + np.add("Knocker", "CC44").unwrap(); + assert!( + np.pending().is_empty(), + "a now-paired device must leave the approval list" + ); + assert!(np.is_paired("cc44")); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn add_replaces_case_insensitively() { + let p = temp(); + let _ = std::fs::remove_file(&p); + let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); + np.add("First", "AB12").unwrap(); + np.add("Second", "ab12").unwrap(); // same device, different hex case + assert_eq!(np.list().len(), 1, "re-add must replace, not duplicate"); + assert_eq!(np.list()[0].name, "Second"); + let _ = std::fs::remove_file(&p); + } + #[test] fn cli_flag_arms_with_no_expiry() { let p = temp(); diff --git a/docs-site/content/docs/pairing.md b/docs-site/content/docs/pairing.md index f8159de..2122b51 100644 --- a/docs-site/content/docs/pairing.md +++ b/docs-site/content/docs/pairing.md @@ -3,9 +3,10 @@ title: Pairing & Trust description: How a client and host establish trust — PIN pairing once, pinned reconnects after. --- -punktfunk has no accounts and no cloud. Trust is established directly between a client and a host, on -your network, with a one-time **PIN pairing**. After that, the device reconnects automatically on a -pinned cryptographic identity. +punktfunk has no accounts and no cloud. Trust is established directly between a client and a host, +on your network, with a one-time pairing — either an **approval click in the host's console** or a +**PIN ceremony**. After that, the device reconnects automatically on a pinned cryptographic +identity. ## How it works @@ -17,7 +18,24 @@ pinned cryptographic identity. - After pairing, the host stores the client's identity in its allow-list, and the client stores the host's fingerprint. Reconnects are automatic — no PIN. -## Arming pairing on the host +## Approving a device from the console (no PIN) + +The fastest way to admit a new device: just **try to connect** from it. On a pairing-required host, +the attempt shows up in the web console's Pairing page under **Waiting for approval** — with the +device's name and identity fingerprint. Click **Approve** (and optionally give it a label like +"Living Room TV"), and the device is paired on the spot: its next connect goes straight through. No +PIN to read or type. + +**Deny** just dismisses the request (the device can knock again later — it's "not now", not a +blocklist). Requests expire on their own after a few minutes. + +This works because approval happens on the host's authenticated management surface — only someone +with console access can admit a device. + +## Pairing with a PIN + +The PIN ceremony is the other path — useful for the *first* device (before the console has admitted +anything) or when you're at the client and the console isn't handy. Pairing has to be **armed** on the host before a client can pair (so a random device can't pair itself). Two ways: diff --git a/docs-site/content/docs/roadmap.md b/docs-site/content/docs/roadmap.md index 2b72e5b..5d4081c 100644 --- a/docs-site/content/docs/roadmap.md +++ b/docs-site/content/docs/roadmap.md @@ -144,7 +144,7 @@ mostly-mechanical port. Recommended start: **Phase 0** — capture an existing m stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because it's unbuildable on the Linux dev box; the trait boundaries are already in the right places. -## 8. Pairing & trust hardening *(next)* +## 8. Pairing & trust hardening *(§8a + §8b-1 done; §8b-2 next)* The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the client) is built and live. Two changes harden it from "works" to "secure by default": @@ -154,24 +154,23 @@ client) is built and live. Two changes harden it from "works" to "secure by defa is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console. Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted". Deployed to the dev box + Bazzite. -- **Delegated pairing approval** *(next — the ergonomic enabler for "mandatory": pair a device - without fetching the host PIN out of band).* Target flow: - 1. Device A is already paired (authenticated) to Host X. - 2. The user tries to connect Device B to Host X. - 3. Host X surfaces a request: *"Allow Device B to pair with Host X?"* - 4. The user approves/denies; on approve, Host X admits Device B — binding B's certificate - fingerprint — with no PIN typed. - - Two buildable layers: - - **§8b-1 (host + web — achievable now):** an unpaired B that connects to an approval-enabled host - is held as a **pending request** `{id, name, fingerprint, requested_at}` in `NativePairing` - instead of a flat reject; mgmt gains `GET /native/pending` + `POST /native/pending/{id}/{approve, - deny}`; the web console lists pending requests with Approve/Deny. The **operator approves from - the console** — delegated approval via the management surface. - - **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired - **Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and - replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI - is a client-agent task. +- ✅ **§8b-1 Delegated approval via the console — done (2026-06-12)** *(the ergonomic enabler for + "mandatory": pair a device without fetching the host PIN out of band).* An identified-but-unpaired + device that knocks on a pairing-required host is held as a **pending request** in `NativePairing` + (in-memory, deduped by fingerprint, 32-entry cap, 10-min expiry — a LAN scanner can't grow it, + and an anonymous client with no certificate records nothing). The mgmt API gains + `GET /native/pending` + `POST /native/pending/{id}/approve` (optional `{name}` to relabel) + + `POST /native/pending/{id}/deny`; the web console's Pairing page shows a **Waiting for approval** + section (live-polling) with Approve/Deny — approve pins the fingerprint on the spot, no PIN. + The `Hello` carries an optional trailing **device name** (same back-compat pattern as + compositor/gamepad/bitrate; `client-rs --name` sends it, fingerprint-derived label otherwise) so + the pending list is human-readable. End-to-end tested (knock → pending → approve → same identity + streams) + unit/mgmt tests. +- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired + **Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and + replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI + is a client-agent task. The Apple connector should also start sending the `Hello` device name + (needs a connect-ABI name parameter; the wire field is already live). PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online. diff --git a/docs-site/content/docs/status.md b/docs-site/content/docs/status.md index 42a7889..45a02c7 100644 --- a/docs-site/content/docs/status.md +++ b/docs-site/content/docs/status.md @@ -29,6 +29,13 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai ## Progress log ### 2026-06-12 +- **Delegated pairing approval (§8b-1)** — an unpaired device that tries to connect to a + pairing-required host now shows up as a **pending request** in the web console's Pairing page; + one click approves it (optionally relabeling) and pairs its certificate fingerprint — no PIN + fetched out of band. New mgmt endpoints (`/native/pending` + approve/deny), an in-memory pending + queue in `NativePairing` (fp-deduped, capped, 10-min expiry), and an optional **device name** in + the `Hello` (back-compat trailing field; `client-rs --name` sends it). End-to-end tested. + §8b-2 (approve from a paired device's own app) is the client-side follow-up. - **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04, full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 59c04ea..468823b 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -401,6 +401,184 @@ } } }, + "/api/v1/native/pending": { + "get": { + "tags": [ + "native" + ], + "summary": "List devices awaiting pairing approval", + "description": "Unpaired devices that tried to connect while the host requires pairing. Approve one to pair\nit without a PIN (delegated approval); entries expire after ~10 minutes.", + "operationId": "listPendingDevices", + "responses": { + "200": { + "description": "Devices awaiting approval (empty when none, or when the native host is not enabled)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PendingDevice" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pending/{id}/approve": { + "post": { + "tags": [ + "native" + ], + "summary": "Approve a pending device", + "description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.", + "operationId": "approvePendingDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Pending-request id from the pending list", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovePending" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Device paired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativeClient" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No pending request with that id (expired?)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the trust store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pending/{id}/deny": { + "post": { + "tags": [ + "native" + ], + "summary": "Deny a pending device", + "description": "Drops the request. Not a blocklist — the device's next attempt knocks again.", + "operationId": "denyPendingDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Pending-request id from the pending list", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "Request dropped" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No pending request with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/pair": { "get": { "tags": [ @@ -623,6 +801,20 @@ } } }, + "ApprovePending": { + "type": "object", + "description": "Approve-pending-device request body. Send `{}` to keep the device's own name.", + "properties": { + "name": { + "type": [ + "string", + "null" + ], + "description": "Operator-chosen label for the device (defaults to the name it knocked with).", + "example": "Living Room TV" + } + } + }, "ArmNativePairing": { "type": "object", "description": "Arm-native-pairing request body.", @@ -860,6 +1052,39 @@ } } }, + "PendingDevice": { + "type": "object", + "description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band).", + "required": [ + "id", + "name", + "fingerprint", + "age_secs" + ], + "properties": { + "age_secs": { + "type": "integer", + "format": "int64", + "description": "Seconds since the device last knocked.", + "minimum": 0 + }, + "fingerprint": { + "type": "string", + "description": "Hex SHA-256 of the device's certificate — what approval pins." + }, + "id": { + "type": "integer", + "format": "int32", + "description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "Best-effort device label (the client's own name, else fingerprint-derived).", + "example": "Enrico's MacBook" + } + } + }, "PortMap": { "type": "object", "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 62cd6c5..01c7300 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -146,6 +146,12 @@ // `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. #define MAX_DATAGRAM_BYTES 2048 +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on +// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway). +#define HELLO_NAME_MAX 64 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`Reconfigure`] (first byte after the magic). #define MSG_RECONFIGURE 1 diff --git a/web/messages/de.json b/web/messages/de.json index cfcde5a..8cade95 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -58,6 +58,14 @@ "pairing_native_devices": "Gekoppelte Geräte", "pairing_native_empty": "Noch keine Geräte gekoppelt.", "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", + "pairing_pending_title": "Warten auf Freigabe", + "pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.", + "pairing_pending_approve": "Freigeben", + "pairing_pending_deny": "Ablehnen", + "pairing_pending_name_prompt": "Gerät benennen:", + "pairing_pending_age_just_now": "gerade eben", + "pairing_pending_age_secs": "vor {s}s", + "pairing_pending_age_mins": "vor {min} min", "pairing_moonlight_title": "Moonlight-Kopplung (GameStream)", "settings_title": "Einstellungen", "settings_token_label": "API-Token", diff --git a/web/messages/en.json b/web/messages/en.json index f81046f..d1d50bf 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -58,6 +58,14 @@ "pairing_native_devices": "Paired devices", "pairing_native_empty": "No devices paired yet.", "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", + "pairing_pending_title": "Waiting for approval", + "pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.", + "pairing_pending_approve": "Approve", + "pairing_pending_deny": "Deny", + "pairing_pending_name_prompt": "Name this device:", + "pairing_pending_age_just_now": "just now", + "pairing_pending_age_secs": "{s}s ago", + "pairing_pending_age_mins": "{min} min ago", "pairing_moonlight_title": "Moonlight (GameStream) pairing", "settings_title": "Settings", "settings_token_label": "API token", diff --git a/web/src/routes/pairing.tsx b/web/src/routes/pairing.tsx index f8bc694..ef7c45a 100644 --- a/web/src/routes/pairing.tsx +++ b/web/src/routes/pairing.tsx @@ -1,21 +1,33 @@ -import { useState } from 'react' -import { createFileRoute } from '@tanstack/react-router' -import { useQueryClient } from '@tanstack/react-query' -import { KeyRound, CheckCircle2, Smartphone, Timer, Trash2 } from 'lucide-react' +import { useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { + KeyRound, + CheckCircle2, + Smartphone, + Timer, + Trash2, + UserPlus, + X, +} from "lucide-react"; import { useGetNativePairing, useArmNativePairing, useDisarmNativePairing, useListNativeClients, useUnpairNativeClient, + useListPendingDevices, + useApprovePendingDevice, + useDenyPendingDevice, getGetNativePairingQueryKey, getListNativeClientsQueryKey, -} from '@/api/gen/native/native' + getListPendingDevicesQueryKey, +} from "@/api/gen/native/native"; import { useGetPairingStatus, useSubmitPairingPin, getGetPairingStatusQueryKey, -} from '@/api/gen/pairing/pairing' +} from "@/api/gen/pairing/pairing"; import { Table, TableBody, @@ -23,49 +35,151 @@ import { TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { QueryState } from '@/components/query-state' -import { m } from '@/paraglide/messages' -import { useLocale } from '@/lib/i18n' +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { QueryState } from "@/components/query-state"; +import { m } from "@/paraglide/messages"; +import { useLocale } from "@/lib/i18n"; -export const Route = createFileRoute('/pairing')({ component: PairingPage }) +export const Route = createFileRoute("/pairing")({ component: PairingPage }); /** Seconds → `m:ss`. */ function fmtTime(secs: number): string { - const s = Math.max(0, Math.floor(secs)) - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}` + const s = Math.max(0, Math.floor(secs)); + return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; } function PairingPage() { - useLocale() + useLocale(); return (

{m.pairing_title()}

+
- ) + ); +} + +/** Seconds since a knock → a short relative label. */ +function fmtAge(secs: number): string { + if (secs < 10) return m.pairing_pending_age_just_now(); + if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) }); + return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) }); +} + +/** + * Devices awaiting delegated approval: an unpaired device that tried to connect shows up here, + * and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty + * (the common case); polls so a knock appears while the operator is looking at the page. + */ +function PendingDevices() { + const qc = useQueryClient(); + const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } }); + const approve = useApprovePendingDevice(); + const deny = useDenyPendingDevice(); + const rows = pending.data ?? []; + // Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow + // a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other + // section. (A 401 is handled globally by the fetcher's redirect-to-login.) + if (rows.length === 0 && !pending.error) return null; + + const refresh = () => { + qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() }); + qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); + }; + const onApprove = (id: number, currentName: string) => { + const name = prompt(m.pairing_pending_name_prompt(), currentName); + if (name == null) return; // operator cancelled + approve.mutate( + { id, data: { name: name.trim() ? name.trim() : null } }, + { onSuccess: refresh }, + ); + }; + + return ( +
+

+ + {m.pairing_pending_title()} +

+

+ {m.pairing_pending_desc()} +

+ + + + + + {rows.map((p) => ( + + {p.name} + + {p.fingerprint.slice(0, 16)}… + + + {fmtAge(p.age_secs)} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+
+ ); } /** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */ function NativePairingCard() { - const qc = useQueryClient() + const qc = useQueryClient(); // Poll fast while armed (live countdown), slow otherwise. const status = useGetNativePairing({ query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) }, - }) - const arm = useArmNativePairing() - const disarm = useDisarmNativePairing() - const d = status.data - const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }) + }); + const arm = useArmNativePairing(); + const disarm = useDisarmNativePairing(); + const d = status.data; + const refresh = () => + qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }); return ( - + @@ -75,7 +189,9 @@ function NativePairingCard() { {!d?.enabled ? ( -

{m.pairing_native_disabled()}

+

+ {m.pairing_native_disabled()} +

) : d.armed && d.pin ? (

{m.pairing_native_enter()}

@@ -99,10 +215,17 @@ function NativePairingCard() {
) : ( <> -

{m.pairing_native_desc()}

+

+ {m.pairing_native_desc()} +

{submit.isSuccess && ( @@ -239,11 +378,13 @@ function MoonlightPairingCard() { {m.pairing_success()}

)} - {submit.isError &&

{m.pairing_failed()}

} + {submit.isError && ( +

{m.pairing_failed()}

+ )} )}
- ) + ); }