//! Client identity, the known-hosts (pinned fingerprint) store, and app settings. //! //! Ported near-verbatim from the GTK Linux client; the only platform change is the config //! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared //! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`) //! keep the same names so the trust model is identical across the native clients. use anyhow::{anyhow, Context, Result}; use punktfunk_core::quic::endpoint; use serde::{Deserialize, Serialize}; use std::path::PathBuf; pub fn config_dir() -> Result { let appdata = std::env::var("APPDATA").context("APPDATA unset")?; Ok(PathBuf::from(appdata).join("punktfunk")) } /// This client's persistent identity, generated on first use — presented on every connect /// so hosts can recognize it once paired. pub fn load_or_create_identity() -> Result<(String, String)> { let dir = config_dir()?; let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { return Ok((c, k)); } let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; std::fs::create_dir_all(&dir)?; std::fs::write(&cp, &c)?; std::fs::write(&kp, &k)?; tracing::info!(cert = %cp.display(), "generated client identity"); Ok((c, k)) } pub fn hex(fp: &[u8; 32]) -> String { fp.iter().map(|b| format!("{b:02x}")).collect() } pub fn parse_hex32(s: &str) -> Option<[u8; 32]> { if s.len() != 64 { return None; } let mut out = [0u8; 32]; for (i, b) in out.iter_mut().enumerate() { *b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?; } Some(out) } /// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a /// PIN ceremony) and where we last reached it. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct KnownHost { pub name: String, pub addr: String, pub port: u16, /// SHA-256 of the host certificate, lowercase hex — the pin for every later connect. pub fp_hex: String, /// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use). pub paired: bool, } #[derive(Default, Serialize, Deserialize)] pub struct KnownHosts { pub hosts: Vec, } impl KnownHosts { fn path() -> Result { Ok(config_dir()?.join("client-known-hosts.json")) } pub fn load() -> KnownHosts { Self::path() .and_then(|p| Ok(std::fs::read_to_string(p)?)) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } pub fn save(&self) -> Result<()> { let p = Self::path()?; std::fs::create_dir_all(p.parent().unwrap())?; std::fs::write(&p, serde_json::to_string_pretty(self)?)?; Ok(()) } // Used by the GUI host-list's pinned-fingerprint trust decision (the silent-reconnect // path); the current CLI trust flow keys on address. Kept for parity with the other // clients' known-hosts API — wired when the discovered-hosts UI lands. #[allow(dead_code)] pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> { self.hosts.iter().find(|h| h.fp_hex == fp_hex) } pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> { self.hosts.iter().find(|h| h.addr == addr && h.port == port) } /// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades /// (a later TOFU connect must not demote a PIN-paired host). pub fn upsert(&mut self, entry: KnownHost) { if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) { h.name = entry.name; h.addr = entry.addr; h.port = entry.port; h.paired |= entry.paired; } else { self.hosts.push(entry); } } } /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] #[serde(default)] pub struct Settings { /// Stream mode; `0` = the native size/refresh of the monitor the window is on, /// resolved at connect time. pub width: u32, pub height: u32, pub refresh_hz: u32, /// Requested encoder bitrate (kbps); 0 = host default. pub bitrate_kbps: u32, pub gamepad: String, /// Which host compositor backend to request (advisory; the host falls back to /// auto-detect when unavailable). pub compositor: String, /// Grab system shortcuts (Alt+Tab, Win…) while input is captured. pub inhibit_shortcuts: bool, /// Stream the default microphone to the host's virtual mic source. pub mic_enabled: bool, } impl Default for Settings { fn default() -> Self { Settings { width: 0, height: 0, refresh_hz: 0, bitrate_kbps: 0, gamepad: "auto".into(), compositor: "auto".into(), inhibit_shortcuts: true, mic_enabled: false, } } } impl Settings { fn path() -> Result { Ok(config_dir()?.join("client-windows-settings.json")) } pub fn load() -> Settings { Self::path() .and_then(|p| Ok(std::fs::read_to_string(p)?)) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } pub fn save(&self) { let Ok(p) = Self::path() else { return }; let _ = std::fs::create_dir_all(p.parent().unwrap()); if let Ok(s) = serde_json::to_string_pretty(self) { let _ = std::fs::write(&p, s); } } }