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
+51 -1
View File
@@ -49,6 +49,7 @@ pub use chanora_audio::{AudioEngine, AudioEngineConfig};
pub use chanora_protocol::{
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
};
pub use chanora_storage::IdentityFileStore;
/// Errors that can arise during top-level orchestration.
#[derive(Debug, Error)]
@@ -179,6 +180,11 @@ pub struct ChanoraSession {
/// `connectivity_plus` callbacks. Supervisor observes via
/// [`watch::Receiver`].
network_tx: watch::Sender<NetworkState>,
/// Beta identity persistence. Optional — when `None`, the
/// per-connect identity is whatever `ConnectConfig::identity`
/// carries (or a fresh ephemeral one if that is also `None`).
/// Wired by [`Self::init_storage`].
identity_store: Arc<Mutex<Option<IdentityFileStore>>>,
}
impl ChanoraSession {
@@ -190,9 +196,25 @@ impl ChanoraSession {
inner: Arc::new(Mutex::new(None)),
events_tx,
network_tx,
identity_store: Arc::new(Mutex::new(None)),
}
}
/// Wire a directory-backed identity store. Called by the bridge
/// during `bridge_init` once Flutter has resolved the platform
/// app-private storage directory. Subsequent [`Self::connect`]
/// calls will reuse the stored identity, or generate-and-store
/// one if none exists yet.
///
/// Beta caveat: the file is *not* encrypted at rest — see
/// `chanora_storage::IdentityFileStore` for the full gap notice.
pub async fn init_storage(&self, dir: impl AsRef<std::path::Path>) -> Result<(), CoreError> {
let store = IdentityFileStore::new(dir)?;
info!(target: "chanora_core", path = ?store.path(), "identity store initialised");
*self.identity_store.lock().await = Some(store);
Ok(())
}
/// Push an OS connectivity update. Called by the bridge when
/// `connectivity_plus` fires. Safe to call from any thread.
pub fn set_network_state(&self, state: NetworkState) {
@@ -220,11 +242,39 @@ impl ChanoraSession {
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
/// if a connection is already active (DEC-006). Audio is not
/// started automatically; call [`Self::start_audio`] after.
pub async fn connect(&self, cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
///
/// Identity policy (A.2): if `cfg.identity` is `Some(_)`, use it
/// verbatim. Otherwise, if [`Self::init_storage`] has been
/// called and the store already holds an identity, load and
/// reuse it. Otherwise generate a fresh identity and — if a
/// store is wired — persist it before dialling so the *same*
/// UID is presented on every subsequent connect from this
/// install.
pub async fn connect(&self, mut cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
let mut guard = self.inner.lock().await;
if guard.is_some() {
return Err(CoreError::AlreadyConnected);
}
// Resolve identity from the store before dialling.
if cfg.identity.is_none() {
let store_guard = self.identity_store.lock().await;
if let Some(store) = store_guard.as_ref() {
match store.load()? {
Some(saved) => {
info!(target: "chanora_core", "reusing persisted identity");
cfg.identity = Some(saved);
}
None => {
let fresh = chanora_protocol::ProtocolClient::generate_identity();
store.save(&fresh)?;
info!(target: "chanora_core", "generated + persisted fresh identity");
cfg.identity = Some(fresh);
}
}
}
}
let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?;
let snap = client.snapshot().await?;