* 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.
1333 lines
50 KiB
Rust
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()
|
|
}
|
|
}
|