From cc09e56cd272a907080a3b124e52fd0b286a12c3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 12 Nov 2026 08:20:13 +0100 Subject: [PATCH] crdt_sync_flutter 0.6.0: openEncryptedStore Todo: fda71ed1 Co-Authored-By: Claude Sonnet 5.5 --- crdt_sync_flutter/lib/crdt_sync_flutter.dart | 1 + crdt_sync_flutter/lib/src/account_store.dart | 4 +- crdt_sync_flutter/lib/src/e2e_bootstrap.dart | 76 +++++++++++ crdt_sync_flutter/pubspec.lock | 14 +- crdt_sync_flutter/pubspec.yaml | 4 +- .../test/account_store_test.dart | 4 +- .../test/e2e_bootstrap_test.dart | 126 ++++++++++++++++++ 7 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 crdt_sync_flutter/lib/src/e2e_bootstrap.dart create mode 100644 crdt_sync_flutter/test/e2e_bootstrap_test.dart diff --git a/crdt_sync_flutter/lib/crdt_sync_flutter.dart b/crdt_sync_flutter/lib/crdt_sync_flutter.dart index d160ee9..7d6850e 100644 --- a/crdt_sync_flutter/lib/crdt_sync_flutter.dart +++ b/crdt_sync_flutter/lib/crdt_sync_flutter.dart @@ -14,6 +14,7 @@ library; export 'src/account_store.dart'; export 'src/bootstrap.dart'; export 'src/device_id.dart'; +export 'src/e2e_bootstrap.dart'; export 'src/google_any_sign_in.dart'; export 'src/google_desktop_sign_in.dart'; export 'src/google_pkce.dart'; diff --git a/crdt_sync_flutter/lib/src/account_store.dart b/crdt_sync_flutter/lib/src/account_store.dart index a4074b6..d39a866 100644 --- a/crdt_sync_flutter/lib/src/account_store.dart +++ b/crdt_sync_flutter/lib/src/account_store.dart @@ -108,9 +108,7 @@ Future _adoptSeededSession( }) async { final httpClient = client ?? http.Client(); try { - final response = await httpClient.get( - base.resolve(routes.credentialsPath), - ); + final response = await httpClient.get(base.resolve(routes.credentialsPath)); if (response.statusCode != 200) return null; final body = jsonDecode(utf8.decode(response.bodyBytes)); if (body is! Map) return null; diff --git a/crdt_sync_flutter/lib/src/e2e_bootstrap.dart b/crdt_sync_flutter/lib/src/e2e_bootstrap.dart new file mode 100644 index 0000000..e125d05 --- /dev/null +++ b/crdt_sync_flutter/lib/src/e2e_bootstrap.dart @@ -0,0 +1,76 @@ +/// End-to-end encryption for an app's sync prefix, keyed by the shared +/// crdt-sync passphrase kept in the OS keystore (prompt-free after setup). +library; + +import 'dart:developer'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync_flutter/src/keystore.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Keystore key holding the shared crdt-sync passphrase. +const String kE2ePassphraseKey = 'crdt_sync_e2e_passphrase'; + +/// Stores the passphrase this device derives its key from. +Future saveE2ePassphrase( + String passphrase, { + FlutterSecureStorage storage = kSecureStorage, +}) => storage.write(key: kE2ePassphraseKey, value: passphrase); + +/// Forgets the passphrase; the next open is locked. +Future clearE2ePassphrase({ + FlutterSecureStorage storage = kSecureStorage, +}) => storage.delete(key: kE2ePassphraseKey); + +/// Opens the encrypted store for [oldPrefix] (the app's former plaintext +/// prefix; data lives on `E2eLayout.of(oldPrefix)`). +/// +/// Fail closed: no passphrase, a wrong one, or an unreadable header returns +/// [E2eLocked] and the caller must not sync (show an icon-only lock state). +/// On success the old plaintext prefix is copied over once per call, which is +/// idempotent and picks up writes from builds not yet migrated; the old +/// prefix is never deleted here. +Future openEncryptedStore({ + required RemoteStore store, + required String oldPrefix, + FlutterSecureStorage storage = kSecureStorage, + Argon2Params params = Argon2Params.recommended, +}) async { + final passphrase = await storage.read(key: kE2ePassphraseKey); + if (passphrase == null || passphrase.isEmpty) { + return E2eLocked(E2eLockReason.needsPassphrase); + } + final layout = E2eLayout.of(oldPrefix); + final E2eKey key; + try { + key = await unlockE2e( + store: store, + pathPrefix: layout.prefix, + passphrase: passphrase, + params: params, + e2ePath: layout.e2ePath, + ); + } on E2eWrongPassphraseError { + return E2eLocked(E2eLockReason.wrongKey); + } on E2eHeaderError { + return E2eLocked(E2eLockReason.badHeader); + } + try { + await copyToEncrypted( + store: store, + key: key, + fromPrefix: oldPrefix, + layout: layout, + ); + } on Object catch (error, stackTrace) { + // The copy is retried next open; sync itself must not stop for it. + log('e2e copy failed', level: 900, error: error, stackTrace: stackTrace); + } + return openE2e( + store: store, + pathPrefix: layout.prefix, + key: key, + e2ePath: layout.e2ePath, + revsPath: layout.revsPath, + ); +} diff --git a/crdt_sync_flutter/pubspec.lock b/crdt_sync_flutter/pubspec.lock index 7f39ed2..4d32d60 100644 --- a/crdt_sync_flutter/pubspec.lock +++ b/crdt_sync_flutter/pubspec.lock @@ -61,11 +61,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.12.0" - resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3" + ref: "crdt_sync_dart-v0.13.0" + resolved-ref: "51e562a76460eb25202a92644b2f1d202dba8dc5" url: "https://github.com/kuhyx/utils" source: git - version: "0.12.0" + version: "0.13.0" crypto: dependency: "direct main" description: @@ -74,6 +74,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.8" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" fake_async: dependency: transitive description: diff --git a/crdt_sync_flutter/pubspec.yaml b/crdt_sync_flutter/pubspec.yaml index ecff8b1..ba05931 100644 --- a/crdt_sync_flutter/pubspec.yaml +++ b/crdt_sync_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: >- persisted per-install device id, and a one-call bootstrap. Removes the ~150 lines of firebase_backend.dart every consuming app was copying. publish_to: 'none' -version: 0.5.1 +version: 0.6.0 environment: sdk: ^3.12.2 @@ -13,7 +13,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.12.1 + ref: crdt_sync_dart-v0.13.0 path: crdt_sync_dart crypto: 3.0.8 flutter: diff --git a/crdt_sync_flutter/test/account_store_test.dart b/crdt_sync_flutter/test/account_store_test.dart index cca9fba..0305af2 100644 --- a/crdt_sync_flutter/test/account_store_test.dart +++ b/crdt_sync_flutter/test/account_store_test.dart @@ -20,9 +20,7 @@ void main() { group('loadAccount', () { test('returns the stored account without touching the wrapper', () async { const stored = FirebaseAccount(email: 'a@b.c', password: 'pw'); - installFakeSecureStorage( - initial: {kAccountKey: stored.toJsonString()}, - ); + installFakeSecureStorage(initial: {kAccountKey: stored.toJsonString()}); var requests = 0; final account = await loadAccount( routes: routes, diff --git a/crdt_sync_flutter/test/e2e_bootstrap_test.dart b/crdt_sync_flutter/test/e2e_bootstrap_test.dart new file mode 100644 index 0000000..56fe4eb --- /dev/null +++ b/crdt_sync_flutter/test/e2e_bootstrap_test.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _old = 'todo-sync/notes'; +const _cheap = Argon2Params(memoryKiB: 8, iterations: 1, parallelism: 1); + +class _Remote implements RemoteStore { + final files = {}; + @override + Future> listDirectory(String path) async => files.keys + .where((k) => k.startsWith('$path/')) + .map((k) => k.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + @override + Future getFileText(String path) async => files[path]; + @override + Future putFileText( + String path, + String text, { + required String message, + }) async => files[path] = text; + @override + Future deleteFile(String path, {String message = ''}) async => + files.remove(path); + @override + Future canAccessRemote() async => true; + @override + void close() {} +} + +String _log(String title) => jsonEncode({ + 'n1': { + 'id': 'n1', + 'fields': { + 'title': [title, '2026-01-01T00:00:00.000Z-0000-a'], + }, + 'deleted': false, + 'deleted_hlc': null, + }, +}); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const storage = FlutterSecureStorage(); + + setUp(installFakeSecureStorage); + + test('no passphrase on the device is locked, never plaintext', () async { + final state = await openEncryptedStore( + store: _Remote(), + oldPrefix: _old, + params: _cheap, + ); + expect(state, isA()); + expect((state as E2eLocked).reason, E2eLockReason.needsPassphrase); + }); + + test('right passphrase unlocks and copies old plaintext once', () async { + final remote = _Remote()..files['$_old/phone/log.json'] = _log('milk'); + await saveE2ePassphrase('pw', storage: storage); + final state = await openEncryptedStore( + store: remote, + oldPrefix: _old, + params: _cheap, + ); + expect(state, isA()); + final layout = E2eLayout.of(_old); + expect( + remote.files['${layout.prefix}/phone/log.json'], + isNot(contains('milk')), + ); + expect(remote.files['$_old/phone/log.json'], contains('milk')); + final again = await openEncryptedStore( + store: remote, + oldPrefix: _old, + params: _cheap, + ); + expect(again, isA()); + }); + + test('second device with the same passphrase reads the data', () async { + final remote = _Remote(); + await saveE2ePassphrase('pw', storage: storage); + final first = + await openEncryptedStore(store: remote, oldPrefix: _old, params: _cheap) + as E2eReady; + final layout = E2eLayout.of(_old); + await first.store.putFileText( + '${layout.prefix}/phone/log.json', + _log('tea'), + message: 'm', + ); + final second = + await openEncryptedStore(store: remote, oldPrefix: _old, params: _cheap) + as E2eReady; + final text = await second.store.getFileText( + '${layout.prefix}/phone/log.json', + ); + expect(text, contains('tea')); + }); + + test('wrong passphrase is locked and merges nothing', () async { + final remote = _Remote(); + await saveE2ePassphrase('pw', storage: storage); + await openEncryptedStore(store: remote, oldPrefix: _old, params: _cheap); + await saveE2ePassphrase('nope', storage: storage); + final state = await openEncryptedStore( + store: remote, + oldPrefix: _old, + params: _cheap, + ); + expect((state as E2eLocked).reason, E2eLockReason.wrongKey); + }); + + test('clearing the passphrase forgets it', () async { + await saveE2ePassphrase('pw', storage: storage); + await clearE2ePassphrase(storage: storage); + expect(await storage.read(key: kE2ePassphraseKey), isNull); + }); +}