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:
EdisonJwa
2026-05-15 01:25:07 +08:00
parent f52d702e27
commit 71ecb83781
12 changed files with 506 additions and 23 deletions
+22
View File
@@ -233,6 +233,28 @@ pub struct BridgeAudioStats {
pub ptt_active: bool,
}
// ---------- Storage (A.2) ----------
/// Wire the identity persistence store to a platform-private
/// directory. Should be called once on app start after Flutter has
/// resolved `getApplicationSupportDirectory()` (or equivalent).
///
/// Subsequent [`connect`] calls will reuse the persisted identity,
/// or generate-and-persist a fresh one on first use. This keeps the
/// server-visible UID stable across app restarts.
///
/// Beta caveat: the identity is stored as a plain file (mode 0600
/// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents
/// this gap; the v0.4 storage rework lands the proper Secret
/// Service + Android Keystore + iOS Keychain backends.
pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().init_storage(&dir).await })
.await
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
Ok(())
}
// ---------- Connectivity (A.6.1) ----------
/// Coarse OS-reported network state. Mirrors
+43 -6
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1212711005;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1702138901;
// Section: executor
@@ -223,6 +223,42 @@ fn wire__crate__api__events_stream_impl(
},
)
}
fn wire__crate__api__init_storage_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "init_storage",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_dir = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::init_storage(api_dir).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__is_connected_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -677,10 +713,11 @@ fn pde_ffi_dispatcher_primary_impl(
3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -693,7 +730,7 @@ fn pde_ffi_dispatcher_sync_impl(
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
7 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
8 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
+3
View File
@@ -87,6 +87,9 @@ impl From<chanora_core::CoreError> for BridgeError {
}) => BridgeError::DnsFailed { host, reason },
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
chanora_core::CoreError::Storage(s) => {
BridgeError::Connection(format!("storage: {s}"))
}
other => BridgeError::Unmapped(format!("{other}")),
}
}
+11
View File
@@ -130,6 +130,17 @@ impl SnapshotProbe {
}
impl ProtocolClient {
/// Generate a fresh, persistable TS3 identity string. The
/// returned value is the canonical `counter`V`base64key` form
/// accepted by [`ConnectConfig::identity`] and by tsclientlib's
/// `Identity::new_from_str`. Callers should persist it via
/// `chanora_storage` so subsequent connects reuse the same
/// identity and the server sees the same client UID.
pub fn generate_identity() -> String {
let id = Identity::create();
format!("{}V{}", id.counter(), id.key().to_ts())
}
/// Dial the server and wait for the initial state snapshot. The
/// returned client is ready for [`Self::snapshot`] and
/// [`Self::disconnect`] calls.
+184 -5
View File
@@ -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
}
}