From e5990c9c15761934110f955529bc1d68479b06ff Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 12 Nov 2026 09:44:19 +0100 Subject: [PATCH] crdt_sync_flutter 0.8.0: keystore key cache Todo: fda71ed1 Co-Authored-By: Claude Opus 5.5 --- crdt_sync_flutter/lib/src/e2e_bootstrap.dart | 51 +++++++++++++++++-- crdt_sync_flutter/pubspec.lock | 6 +-- crdt_sync_flutter/pubspec.yaml | 4 +- .../test/e2e_bootstrap_test.dart | 36 +++++++++++++ 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/crdt_sync_flutter/lib/src/e2e_bootstrap.dart b/crdt_sync_flutter/lib/src/e2e_bootstrap.dart index 2ef1a04..1a60721 100644 --- a/crdt_sync_flutter/lib/src/e2e_bootstrap.dart +++ b/crdt_sync_flutter/lib/src/e2e_bootstrap.dart @@ -2,6 +2,8 @@ /// crdt-sync passphrase kept in the OS keystore (prompt-free after setup). library; +import 'dart:convert'; + import 'package:crdt_sync/crdt_sync.dart'; import 'package:crdt_sync_flutter/src/keystore.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -9,16 +11,54 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; /// Keystore key holding the shared crdt-sync passphrase. const String kE2ePassphraseKey = kE2ePassphraseStorageKey; -/// Stores the passphrase this device derives its key from. +/// Stores the passphrase this device derives its key from, dropping keys +/// cached from an earlier one so the new passphrase is what gets checked. Future saveE2ePassphrase( String passphrase, { FlutterSecureStorage storage = kSecureStorage, -}) => storage.write(key: kE2ePassphraseKey, value: passphrase); +}) async { + await _dropCachedKeys(storage); + await storage.write(key: kE2ePassphraseKey, value: passphrase); +} -/// Forgets the passphrase; the next open is locked. +Future _dropCachedKeys(FlutterSecureStorage storage) async { + final all = await storage.readAll(); + for (final key in all.keys) { + if (key.startsWith(kE2eKeyCachePrefix)) await storage.delete(key: key); + } +} + +/// Keystore key prefix for derived keys, one per old prefix. +const String kE2eKeyCachePrefix = 'crdt_sync_e2e_key:'; + +/// Forgets the passphrase and every cached key; the next open is locked. Future clearE2ePassphrase({ FlutterSecureStorage storage = kSecureStorage, -}) => storage.delete(key: kE2ePassphraseKey); +}) async { + await storage.delete(key: kE2ePassphraseKey); + await _dropCachedKeys(storage); +} + +/// The derived key for [oldPrefix], cached in the OS keystore so Argon2id +/// runs once per device instead of on every sync tick. +E2eKeyCache keystoreKeyCache( + String oldPrefix, { + FlutterSecureStorage storage = kSecureStorage, +}) { + final name = '$kE2eKeyCachePrefix$oldPrefix'; + return E2eKeyCache( + read: () async { + final text = await storage.read(key: name); + if (text == null) return null; + try { + return base64.decode(text); + } on FormatException { + return null; + } + }, + write: (bytes) => storage.write(key: name, value: base64.encode(bytes)), + ); +} /// [openEncryptedStore] reading the passphrase from the OS keystore. Future openEncryptedStoreFromKeystore({ @@ -26,9 +66,12 @@ Future openEncryptedStoreFromKeystore({ required String oldPrefix, FlutterSecureStorage storage = kSecureStorage, Argon2Params params = Argon2Params.recommended, + void Function()? onDerive, }) => openEncryptedStore( store: store, oldPrefix: oldPrefix, readPassphrase: () => storage.read(key: kE2ePassphraseKey), params: params, + keyCache: keystoreKeyCache(oldPrefix, storage: storage), + onDerive: onDerive, ); diff --git a/crdt_sync_flutter/pubspec.lock b/crdt_sync_flutter/pubspec.lock index e3294fb..8dae0ca 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.13.1" - resolved-ref: eebeb0b51053f160f163140141aba10a447ba1e8 + ref: "crdt_sync_dart-v0.14.0" + resolved-ref: "84343aec2dd07c38cba40690c3b5f2c84ae9cdf6" url: "https://github.com/kuhyx/utils" source: git - version: "0.13.1" + version: "0.14.0" crypto: dependency: "direct main" description: diff --git a/crdt_sync_flutter/pubspec.yaml b/crdt_sync_flutter/pubspec.yaml index 483a9a9..a4b250d 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.7.0 +version: 0.8.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.13.1 + ref: crdt_sync_dart-v0.14.0 path: crdt_sync_dart crypto: 3.0.8 flutter: diff --git a/crdt_sync_flutter/test/e2e_bootstrap_test.dart b/crdt_sync_flutter/test/e2e_bootstrap_test.dart index 6c9a185..70fb662 100644 --- a/crdt_sync_flutter/test/e2e_bootstrap_test.dart +++ b/crdt_sync_flutter/test/e2e_bootstrap_test.dart @@ -135,4 +135,40 @@ void main() { await clearE2ePassphrase(storage: storage); expect(await storage.read(key: kE2ePassphraseKey), isNull); }); + + test('the derived key is cached: Argon2 runs once per device', () async { + final remote = _Remote(); + await saveE2ePassphrase('pw', storage: storage); + var derived = 0; + for (var i = 0; i < 3; i++) { + resetE2eCopyMemo(); + final state = await openEncryptedStoreFromKeystore( + store: remote, + oldPrefix: _old, + params: _cheap, + onDerive: () => derived++, + ); + expect(state, isA()); + } + expect(derived, 1); + await clearE2ePassphrase(storage: storage); + final locked = await openEncryptedStoreFromKeystore( + store: remote, + oldPrefix: _old, + params: _cheap, + ); + expect(locked, isA()); + }); + + test('a corrupt cached key falls back to the passphrase', () async { + final remote = _Remote(); + await saveE2ePassphrase('pw', storage: storage); + await storage.write(key: '${kE2eKeyCachePrefix}$_old', value: '%%%'); + final state = await openEncryptedStoreFromKeystore( + store: remote, + oldPrefix: _old, + params: _cheap, + ); + expect(state, isA()); + }); }