crdt_sync_flutter 0.6.1: keystore wrapper over dart open

Todo: fda71ed1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-12 08:21:26 +01:00
parent eebeb0b510
commit 33791da6ff
4 changed files with 21 additions and 63 deletions

View File

@ -2,14 +2,12 @@
/// 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';
const String kE2ePassphraseKey = kE2ePassphraseStorageKey;
/// Stores the passphrase this device derives its key from.
Future<void> saveE2ePassphrase(
@ -22,55 +20,15 @@ Future<void> 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<E2eState> openEncryptedStore({
/// [openEncryptedStore] reading the passphrase from the OS keystore.
Future<E2eState> openEncryptedStoreFromKeystore({
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 await openE2e(
store: store,
pathPrefix: layout.prefix,
key: key,
e2ePath: layout.e2ePath,
revsPath: layout.revsPath,
);
}
}) => openEncryptedStore(
store: store,
oldPrefix: oldPrefix,
readPassphrase: () => storage.read(key: kE2ePassphraseKey),
params: params,
);

View File

@ -61,11 +61,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.13.0"
resolved-ref: "51e562a76460eb25202a92644b2f1d202dba8dc5"
ref: "crdt_sync_dart-v0.13.1"
resolved-ref: eebeb0b51053f160f163140141aba10a447ba1e8
url: "https://github.com/kuhyx/utils"
source: git
version: "0.13.0"
version: "0.13.1"
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.6.0
version: 0.6.1
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.0
ref: crdt_sync_dart-v0.13.1
path: crdt_sync_dart
crypto: 3.0.8
flutter:

View File

@ -52,7 +52,7 @@ void main() {
setUp(installFakeSecureStorage);
test('no passphrase on the device is locked, never plaintext', () async {
final state = await openEncryptedStore(
final state = await openEncryptedStoreFromKeystore(
store: _Remote(),
oldPrefix: _old,
params: _cheap,
@ -64,7 +64,7 @@ void main() {
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(
final state = await openEncryptedStoreFromKeystore(
store: remote,
oldPrefix: _old,
params: _cheap,
@ -76,7 +76,7 @@ void main() {
isNot(contains('milk')),
);
expect(remote.files['$_old/phone/log.json'], contains('milk'));
final again = await openEncryptedStore(
final again = await openEncryptedStoreFromKeystore(
store: remote,
oldPrefix: _old,
params: _cheap,
@ -88,7 +88,7 @@ void main() {
final remote = _Remote();
await saveE2ePassphrase('pw', storage: storage);
final first =
await openEncryptedStore(store: remote, oldPrefix: _old, params: _cheap)
await openEncryptedStoreFromKeystore(store: remote, oldPrefix: _old, params: _cheap)
as E2eReady;
final layout = E2eLayout.of(_old);
await first.store.putFileText(
@ -97,7 +97,7 @@ void main() {
message: 'm',
);
final second =
await openEncryptedStore(store: remote, oldPrefix: _old, params: _cheap)
await openEncryptedStoreFromKeystore(store: remote, oldPrefix: _old, params: _cheap)
as E2eReady;
final text = await second.store.getFileText(
'${layout.prefix}/phone/log.json',
@ -108,9 +108,9 @@ void main() {
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 openEncryptedStoreFromKeystore(store: remote, oldPrefix: _old, params: _cheap);
await saveE2ePassphrase('nope', storage: storage);
final state = await openEncryptedStore(
final state = await openEncryptedStoreFromKeystore(
store: remote,
oldPrefix: _old,
params: _cheap,