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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
Krzysztof Rudnicki 2026-08-04 23:01:54 +02:00
parent dd6e6d5571
commit 6c9cc71ae1
4 changed files with 217 additions and 1 deletions

View File

@ -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';

View File

@ -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<String?> Function(String key);
/// Writes a secret under a key.
typedef SecretWriter = Future<void> Function(String key, String value);
/// Deletes a secret by key.
typedef SecretDeleter = Future<void> 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<FirebaseCredentials?> 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<String, dynamic>) return null;
return FirebaseCredentials.fromJson(decoded);
} on FormatException {
return null;
} on TypeError {
return null;
}
}
@override
Future<void> save(FirebaseCredentials credentials) =>
write(key, jsonEncode(credentials.toJson()));
@override
Future<void> clear() => delete(key);
}

View File

@ -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:

View File

@ -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<String, String> values = {};
bool readThrows = false;
Future<String?> read(String key) async {
if (readThrows) throw const FormatException('keystore unavailable');
return values[key];
}
Future<void> write(String key, String value) async => values[key] = value;
Future<void> 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);
});
}