Files
chanora/crates/chanora_storage/src/lib.rs
T
Edison Jwa fb2a8e0a80 docs(rust): add doc comments to delta enums and fix dead_code warnings (#24)
* docs(rust): add doc comments to delta enums and fix dead_code warnings

Add missing documentation to ProtocolDelta, CoreDelta, and BridgeDelta
enum variants and their struct fields across the protocol, core, and
bridge crates. Document the ChannelId::ROOT constant and the
take_delta_rx adapter method.

Fix dead_code warnings:
- keyring_disabled: add #[cfg] gate matching its callers
- snapshot_signature: add #[cfg(test)] for future test use

* fix(rust): correct `order` field docs to predecessor channel ID, narrow audio engine cfg gates

- Correct `order` field documentation in ProtocolDelta, SessionEvent,
  and BridgeEvent from 'sort order' to 'predecessor channel ID
  (TeamSpeak linked-list ordering hint)' per Copilot review feedback.
- Narrow AudioEngine voice_out_tx, voice_activity_selector, and mic_gain
  cfg gates from ios+macos+android to android-only, since these fields
  are only read from self in android_restart_voice_unit. On iOS/macOS the
  values are passed directly to the voice backend at construction time.
2026-06-05 11:54:59 +09:00

1333 lines
50 KiB
Rust

//! # `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
//! `<storage_dir>/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<Path>) -> Result<Self, StorageError> {
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<Option<[u8; 32]>, 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<Option<[u8; 32]>, 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<impl Crypto + Clone + std::fmt::Debug + 'static, StorageError> {
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<Option<String>, 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<fs::File, StorageError> {
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<fs::File, StorageError> {
// 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<Vec<u8>, StorageError>;
/// Decrypt a blob previously produced by [`Self::encrypt`].
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError>;
}
impl Crypto for DekCrypto {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
DekCrypto::encrypt(self, plaintext)
}
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, 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 `<storage_dir>`.
///
/// 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<Vec<u8>, 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<Vec<u8>, 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<String>,
}
/// SQLite-backed bookmark store. The DB file lives at
/// `<storage_dir>/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<Connection>,
crypto: Option<Box<dyn Crypto>>,
}
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<Path>) -> Result<Self, StorageError> {
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<C>(dir: impl AsRef<Path>, crypto: C) -> Result<Self, StorageError>
where
C: Crypto + 'static,
{
Self::open(dir.as_ref(), Some(Box::new(crypto) as Box<dyn Crypto>))
}
fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
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<i64, 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()
};
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<i64, 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 existing: Option<i64> = 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<Vec<Bookmark>, 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<String> = row.get(4)?;
let blob: Option<Vec<u8>> = 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<String>, Option<Vec<u8>>) = 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<String>, Option<Vec<u8>>) = 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()
}
}