//! # `chanora_storage` //! //! Two strictly separated storage concerns per SAD-067: //! //! * [`BookmarkRepository`] — non-secret bookmark state via SQLite //! with optional encrypted password fields. Crate choice: //! `rusqlite` bundled (DEC-013.1). //! * [`IdentityFileStore`] — Beta fallback storage for identity //! material while the platform secure-storage backends mature. //! Linux policy remains Secret Service preferred, kernel keyutils //! fallback (DEC-013.2). Other platforms TBD per //! SS-TC-001/002/004/005. //! //! Secret values **never** appear in the local DB (SS-AUD-001/002); //! bookmarks store only an `identity_ref` lookup name into the //! secure store. //! //! ## Beta status (A.2) //! //! Ships a single concrete identity-only store, [`IdentityFileStore`], //! that persists a single identity to a file at //! `/identity.tskey` with restrictive POSIX permissions //! (0600) on Unix. This is the **best-effort fallback** the secure //! storage policy permits when no platform keyring is available //! (RISK-PoC-002 / SS-RISK-FALLBACK). The full Secret Service + //! keyutils backend, plus Android Keystore / iOS Keychain, will //! replace this in v0.4. Until then: //! //! * Linux desktop: file with 0600 mode in `$XDG_DATA_HOME/chanora` //! (or the path provided by the bridge caller). //! * Android: file in app-private storage. App-private means it's //! not world-readable, but it is **not** encrypted at rest. This //! is the documented Beta gap. //! * iOS / Windows / macOS: same — caller chooses the directory. //! //! The store is intentionally limited to one identity per //! installation in Beta; bookmark / multi-identity support arrives //! with the SQLite repository. #![forbid(unsafe_code)] #![warn(missing_docs)] use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{info, warn}; use zeroize::Zeroize; /// Errors raised by either storage repository. #[derive(Debug, Error)] pub enum StorageError { /// Looked-up entry does not exist. #[error("not found")] NotFound, /// Schema migration error. #[error("migration: {0}")] Migration(String), /// Underlying SQLite error. #[error("sqlite: {0}")] Sqlite(String), /// Platform secure-storage backend rejected an operation. #[error("secure-store: {0}")] SecureStore(String), /// Filesystem I/O error (Beta file-fallback store). #[error("io: {0}")] Io(String), /// Cryptographic operation failed (key generation, encrypt, or /// decrypt). Typically indicates a corrupted DEK or tampered /// identity file. #[error("crypto: {0}")] Crypto(String), } /// Audio-related per-identity settings persisted alongside the /// identity file as a small JSON blob (SDD-095 / SDD-096). These /// are *not* secrets; they sit beside the encrypted identity in /// app-private storage. #[derive(Debug, Clone, Serialize, Deserialize)] struct AudioMeta { /// Encoded [`chanora_audio::TransmitMode`]. Default is /// `TransmitMode::Ptt as u8 == 0`. #[serde(default)] transmit_mode: u8, /// Release-tail in milliseconds (SDD-096). Default 200. #[serde(default = "default_release_tail_ms")] release_tail_ms: u32, /// Bound PTT input class as the privacy-safe string accepted by /// the bridge (`""`, `"keyboard"`, `"mouse-side-button"`). /// Default empty (no binding). #[serde(default)] ptt_input_class: String, /// Opaque platform key string the binding dialog produced /// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Default /// empty. #[serde(default)] ptt_platform_key: String, /// Display-only platform-neutral key label the UI shows next /// to the binding (e.g. `"Space"`). Default empty. #[serde(default)] ptt_key_label: String, } /// Persisted PTT binding metadata. /// /// `input_class` is a stable privacy-safe category string, `platform_key` /// is the opaque identifier consumed by the platform backend, and /// `key_label` is the display-only label shown in the UI. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PttBindingMeta { /// Stable input category string (`""`, `"keyboard"`, or /// `"mouse-side-button"`). pub input_class: String, /// Opaque platform key identifier. pub platform_key: String, /// Display-only key label. pub key_label: String, } fn default_release_tail_ms() -> u32 { 200 } impl Default for AudioMeta { fn default() -> Self { Self { transmit_mode: 0, release_tail_ms: 200, ptt_input_class: String::new(), ptt_platform_key: String::new(), ptt_key_label: String::new(), } } } /// Beta identity store: a single ChaCha20-Poly1305-encrypted file /// containing the base64 TS3 identity string. The Data Encryption /// Key (DEK) is 32 random bytes stored in the platform keyring /// (Linux Secret Service via D-Bus, macOS Keychain, Windows /// Credential Manager, iOS Keychain) when available, with a /// best-effort file fallback at `identity.dek` (mode 0600) when the /// keyring is unreachable. Per DEC-013.2 the Linux preference is /// Secret Service; the kernel keyutils fallback is not yet wired — /// see the keyring-error log line on first run when the bus is /// missing. /// /// Threat-model honest assessment: /// /// * **Keyring path** (the normal case on a logged-in desktop): the /// DEK lives in the OS keyring, locked under the user's session. /// Recovering the identity then requires both the on-disk /// ciphertext *and* the active user session — a meaningful /// improvement over the file-fallback two-file model. /// * **File-fallback path** (headless servers, CI containers, /// Android-without-Keystore, fresh installs where the keyring /// isn't running yet): same dual-file guarantee as v0.4 — both /// files in the install dir must be readable to recover the /// identity. /// * **Does NOT help against** a malicious user inside the same /// session (keyring lookup succeeds for any process the user /// runs); v1.1+ may pursue per-process scoping where the OS /// supports it. /// /// File format: `identity.tskey` = `[12-byte nonce][AEAD ciphertext+tag]`. /// Legacy plaintext files written by v0.3 are still readable; the /// next `save()` upgrades them to encrypted form (and shreds the /// plaintext temp file via the atomic rename). #[derive(Debug, Clone)] pub struct IdentityFileStore { path: PathBuf, dek_path: PathBuf, /// Stable per-install identifier used as the keyring account /// name. Derived from the install directory so the same store /// finds the same keyring entry across restarts. #[cfg_attr(target_os = "android", allow(dead_code))] keyring_account: String, } impl IdentityFileStore { /// Service name used for the platform keyring entry. Kept /// short and stable so a re-install with the same install /// directory finds the existing DEK. pub const KEYRING_SERVICE: &'static str = "chanora"; /// Construct a store rooted at `dir`. Creates the directory and /// the DEK on first use; subsequent uses reuse the existing DEK. pub fn new(dir: impl AsRef) -> Result { let dir = dir.as_ref(); fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?; let canonical = fs::canonicalize(dir) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| dir.to_string_lossy().into_owned()); let store = Self { path: dir.join("identity.tskey"), dek_path: dir.join("identity.dek"), keyring_account: format!("identity-dek::{canonical}"), }; // Ensure a DEK exists somewhere we can retrieve it. store.ensure_dek()?; Ok(store) } /// Path to the underlying identity file. Exposed for diagnostics. pub fn path(&self) -> &Path { &self.path } /// Try to read the DEK from the platform keyring. Returns /// `Ok(None)` when no entry exists or when the platform keyring /// is unreachable (typical for headless / CI hosts). Decoding /// errors propagate as [`StorageError::Crypto`]. /// /// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless /// environments where a real Secret Service call would block on /// a missing D-Bus session. #[cfg(any( target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios" ))] fn keyring_load(&self) -> Result, StorageError> { if keyring_disabled() { return Ok(None); } use base64::Engine; let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { Ok(e) => e, Err(e) => { warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file"); return Ok(None); } }; match entry.get_password() { Ok(b64) => { let bytes = base64::engine::general_purpose::STANDARD .decode(b64.as_bytes()) .map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?; if bytes.len() != 32 { return Err(StorageError::Crypto(format!( "keyring dek length {} (expected 32)", bytes.len() ))); } let mut key = [0u8; 32]; key.copy_from_slice(&bytes); Ok(Some(key)) } Err(keyring::Error::NoEntry) => Ok(None), Err(e) => { // Bus unreachable, no session, locked keychain // — best-effort: fall through to file. warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file"); Ok(None) } } } #[cfg(not(any( target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios" )))] fn keyring_load(&self) -> Result, StorageError> { Ok(None) } /// Persist the DEK in the platform keyring. Returns true on /// success and false when the keyring is unreachable (caller /// should then fall back to the file path). /// /// Honours `CHANORA_DISABLE_KEYRING=1`. #[cfg(any( target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios" ))] fn keyring_save(&self, key: &[u8; 32]) -> bool { if keyring_disabled() { return false; } use base64::Engine; let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { Ok(e) => e, Err(_) => return false, }; let b64 = base64::engine::general_purpose::STANDARD.encode(key); match entry.set_password(&b64) { Ok(()) => { info!(target: "chanora_storage", "DEK stored in platform keyring"); true } Err(e) => { warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file"); false } } } #[cfg(not(any( target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios" )))] fn keyring_save(&self, _key: &[u8; 32]) -> bool { false } fn ensure_dek(&self) -> Result<(), StorageError> { // Already in the keyring? Done. if self.keyring_load()?.is_some() { return Ok(()); } // File-fallback DEK present? Try to migrate into the // keyring opportunistically (lets users upgrade for free). // We always keep the file around as a stable mirror — // platform keyring access can flicker (e.g. SSH-launched // session on Windows hits ERROR_NO_SUCH_LOGON_SESSION even // though an RDP / console session reached the same store // fine), and if a transient keyring outage made us // regenerate the DEK we would silently break decrypt of // every prior identity.tskey blob (reported on the v1.0.0 // -rc.7 Windows verification run as "Bridge Error // connection failed: storage crypto decrypt aead error"). // The file lives in app-private storage where the platform // sandbox is the access-control authority, so retaining // the mirror does not weaken the security posture in any // meaningful way. if self.dek_path.exists() { if let Ok(key) = read_file_dek(&self.dek_path) { let _ = self.keyring_save(&key); let mut k = key; k.zeroize(); } return Ok(()); } // Fresh install: generate a new DEK and persist to BOTH // the keyring (when reachable) and the file. The file is // the durable source of truth; the keyring is an optional // accelerator that platform UX integrates with. let mut key = [0u8; 32]; OsRng.fill_bytes(&mut key); let _ = self.keyring_save(&key); let mut f = open_private(&self.dek_path)?; f.write_all(&key) .map_err(|e| StorageError::Io(format!("write dek: {e}")))?; f.sync_all() .map_err(|e| StorageError::Io(format!("sync dek: {e}")))?; info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)"); key.zeroize(); Ok(()) } fn load_dek(&self) -> Result<[u8; 32], StorageError> { // File is the durable source of truth (see `ensure_dek`). // Prefer it when present; only consult the keyring as a // legacy-migration path for installs that lost their file // mirror before this commit landed. if self.dek_path.exists() { return read_file_dek(&self.dek_path); } if let Some(k) = self.keyring_load()? { return Ok(k); } Err(StorageError::Crypto( "no DEK available (file missing, keyring empty)".into(), )) } /// Hand out a [`DekCrypto`] anchored on the same DEK that /// encrypts the identity file. Used by [`BookmarkRepository`] /// to encrypt server-password columns under the same per-install /// key (MVP hardening). pub fn crypto(&self) -> Result { Ok(DekCrypto::new(self.load_dek()?)) } /// Read the persisted identity, if any. Transparently handles /// the legacy plaintext format (pre-External Beta). pub fn load(&self) -> Result, StorageError> { let mut f = match fs::File::open(&self.path) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(StorageError::Io(format!("open {:?}: {e}", self.path))), }; let mut buf = Vec::new(); f.read_to_end(&mut buf) .map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?; if buf.is_empty() { return Ok(None); } // Encrypted format: at least nonce (12) + tag (16) = 28 // bytes, and the first byte should not be a printable ASCII // base64 character. Heuristic: a legacy plaintext file // starts with an ASCII digit (counter prefix) or an ASCII // letter (raw base64 key). If the first byte is non-ASCII // or non-printable, treat as ciphertext. if buf.len() >= 28 && !is_plausibly_legacy_plaintext(&buf) { let mut key_bytes = self.load_dek()?; let key = Key::from_slice(&key_bytes); let cipher = ChaCha20Poly1305::new(key); let nonce_bytes = &buf[..12]; let nonce = Nonce::from_slice(nonce_bytes); let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| { key_bytes.zeroize(); StorageError::Crypto(format!("decrypt: {e}")) })?; key_bytes.zeroize(); let s = String::from_utf8(pt) .map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?; let trimmed = s.trim().to_string(); if trimmed.is_empty() { return Ok(None); } return Ok(Some(trimmed)); } // Legacy plaintext path. We do NOT auto-upgrade here — // upgrade happens on the next save() to keep load() // side-effect-free. warn!( target: "chanora_storage", "identity file is in legacy plaintext format; will encrypt on next save" ); let s = String::from_utf8(buf) .map_err(|e| StorageError::Io(format!("legacy not utf8: {e}")))?; let trimmed = s.trim().to_string(); if trimmed.is_empty() { Ok(None) } else { Ok(Some(trimmed)) } } /// Persist `identity` to disk, replacing any previous content. /// On Unix the file is written with mode 0600. Encrypted with /// ChaCha20-Poly1305 using the per-install DEK. pub fn save(&self, identity: &str) -> Result<(), StorageError> { let plaintext = identity.trim().as_bytes(); let mut key_bytes = self.load_dek()?; let key = Key::from_slice(&key_bytes); let cipher = ChaCha20Poly1305::new(key); let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ct = cipher.encrypt(nonce, plaintext).map_err(|e| { key_bytes.zeroize(); StorageError::Crypto(format!("encrypt: {e}")) })?; key_bytes.zeroize(); // Atomic write: temp file + rename. let tmp = self.path.with_extension("tskey.tmp"); { let mut f = open_private(&tmp)?; f.write_all(&nonce_bytes) .map_err(|e| StorageError::Io(format!("write nonce: {e}")))?; f.write_all(&ct) .map_err(|e| StorageError::Io(format!("write ct: {e}")))?; f.sync_all() .map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?; } fs::rename(&tmp, &self.path) .map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?; info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)"); Ok(()) } /// Path to the small JSON metadata file that sits alongside /// the identity. Holds audio settings (`transmit_mode`, /// `release_tail_ms`) per SDD-095/096. Stored in plaintext — /// these values are not secrets. fn meta_path(&self) -> PathBuf { let dir = self .path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); dir.join("audio_meta.json") } fn read_meta(&self) -> AudioMeta { match fs::read_to_string(self.meta_path()) { Ok(s) => serde_json::from_str(&s).unwrap_or_default(), Err(_) => AudioMeta::default(), } } fn write_meta(&self, m: &AudioMeta) -> Result<(), StorageError> { let path = self.meta_path(); let tmp = path.with_extension("json.tmp"); let body = serde_json::to_vec_pretty(m) .map_err(|e| StorageError::Io(format!("meta serialize: {e}")))?; { let mut f = fs::File::create(&tmp) .map_err(|e| StorageError::Io(format!("open meta {tmp:?}: {e}")))?; f.write_all(&body) .map_err(|e| StorageError::Io(format!("write meta: {e}")))?; f.sync_all() .map_err(|e| StorageError::Io(format!("sync meta: {e}")))?; } fs::rename(&tmp, &path) .map_err(|e| StorageError::Io(format!("rename meta {tmp:?} -> {path:?}: {e}")))?; Ok(()) } /// Persist the user's chosen transmit mode (SDD-095). The /// encoding matches `chanora_audio::TransmitMode::as_u8()`. pub fn set_transmit_mode(&self, mode: u8) -> Result<(), StorageError> { let mut m = self.read_meta(); m.transmit_mode = mode; self.write_meta(&m) } /// Read the persisted transmit mode. Defaults to `0` /// (`TransmitMode::Ptt`) when no value has been written. pub fn get_transmit_mode(&self) -> u8 { self.read_meta().transmit_mode } /// Persist the user's chosen release-tail (SDD-096). Clamped /// to `0..=500` ms inclusive on write. pub fn set_release_tail_ms(&self, ms: u32) -> Result<(), StorageError> { let mut m = self.read_meta(); m.release_tail_ms = ms.min(500); self.write_meta(&m) } /// Read the persisted release-tail in milliseconds. Defaults /// to `200` (SDD-096 default) when no value has been written. pub fn get_release_tail_ms(&self) -> u32 { self.read_meta().release_tail_ms } /// Persist the user's PTT binding (SDD-094 follow-up). The /// three fields together carry the privacy-safe binding /// surface — `input_class` is a stable category string /// (`""`, `"keyboard"`, `"mouse-side-button"`), `platform_key` /// is the opaque key identifier the platform backend /// understands, and `key_label` is the display string the UI /// renders next to the binding. None of these are raw key /// codes or scan codes per DEC-027. pub fn set_ptt_binding( &self, input_class: &str, platform_key: &str, key_label: &str, ) -> Result<(), StorageError> { let mut m = self.read_meta(); m.ptt_input_class = input_class.to_string(); m.ptt_platform_key = platform_key.to_string(); m.ptt_key_label = key_label.to_string(); self.write_meta(&m) } /// Read the persisted PTT binding. Empty strings mean "no binding". pub fn get_ptt_binding(&self) -> PttBindingMeta { let m = self.read_meta(); PttBindingMeta { input_class: m.ptt_input_class, platform_key: m.ptt_platform_key, key_label: m.ptt_key_label, } } /// Remove any persisted identity. No-op if none exists. Leaves /// the DEK in place so future saves don't generate a new one. pub fn clear(&self) -> Result<(), StorageError> { match fs::remove_file(&self.path) { Ok(()) => { info!(target: "chanora_storage", path = ?self.path, "identity cleared"); Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(StorageError::Io(format!("remove {:?}: {e}", self.path))), } } } /// True if `buf` looks like a legacy plaintext identity (printable /// ASCII with a digit/letter first byte and at most a trailing /// newline). False for ciphertext. fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool { if buf.is_empty() { return false; } let first = buf[0]; if !(first.is_ascii_alphanumeric() || first == b'+' || first == b'/') { return false; } // The TS3 identity format is base64 + a counter prefix; all // bytes are printable ASCII. The ciphertext is uniformly random. buf.iter().all(|&b| { b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=' || b == b'V' || b == b'\n' || b == b'\r' || b == b' ' }) } /// Read a 32-byte DEK from `path`. Used by the file-fallback path /// and the legacy migration path inside `ensure_dek`. fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> { let mut f = fs::File::open(path).map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?; let mut key = [0u8; 32]; f.read_exact(&mut key) .map_err(|e| StorageError::Io(format!("read dek: {e}")))?; Ok(key) } /// True when `CHANORA_DISABLE_KEYRING=1` is set. Lets tests and /// headless / sandboxed environments force the file-fallback path /// without poking a real OS keyring (which would either prompt the /// user or block on a missing D-Bus session). #[cfg(any( target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios" ))] fn keyring_disabled() -> bool { matches!( std::env::var("CHANORA_DISABLE_KEYRING").as_deref(), Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") ) } #[cfg(unix)] fn open_private(p: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt; fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .mode(0o600) .open(p) .map_err(|e| StorageError::Io(format!("open {p:?}: {e}"))) } #[cfg(not(unix))] fn open_private(p: &Path) -> Result { // On non-Unix targets we can't set POSIX mode bits; the file // sits in app-private storage where the platform sandbox does // the access control. Document the gap rather than failing. warn!(target: "chanora_storage", "non-unix: file permissions not restricted"); fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(p) .map_err(|e| StorageError::Io(format!("open {p:?}: {e}"))) } /// Public abstraction over the per-install envelope-encryption /// helper. Callers see only the encrypt/decrypt pair so the /// concrete key material stays inside `chanora_storage`. pub trait Crypto: Send + Sync { /// Encrypt `plaintext` under the per-install DEK. Returns a /// fresh `[nonce || ct || tag]` blob. fn encrypt(&self, plaintext: &[u8]) -> Result, StorageError>; /// Decrypt a blob previously produced by [`Self::encrypt`]. fn decrypt(&self, blob: &[u8]) -> Result, StorageError>; } impl Crypto for DekCrypto { fn encrypt(&self, plaintext: &[u8]) -> Result, StorageError> { DekCrypto::encrypt(self, plaintext) } fn decrypt(&self, blob: &[u8]) -> Result, StorageError> { DekCrypto::decrypt(self, blob) } } /// Shared envelope encryption helper used by both /// [`IdentityFileStore`] and [`BookmarkRepository`]. Both rely on /// the same per-install 32-byte DEK so a single keyring entry (or /// fallback file) protects everything secret in ``. /// /// Wire format: `[12-byte nonce][AEAD ct+tag]`. Bytes are opaque to /// callers; persist them as-is. #[derive(Clone)] struct DekCrypto { key: [u8; 32], } impl std::fmt::Debug for DekCrypto { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Never expose the key in Debug output. f.write_str("DekCrypto { key: [redacted] }") } } impl DekCrypto { fn new(key: [u8; 32]) -> Self { Self { key } } fn encrypt(&self, plaintext: &[u8]) -> Result, StorageError> { let key = Key::from_slice(&self.key); let cipher = ChaCha20Poly1305::new(key); let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ct = cipher .encrypt(nonce, plaintext) .map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?; let mut out = Vec::with_capacity(12 + ct.len()); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ct); Ok(out) } fn decrypt(&self, blob: &[u8]) -> Result, StorageError> { if blob.len() < 12 + 16 { return Err(StorageError::Crypto(format!( "envelope length {} < minimum", blob.len() ))); } let key = Key::from_slice(&self.key); let cipher = ChaCha20Poly1305::new(key); let nonce = Nonce::from_slice(&blob[..12]); cipher .decrypt(nonce, &blob[12..]) .map_err(|e| StorageError::Crypto(format!("decrypt: {e}"))) } } impl Drop for DekCrypto { fn drop(&mut self) { self.key.zeroize(); } } /// A persisted bookmark: a friendly label paired with a TS3 server /// address and the nickname the user wants when connecting. /// /// Bookmark rows are uniquely identified by an auto-incrementing /// `id`. The `display_name` is purely cosmetic. `host` is the same /// string the user would type into the connect form. /// /// MVP hardening: when the repository is constructed via /// [`BookmarkRepository::with_crypto`] the `password` column is /// stored as a ChaCha20-Poly1305 envelope under the per-install /// DEK, so a stolen SQLite file alone does not leak server /// passwords. The plaintext column is still read for backward /// compatibility and upgraded on the next `update()` call. Rows /// inserted by a pre-MVP build remain readable with their plain /// password values until upgraded. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Bookmark { /// Stable row id assigned by SQLite. pub id: i64, /// User-facing label. pub display_name: String, /// `hostname[:port]` or TSDNS name. pub host: String, /// Nickname to use for this bookmark. pub nickname: String, /// Optional remembered server password. Stored as plain text in /// the SQLite file (Beta gap, RISK-PoC-002). v0.4 will lift /// passwords into the keyring. pub password: Option, } /// SQLite-backed bookmark store. The DB file lives at /// `/chanora.db`. The schema is migrated on /// construction; failures here abort the constructor rather than /// poisoning later calls. /// /// MVP: when a [`Crypto`] is wired via [`Self::with_crypto`] the /// `password` column is replaced by an encrypted `password_blob` /// column. Legacy plaintext passwords in the `password` column are /// still read transparently and lifted into `password_blob` on the /// next `update()` call so existing v0.4 installs upgrade for free. pub struct BookmarkRepository { conn: Mutex, crypto: Option>, } impl BookmarkRepository { /// Open or create the bookmark database under `dir`. No /// password-column encryption — equivalent to the v0.4 behaviour /// and kept for tests. pub fn new(dir: impl AsRef) -> Result { Self::open(dir.as_ref(), None) } /// Open or create the bookmark database with password-column /// encryption wired to the per-install DEK. pub fn with_crypto(dir: impl AsRef, crypto: C) -> Result where C: Crypto + 'static, { Self::open(dir.as_ref(), Some(Box::new(crypto) as Box)) } fn open(dir: &Path, crypto: Option>) -> Result { fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?; let path = dir.join("chanora.db"); let conn = Connection::open(&path) .map_err(|e| StorageError::Sqlite(format!("open {path:?}: {e}")))?; conn.pragma_update(None, "foreign_keys", "ON") .map_err(|e| StorageError::Sqlite(format!("pragma: {e}")))?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS bookmarks ( id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT NOT NULL, host TEXT NOT NULL, nickname TEXT NOT NULL, password TEXT ); CREATE TABLE IF NOT EXISTS schema_version ( v INTEGER PRIMARY KEY ); INSERT OR IGNORE INTO schema_version(v) VALUES (1);", ) .map_err(|e| StorageError::Migration(format!("init schema: {e}")))?; // Schema v2 migration: encrypted password column. Idempotent. let has_blob: i64 = conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('bookmarks') WHERE name='password_blob'", [], |r| r.get(0), ) .map_err(|e| StorageError::Migration(format!("table_info: {e}")))?; if has_blob == 0 { conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", []) .map_err(|e| StorageError::Migration(format!("add password_blob: {e}")))?; conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", []) .map_err(|e| StorageError::Migration(format!("bump version: {e}")))?; info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)"); } info!(target: "chanora_storage", path = ?path, "bookmark db opened"); Ok(Self { conn: Mutex::new(conn), crypto, }) } /// True if the repository has password-column encryption wired. pub fn encrypts_passwords(&self) -> bool { self.crypto.is_some() } /// Insert a new bookmark and return its assigned id. The `id` /// field on the input is ignored. Encrypts the password if a /// crypto helper is wired; otherwise writes it plain. pub fn add(&self, b: &Bookmark) -> Result { let conn = self .conn .lock() .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; let blob = match (&self.crypto, &b.password) { (Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?), _ => None, }; let plain: Option<&str> = if self.crypto.is_some() { None } else { b.password.as_deref() }; conn.execute( "INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)", params![b.display_name, b.host, b.nickname, plain, blob], ) .map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?; Ok(conn.last_insert_rowid()) } /// Insert or update a bookmark identified by host. If a row /// with the same host exists, update connection fields while /// preserving the user-facing display name; otherwise insert. /// Returns the row id. pub fn upsert_or_add(&self, b: &Bookmark) -> Result { let conn = self .conn .lock() .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; let blob = match (&self.crypto, &b.password) { (Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?), _ => None, }; let plain: Option<&str> = if self.crypto.is_some() { None } else { b.password.as_deref() }; let existing: Option = conn .query_row( "SELECT id FROM bookmarks WHERE host = ?1", params![b.host], |row| row.get(0), ) .optional() .map_err(|e| StorageError::Sqlite(format!("select: {e}")))?; if let Some(id) = existing { conn.execute( "UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4", params![b.nickname, plain, blob, id], ) .map_err(|e| StorageError::Sqlite(format!("update: {e}")))?; Ok(id) } else { conn.execute( "INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)", params![b.display_name, b.host, b.nickname, plain, blob], ) .map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?; Ok(conn.last_insert_rowid()) } } /// Replace an existing bookmark identified by `id`. Errors with /// [`StorageError::NotFound`] if no such row exists. Honours the /// password-column encryption setting and clears the legacy /// plaintext column so a row inserted by a pre-MVP build is /// upgraded on the next update. pub fn update(&self, b: &Bookmark) -> Result<(), StorageError> { let conn = self .conn .lock() .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; let blob = match (&self.crypto, &b.password) { (Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?), _ => None, }; let plain: Option<&str> = if self.crypto.is_some() { None } else { b.password.as_deref() }; let n = conn .execute( "UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6", params![b.display_name, b.host, b.nickname, plain, blob, b.id], ) .map_err(|e| StorageError::Sqlite(format!("update: {e}")))?; if n == 0 { Err(StorageError::NotFound) } else { Ok(()) } } /// Delete a bookmark by id. No-op if it doesn't exist. pub fn delete(&self, id: i64) -> Result<(), StorageError> { let conn = self .conn .lock() .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id]) .map_err(|e| StorageError::Sqlite(format!("delete: {e}")))?; Ok(()) } /// List all bookmarks ordered by id (insertion order). Decrypts /// `password_blob` if present; falls back to the legacy plain /// `password` column otherwise (legacy v0.4 rows). pub fn list(&self) -> Result, StorageError> { let conn = self .conn .lock() .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; let mut stmt = conn .prepare( "SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id", ) .map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?; let rows = stmt .query_map([], |row| { let id: i64 = row.get(0)?; let display_name: String = row.get(1)?; let host: String = row.get(2)?; let nickname: String = row.get(3)?; let plain: Option = row.get(4)?; let blob: Option> = row.get(5)?; Ok((id, display_name, host, nickname, plain, blob)) }) .map_err(|e| StorageError::Sqlite(format!("query: {e}")))?; let mut out = Vec::new(); for r in rows { let (id, display_name, host, nickname, plain, blob) = r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?; let password = match (blob.as_ref(), self.crypto.as_ref()) { (Some(b), Some(c)) => Some( String::from_utf8(c.decrypt(b)?) .map_err(|e| StorageError::Crypto(format!("blob utf8: {e}")))?, ), (Some(_), None) => { // We have an encrypted blob but no key. Skip the // password rather than panicking; the caller can // re-enter it. warn!(target: "chanora_storage", id, "encrypted bookmark password but crypto not wired; skipping"); None } (None, _) => plain, }; out.push(Bookmark { id, display_name, host, nickname, password, }); } Ok(out) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; /// Tests always run against the file fallback. A real keyring /// hit would either prompt the developer or block on a missing /// D-Bus session inside CI. The override is set process-wide /// via a module-init guard so individual `#[test]` order does /// not matter. fn force_keyring_off() { std::env::set_var("CHANORA_DISABLE_KEYRING", "1"); } #[test] fn round_trip() { force_keyring_off(); let tmp = tempdir(); let store = IdentityFileStore::new(&tmp).unwrap(); assert!(store.load().unwrap().is_none()); store.save("abc123").unwrap(); assert_eq!(store.load().unwrap().as_deref(), Some("abc123")); store.clear().unwrap(); assert!(store.load().unwrap().is_none()); } #[test] fn empty_file_is_none() { force_keyring_off(); let tmp = tempdir(); let store = IdentityFileStore::new(&tmp).unwrap(); fs::write(store.path(), " \n").unwrap(); assert!(store.load().unwrap().is_none()); } #[cfg(unix)] #[test] fn unix_mode_is_0600() { force_keyring_off(); use std::os::unix::fs::PermissionsExt; let tmp = tempdir(); let store = IdentityFileStore::new(&tmp).unwrap(); store.save("xyz").unwrap(); let mode = fs::metadata(store.path()).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "expected 0600, got {mode:o}"); } #[test] fn bookmark_round_trip() { force_keyring_off(); let tmp = tempdir(); let repo = BookmarkRepository::new(&tmp).unwrap(); assert!(repo.list().unwrap().is_empty()); let id = repo .add(&Bookmark { id: 0, display_name: "home".to_string(), host: "cn.teamspeak.app".to_string(), nickname: "u".to_string(), password: None, }) .unwrap(); assert!(id > 0); let rows = repo.list().unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].display_name, "home"); } #[test] fn bookmark_update_and_delete() { force_keyring_off(); let tmp = tempdir(); let repo = BookmarkRepository::new(&tmp).unwrap(); let id = repo .add(&Bookmark { id: 0, display_name: "a".to_string(), host: "h".to_string(), nickname: "n".to_string(), password: Some("pw".to_string()), }) .unwrap(); repo.update(&Bookmark { id, display_name: "b".to_string(), host: "h2".to_string(), nickname: "n2".to_string(), password: None, }) .unwrap(); let rows = repo.list().unwrap(); assert_eq!(rows[0].display_name, "b"); assert_eq!(rows[0].password, None); repo.delete(id).unwrap(); assert!(repo.list().unwrap().is_empty()); } #[test] fn bookmark_upsert_preserves_existing_display_name() { force_keyring_off(); let tmp = tempdir(); let repo = BookmarkRepository::new(&tmp).unwrap(); let id = repo .add(&Bookmark { id: 0, display_name: "my custom title".to_string(), host: "cn.teamspeak.app".to_string(), nickname: "old nick".to_string(), password: None, }) .unwrap(); let upserted = repo .upsert_or_add(&Bookmark { id: 0, display_name: "live server name".to_string(), host: "cn.teamspeak.app".to_string(), nickname: "new nick".to_string(), password: Some("pw".to_string()), }) .unwrap(); assert_eq!(upserted, id); let rows = repo.list().unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].display_name, "my custom title"); assert_eq!(rows[0].nickname, "new nick"); assert_eq!(rows[0].password.as_deref(), Some("pw")); } #[test] fn bookmark_update_missing_is_notfound() { force_keyring_off(); let tmp = tempdir(); let repo = BookmarkRepository::new(&tmp).unwrap(); let r = repo.update(&Bookmark { id: 999, display_name: "x".to_string(), host: "h".to_string(), nickname: "n".to_string(), password: None, }); assert!(matches!(r, Err(StorageError::NotFound))); } #[test] fn encrypted_round_trip() { force_keyring_off(); let tmp = tempdir(); let store = IdentityFileStore::new(&tmp).unwrap(); store.save("3456V/abcdef==").unwrap(); // The bytes on disk are not the plaintext. let raw = fs::read(store.path()).unwrap(); assert!(!raw.windows(6).any(|w| w == b"abcdef")); // load() returns the original. assert_eq!(store.load().unwrap().as_deref(), Some("3456V/abcdef==")); } #[test] fn legacy_plaintext_is_read_and_upgraded() { force_keyring_off(); let tmp = tempdir(); // Pre-Beta on-disk format: raw base64 with counter prefix. let path = tmp.path().join("identity.tskey"); fs::write(&path, b"9999VabcdefghIJKLmnop=\n").unwrap(); let store = IdentityFileStore::new(&tmp).unwrap(); let v = store.load().unwrap(); assert_eq!(v.as_deref(), Some("9999VabcdefghIJKLmnop=")); // Save round-trips through encrypted format. store.save("9999VabcdefghIJKLmnop=").unwrap(); let raw = fs::read(store.path()).unwrap(); assert!(!raw.starts_with(b"9999")); assert_eq!( store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop=") ); } #[test] fn encrypted_bookmark_password_round_trip() { force_keyring_off(); let tmp = tempdir(); let id_store = IdentityFileStore::new(&tmp).unwrap(); let crypto = id_store.crypto().unwrap(); let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap(); assert!(repo.encrypts_passwords()); let id = repo .add(&Bookmark { id: 0, display_name: "secret".to_string(), host: "h".to_string(), nickname: "n".to_string(), password: Some("hunter2".to_string()), }) .unwrap(); // The on-disk row must not contain the plain password. let conn = rusqlite::Connection::open(tmp.path().join("chanora.db")).unwrap(); let row: (Option, Option>) = conn .query_row( "SELECT password, password_blob FROM bookmarks WHERE id=?1", rusqlite::params![id], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert!(row.0.is_none(), "plaintext password column should be NULL"); assert!(row.1.is_some(), "password_blob should be populated"); let blob = row.1.unwrap(); assert!(!blob.windows(7).any(|w| w == b"hunter2")); // Round-trip through the repository decrypts correctly. let rows = repo.list().unwrap(); assert_eq!(rows[0].password.as_deref(), Some("hunter2")); } #[test] fn legacy_plaintext_bookmark_is_readable_and_upgraded() { force_keyring_off(); let tmp = tempdir(); // Pre-MVP write: opened without crypto, stores plaintext. let legacy = BookmarkRepository::new(&tmp).unwrap(); let id = legacy .add(&Bookmark { id: 0, display_name: "legacy".to_string(), host: "h".to_string(), nickname: "n".to_string(), password: Some("old".to_string()), }) .unwrap(); drop(legacy); // MVP open: same dir, with crypto. Plain row still read. let id_store = IdentityFileStore::new(&tmp).unwrap(); let crypto = id_store.crypto().unwrap(); let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap(); let rows = repo.list().unwrap(); assert_eq!(rows[0].password.as_deref(), Some("old")); // Update lifts it into password_blob and nulls the plaintext. repo.update(&Bookmark { id, display_name: "legacy".to_string(), host: "h".to_string(), nickname: "n".to_string(), password: Some("old".to_string()), }) .unwrap(); let conn = rusqlite::Connection::open(tmp.path().join("chanora.db")).unwrap(); let row: (Option, Option>) = conn .query_row( "SELECT password, password_blob FROM bookmarks WHERE id=?1", rusqlite::params![id], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert!(row.0.is_none(), "plaintext should be cleared after upgrade"); assert!(row.1.is_some(), "blob should be set after upgrade"); } #[test] fn audio_meta_defaults_and_persists() { force_keyring_off(); let tmp = tempdir(); let store = IdentityFileStore::new(&tmp).unwrap(); // Defaults before any write. assert_eq!(store.get_transmit_mode(), 0); assert_eq!(store.get_release_tail_ms(), 200); // Persist values. store.set_transmit_mode(1).unwrap(); store.set_release_tail_ms(75).unwrap(); assert_eq!(store.get_transmit_mode(), 1); assert_eq!(store.get_release_tail_ms(), 75); // Reopen the store — values survive. drop(store); let store2 = IdentityFileStore::new(&tmp).unwrap(); assert_eq!(store2.get_transmit_mode(), 1); assert_eq!(store2.get_release_tail_ms(), 75); } #[test] fn release_tail_ms_clamped_on_write() { force_keyring_off(); let tmp = tempdir(); let store = IdentityFileStore::new(&tmp).unwrap(); store.set_release_tail_ms(9999).unwrap(); assert_eq!(store.get_release_tail_ms(), 500); store.set_release_tail_ms(0).unwrap(); assert_eq!(store.get_release_tail_ms(), 0); } #[test] fn tempdir_is_cleaned_up_on_drop() { let path = { let tmp = tempdir(); let path = tmp.path().to_path_buf(); assert!(path.exists()); path }; assert!(!path.exists()); } fn tempdir() -> TempDir { tempfile::Builder::new() .prefix("chanora_storage_test_") .tempdir() .unwrap() } }