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
+18 -4
View File
@@ -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<void> 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<void> _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
@@ -37,6 +37,21 @@ Future<void> startAudio() => RustLib.instance.api.crateApiStartAudio();
Future<void> 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<void> 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
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<BridgeEvent> crateApiEventsStream();
Future<void> crateApiInitStorage({required String dir});
Future<bool> 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<void> 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<bool> 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_,
);
},
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
+121 -1
View File
@@ -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"
+1
View File
@@ -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: