diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index a07fd78..d41854b 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; import 'l10n/generated/app_localizations.dart'; import 'src/rust/api.dart' as rust; @@ -21,14 +22,27 @@ import 'src/rust/frb_generated.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); - // Push the OS-reported connectivity state into the core supervisor. - // The supervisor uses this to short-circuit reconnect backoff when - // the network comes back, and to pre-charge the loss watchdog when - // the OS already knows we're offline (A.6.1). + // A.2 — wire identity persistence to the platform's app-private + // directory so the same TS3 UID is presented on every restart. + // Beta: stored as a plain 0600 file (RISK-PoC-002). + unawaited(_wireStorage()); + // A.6.1 — push the OS-reported connectivity state into the core + // supervisor so reconnects redial promptly when the network + // returns. unawaited(_wireConnectivity()); runApp(const ChanoraApp()); } +Future _wireStorage() async { + try { + final dir = await getApplicationSupportDirectory(); + await rust.initStorage(dir: dir.path); + } catch (_) { + // Storage is best-effort — on failure the app still works but + // each session gets a fresh ephemeral identity. + } +} + /// Map `connectivity_plus`' list-of-results to our coarse tri-state. /// We consider the device "Online" if any of the reported transports /// is non-`none`. This is intentionally permissive — the supervisor's diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index e605985..8a8c1ea 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -37,6 +37,21 @@ Future startAudio() => RustLib.instance.api.crateApiStartAudio(); Future setPtt({required bool active}) => RustLib.instance.api.crateApiSetPtt(active: active); +/// 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. +Future initStorage({required String dir}) => + RustLib.instance.api.crateApiInitStorage(dir: dir); + /// Notify the core of the latest OS-reported connectivity state. /// Called by the Flutter side from `connectivity_plus` callbacks. /// The core's supervisor uses this to (a) pre-charge the watchdog diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 0fb2f0a..f9cd951 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1212711005; + int get rustContentHash => 1702138901; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -92,6 +92,8 @@ abstract class RustLibApi extends BaseApi { Stream crateApiEventsStream(); + Future crateApiInitStorage({required String dir}); + Future crateApiIsConnected(); void crateApiSetNetworkState({required BridgeNetworkState state}); @@ -256,6 +258,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiEventsStreamConstMeta => const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]); + @override + Future crateApiInitStorage({required String dir}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(dir, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 6, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiInitStorageConstMeta, + argValues: [dir], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiInitStorageConstMeta => + const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]); + @override Future crateApiIsConnected() { return handler.executeNormal( @@ -265,7 +295,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 6, + funcId: 7, port: port_, ); }, @@ -290,7 +320,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -316,7 +346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 8, + funcId: 9, port: port_, ); }, @@ -343,7 +373,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 9, + funcId: 10, port: port_, ); }, @@ -370,7 +400,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 11, port: port_, ); }, diff --git a/apps/chanora_flutter/linux/flutter/generated_plugins.cmake b/apps/chanora_flutter/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/apps/chanora_flutter/linux/flutter/generated_plugins.cmake +++ b/apps/chanora_flutter/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock index 3b8043c..7739c6d 100644 --- a/apps/chanora_flutter/pubspec.lock +++ b/apps/chanora_flutter/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -285,6 +293,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" http_multi_server: dependency: transitive description: @@ -317,6 +333,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -397,6 +429,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -405,6 +445,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -421,6 +469,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -429,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -461,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shelf: dependency: transitive description: @@ -602,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -620,4 +740,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.5 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index dd7e8bb..40c8b60 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: flutter_rust_bridge: 2.12.0 freezed_annotation: ^3.1.0 connectivity_plus: ^6.1.0 + path_provider: ^2.1.4 dev_dependencies: flutter_test: diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index f5d2d98..66e0029 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -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, + /// 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>>, } 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) -> 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 { + /// + /// 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 { 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?; diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index bf2bb4b..cbb1880 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -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 diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index f55de7d..4869136 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -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::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 = ::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!(), } } diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index f031290..0250141 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -87,6 +87,9 @@ impl From 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}")), } } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index fb78cfc..9fe341c 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -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. diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 95932da..77295b6 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -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 +//! `/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) -> Result { + 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, 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 { + 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 { + // 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 + } }