From eebeb0b51053f160f163140141aba10a447ba1e8 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 12 Nov 2026 08:21:06 +0100 Subject: [PATCH] crdt_sync_dart 0.13.1: openEncryptedStore Todo: fda71ed1 Co-Authored-By: Claude Sonnet 5.5 --- crdt_sync_dart/lib/crdt_sync.dart | 1 + crdt_sync_dart/lib/src/e2e_open.dart | 64 ++++++++++++++++++++++++++ crdt_sync_dart/pubspec.yaml | 2 +- crdt_sync_dart/test/e2e_open_test.dart | 63 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 crdt_sync_dart/lib/src/e2e_open.dart create mode 100644 crdt_sync_dart/test/e2e_open_test.dart diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index 961694f..3c62291 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -7,6 +7,7 @@ export 'src/e2e_cipher.dart'; export 'src/e2e_envelope.dart'; export 'src/e2e_key.dart'; export 'src/e2e_migration.dart'; +export 'src/e2e_open.dart'; export 'src/encrypting_store.dart'; export 'src/firebase_account_provisioning.dart'; export 'src/firebase_auth_error.dart'; diff --git a/crdt_sync_dart/lib/src/e2e_open.dart b/crdt_sync_dart/lib/src/e2e_open.dart new file mode 100644 index 0000000..992cb7d --- /dev/null +++ b/crdt_sync_dart/lib/src/e2e_open.dart @@ -0,0 +1,64 @@ +/// Opens the encrypted store for an app whose old plaintext prefix is +/// [oldPrefix]; data lives on `E2eLayout.of(oldPrefix)`. +/// +/// [readPassphrase] supplies the shared crdt-sync passphrase from the OS +/// keystore, so the open is prompt-free. Fail closed: a missing or wrong +/// passphrase, or an unreadable header, returns [E2eLocked] and the caller +/// must not sync. On success the old plaintext prefix is copied over once +/// per call (idempotent; picks up writes from builds not yet migrated) and +/// is never deleted here. +library; + +import 'dart:developer'; + +import 'e2e_key.dart'; +import 'e2e_migration.dart'; +import 'remote_store.dart'; + +/// Keystore entry name apps store the shared passphrase under. +const String kE2ePassphraseStorageKey = 'crdt_sync_e2e_passphrase'; + +Future openEncryptedStore({ + required RemoteStore store, + required String oldPrefix, + required Future Function() readPassphrase, + Argon2Params params = Argon2Params.recommended, +}) async { + final passphrase = await readPassphrase(); + 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 await openE2e( + store: store, + pathPrefix: layout.prefix, + key: key, + e2ePath: layout.e2ePath, + revsPath: layout.revsPath, + ); +} diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 13b58b9..f31a5f5 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.13.0 +version: 0.13.1 publish_to: 'none' environment: diff --git a/crdt_sync_dart/test/e2e_open_test.dart b/crdt_sync_dart/test/e2e_open_test.dart new file mode 100644 index 0000000..b5046b5 --- /dev/null +++ b/crdt_sync_dart/test/e2e_open_test.dart @@ -0,0 +1,63 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +import 'e2e_fakes.dart'; + +const _old = 'app/notes'; + +Future _open(MemoryRemote r, String? pass) => openEncryptedStore( + store: r, + oldPrefix: _old, + readPassphrase: () async => pass, + params: cheapArgon, +); + +void main() { + test('no passphrase is locked', () async { + for (final p in [null, '']) { + final s = await _open(MemoryRemote(), p); + expect((s as E2eLocked).reason, E2eLockReason.needsPassphrase); + } + }); + + test('right passphrase copies and is ready; wrong one is locked', () async { + final r = MemoryRemote({ + '$_old/phone/log.json': encodeLog(logOf({'a': 'milk'})), + }); + expect(await _open(r, 'pw'), isA()); + expect(r.files['app/notes-e2e/phone/log.json'], isNot(contains('milk'))); + expect(await _open(r, 'pw'), isA()); + expect( + ((await _open(r, 'bad')) as E2eLocked).reason, + E2eLockReason.wrongKey, + ); + }); + + test('a corrupt header locks as badHeader', () async { + final r = MemoryRemote({'app/e2e': 'garbage'}); + expect( + ((await _open(r, 'pw')) as E2eLocked).reason, + E2eLockReason.badHeader, + ); + }); + + test('a failing copy does not stop the open', () async { + final r = MemoryRemote(); + r.files['$_old/phone/log.json'] = encodeLog(logOf({'a': 'x'})); + final failing = _FailingList(r); + final s = await openEncryptedStore( + store: failing, + oldPrefix: _old, + readPassphrase: () async => 'pw', + params: cheapArgon, + ); + expect(s, isA()); + }); +} + +class _FailingList extends PlainRemote { + _FailingList(super.inner); + @override + Future> listDirectory(String path) => + throw StateError('list down'); +}