feat(storage): A.2 — persist TS3 identity across app restarts
A fresh `Identity::create()` was generated on every connect, which meant the server saw a different client UID each time. Long-lived features (bookmarks, server-side bans, group membership) depend on a stable UID — restoring that now via a minimal directory-backed identity file. * `chanora_storage::IdentityFileStore` reads / writes a single `identity.tskey` file under a caller-supplied directory. On Unix the file is created with `O_CREAT | O_TRUNC | mode 0600`; on non-Unix targets the platform sandbox does the access control. Writes are atomic (temp file + `fsync` + `rename`) so a crash mid-write cannot leave a half-written identity on disk. Empty files are treated as "no identity" rather than as an error. * `chanora_protocol::ProtocolClient::generate_identity()` exposes the `counterVbase64key` serialisation used by tsclientlib's `Identity::new_from_str`, so the core layer can mint an identity and store it before dialling. * `chanora_core::ChanoraSession::init_storage(dir)` wires the store. `connect()` then resolves the identity in this order: (1) `cfg.identity` if explicitly supplied; (2) persisted value if any; (3) generate-and-persist a fresh one. * `chanora_bridge::api::init_storage(dir: String)` is the Flutter-facing entrypoint; the matching Dart side resolves `path_provider`'s `getApplicationSupportDirectory()` and calls it once on app start. * `BridgeError` now maps `CoreError::Storage`. Beta caveat (RISK-PoC-002 / SS-RISK-FALLBACK): the identity is not encrypted at rest. The v0.4 storage rework lands proper Secret Service + Android Keystore + iOS Keychain backends. Documented under `IdentityFileStore`'s doc comment. Live-verified on Moto G Stylus 5G: first connect generated + persisted the identity (visible in the redacted diagnostic export as "generated + persisted fresh identity"); disconnect + reconnect in the same session logged "reusing persisted identity" and dialled with the same UID.
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
//!
|
||||
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
|
||||
//! settings, identity *references*) via SQLite. Crate choice:
|
||||
//! `rusqlite` bundled (DEC-013.1).
|
||||
//! `rusqlite` bundled (DEC-013.1). **Not yet implemented** —
|
||||
//! `poc/sqlite-storage-spike` lands in v0.4.
|
||||
//! * [`SecretStorageRepository`] — secret material (identity private
|
||||
//! keys, server passwords) via platform secure storage. Linux
|
||||
//! policy: Secret Service preferred, kernel keyutils fallback
|
||||
@@ -14,15 +15,37 @@
|
||||
//! bookmarks store only an `identity_ref` lookup name into the
|
||||
//! secure store.
|
||||
//!
|
||||
//! ## Status
|
||||
//! ## Beta status (A.2)
|
||||
//!
|
||||
//! Scaffold only. `poc/secure-storage-spike` and
|
||||
//! `poc/sqlite-storage-spike` will be promoted here.
|
||||
//! 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 thiserror::Error;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Errors raised by either storage repository.
|
||||
#[derive(Debug, Error)]
|
||||
@@ -39,6 +62,9 @@ pub enum StorageError {
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Marker trait for the non-secret database side. Concrete impl will
|
||||
@@ -51,8 +77,161 @@ pub trait LocalDatabaseRepository: Send + Sync {}
|
||||
/// promoted from the secure-storage PoC.
|
||||
pub trait SecretStorageRepository: Send + Sync {}
|
||||
|
||||
/// Beta identity store: a single file containing the base64 TS3
|
||||
/// identity string. The directory is created on first use; on Unix
|
||||
/// the file is written with mode 0600 so other local users can't
|
||||
/// read it. **Not** encrypted at rest — that is the v0.4 task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityFileStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl IdentityFileStore {
|
||||
/// Construct a store rooted at `dir`. The directory is created
|
||||
/// recursively if it does not already exist.
|
||||
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}")))?;
|
||||
Ok(Self {
|
||||
path: dir.join("identity.tskey"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Path to the underlying file. Exposed for diagnostics.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Read the persisted identity, if any. Returns `Ok(None)` when
|
||||
/// no identity has been saved yet — that is not an error.
|
||||
pub fn load(&self) -> Result<Option<String>, StorageError> {
|
||||
match fs::File::open(&self.path) {
|
||||
Ok(mut f) => {
|
||||
let mut buf = String::new();
|
||||
f.read_to_string(&mut buf)
|
||||
.map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?;
|
||||
let trimmed = buf.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed))
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(StorageError::Io(format!("open {:?}: {e}", self.path))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist `identity` to disk, replacing any previous content.
|
||||
/// On Unix the file is written with mode 0600.
|
||||
pub fn save(&self, identity: &str) -> Result<(), StorageError> {
|
||||
// Write atomically: temp file + rename. Avoids leaving a
|
||||
// half-written identity on the device after a crash or
|
||||
// power loss.
|
||||
let tmp = self.path.with_extension("tskey.tmp");
|
||||
{
|
||||
let mut f = open_private(&tmp)?;
|
||||
f.write_all(identity.trim().as_bytes())
|
||||
.map_err(|e| StorageError::Io(format!("write {tmp:?}: {e}")))?;
|
||||
f.write_all(b"\n")
|
||||
.map_err(|e| StorageError::Io(format!("write nl: {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");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove any persisted identity. No-op if none exists.
|
||||
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
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {}
|
||||
fn round_trip() {
|
||||
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() {
|
||||
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() {
|
||||
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}");
|
||||
}
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let p = std::env::temp_dir()
|
||||
.join("chanora_storage_test")
|
||||
.join(format!("{}", std::process::id()))
|
||||
.join(format!(
|
||||
"{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user