crdt_sync_flutter 0.8.0: keystore key cache

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-12 09:44:19 +01:00
parent 84343aec2d
commit e5990c9c15
4 changed files with 88 additions and 9 deletions

View File

@ -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<void> 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<void> _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<void> 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<E2eState> openEncryptedStoreFromKeystore({
@ -26,9 +66,12 @@ Future<E2eState> 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,
);

View File

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

View File

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

View File

@ -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<E2eReady>());
}
expect(derived, 1);
await clearE2ePassphrase(storage: storage);
final locked = await openEncryptedStoreFromKeystore(
store: remote,
oldPrefix: _old,
params: _cheap,
);
expect(locked, isA<E2eLocked>());
});
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<E2eReady>());
});
}