//! 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::punktfunk1`]) 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::Result; use std::net::IpAddr; use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant}; use tokio::sync::Notify; /// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`. /// (Separate from GameStream pairing, which has its own store and ceremony.) #[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. /// /// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**: /// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This /// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific /// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint). /// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the /// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN). #[derive(Default)] struct Armed { pin: Option, expires_at: Option, bound_fp: Option, } /// The result of resolving the armed PIN for a specific client fingerprint ([`NativePairing::pin_for_attempt`]). pub enum PinAttempt { /// No window is armed (disarmed/expired) — reject; do not run the ceremony. Disarmed, /// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so /// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9). BoundToOther, /// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint). Pin(String), } /// 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, /// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host /// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it). src_ip: Option, /// True while a connection is held open in [`NativePairing::wait_for_decision`] for this knock. /// A live parked knock is a genuine device waiting for the operator — eviction skips it unless /// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13). parked: bool, } #[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, } /// The outcome of [`NativePairing::wait_for_decision`] — what an operator did with a parked, /// unpaired knock (delegated approval, roadmap §8b-1). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PairingDecision { /// The operator clicked Approve (the fingerprint is now paired) — admit the session. Approved, /// The operator denied, or the pending entry was otherwise dropped without pairing — reject. Denied, /// No decision within the wait window — reject; the device can knock again. TimedOut, } /// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be /// approvable days later when the operator no longer remembers the context). const PENDING_TTL: Duration = Duration::from_secs(10 * 60); /// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop. const PENDING_CAP: usize = 32; /// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide /// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address- /// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts. const MAX_PENDING_PER_IP: usize = 4; /// 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, /// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock /// denied/dropped), so a QUIC connection parked in [`NativePairing::wait_for_decision`] wakes /// the instant an operator acts in the console — the substrate for delegated approval admitting /// a session with no client reconnect. changed: Notify, } /// A snapshot for the management API / web console. 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 { // `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the // native paired-store works without a HOME env var (which a Windows service/task doesn't set). Ok(crate::gamestream::config_dir().join("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() { crate::gamestream::create_private_dir(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. The temp is // written owner-only so a local user can't inject a fingerprint to pair themselves. let tmp = state.path.with_extension("json.tmp"); crate::gamestream::write_secret_file(&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)) } /// 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` /// (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, bound_fp: None, } } else { Armed::default() }; Ok(NativePairing { arm: Mutex::new(arm), paired: Mutex::new(PairedState { path, clients }), pending: Mutex::new(PendingState::default()), changed: Notify::new(), }) } /// Arm pairing with a fresh random PIN, valid for `ttl`, **unbound** (any well-formed attempt /// consumes it). Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device /// fingerprint on untrusted LANs — an unbound window is burnable by any peer (#9). pub fn arm(&self, ttl: Duration) -> String { self.arm_for(ttl, None) } /// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is /// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an /// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN. pub fn arm_for(&self, ttl: Duration, bound_fp: Option) -> String { let pin = random_pin(); *self.arm.lock().unwrap() = Armed { pin: Some(pin.clone()), expires_at: Some(Instant::now() + ttl), bound_fp, }; pin } /// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9): /// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different /// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony. pub fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt { let mut arm = self.arm.lock().unwrap(); Self::expire(&mut arm); match &arm.pin { None => PinAttempt::Disarmed, Some(pin) => match &arm.bound_fp { Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => { PinAttempt::BoundToOther } _ => PinAttempt::Pin(pin.clone()), }, } } /// 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 — /// 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 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)); } // Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just // completed (console approve or the PIN ceremony), so it can admit the session with no // reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the // fully settled state (paired = true, no longer pending) — see `wait_for_decision`. self.changed.notify_waiters(); Ok(()) } /// 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. 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 { 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); } /// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active /// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never /// evict it under load); only if every candidate is parked does it fall back to the oldest of /// those (#13). Returns the index, or `None` if there's nothing to evict. fn evict_index(items: &[Pending], only_ip: Option) -> Option { let pick = |allow_parked: bool| { items .iter() .enumerate() .filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip))) .filter(|(_, p)| allow_parked || !p.parked) .min_by_key(|(_, p)| p.requested_at) .map(|(i, _)| i) }; pick(false).or_else(|| pick(true)) } /// 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; the queue is bounded two ways so a flood can't crowd out a /// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at /// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked** /// entry (never a live, held-open parked knock). The name is sanitized (untrusted). pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option) { 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; if p.src_ip.is_none() { p.src_ip = src_ip; } return; } // Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its // own oldest entry first so it can't crowd out other devices' knocks (#13). if let Some(ip) = src_ip { if pending .items .iter() .filter(|p| p.src_ip == Some(ip)) .count() >= MAX_PENDING_PER_IP { if let Some(i) = Self::evict_index(&pending.items, Some(ip)) { pending.items.remove(i); } } } // Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks // recency after in-place refreshes, so pick explicitly). if pending.items.len() >= PENDING_CAP { if let Some(i) = Self::evict_index(&pending.items, None) { pending.items.remove(i); } } 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(), src_ip, parked: false, }); } /// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone). /// A parked entry is protected from eviction under load (#13). fn set_parked(&self, fp_hex: &str, parked: bool) { let mut pending = self.pending.lock().unwrap(); if let Some(p) = pending .items .iter_mut() .find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex)) { p.parked = parked; } } /// 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() } /// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped /// first, so this also reports whether a parked knock is still live.) pub fn pending_contains(&self, fp_hex: &str) -> bool { let mut pending = self.pending.lock().unwrap(); Self::expire_pending(&mut pending); pending .items .iter() .any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex)) } /// Approve a pending knock: pair its fingerprint (under `name_override` if the operator /// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such /// (or expired) id. pub fn approve_pending( &self, id: u32, name_override: Option<&str>, ) -> Result> { // Read (do NOT pre-remove) the entry: `add()` pins the fingerprint and THEN clears its // pending entry — an order `wait_for_decision` relies on so a parked waiter never observes // the device as "neither pending nor paired" (which would read as a denial). Removing here // first would open exactly that window. let (knock_name, fp_hex) = { let mut pending = self.pending.lock().unwrap(); Self::expire_pending(&mut pending); match pending.items.iter().find(|p| p.id == id) { Some(p) => (p.name.clone(), p.fp_hex.clone()), None => return Ok(None), } }; // pending lock released — add() takes the paired then pending locks let name = name_override.unwrap_or(&knock_name).to_string(); self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters Ok(Some(PairedClient { name, fingerprint: fp_hex, })) } /// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock /// re-creates an entry — deny is "not now", not a blocklist. pub fn deny_pending(&self, id: u32) -> bool { let removed = { let mut pending = self.pending.lock().unwrap(); let before = pending.items.len(); pending.items.retain(|p| p.id != id); pending.items.len() != before }; if removed { // Wake a parked waiter so it returns `Denied` at once instead of holding the // connection open until the approval window lapses. self.changed.notify_waiters(); } removed } /// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`. /// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console /// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is /// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no /// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`] /// to keep the knocking connection open until a human clicks Approve — so the device pairs and /// streams with no reconnect (delegated approval, roadmap §8b-1). pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision { // Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open // connection out of the pending queue while the operator decides (#13). Cleared on every // exit path by the guard's Drop. self.set_parked(fp_hex, true); struct ParkGuard<'a> { np: &'a NativePairing, fp: &'a str, } impl Drop for ParkGuard<'_> { fn drop(&mut self) { self.np.set_parked(self.fp, false); } } let _park = ParkGuard { np: self, fp: fp_hex, }; let deadline = tokio::time::Instant::now() + timeout; loop { // Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that // lands between the state check and the await still wakes us (no lost notification). let notified = self.changed.notified(); tokio::pin!(notified); notified.as_mut().enable(); if self.is_paired(fp_hex) { return PairingDecision::Approved; } if !self.pending_contains(fp_hex) { // Neither pending nor paired. This is almost always a denial — but it can also be // the tiny interval inside `add()` between pinning and clearing the pending entry. // Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a // cleared-pending observation that is really an approval will now read as paired. if self.is_paired(fp_hex) { return PairingDecision::Approved; } return PairingDecision::Denied; } tokio::select! { _ = &mut notified => {} _ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut, } } } } #[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 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", None); np.note_pending("Bedroom TV", "aa11", None); 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", None); 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. // Flood from many DISTINCT source IPs (so the per-IP cap doesn't kick in) → the global cap // holds at PENDING_CAP, evicting the oldest non-parked entries first. for i in 0..(PENDING_CAP + 3) { let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]); np.note_pending("flood", &format!("f{i:03}"), Some(ip)); } 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", None); 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(); 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); } #[tokio::test] async fn wait_for_decision_approve_deny_timeout() { use std::sync::Arc; let p = temp(); let _ = std::fs::remove_file(&p); let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap()); // TimedOut: a parked knock with no decision returns TimedOut; the entry survives. np.note_pending("Knocker", "ab01", None); let d = np .wait_for_decision("ab01", Duration::from_millis(80)) .await; assert_eq!(d, PairingDecision::TimedOut); assert!(np.pending_contains("ab01")); // Approved: approving WHILE parked wakes the waiter with Approved. let np2 = np.clone(); let waiter = tokio::spawn( async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await }, ); tokio::time::sleep(Duration::from_millis(30)).await; let id = np .pending() .into_iter() .find(|x| x.fingerprint == "ab01") .unwrap() .id; np.approve_pending(id, Some("Approved")).unwrap().unwrap(); assert_eq!(waiter.await.unwrap(), PairingDecision::Approved); assert!(np.is_paired("ab01")); // Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout). np.note_pending("Knock2", "cd02", None); let np3 = np.clone(); let waiter = tokio::spawn( async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await }, ); tokio::time::sleep(Duration::from_millis(30)).await; let id = np .pending() .into_iter() .find(|x| x.fingerprint == "cd02") .unwrap() .id; assert!(np.deny_pending(id)); assert_eq!(waiter.await.unwrap(), PairingDecision::Denied); assert!(!np.is_paired("cd02")); // Already paired before the call → immediate Approved (no waiting). let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await; assert_eq!(d, PairingDecision::Approved); let _ = std::fs::remove_file(&p); } /// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker) /// fingerprint can neither pair nor BURN the window (it's rejected without a PIN). #[test] fn armed_pin_is_fingerprint_bindable() { let p = temp(); let _ = std::fs::remove_file(&p); let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); // Unbound: any fingerprint resolves to the PIN (legacy behavior). let pin = np.arm(Duration::from_secs(60)); assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin)); assert!(matches!(np.pin_for_attempt("bb22"), PinAttempt::Pin(_))); // Bound to AA11: only that fp (case-insensitive) gets the PIN; another fp is BoundToOther — // the caller rejects it WITHOUT consuming the window. let pin = np.arm_for(Duration::from_secs(60), Some("AA11".into())); assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin)); assert!(matches!( np.pin_for_attempt("bb22"), PinAttempt::BoundToOther )); np.disarm(); assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Disarmed)); let _ = std::fs::remove_file(&p); } /// #13: one source IP can't exceed the per-IP cap, and a parked (held-open) genuine knock is /// never evicted by a flood — even one that fills the global cap from many distinct IPs. #[test] fn pending_per_ip_cap_and_parked_protection() { let p = temp(); let _ = std::fs::remove_file(&p); let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap(); // Per-IP cap: one source flooding distinct fingerprints holds at most MAX_PENDING_PER_IP. let attacker = IpAddr::from([192, 168, 1, 66]); for i in 0..20 { np.note_pending("flood", &format!("atk{i:03}"), Some(attacker)); } assert_eq!( np.pending().len(), MAX_PENDING_PER_IP, "one IP can't exceed the per-IP cap" ); // A genuine knock from a different IP, parked (a live held-open connection), survives a flood // from many distinct IPs that fills the global cap. let legit = IpAddr::from([192, 168, 1, 50]); np.note_pending("Living Room", "legit01", Some(legit)); np.set_parked("legit01", true); for i in 0..(PENDING_CAP * 2) { let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]); np.note_pending("flood2", &format!("g{i:04}"), Some(ip)); } assert!( np.pending_contains("legit01"), "a parked, held-open knock is never evicted by a flood" ); assert!(np.pending().len() <= PENDING_CAP, "global cap still holds"); let _ = std::fs::remove_file(&p); } }