mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-15 07:50:58 +01:00
crdt_sync_dart 0.13.1: openEncryptedStore
Todo: fda71ed1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2813a88db6
commit
eebeb0b510
@ -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';
|
||||
|
||||
64
crdt_sync_dart/lib/src/e2e_open.dart
Normal file
64
crdt_sync_dart/lib/src/e2e_open.dart
Normal file
@ -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<E2eState> openEncryptedStore({
|
||||
required RemoteStore store,
|
||||
required String oldPrefix,
|
||||
required Future<String?> 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,
|
||||
);
|
||||
}
|
||||
@ -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:
|
||||
|
||||
63
crdt_sync_dart/test/e2e_open_test.dart
Normal file
63
crdt_sync_dart/test/e2e_open_test.dart
Normal file
@ -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<E2eState> _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<E2eReady>());
|
||||
expect(r.files['app/notes-e2e/phone/log.json'], isNot(contains('milk')));
|
||||
expect(await _open(r, 'pw'), isA<E2eReady>());
|
||||
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<E2eReady>());
|
||||
});
|
||||
}
|
||||
|
||||
class _FailingList extends PlainRemote {
|
||||
_FailingList(super.inner);
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) =>
|
||||
throw StateError('list down');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user