//! Native persistence boundary for the Linux client. //! //! Settings are deliberately limited to non-secret connection metadata. The //! session credential is stored as a `user` key in the Linux kernel keyring //! through the `keyctl` utility. If keyring access fails, callers receive an //! error and must ask for credentials again; no plaintext fallback is allowed. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ fs, io, path::{Path, PathBuf}, process::Command, }; pub const KEYRING_SERVICE: &str = "trixie.server.session"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Settings { pub server_address: String, pub username: String, } impl Settings { pub fn load(path: impl AsRef) -> io::Result { let bytes = fs::read(path)?; serde_json::from_slice(&bytes) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } pub fn save(&self, path: impl AsRef) -> io::Result<()> { let path = path.as_ref(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let bytes = serde_json::to_vec_pretty(self) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let temporary = path.with_extension("json.tmp"); fs::write(&temporary, bytes)?; fs::rename(temporary, path) } } pub fn default_settings_path() -> PathBuf { let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")); base.join("trixie").join("settings.json") } fn key_description(settings: &Settings) -> String { let mut digest = Sha256::new(); digest.update(settings.server_address.as_bytes()); digest.update([0]); digest.update(settings.username.as_bytes()); let digest = digest.finalize(); format!("{KEYRING_SERVICE}:{}", hex_encode(&digest)) } fn hex_encode(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut result = String::with_capacity(bytes.len() * 2); for byte in bytes { result.push(HEX[(byte >> 4) as usize] as char); result.push(HEX[(byte & 0x0f) as usize] as char); } result } fn keyctl(args: &[&str]) -> io::Result> { let output = Command::new("keyctl").args(args).output()?; if !output.status.success() { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "Linux kernel keyring access failed", )); } Ok(output.stdout) } pub fn store_session(settings: &Settings, session: &str) -> io::Result<()> { if session.is_empty() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty session")); } let description = key_description(settings); keyctl(&["add", "user", &description, session, "@s"]).map(|_| ()) } pub fn load_session(settings: &Settings) -> io::Result> { let description = key_description(settings); let id = match keyctl(&["search", "@s", "user", &description]) { Ok(value) => String::from_utf8_lossy(&value).trim().to_string(), Err(_) => return Ok(None), }; if id.is_empty() { return Ok(None); } let value = keyctl(&["pipe", &id])?; String::from_utf8(value) .map(Some) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } pub fn clear_session(settings: &Settings) -> io::Result<()> { let description = key_description(settings); let id = String::from_utf8_lossy(&keyctl(&["search", "@s", "user", &description])?) .trim() .to_string(); if id.is_empty() { return Ok(()); } keyctl(&["unlink", &id, "@s"]).map(|_| ()) } #[cfg(test)] mod tests { use super::*; fn settings() -> Settings { Settings { server_address: "https://trixie.example".into(), username: "admin".into(), } } #[test] fn settings_round_trip_contains_no_password_field() { let directory = tempfile::tempdir().expect("temporary directory"); let path = directory.path().join("settings.json"); settings().save(&path).expect("save settings"); let text = fs::read_to_string(&path).expect("read settings"); assert!(!text.contains("password")); assert_eq!(Settings::load(path).expect("load settings"), settings()); } #[test] fn key_description_is_stable_but_scoped_to_connection_and_user() { let first = key_description(&settings()); assert_eq!(first, key_description(&settings())); let mut different = settings(); different.username = "moderator".into(); assert_ne!(first, key_description(&different)); assert!(first.starts_with(KEYRING_SERVICE)); } }