From 5ca860533ec741ec577afc01dfde3e001cf65889 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 11 Jun 2026 09:40:33 +0000 Subject: [PATCH] refactor(native-pairing): extract shared on-demand arming state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for web-UI-driven native (punktfunk/1) pairing. Replaces m3's fixed startup PIN + local paired store with a shared `NativePairing` (new module): arm-on-demand with a fresh, time-limited PIN (`arm(ttl)`), `current_pin()` read per ceremony so a lapsed window stops pairing, plus the trust store (list/add/ remove/is_paired) and a `status()` snapshot. The management API (next commit) and the QUIC accept loop share one handle. CLI `--allow-pairing`/`--require-pairing` still arm at startup (no expiry, PIN logged) — back-compat. m3 pairing ceremony + gate and the C-ABI roundtrip stay green; new unit tests for arm/expire/pair. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/m3.rs | 121 +++------ crates/punktfunk-host/src/main.rs | 1 + crates/punktfunk-host/src/native_pairing.rs | 266 ++++++++++++++++++++ 3 files changed, 295 insertions(+), 93 deletions(-) create mode 100644 crates/punktfunk-host/src/native_pairing.rs diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index ac253c0..1df5f20 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -67,64 +67,13 @@ pub struct M3Options { pub paired_store: Option, } -/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`. -/// (Separate from GameStream pairing, which has its own store and ceremony.) -#[derive(Default, serde::Serialize, serde::Deserialize)] -struct PairedClients { - clients: Vec, -} - -#[derive(serde::Serialize, serde::Deserialize)] -struct PairedClient { - name: String, - /// Hex SHA-256 of the client's certificate. - fingerprint: String, -} - -/// The store plus where it persists (the path is injectable for tests). -struct PairedState { - path: std::path::PathBuf, - clients: PairedClients, -} - -type PairedStore = Arc>; - -fn paired_path() -> Result { - let home = std::env::var("HOME").context("HOME unset")?; - Ok(std::path::PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json")) -} - -fn load_paired(path: &std::path::Path) -> PairedClients { - std::fs::read(path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default() -} - -fn save_paired(state: &PairedState) -> Result<()> { - if let Some(dir) = state.path.parent() { - std::fs::create_dir_all(dir)?; - } - // Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which - // would silently lock out every paired client on a --require-pairing host). Write a - // temp beside the target, then rename. - let tmp = state.path.with_extension("json.tmp"); - std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?; - std::fs::rename(&tmp, &state.path)?; - Ok(()) -} +/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API. +use crate::native_pairing::NativePairing; /// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with /// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate). const PAIRING_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2); -impl PairedClients { - fn contains(&self, fp: &[u8; 32]) -> bool { - let hex = fingerprint_hex(fp); - self.clients.iter().any(|c| c.fingerprint == hex) - } -} - /// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping). pub fn test_frame(idx: u32, len: usize) -> Vec { let mut d = vec![0u8; len]; @@ -148,7 +97,14 @@ pub fn run(opts: M3Options) -> Result<()> { .enable_all() .build() .context("tokio runtime")?; - rt.block_on(serve(opts)) + // Standalone CLI: arm at startup iff --allow-pairing/--require-pairing (back-compat — the PIN + // is logged). The unified `serve --native` path instead arms on demand via the management API. + let np = Arc::new(NativePairing::load_with( + opts.paired_store.clone(), + opts.pairing_pin.clone(), + opts.allow_pairing || opts.require_pairing, + )?); + rt.block_on(serve(opts, np)) } fn fingerprint_hex(fp: &[u8; 32]) -> String { @@ -159,7 +115,7 @@ fn fingerprint_hex(fp: &[u8; 32]) -> String { /// served one at a time (the virtual output + NVENC are single-tenant); a client that /// connects mid-session waits in the accept queue. A failed session logs and the loop /// keeps serving — only endpoint-level failures are fatal. -async fn serve(opts: M3Options) -> Result<()> { +async fn serve(opts: M3Options, np: Arc) -> Result<()> { let identity = crate::gamestream::cert::ServerIdentity::load_or_create() .context("load host identity (~/.config/punktfunk)")?; let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem) @@ -188,32 +144,17 @@ async fn serve(opts: M3Options) -> Result<()> { // One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink // (0xCB) is Opus-decoded and fed into a persistent PipeWire Audio/Source host apps record from. let mic_service = MicService::start(); - let paired_at = match &opts.paired_store { - Some(p) => p.clone(), - None => paired_path()?, - }; - let paired: PairedStore = Arc::new(std::sync::Mutex::new(PairedState { - clients: load_paired(&paired_at), - path: paired_at, - })); - // The arming PIN: one PIN for the whole pairing window (NOT per-ceremony), because the - // SPAKE2 client must know the PIN to build its first message — so the user has to read - // the PIN before connecting. Generated once when pairing is armed, displayed here. - let arming_pin = if opts.allow_pairing || opts.require_pairing { - let pin = opts.pairing_pin.clone().unwrap_or_else(|| { - use rand::Rng; - format!("{:04}", rand::thread_rng().gen_range(0..10_000u32)) - }); - let n = paired.lock().unwrap().clients.clients.len(); + // Pairing state (arming PIN + trust store) is shared with the management API. If it was armed + // at startup (the CLI flags), surface the PIN the headless operator reads from the log; the + // web console arms it on demand instead (a fresh, time-limited PIN). + let st = np.status(); + if let Some(pin) = &st.pin { tracing::info!( - paired = n, + paired = st.paired_clients, require = opts.require_pairing, "PAIRING ARMED — enter this PIN on the client to pair: {pin}" ); - Some(pin) - } else { - None - }; + } let last_pairing = std::sync::Mutex::new(None::); let mut served = 0u32; @@ -238,9 +179,8 @@ async fn serve(opts: M3Options) -> Result<()> { injector.sender(), mic_service.sender(), &fingerprint, - &paired, + &np, &last_pairing, - arming_pin.as_deref(), ) .await { @@ -281,7 +221,7 @@ async fn pair_ceremony( mut recv: quinn::RecvStream, req: PairRequest, host_fp: &[u8; 32], - paired: &PairedStore, + np: &NativePairing, pin: &str, ) -> Result<()> { use punktfunk_core::quic::pake; @@ -318,14 +258,7 @@ async fn pair_ceremony( let ok = pake::verify(&confirms.client, &proof.confirm); if ok { - let mut store = paired.lock().unwrap(); - let hex = fingerprint_hex(&client_fp); - store.clients.clients.retain(|c| c.fingerprint != hex); // re-pair updates the name - store.clients.clients.push(PairedClient { - name: req.name.clone(), - fingerprint: hex, - }); - if let Err(e) = save_paired(&store) { + if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) { tracing::error!(error = %format!("{e:#}"), "could not persist paired clients"); } tracing::info!(name = %req.name, "pairing complete — client trusted"); @@ -356,9 +289,8 @@ async fn serve_session( inj_tx: std::sync::mpsc::Sender, mic_tx: std::sync::mpsc::Sender>, host_fp: &[u8; 32], - paired: &PairedStore, + np: &NativePairing, last_pairing: &std::sync::Mutex>, - arming_pin: Option<&str>, ) -> Result<()> { let peer = conn.remote_address(); @@ -371,7 +303,10 @@ async fn serve_session( .await .map_err(|_| anyhow!("first message timeout"))??; if let Ok(req) = PairRequest::decode(&first) { - let pin = arming_pin.context("pairing not armed (start with --allow-pairing)")?; + // Read the live arming PIN per attempt, so a window that lapsed no longer pairs. + let pin = np + .current_pin() + .context("pairing not armed (arm it in the console, or start with --allow-pairing)")?; { let mut last = last_pairing.lock().unwrap(); if let Some(t) = *last { @@ -382,7 +317,7 @@ async fn serve_session( } *last = Some(std::time::Instant::now()); } - return pair_ceremony(&conn, send, recv, req, host_fp, paired, pin).await; + return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await; } let source = opts.source; @@ -397,7 +332,7 @@ async fn serve_session( ); if opts.require_pairing { let known = endpoint::peer_fingerprint(&conn) - .map(|fp| paired.lock().unwrap().clients.contains(&fp)) + .map(|fp| np.is_paired(&fingerprint_hex(&fp))) .unwrap_or(false); anyhow::ensure!( known, diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index b396e6c..a5444bc 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -21,6 +21,7 @@ mod inject; mod m0; mod m3; mod mgmt; +mod native_pairing; mod pipeline; mod pwinit; mod vdisplay; diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs new file mode 100644 index 0000000..3403aea --- /dev/null +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -0,0 +1,266 @@ +//! Shared native (`punktfunk/1`) pairing state — the on-demand arming PIN (with expiry) plus the +//! persistent paired-clients store. One [`NativePairing`] handle is shared by the punktfunk/1 QUIC +//! accept loop ([`crate::m3`]) and the management API ([`crate::mgmt`]), so an operator can **arm +//! pairing and read the PIN from the web console** instead of the service log. +//! +//! The PIN direction is inherent to the SPAKE2 ceremony: the *host* mints the PIN and the *client* +//! enters it (the client needs it to build its first message). So the UI **displays** the PIN — +//! armed on demand for a short window — rather than accepting one. + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`. +/// (Separate from GameStream pairing, which has its own store and ceremony.) +#[derive(Default, serde::Serialize, serde::Deserialize)] +pub struct PairedClients { + pub clients: Vec, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct PairedClient { + pub name: String, + /// Hex SHA-256 of the client's certificate. + pub fingerprint: String, +} + +impl PairedClients { + fn contains(&self, fp_hex: &str) -> bool { + self.clients + .iter() + .any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex)) + } +} + +struct PairedState { + path: PathBuf, + clients: PairedClients, +} + +/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no +/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms. +#[derive(Default)] +struct Armed { + pin: Option, + expires_at: Option, +} + +/// Shared native-pairing state: the arming PIN window + the persistent trust store. +pub struct NativePairing { + arm: Mutex, + paired: Mutex, +} + +/// A snapshot for the management API / web console. +pub struct NativePairingStatus { + pub armed: bool, + /// The PIN to display while armed (the operator reads it; the user enters it on the client). + pub pin: Option, + /// Seconds left in a timed window (`None` = armed with no expiry, e.g. the CLI flag). + pub expires_in_secs: Option, + pub paired_clients: u32, +} + +fn default_path() -> Result { + let home = std::env::var("HOME").context("HOME unset")?; + Ok(PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json")) +} + +fn load(path: &std::path::Path) -> PairedClients { + std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +fn save(state: &PairedState) -> Result<()> { + if let Some(dir) = state.path.parent() { + std::fs::create_dir_all(dir)?; + } + // Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would + // silently lock out every paired client on a --require-pairing host). Temp + rename. + let tmp = state.path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?; + std::fs::rename(&tmp, &state.path)?; + Ok(()) +} + +fn random_pin() -> String { + use rand::Rng; + format!("{:04}", rand::thread_rng().gen_range(0..10_000u32)) +} + +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` + /// (or a fresh random PIN) and **no expiry** — back-compat with the headless CLI flow. + pub fn load_with( + store_path: Option, + fixed_pin: Option, + arm_at_start: bool, + ) -> Result { + let path = match store_path { + Some(p) => p, + None => default_path()?, + }; + let clients = load(&path); + let arm = if arm_at_start { + Armed { + pin: Some(fixed_pin.unwrap_or_else(random_pin)), + expires_at: None, + } + } else { + Armed::default() + }; + Ok(NativePairing { + arm: Mutex::new(arm), + paired: Mutex::new(PairedState { path, clients }), + }) + } + + /// Arm pairing with a fresh random PIN, valid for `ttl`. Returns the PIN to display. + pub fn arm(&self, ttl: Duration) -> String { + let pin = random_pin(); + *self.arm.lock().unwrap() = Armed { + pin: Some(pin.clone()), + expires_at: Some(Instant::now() + ttl), + }; + pin + } + + /// Disarm pairing (no new ceremonies accepted). + pub fn disarm(&self) { + *self.arm.lock().unwrap() = Armed::default(); + } + + /// Expire a timed window if its deadline passed (called under the lock before any read). + fn expire(arm: &mut Armed) { + if let Some(t) = arm.expires_at { + if Instant::now() >= t { + *arm = Armed::default(); + } + } + } + + /// The current valid PIN, or `None` if disarmed/expired. The QUIC ceremony reads this + /// per-attempt, so a window that lapsed mid-connection no longer pairs. + pub fn current_pin(&self) -> Option { + let mut arm = self.arm.lock().unwrap(); + Self::expire(&mut arm); + arm.pin.clone() + } + + /// A snapshot for the management API. + pub fn status(&self) -> NativePairingStatus { + let mut arm = self.arm.lock().unwrap(); + Self::expire(&mut arm); + let expires_in_secs = arm + .expires_at + .map(|t| t.saturating_duration_since(Instant::now()).as_secs()); + NativePairingStatus { + armed: arm.pin.is_some(), + pin: arm.pin.clone(), + expires_in_secs, + paired_clients: self.paired.lock().unwrap().clients.clients.len() as u32, + } + } + + /// Is this client (hex SHA-256 fingerprint) in the paired set? + pub fn is_paired(&self, fp_hex: &str) -> bool { + self.paired.lock().unwrap().clients.contains(fp_hex) + } + + /// Record a successful pairing (re-pairing the same fingerprint just updates the name). + 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) + } + + /// The paired clients (for the management API's device list). + pub fn list(&self) -> Vec { + self.paired.lock().unwrap().clients.clients.clone() + } + + /// Remove a paired client by fingerprint. Returns whether one was removed. + pub fn remove(&self, fp_hex: &str) -> Result { + let mut p = self.paired.lock().unwrap(); + let before = p.clients.clients.len(); + p.clients + .clients + .retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex)); + let removed = p.clients.clients.len() != before; + if removed { + save(&p)?; + } + Ok(removed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp() -> PathBuf { + // A unique-ish temp path without Date/rand-in-test fuss: pid + addr of a local. + let x = 0u8; + std::env::temp_dir().join(format!( + "pf-native-pair-{}-{}.json", + std::process::id(), + &x as *const _ as usize + )) + } + + #[test] + fn arm_expire_and_pair() { + let p = temp(); + let _ = std::fs::remove_file(&p); + let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); + // Disarmed by default. + assert!(np.current_pin().is_none()); + assert!(!np.status().armed); + + // Arm with a tiny TTL → a PIN appears, then expires. + let pin = np.arm(Duration::from_millis(40)); + assert_eq!(pin.len(), 4); + assert_eq!(np.current_pin().as_deref(), Some(pin.as_str())); + assert!(np.status().armed); + std::thread::sleep(Duration::from_millis(60)); + assert!(np.current_pin().is_none(), "window should have expired"); + assert!(!np.status().armed); + + // Pair / list / unpair. + assert!(!np.is_paired("ab12")); + np.add("Living Room", "AB12").unwrap(); + assert!( + np.is_paired("ab12"), + "fingerprint match is case-insensitive" + ); + assert_eq!(np.list().len(), 1); + assert_eq!(np.status().paired_clients, 1); + assert!(np.remove("ab12").unwrap()); + assert!(!np.remove("ab12").unwrap()); + assert!(np.list().is_empty()); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn cli_flag_arms_with_no_expiry() { + let p = temp(); + let _ = std::fs::remove_file(&p); + let np = NativePairing::load_with(Some(p.clone()), Some("1234".into()), true).unwrap(); + assert_eq!(np.current_pin().as_deref(), Some("1234")); + let s = np.status(); + assert!(s.armed); + assert_eq!(s.expires_in_secs, None, "CLI arming has no expiry"); + np.disarm(); + assert!(np.current_pin().is_none()); + let _ = std::fs::remove_file(&p); + } +}