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:
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user