From 6c9cc71ae18f98bc659a97de14b630839bc643e4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 4 Aug 2026 23:01:54 +0200 Subject: [PATCH] crdt_sync_dart 0.5.1: a shared secure credential store Every Flutter consumer already keeps its GitHub token in flutter_secure_storage, which is where a Firebase refresh token belongs too: it is the long-lived secret, while ID tokens expire in about an hour. Takes read/write/delete closures rather than depending on flutter_secure_storage directly -- this package is pure Dart, and a Flutter plugin dependency would make it unusable from the command-line tools and from plain `dart test`. The adapter is two lines at each call site. Keyed at `firebase.credentials`, namespaced away from each app's `sync.token` so both secrets coexist for the whole mirrored cutover. Every load failure degrades to "not signed in" rather than throwing: an app that threw here would be permanently unable to sync, with no way back short of a reinstall. 10 tests, 100% line coverage; 228 in the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc --- crdt_sync_dart/lib/crdt_sync.dart | 1 + .../lib/src/secure_credential_store.dart | 98 +++++++++++++++ crdt_sync_dart/pubspec.yaml | 2 +- .../test/secure_credential_store_test.dart | 117 ++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 crdt_sync_dart/lib/src/secure_credential_store.dart create mode 100644 crdt_sync_dart/test/secure_credential_store_test.dart diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index eb4cbbb..8bd2999 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -10,5 +10,6 @@ export 'src/log.dart'; export 'src/mirror_store.dart'; export 'src/record.dart'; export 'src/remote_store.dart'; +export 'src/secure_credential_store.dart'; export 'src/store.dart'; export 'src/sync.dart'; diff --git a/crdt_sync_dart/lib/src/secure_credential_store.dart b/crdt_sync_dart/lib/src/secure_credential_store.dart new file mode 100644 index 0000000..9a4fc13 --- /dev/null +++ b/crdt_sync_dart/lib/src/secure_credential_store.dart @@ -0,0 +1,98 @@ +/// A [FirebaseCredentialStore] backed by an injected key-value secret store. +/// +/// Every Flutter consumer of this package already keeps its GitHub token in +/// `flutter_secure_storage` (Android Keystore / libsecret), which is exactly +/// where a Firebase refresh token belongs too: it is the long-lived secret, +/// while ID tokens expire in about an hour. +/// +/// This library deliberately does **not** depend on `flutter_secure_storage` +/// itself -- it is a pure Dart package, and pulling in a Flutter plugin would +/// make it unusable from the command-line tools and from plain `dart test`. +/// The app passes read/write/delete closures instead, which is a two-line +/// adapter at the call site and keeps this file testable with no plugin and +/// no platform channel. +library; + +import 'dart:convert'; + +import 'firebase_auth_rest.dart'; + +/// Reads a secret by key, returning null when absent. +typedef SecretReader = Future Function(String key); + +/// Writes a secret under a key. +typedef SecretWriter = Future Function(String key, String value); + +/// Deletes a secret by key. +typedef SecretDeleter = Future Function(String key); + +/// Persists Firebase credentials through a platform secret store. +/// +/// Usage from a Flutter app, where `_secure` is a `FlutterSecureStorage`: +/// +/// ```dart +/// SecureCredentialStore( +/// read: (k) => _secure.read(key: k), +/// write: (k, v) => _secure.write(key: k, value: v), +/// delete: (k) => _secure.delete(key: k), +/// ) +/// ``` +class SecureCredentialStore implements FirebaseCredentialStore { + SecureCredentialStore({ + required this.read, + required this.write, + required this.delete, + this.key = defaultKey, + }); + + /// Where the credentials live in the secret store. + /// + /// Namespaced away from each app's `sync.token` (its GitHub PAT) so the two + /// can coexist for the whole mirrored cutover period. + static const defaultKey = 'firebase.credentials'; + + /// Reads the credentials blob from the platform secret store. + final SecretReader read; + + /// Writes the credentials blob to the platform secret store. + final SecretWriter write; + + /// Removes the credentials blob from the platform secret store. + final SecretDeleter delete; + + /// The secret-store key used for the credentials blob. + final String key; + + /// Returns the stored credentials, or null if absent or unreadable. + /// + /// A corrupt or half-written value reads as "not signed in" rather than + /// throwing: the caller's next step is to sign in again, which repairs it. + /// Failing here instead would leave an app permanently unable to sync with + /// no way back short of a reinstall. + @override + Future load() async { + final String? raw; + try { + raw = await read(key); + } on Exception { + return null; + } + if (raw == null || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return FirebaseCredentials.fromJson(decoded); + } on FormatException { + return null; + } on TypeError { + return null; + } + } + + @override + Future save(FirebaseCredentials credentials) => + write(key, jsonEncode(credentials.toJson())); + + @override + Future clear() => delete(key); +} diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 365ad60..61f1881 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -2,7 +2,7 @@ name: crdt_sync description: >- Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps. Pure Dart, no Flutter dependency. -version: 0.5.0 +version: 0.5.1 publish_to: 'none' environment: diff --git a/crdt_sync_dart/test/secure_credential_store_test.dart b/crdt_sync_dart/test/secure_credential_store_test.dart new file mode 100644 index 0000000..67c97a8 --- /dev/null +++ b/crdt_sync_dart/test/secure_credential_store_test.dart @@ -0,0 +1,117 @@ +/// Tests for the platform-secret-backed credential store. +/// +/// The load path degrades to "not signed in" on every failure rather than +/// throwing: an app that threw here would be permanently unable to sync, with +/// no way back short of a reinstall. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +/// An in-memory stand-in for `flutter_secure_storage`. +class _FakeSecrets { + final Map values = {}; + bool readThrows = false; + + Future read(String key) async { + if (readThrows) throw const FormatException('keystore unavailable'); + return values[key]; + } + + Future write(String key, String value) async => values[key] = value; + + Future delete(String key) async => values.remove(key); +} + +SecureCredentialStore _store(_FakeSecrets secrets, {String? key}) => + SecureCredentialStore( + read: secrets.read, + write: secrets.write, + delete: secrets.delete, + key: key ?? SecureCredentialStore.defaultKey, + ); + +FirebaseCredentials _creds() => FirebaseCredentials( + idToken: 'id', + refreshToken: 'refresh', + expiresAt: DateTime.utc(2026, 8, 5, 7, 45), +); + +void main() { + test('round-trips credentials', () async { + final secrets = _FakeSecrets(); + + await _store(secrets).save(_creds()); + final loaded = await _store(secrets).load(); + + expect(loaded, isNotNull); + expect(loaded!.refreshToken, 'refresh'); + expect(loaded.idToken, 'id'); + expect(loaded.expiresAt, DateTime.utc(2026, 8, 5, 7, 45)); + }); + + test('does not collide with the app GitHub token key', () async { + // Both secrets coexist for the whole mirrored cutover period. + final secrets = _FakeSecrets(); + secrets.values['sync.token'] = 'github-pat'; + + await _store(secrets).save(_creds()); + + expect(secrets.values['sync.token'], 'github-pat'); + expect(secrets.values.containsKey('firebase.credentials'), isTrue); + }); + + test('honours a custom key', () async { + final secrets = _FakeSecrets(); + + await _store(secrets, key: 'other').save(_creds()); + + expect(secrets.values.containsKey('other'), isTrue); + }); + + test('reports absent credentials as not signed in', () async { + expect(await _store(_FakeSecrets()).load(), isNull); + }); + + test('treats an empty value as not signed in', () async { + final secrets = _FakeSecrets()..values['firebase.credentials'] = ''; + + expect(await _store(secrets).load(), isNull); + }); + + test('treats corrupt JSON as not signed in', () async { + final secrets = _FakeSecrets()..values['firebase.credentials'] = '{oops'; + + expect(await _store(secrets).load(), isNull); + }); + + test('treats a non-object value as not signed in', () async { + final secrets = _FakeSecrets()..values['firebase.credentials'] = '[1,2]'; + + expect(await _store(secrets).load(), isNull); + }); + + test('treats a wrong-shaped object as not signed in', () async { + final secrets = _FakeSecrets() + ..values['firebase.credentials'] = jsonEncode({'id_token': 'only'}); + + expect(await _store(secrets).load(), isNull); + }); + + test('treats an unreadable keystore as not signed in', () async { + final secrets = _FakeSecrets()..readThrows = true; + + expect(await _store(secrets).load(), isNull); + }); + + test('clear removes the credentials', () async { + final secrets = _FakeSecrets(); + await _store(secrets).save(_creds()); + + await _store(secrets).clear(); + + expect(secrets.values, isEmpty); + }); +}