Files
punktfunk/crates/punktfunk-host/src/native_pairing/store.rs
T
enricobuehlerandClaude Opus 4.8 c42ce88921 refactor(host/W6.1): extract secret/config-dir helpers into the pf-paths leaf crate
Second de-coupling for the host crate carve (plan §W6.1 leaf). config_dir /
create_private_dir / write_secret_file (+ the Windows DACL helpers) were pub(crate) in the
gamestream junk drawer, yet consumed by vdisplay, stats, gpu, library, mgmt_token,
native_pairing and the Windows service — many of which become pf-media / pf-vdisplay, for
which crate::gamestream would be an illegal upward edge. New leaf crate pf-paths (pure std
+ tracing) owns them; ~40 call sites across 14 files repoint to pf_paths::. gamestream
keeps only its own concerns.

Verified: Linux (home-worker-5) clippy -p pf-paths -p punktfunk-host --all-targets
-D warnings + tests (347 pass, incl. secrets_are_written_owner_only); Windows
(192.168.1.158) clippy --features nvenc,amf-qsv --all-targets green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:07:26 +02:00

138 lines
5.1 KiB
Rust

//! The persistent native-pairing trust store: `~/.config/punktfunk/punktfunk1-paired.json`
//! (plan §W5 — carved out of the [`super`] facade). Owns the paired-clients [`Mutex`] and the
//! atomic-replace persistence; the pending-approval side of a pairing lives in [`super::approval`].
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// 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<PairedClient>,
}
#[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,
}
fn default_path() -> Result<PathBuf> {
// `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(pf_paths::config_dir().join("punktfunk1-paired.json"))
}
fn load(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() {
pf_paths::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");
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
std::fs::rename(&tmp, &state.path)?;
Ok(())
}
/// The persistent trust store — the paired-clients set behind a [`Mutex`], backed by an
/// atomic-replace JSON file.
pub(super) struct TrustStore {
paired: Mutex<PairedState>,
}
impl TrustStore {
/// Open (load) the trust store. `store_path = None` uses the default config path.
pub(super) fn open(store_path: Option<PathBuf>) -> Result<TrustStore> {
let path = match store_path {
Some(p) => p,
None => default_path()?,
};
let clients = load(&path);
Ok(TrustStore {
paired: Mutex::new(PairedState { path, clients }),
})
}
/// Is this client (hex SHA-256 fingerprint) in the paired set?
pub(super) 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. (Clearing any pending knock for this fingerprint is the caller's job —
/// see [`super::approval::ApprovalQueue::admit_and_clear`].)
pub(super) fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
let name = super::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);
}
Ok(())
}
/// The paired clients (for the management API's device list).
pub(super) fn list(&self) -> Vec<PairedClient> {
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(super) fn remove(&self, fp_hex: &str) -> Result<bool> {
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)
}
/// The number of paired clients (for the status snapshot).
pub(super) fn count(&self) -> u32 {
self.paired.lock().unwrap().clients.clients.len() as u32
}
}