From 51e562a76460eb25202a92644b2f1d202dba8dc5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 12 Nov 2026 08:19:10 +0100 Subject: [PATCH] crdt_sync_dart 0.13.0: E2E envelope + migration Todo: fda71ed1 Co-Authored-By: Claude Sonnet 5.5 --- crdt_sync_dart/lib/crdt_sync.dart | 6 + crdt_sync_dart/lib/src/e2e_audit.dart | 23 ++ crdt_sync_dart/lib/src/e2e_cipher.dart | 97 ++++++ crdt_sync_dart/lib/src/e2e_envelope.dart | 123 ++++++++ crdt_sync_dart/lib/src/e2e_key.dart | 254 +++++++++++++++ crdt_sync_dart/lib/src/e2e_migration.dart | 180 +++++++++++ crdt_sync_dart/lib/src/encrypting_store.dart | 80 +++++ crdt_sync_dart/lib/src/sync.dart | 21 +- crdt_sync_dart/pubspec.lock | 32 ++ crdt_sync_dart/pubspec.yaml | 4 +- .../test/default_client_and_io_test.dart | 99 +++--- crdt_sync_dart/test/e2e_audit_test.dart | 56 ++++ crdt_sync_dart/test/e2e_cipher_test.dart | 130 ++++++++ crdt_sync_dart/test/e2e_envelope_test.dart | 156 ++++++++++ crdt_sync_dart/test/e2e_fakes.dart | 110 +++++++ crdt_sync_dart/test/e2e_key_test.dart | 181 +++++++++++ crdt_sync_dart/test/e2e_migration_test.dart | 167 ++++++++++ .../test/encrypting_store_test.dart | 290 ++++++++++++++++++ crdt_sync_dart/tool/e2e_audit.dart | 47 +++ crdt_sync_dart/tool/interop_check.dart | 14 +- crdt_sync_dart/tool/live_smoke_test.dart | 45 ++- 21 files changed, 2045 insertions(+), 70 deletions(-) create mode 100644 crdt_sync_dart/lib/src/e2e_audit.dart create mode 100644 crdt_sync_dart/lib/src/e2e_cipher.dart create mode 100644 crdt_sync_dart/lib/src/e2e_envelope.dart create mode 100644 crdt_sync_dart/lib/src/e2e_key.dart create mode 100644 crdt_sync_dart/lib/src/e2e_migration.dart create mode 100644 crdt_sync_dart/lib/src/encrypting_store.dart create mode 100644 crdt_sync_dart/test/e2e_audit_test.dart create mode 100644 crdt_sync_dart/test/e2e_cipher_test.dart create mode 100644 crdt_sync_dart/test/e2e_envelope_test.dart create mode 100644 crdt_sync_dart/test/e2e_fakes.dart create mode 100644 crdt_sync_dart/test/e2e_key_test.dart create mode 100644 crdt_sync_dart/test/e2e_migration_test.dart create mode 100644 crdt_sync_dart/test/encrypting_store_test.dart create mode 100644 crdt_sync_dart/tool/e2e_audit.dart diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index 24515be..961694f 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -2,6 +2,12 @@ library; export 'src/device.dart'; +export 'src/e2e_audit.dart'; +export 'src/e2e_cipher.dart'; +export 'src/e2e_envelope.dart'; +export 'src/e2e_key.dart'; +export 'src/e2e_migration.dart'; +export 'src/encrypting_store.dart'; export 'src/firebase_account_provisioning.dart'; export 'src/firebase_auth_error.dart'; export 'src/firebase_auth_rest.dart'; diff --git a/crdt_sync_dart/lib/src/e2e_audit.dart b/crdt_sync_dart/lib/src/e2e_audit.dart new file mode 100644 index 0000000..6f2b84c --- /dev/null +++ b/crdt_sync_dart/lib/src/e2e_audit.dart @@ -0,0 +1,23 @@ +import 'e2e_cipher.dart'; +import 'e2e_envelope.dart'; +import 'firebase_keys.dart'; + +/// Plaintext leaves in a decoded RTDB dump; empty once an app is migrated. +/// Decodes because RTDB serves blobs as escaped strings that defeat grep. +List plaintextBlobPaths(Object? tree) { + final found = []; + void walk(Object? node, String path) { + if (node is String) { + if (E2eCipher.isPlaintext(node) && !isE2eEnvelope(node)) found.add(path); + } else if (node is Map) { + for (final MapEntry(:key, :value) in node.entries) { + if (key == 'e2e' && value is String) continue; + final segment = decodeKey(key as String); + walk(value, path.isEmpty ? segment : '$path/$segment'); + } + } + } + + walk(tree, ''); + return found; +} diff --git a/crdt_sync_dart/lib/src/e2e_cipher.dart b/crdt_sync_dart/lib/src/e2e_cipher.dart new file mode 100644 index 0000000..b5d492e --- /dev/null +++ b/crdt_sync_dart/lib/src/e2e_cipher.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; + +const int e2eFormatVersion = 1; + +const _nonceLength = 24; +const _tagLength = 16; + +/// A FormatException so `syncLog` skips the peer instead of aborting the tick. +class E2eDecryptError extends FormatException { + E2eDecryptError(super.message); + + @override + String toString() => 'E2eDecryptError: $message'; +} + +/// XChaCha20-Poly1305 per blob: base64 `[version][nonce][ct][tag]`, with the +/// version byte and remote path as AAD so blobs cannot be swapped. +/// +/// [acceptPlaintext] reads un-migrated peers; turn it off after migration. +class E2eCipher { + E2eCipher(List key, {this.acceptPlaintext = true}) + : _key = SecretKey(List.unmodifiable(key)) { + if (key.length != 32) { + throw ArgumentError.value(key.length, 'key', 'must be 32 bytes'); + } + } + + final SecretKey _key; + final bool acceptPlaintext; + + static final _aead = Xchacha20.poly1305Aead(); + + static bool isPlaintext(String stored) { + final trimmed = stored.trimLeft(); + return trimmed.startsWith('{') || trimmed.startsWith('['); + } + + Future seal(String plaintext, {required String path}) async { + final box = await _aead.encrypt( + utf8.encode(plaintext), + secretKey: _key, + nonce: _aead.newNonce(), + aad: _aad(e2eFormatVersion, path), + ); + return base64.encode([ + e2eFormatVersion, + ...box.nonce, + ...box.cipherText, + ...box.mac.bytes, + ]); + } + + Future open(String stored, {required String path}) async { + if (isPlaintext(stored)) { + if (acceptPlaintext) return stored; + throw E2eDecryptError('plaintext blob at $path after migration'); + } + final List bytes; + try { + bytes = base64.decode(stored); + } on FormatException { + throw E2eDecryptError('blob at $path is neither JSON nor base64'); + } + if (bytes.isEmpty) throw E2eDecryptError('empty blob at $path'); + final version = bytes.first; + if (version != e2eFormatVersion) { + throw E2eDecryptError( + 'blob at $path uses format $version; update the app to read it', + ); + } + if (bytes.length < 1 + _nonceLength + _tagLength) { + throw E2eDecryptError('blob at $path is truncated'); + } + final box = SecretBox( + bytes.sublist(1 + _nonceLength, bytes.length - _tagLength), + nonce: bytes.sublist(1, 1 + _nonceLength), + mac: Mac(bytes.sublist(bytes.length - _tagLength)), + ); + try { + final clear = await _aead.decrypt( + box, + secretKey: _key, + aad: _aad(version, path), + ); + return utf8.decode(clear); + } on SecretBoxAuthenticationError { + throw E2eDecryptError('blob at $path failed authentication'); + } + } + + static List _aad(int version, String path) => [ + version, + ...utf8.encode(path), + ]; +} diff --git a/crdt_sync_dart/lib/src/e2e_envelope.dart b/crdt_sync_dart/lib/src/e2e_envelope.dart new file mode 100644 index 0000000..c08dd93 --- /dev/null +++ b/crdt_sync_dart/lib/src/e2e_envelope.dart @@ -0,0 +1,123 @@ +/// The per-record wire envelope: record ids, the tombstone and the newest HLC +/// stay in clear so the relay can order and inspect; every field name and +/// value is sealed. Merge still runs on the decrypted [Log]. +library; + +import 'dart:convert'; + +import 'e2e_cipher.dart'; +import 'hlc.dart'; + +const int _envelopeVersion = 1; + +/// Whether [text] is an envelope (explicit `"v"` integer), not legacy plaintext. +/// +/// A legacy log maps record ids to objects, so an id named `v` still maps to +/// a map, never an int. +bool isE2eEnvelope(String text) { + try { + final json = jsonDecode(text); + return json is Map && json['v'] is int; + } on FormatException { + return false; + } +} + +String _aad( + String path, + String id, + bool deleted, + String? deletedHlc, + String? h, +) => '$path#$id#${deleted ? 1 : 0}#${deletedHlc ?? ''}#${h ?? ''}'; + +/// Seals a log (the `logToJson` shape) into an envelope. Text that is not +/// log-shaped is sealed whole, so nothing is ever pushed in clear. +Future sealLogText( + String text, + E2eCipher cipher, { + required String path, +}) async { + final Map raw; + try { + raw = (jsonDecode(text) as Map).cast(); + for (final entry in raw.entries) { + final rec = entry.value as Map; + if (rec['id'] != entry.key || rec['fields'] is! Map) { + throw const FormatException('not a log'); + } + } + } on Object { + return jsonEncode({ + 'v': _envelopeVersion, + 'b': await cipher.seal(text, path: path), + }); + } + final records = {}; + for (final entry in raw.entries) { + final rec = (entry.value as Map).cast(); + final fields = (rec['fields'] as Map).cast(); + Hlc? newest; + for (final field in fields.values) { + final hlc = Hlc.fromStr((field as List)[1] as String); + if (newest == null || hlc > newest) newest = hlc; + } + final deleted = rec['deleted'] as bool? ?? false; + final deletedHlc = rec['deleted_hlc'] as String?; + final h = newest?.toStr(); + records[entry.key] = { + 'd': deleted, + 'dh': deletedHlc, + 'h': h, + 'p': await cipher.seal( + jsonEncode(fields), + path: _aad(path, entry.key, deleted, deletedHlc, h), + ), + }; + } + return jsonEncode({'v': _envelopeVersion, 'r': records}); +} + +/// Opens an envelope back to the `logToJson` shape. Legacy plaintext is +/// returned as-is while the cipher accepts it. +Future openLogText( + String text, + E2eCipher cipher, { + required String path, +}) async { + if (!isE2eEnvelope(text)) { + if (cipher.acceptPlaintext) return text; + throw E2eDecryptError('plaintext blob at $path after migration'); + } + final json = (jsonDecode(text) as Map).cast(); + if (json['v'] != _envelopeVersion) { + throw E2eDecryptError( + 'blob at $path uses format ${json['v']}; update the app to read it', + ); + } + final whole = json['b']; + if (whole is String) return cipher.open(whole, path: path); + final records = json['r']; + if (records is! Map) throw E2eDecryptError('envelope at $path is mis-shaped'); + final out = {}; + for (final entry in records.entries) { + final id = entry.key as String; + final rec = entry.value; + if (rec is! Map || rec['p'] is! String || rec['d'] is! bool) { + throw E2eDecryptError('record $id at $path is mis-shaped'); + } + final deletedHlc = rec['dh'] as String?; + final h = rec['h'] as String?; + final clear = await cipher.open( + rec['p'] as String, + path: _aad(path, id, rec['d'] as bool, deletedHlc, h), + ); + out[id] = { + 'id': id, + 'fields': jsonDecode(clear), + 'deleted': rec['d'], + 'deleted_hlc': deletedHlc, + }; + } + return jsonEncode(out); +} diff --git a/crdt_sync_dart/lib/src/e2e_key.dart b/crdt_sync_dart/lib/src/e2e_key.dart new file mode 100644 index 0000000..36fddc1 --- /dev/null +++ b/crdt_sync_dart/lib/src/e2e_key.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; + +import 'e2e_cipher.dart'; +import 'encrypting_store.dart'; +import 'remote_store.dart'; + +const _saltLength = 16; +const _headerVersion = 1; + +/// Argon2id cost, stored in the header so it can be raised later. +class Argon2Params { + const Argon2Params({ + required this.memoryKiB, + required this.iterations, + required this.parallelism, + }); + + /// OWASP's floor (19 MiB, t=2); higher stalls the Flutter-web desktop build. + static const recommended = Argon2Params( + memoryKiB: 19456, + iterations: 2, + parallelism: 1, + ); + + final int memoryKiB; + final int iterations; + final int parallelism; +} + +class E2eHeaderError extends FormatException { + E2eHeaderError(super.message); + + @override + String toString() => 'E2eHeaderError: $message'; +} + +class E2eWrongPassphraseError implements Exception { + @override + String toString() => 'E2eWrongPassphraseError: wrong sync passphrase'; +} + +/// The clear header's path: a sibling of the device dirs, like `revs`. +String defaultE2ePath(String pathPrefix) { + final cut = pathPrefix.lastIndexOf('/'); + return cut < 0 ? '$pathPrefix/e2e' : '${pathPrefix.substring(0, cut)}/e2e'; +} + +class _Header { + _Header(this.salt, this.params, this.check); + + factory _Header.parse(String text) { + final Object? json; + try { + json = jsonDecode(text); + } on FormatException { + throw E2eHeaderError('e2e header is not JSON'); + } + if (json is! Map) { + throw E2eHeaderError('e2e header is not an object'); + } + if (json['v'] != _headerVersion) { + throw E2eHeaderError('e2e header v${json['v']}; update the app'); + } + try { + if (json['kdf'] != 'argon2id') throw const FormatException(); + return _Header( + base64.decode(json['salt'] as String), + Argon2Params( + memoryKiB: json['m'] as int, + iterations: json['t'] as int, + parallelism: json['p'] as int, + ), + json['check'] as String, + ); + } on Object { + throw E2eHeaderError('e2e header is malformed'); + } + } + + final List salt; + final Argon2Params params; + final String check; + + String encode() => jsonEncode({ + 'v': _headerVersion, + 'kdf': 'argon2id', + 'salt': base64.encode(salt), + 'm': params.memoryKiB, + 't': params.iterations, + 'p': params.parallelism, + 'check': check, + }); +} + +/// A 32-byte master key plus its HKDF subkeys for sealing, revisions and the +/// passphrase check. Cache [bytes] in the OS keystore, never in RTDB. +class E2eKey { + E2eKey._(this.bytes, this._enc, this._rev, this._check); + + static Future fromBytes(List bytes) { + if (bytes.length != 32) { + throw ArgumentError.value(bytes.length, 'bytes', 'must be 32 bytes'); + } + return _expand(List.unmodifiable(bytes)); + } + + static Future _expand(List master) async { + Future> sub(String label) async { + final key = await Hkdf(hmac: Hmac.sha256(), outputLength: 32).deriveKey( + secretKey: SecretKey(master), + info: utf8.encode('crdt_sync e2e $label'), + ); + return key.bytes; + } + + return E2eKey._( + master, + await sub('enc'), + SecretKey(await sub('rev')), + SecretKey(await sub('check')), + ); + } + + final List bytes; + final List _enc; + final SecretKey _rev; + final SecretKey _check; + + E2eCipher cipher({bool acceptPlaintext = true}) => + E2eCipher(_enc, acceptPlaintext: acceptPlaintext); + + /// Keyed `revisionOf`: a published revision cannot confirm a guessed log. + Future revision(String text) async { + final mac = await Hmac.sha256().calculateMac( + utf8.encode(text), + secretKey: _rev, + ); + return mac.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + Future _checkFor(List salt) async { + final mac = await Hmac.sha256().calculateMac(salt, secretKey: _check); + return base64.encode(mac.bytes); + } +} + +Future _derive( + String passphrase, + List salt, + Argon2Params params, +) async { + final key = await Argon2id( + parallelism: params.parallelism, + memory: params.memoryKiB, + iterations: params.iterations, + hashLength: 32, + ).deriveKeyFromPassword(password: passphrase, nonce: salt); + return E2eKey._expand(await key.extractBytes()); +} + +Future<_Header?> _readHeader(RemoteStore store, String path) async { + final text = await store.getFileText(path); + return text == null ? null : _Header.parse(text); +} + +/// Derives this app's key from [passphrase], creating the header on first use. +/// +/// Throws [E2eWrongPassphraseError] on a failed check and [E2eHeaderError] +/// for an unreadable header, which is never overwritten. +Future unlockE2e({ + required RemoteStore store, + required String pathPrefix, + required String passphrase, + Argon2Params params = Argon2Params.recommended, + Random? random, + String? e2ePath, +}) async { + if (passphrase.isEmpty) { + throw ArgumentError.value('', 'passphrase', 'must not be empty'); + } + final path = e2ePath ?? defaultE2ePath(pathPrefix); + var header = await _readHeader(store, path); + if (header == null) { + final rng = random ?? Random.secure(); + final salt = List.generate(_saltLength, (_) => rng.nextInt(256)); + final key = await _derive(passphrase, salt, params); + final encoded = _Header(salt, params, await key._checkFor(salt)).encode(); + await store.putFileText(path, encoded, message: path); + // Two devices setting up at once: the last write wins, so re-read. + final stored = await store.getFileText(path) ?? encoded; + if (stored == encoded) return key; + header = _Header.parse(stored); + } + final key = await _derive(passphrase, header.salt, header.params); + if (await key._checkFor(header.salt) != header.check) { + throw E2eWrongPassphraseError(); + } + return key; +} + +sealed class E2eState {} + +/// No header and no key: this app has never been encrypted. +final class E2eOff extends E2eState {} + +enum E2eLockReason { needsPassphrase, wrongKey, badHeader } + +/// Encryption is on, but this device must not sync until it has the key. +final class E2eLocked extends E2eState { + E2eLocked(this.reason); + + final E2eLockReason reason; +} + +final class E2eReady extends E2eState { + E2eReady(this.store); + + final EncryptingStore store; +} + +Future openE2e({ + required RemoteStore store, + required String pathPrefix, + E2eKey? key, + String? e2ePath, + String? revsPath, + bool acceptPlaintext = true, +}) async { + final _Header? header; + try { + header = await _readHeader(store, e2ePath ?? defaultE2ePath(pathPrefix)); + } on E2eHeaderError { + return E2eLocked(E2eLockReason.badHeader); + } + if (key == null) { + return header == null ? E2eOff() : E2eLocked(E2eLockReason.needsPassphrase); + } + if (header == null || await key._checkFor(header.salt) != header.check) { + return E2eLocked(E2eLockReason.wrongKey); + } + return E2eReady( + EncryptingStore( + store, + key, + pathPrefix: pathPrefix, + revsPath: revsPath, + e2ePath: e2ePath, + acceptPlaintext: acceptPlaintext, + ), + ); +} diff --git a/crdt_sync_dart/lib/src/e2e_migration.dart b/crdt_sync_dart/lib/src/e2e_migration.dart new file mode 100644 index 0000000..c3e31dc --- /dev/null +++ b/crdt_sync_dart/lib/src/e2e_migration.dart @@ -0,0 +1,180 @@ +/// The one-time copy from a plaintext prefix to `-e2e`. +/// +/// Re-encrypting in place is rejected: an old build still writing the old +/// prefix would be broken mid-migration. Instead the old prefix stays +/// untouched until [deletePlaintextPrefix] verifies the copy. +library; + +import 'dart:convert'; + +import 'e2e_key.dart'; +import 'encrypting_store.dart'; +import 'log.dart'; +import 'record.dart'; +import 'remote_store.dart'; +import 'store.dart'; + +/// Where an app's encrypted data lives, derived from its old plaintext prefix. +class E2eLayout { + const E2eLayout._(this.prefix, this.revsPath, this.e2ePath); + + /// `-e2e`, with revisions and header apart from the old ones so + /// old builds neither see nor clobber them. + factory E2eLayout.of(String oldPrefix) { + final cut = oldPrefix.lastIndexOf('/'); + final parent = cut < 0 ? '' : '${oldPrefix.substring(0, cut)}/'; + final prefix = '$oldPrefix-e2e'; + return E2eLayout._(prefix, '${parent}revs-e2e', defaultE2ePath(prefix)); + } + + final String prefix; + final String revsPath; + final String e2ePath; +} + +class MigrationReport { + const MigrationReport(this.copied, this.skipped); + + /// Blobs written (new or changed) this run. + final int copied; + + /// Source blobs unreadable as a log, relative path. + final List skipped; +} + +Future> _listBlobs(RemoteStore store, String root) async { + if (store is BulkMapReader) { + return (store as BulkMapReader).getStringMap(root); + } + final out = {}; + for (final entry in await store.listDirectory(root)) { + final children = await store.listDirectory('$root/$entry'); + if (children.isEmpty) { + final text = await store.getFileText('$root/$entry'); + if (text != null) out[entry] = text; + continue; + } + for (final child in children) { + final text = await store.getFileText('$root/$entry/$child'); + if (text != null) out['$entry/$child'] = text; + } + } + return out; +} + +EncryptingStore _dest(RemoteStore store, E2eKey key, E2eLayout layout) => + EncryptingStore( + store, + key, + pathPrefix: layout.prefix, + revsPath: layout.revsPath, + e2ePath: layout.e2ePath, + ); + +/// Order-independent signature of a log, to tell "same records" cheaply. +String _signature(Log log) { + final ids = log.keys.toList()..sort(); + return jsonEncode([ + for (final id in ids) + [ + id, + log[id]!.deleted, + log[id]!.deletedHlc?.toStr(), + [ + for (final name in (log[id]!.fields.keys.toList()..sort())) + [ + name, + jsonEncode(log[id]!.fields[name]!.$1), + log[id]!.fields[name]!.$2.toStr(), + ], + ], + ], + ]); +} + +Log? _parse(String text) { + try { + return logFromJson(text); + } on FormatException { + return null; + } on TypeError { + return null; + } +} + +/// Copies every readable plaintext log under [fromPrefix] into the encrypted +/// prefix, merging into what is already there. Idempotent: an unchanged +/// destination is not rewritten. Never touches [fromPrefix]. +Future copyToEncrypted({ + required RemoteStore store, + required E2eKey key, + required String fromPrefix, + required E2eLayout layout, +}) async { + final dest = _dest(store, key, layout); + var copied = 0; + final skipped = []; + final blobs = await _listBlobs(store, fromPrefix); + for (final MapEntry(key: rel, value: text) in blobs.entries) { + final source = _parse(text); + if (source == null) { + skipped.add(rel); + continue; + } + final path = '${layout.prefix}/$rel'; + final existing = await dest.getFileText(path); + final current = existing == null ? null : _parse(existing); + final merged = mergeLogs(current ?? {}, source); + if (current != null && _signature(merged) == _signature(current)) continue; + await dest.putFileText(path, logToJson(merged), message: 'e2e copy'); + copied++; + } + return MigrationReport(copied, skipped); +} + +/// True when every readable plaintext blob is contained in the encrypted copy +/// and none is unreadable. +Future verifyCopy({ + required RemoteStore store, + required E2eKey key, + required String fromPrefix, + required E2eLayout layout, +}) async { + final dest = _dest(store, key, layout); + for (final MapEntry(key: rel, value: text) in (await _listBlobs( + store, + fromPrefix, + )).entries) { + final source = _parse(text); + if (source == null) return false; + final existing = await dest.getFileText('${layout.prefix}/$rel'); + final current = existing == null ? null : _parse(existing); + if (current == null) return false; + if (_signature(mergeLogs(current, source)) != _signature(current)) { + return false; + } + } + return true; +} + +/// Deletes the plaintext prefix, only after [verifyCopy] passes. Returns +/// whether it deleted. +Future deletePlaintextPrefix({ + required RemoteStore store, + required E2eKey key, + required String fromPrefix, + required E2eLayout layout, +}) async { + if (!await verifyCopy( + store: store, + key: key, + fromPrefix: fromPrefix, + layout: layout, + )) { + return false; + } + for (final rel in (await _listBlobs(store, fromPrefix)).keys) { + await store.deleteFile('$fromPrefix/$rel', message: 'e2e cleanup'); + } + return true; +} diff --git a/crdt_sync_dart/lib/src/encrypting_store.dart b/crdt_sync_dart/lib/src/encrypting_store.dart new file mode 100644 index 0000000..7230709 --- /dev/null +++ b/crdt_sync_dart/lib/src/encrypting_store.dart @@ -0,0 +1,80 @@ +import 'e2e_cipher.dart'; +import 'e2e_envelope.dart'; +import 'e2e_key.dart'; +import 'remote_store.dart'; +import 'sync.dart'; + +/// A [RemoteStore] that seals every blob under `pathPrefix` (nested +/// `/` or flat `.json`) except the header, as a +/// per-record envelope (ids and HLCs clear, names and values opaque), and +/// keys every revision under `revsPath`, so the remote is a blind relay. +/// +/// Wrap the outermost store (above a `MirrorStore`) so every backend gets +/// ciphertext. Other paths pass through unchanged. +class EncryptingStore implements RemoteStore, BulkMapReader { + EncryptingStore( + this._inner, + this._key, { + required String pathPrefix, + String? revsPath, + String? e2ePath, + bool acceptPlaintext = true, + }) : _cipher = _key.cipher(acceptPlaintext: acceptPlaintext), + _prefix = pathPrefix, + _revs = revsPath ?? defaultRevsPath(pathPrefix), + _header = e2ePath ?? defaultE2ePath(pathPrefix); + + final RemoteStore _inner; + final E2eKey _key; + final E2eCipher _cipher; + final String _prefix; + final String _revs; + final String _header; + + bool _isRev(String path) => path.startsWith('$_revs/'); + + bool _isLog(String path) => + !_isRev(path) && path != _header && path.startsWith('$_prefix/'); + + @override + Future getFileText(String path) async { + final text = await _inner.getFileText(path); + if (text == null || !_isLog(path)) return text; + return openLogText(text, _cipher, path: path); + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + final stored = _isLog(path) + ? await sealLogText(text, _cipher, path: path) + : _isRev(path) + ? await _key.revision(text) + : text; + await _inner.putFileText(path, stored, message: message); + } + + @override + Future> getStringMap(String path) async { + if (_inner case final BulkMapReader reader) { + return reader.getStringMap(path); + } + return const {}; + } + + @override + Future> listDirectory(String path) => _inner.listDirectory(path); + + @override + Future deleteFile(String path, {String message = ''}) => + _inner.deleteFile(path, message: message); + + @override + Future canAccessRemote() => _inner.canAccessRemote(); + + @override + void close() => _inner.close(); +} diff --git a/crdt_sync_dart/lib/src/sync.dart b/crdt_sync_dart/lib/src/sync.dart index 347cfd4..e1d139b 100644 --- a/crdt_sync_dart/lib/src/sync.dart +++ b/crdt_sync_dart/lib/src/sync.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'e2e_cipher.dart'; import 'log.dart'; import 'record.dart'; import 'remote_store.dart'; @@ -44,6 +45,9 @@ String revisionOf(String encodedLog) => /// treated as this device's own rather than as a peer's, so its /// pre-migration log is not pulled back and re-merged every tick. Pass null /// once that path has been reclaimed. +/// +/// [onPeerSkipped] reports each peer skipped as unreadable, e.g. an +/// [E2eDecryptError] from a wrong key or an app that needs updating. Future syncLog({ required RemoteStore client, required String deviceId, @@ -56,6 +60,7 @@ Future syncLog({ SyncStateStore? stateStore, String? revsPath, String? legacyDeviceId, + void Function(String deviceId, Object error)? onPeerSkipped, }) async { final revs = revsPath ?? defaultRevsPath(pathPrefix); final state = await stateStore?.load() ?? const SyncState(); @@ -80,15 +85,21 @@ Future syncLog({ seenRevs[otherDeviceId] = remoteRev; continue; } - final text = await client.getFileText( - '$pathPrefix/$otherDeviceId/$filename', - ); + final String? text; + try { + text = await client.getFileText('$pathPrefix/$otherDeviceId/$filename'); + } on E2eDecryptError catch (error) { + onPeerSkipped?.call(otherDeviceId, error); + continue; + } if (text == null) continue; try { mergedLog = mergeLogs(mergedLog, decode(text)); - } on FormatException { + } on FormatException catch (error) { + onPeerSkipped?.call(otherDeviceId, error); continue; - } on TypeError { + } on TypeError catch (error) { + onPeerSkipped?.call(otherDeviceId, error); continue; } // Only remembered once the merge succeeded: a corrupt push must be diff --git a/crdt_sync_dart/pubspec.lock b/crdt_sync_dart/pubspec.lock index e77d686..820d0d2 100644 --- a/crdt_sync_dart/pubspec.lock +++ b/crdt_sync_dart/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" cli_config: dependency: transitive description: @@ -81,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.8" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -97,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + glados: + dependency: "direct dev" + description: + name: glados + sha256: c39446c0c4fe83a6ee6ee4fdee5a1afa27050619c7df390bf4c3b77b6f1acb33 + url: "https://pub.dev" + source: hosted + version: "1.1.7" glob: dependency: transitive description: diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 6abcb6a..13b58b9 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.12.1 +version: 0.13.0 publish_to: 'none' environment: @@ -10,8 +10,10 @@ environment: dependencies: crypto: 3.0.8 + cryptography: 2.9.0 http: 1.6.0 dev_dependencies: + glados: 1.1.7 lints: 6.1.0 test: 1.33.0 diff --git a/crdt_sync_dart/test/default_client_and_io_test.dart b/crdt_sync_dart/test/default_client_and_io_test.dart index 2bbddaf..b6f7dcf 100644 --- a/crdt_sync_dart/test/default_client_and_io_test.dart +++ b/crdt_sync_dart/test/default_client_and_io_test.dart @@ -40,9 +40,7 @@ void main() { // Nothing listens on this port, so the GET throws and the `on Exception` // path returns null -- while the `finally` still closes the client the // function created for itself. - final account = await accountFromWrapper( - Uri.parse('http://127.0.0.1:1'), - ); + final account = await accountFromWrapper(Uri.parse('http://127.0.0.1:1')); expect(account, isNull); }); @@ -50,8 +48,10 @@ void main() { final mock = http_testing.MockClient( (_) async => http.Response(jsonEncode({}), 404), ); - expect(await accountFromWrapper(Uri.parse('http://x'), client: mock), - isNull); + expect( + await accountFromWrapper(Uri.parse('http://x'), client: mock), + isNull, + ); // Still usable: the function must not close a client it did not create. expect((await mock.get(Uri.parse('http://x'))).statusCode, 404); }); @@ -106,22 +106,26 @@ void main() { expect(await store.read(), isNull); }); - test('read returns null when the file cannot be read', () async { - // A mode-000 file still passes existsSync, so the guard does not fire - // and readAsString throws PathAccessException (a FileSystemException). - // A directory would NOT work here: File.existsSync() is false for one, - // so read() would return early down the guard path instead. - final file = File('${dir.path}/unreadable.json')..writeAsStringSync('{}'); - final chmod = Process.runSync('chmod', ['000', file.path]); - expect(chmod.exitCode, 0); - addTearDown(() => Process.runSync('chmod', ['644', file.path])); + test( + 'read returns null when the file cannot be read', + () async { + // A mode-000 file still passes existsSync, so the guard does not fire + // and readAsString throws PathAccessException (a FileSystemException). + // A directory would NOT work here: File.existsSync() is false for one, + // so read() would return early down the guard path instead. + final file = File('${dir.path}/unreadable.json') + ..writeAsStringSync('{}'); + final chmod = Process.runSync('chmod', ['000', file.path]); + expect(chmod.exitCode, 0); + addTearDown(() => Process.runSync('chmod', ['644', file.path])); - expect(await FileLogPersistence(file).read(), isNull); - }, - // root ignores the permission bits, so the exception never fires. - skip: Platform.environment['USER'] == 'root' - ? 'permission bits do not apply to root' - : null); + expect(await FileLogPersistence(file).read(), isNull); + }, + // root ignores the permission bits, so the exception never fires. + skip: Platform.environment['USER'] == 'root' + ? 'permission bits do not apply to root' + : null, + ); test('round-trips what it wrote', () async { final store = FileLogPersistence(File('${dir.path}/log.json')); @@ -129,31 +133,38 @@ void main() { expect(await store.read(), '{"hello":"world"}'); }); - test('50 overlapping writes all succeed and the last issued wins', - () async { - // Regression: every call shared one `..tmp`, so the first - // rename moved it away and a later rename threw PathNotFoundException. - final store = FileLogPersistence(File('${dir.path}/log.json')); - await Future.wait([ - for (var i = 0; i < 50; i++) store.write('{"n":$i}'), - ]); - expect(await store.read(), '{"n":49}'); - expect(dir.listSync().map((e) => e.uri.pathSegments.last), - ['log.json']); - }); + test( + '50 overlapping writes all succeed and the last issued wins', + () async { + // Regression: every call shared one `..tmp`, so the first + // rename moved it away and a later rename threw PathNotFoundException. + final store = FileLogPersistence(File('${dir.path}/log.json')); + await Future.wait([ + for (var i = 0; i < 50; i++) store.write('{"n":$i}'), + ]); + expect(await store.read(), '{"n":49}'); + expect(dir.listSync().map((e) => e.uri.pathSegments.last), [ + 'log.json', + ]); + }, + ); - test('a failed write rejects its caller but does not block later writes', - () async { - final target = File('${dir.path}/log.json'); - // A directory at the target path makes the rename fail. - Directory(target.path).createSync(); - final store = FileLogPersistence(target); - await expectLater( - store.write('{"n":0}'), throwsA(isA())); + test( + 'a failed write rejects its caller but does not block later writes', + () async { + final target = File('${dir.path}/log.json'); + // A directory at the target path makes the rename fail. + Directory(target.path).createSync(); + final store = FileLogPersistence(target); + await expectLater( + store.write('{"n":0}'), + throwsA(isA()), + ); - Directory(target.path).deleteSync(); - await store.write('{"n":1}'); - expect(await store.read(), '{"n":1}'); - }); + Directory(target.path).deleteSync(); + await store.write('{"n":1}'); + expect(await store.read(), '{"n":1}'); + }, + ); }); } diff --git a/crdt_sync_dart/test/e2e_audit_test.dart b/crdt_sync_dart/test/e2e_audit_test.dart new file mode 100644 index 0000000..70d41ae --- /dev/null +++ b/crdt_sync_dart/test/e2e_audit_test.dart @@ -0,0 +1,56 @@ +/// The done check: find plaintext blobs in a raw RTDB dump. +/// +/// RTDB returns each blob as a JSON string, so its quotes arrive escaped and +/// `grep -c '"title"'` prints 0 even on plaintext. The audit decodes instead. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +// Shape copied from a live `GET /.json` (scratch data). +const _livePlaintextDump = + r'{"notes":{"pc":{"log~2Ejson":"{\"n2\":{\"id\":\"n2\",\"fields\":' + r'{\"title\":[\"call mum\",\"1970-01-01T00:00:01.001Z-0000-pc\"]},' + r'\"deleted\":false,\"deleted_hlc\":null}}"}},"revs":{"pc":"ab12"}}'; + +void main() { + test('the grep done check misses live plaintext; the audit does not', () { + expect('"title"'.allMatches(_livePlaintextDump), isEmpty); + expect(plaintextBlobPaths(jsonDecode(_livePlaintextDump)), [ + 'notes/pc/log.json', + ]); + }); + + test('sealed blobs, revs and the clear e2e header pass', () async { + final sealed = await E2eCipher( + List.filled(32, 1), + ).seal('{"title":"x"}', path: 'notes/pc/log.json'); + final dump = { + 'e2e': '{"v":1,"kdf":"argon2id"}', + 'notes': { + 'pc': {'log~2Ejson': sealed}, + }, + 'revs': {'pc': 'ab12'}, + }; + expect(plaintextBlobPaths(dump), isEmpty); + }); + + test('every plaintext leaf is reported, with keys unescaped', () { + final dump = { + 'a': { + 'x~2Ey': '[1]', + 'b': {'c': ' {"k":1}'}, + }, + 'n': 3, + 'nil': null, + }; + expect(plaintextBlobPaths(dump), unorderedEquals(['a/x.y', 'a/b/c'])); + }); + + test('a root that is itself plaintext, or empty, is handled', () { + expect(plaintextBlobPaths('{"a":1}'), ['']); + expect(plaintextBlobPaths(null), isEmpty); + }); +} diff --git a/crdt_sync_dart/test/e2e_cipher_test.dart b/crdt_sync_dart/test/e2e_cipher_test.dart new file mode 100644 index 0000000..b0d7e87 --- /dev/null +++ b/crdt_sync_dart/test/e2e_cipher_test.dart @@ -0,0 +1,130 @@ +/// The record-blob AEAD: what makes the remote a blind relay. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:glados/glados.dart' hide expect, group, test, setUp; +import 'package:test/test.dart'; + +const _path = 'todo-sync/notes/phone/log.json'; + +E2eCipher _cipher([int fill = 7, bool acceptPlaintext = true]) => + E2eCipher(List.filled(32, fill), acceptPlaintext: acceptPlaintext); + +void main() { + Glados(any.letterOrDigits, ExploreConfig(numRuns: 60)).test( + 'open(seal(x)) == x for any text', + (text) async { + final cipher = _cipher(); + final sealed = await cipher.seal('{"t":"$text"}', path: _path); + expect(await cipher.open(sealed, path: _path), '{"t":"$text"}'); + }, + ); + + test('sealed text contains no plaintext and is not JSON', () async { + final sealed = await _cipher().seal('{"title":"secret"}', path: _path); + expect(sealed, isNot(contains('title'))); + expect(sealed, isNot(contains('secret'))); + expect(() => jsonDecode(sealed), throwsFormatException); + expect(E2eCipher.isPlaintext(sealed), isFalse); + }); + + test('two seals of the same text differ (random nonce)', () async { + final cipher = _cipher(); + expect( + await cipher.seal('{}', path: _path), + isNot(await cipher.seal('{}', path: _path)), + ); + }); + + test('wire format starts with the version byte', () async { + final bytes = base64.decode(await _cipher().seal('{}', path: _path)); + expect(bytes.first, e2eFormatVersion); + expect(bytes.length, 1 + 24 + 2 + 16); + }); + + test('plaintext JSON passes through during migration', () async { + expect(await _cipher().open(' {"a":1}', path: _path), ' {"a":1}'); + expect(await _cipher().open('[1]', path: _path), '[1]'); + expect(E2eCipher.isPlaintext('\n{'), isTrue); + }); + + test('plaintext is refused once migration is over', () async { + expect( + () => _cipher(7, false).open('{"a":1}', path: _path), + throwsA(isA()), + ); + }); + + group('rejects, as a FormatException so syncLog skips the peer', () { + test('a wrong key', () async { + final sealed = await _cipher(1).seal('{}', path: _path); + expect( + () => _cipher(2).open(sealed, path: _path), + throwsA(isA()), + ); + }); + + test('a blob moved to another path', () async { + final sealed = await _cipher().seal('{}', path: _path); + expect( + () => _cipher().open(sealed, path: 'todo-sync/notes/pc/log.json'), + throwsA(isA()), + ); + }); + + test('a flipped ciphertext byte', () async { + final bytes = base64.decode(await _cipher().seal('{}', path: _path)); + bytes[30] ^= 1; + expect( + () => _cipher().open(base64.encode(bytes), path: _path), + throwsA(isA()), + ); + }); + + test('an unknown version byte', () async { + final bytes = base64.decode(await _cipher().seal('{}', path: _path)); + bytes[0] = 9; + expect( + () => _cipher().open(base64.encode(bytes), path: _path), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('update the app'), + ), + ), + ); + }); + + test('text that is not base64', () { + expect( + () => _cipher().open('not base64 !!', path: _path), + throwsA(isA()), + ); + }); + + test('a blob too short to hold nonce and tag', () { + expect( + () => _cipher().open(base64.encode([1, 2, 3]), path: _path), + throwsA(isA()), + ); + }); + + test('an empty blob', () { + expect( + () => _cipher().open('', path: _path), + throwsA(isA()), + ); + }); + }); + + test('a key that is not 32 bytes is a programming error', () { + expect(() => E2eCipher(const [1, 2]), throwsArgumentError); + }); + + test('E2eDecryptError reads as itself', () { + expect(E2eDecryptError('x').toString(), 'E2eDecryptError: x'); + }); +} diff --git a/crdt_sync_dart/test/e2e_envelope_test.dart b/crdt_sync_dart/test/e2e_envelope_test.dart new file mode 100644 index 0000000..d8d803a --- /dev/null +++ b/crdt_sync_dart/test/e2e_envelope_test.dart @@ -0,0 +1,156 @@ +/// Per-record envelope: ids and HLCs stay clear, field names and values are +/// opaque, and merge results are identical with and without encryption. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:glados/glados.dart' hide expect, group, test, setUp; +import 'package:test/test.dart'; + +import 'e2e_fakes.dart'; + +const _path = 'app/notes-e2e/phone/log.json'; + +E2eCipher _cipher([int fill = 7, bool acceptPlaintext = true]) => + E2eCipher(List.filled(32, fill), acceptPlaintext: acceptPlaintext); + +Hlc _hlc(int ms, int counter, String node) => + Hlc(wallTimeMs: ms, counter: counter, nodeId: node); + +Log _logFrom(List<(int, int, String)> rows) { + final log = {}; + for (final (id, field, value) in rows) { + final key = 'r$id'; + final old = log[key]; + log[key] = Record( + id: key, + fields: { + ...?old?.fields, + 'f$field': (value, _hlc(1000 + id * 7 + field, field, 'n$id')), + }, + deleted: id % 4 == 0, + deletedHlc: id % 4 == 0 ? _hlc(5000, 0, 'n$id') : null, + ); + } + return log; +} + +final _rows = any.list( + any.combine3( + any.intInRange(0, 6), + any.intInRange(0, 4), + any.letterOrDigits, + (a, b, c) => (a, b, c), + ), +); + +void main() { + Glados(_rows, ExploreConfig(numRuns: 40)).test( + 'decode(open(seal(encode(log)))) == log', + (rows) async { + final log = _logFrom(rows); + final cipher = _cipher(); + final sealed = await sealLogText(encodeLog(log), cipher, path: _path); + final opened = await openLogText(sealed, cipher, path: _path); + expect(encodeLog(decodeLog(opened)), encodeLog(log)); + }, + ); + + Glados2(_rows, _rows, ExploreConfig(numRuns: 40)).test( + 'merge through the envelope == merge without it', + (a, b) async { + final cipher = _cipher(); + Future viaCipher(Log log) async => decodeLog( + await openLogText( + await sealLogText(encodeLog(log), cipher, path: _path), + cipher, + path: _path, + ), + ); + final la = _logFrom(a); + final lb = _logFrom(b); + final plain = mergeLogs(la, lb); + final sealed = mergeLogs(await viaCipher(la), await viaCipher(lb)); + expect(encodeLog(sealed), encodeLog(plain)); + }, + ); + + test('ids and HLCs are clear, names and values are not', () async { + final log = logOf({'n1': 'buy milk'}); + final sealed = await sealLogText(encodeLog(log), _cipher(), path: _path); + final json = jsonDecode(sealed) as Map; + expect(json['v'], 1); + final rec = (json['r'] as Map)['n1'] as Map; + expect(rec['h'], log['n1']!.fields['title']!.$2.toStr()); + expect(rec['d'], false); + expect(sealed, isNot(contains('title'))); + expect(sealed, isNot(contains('buy milk'))); + expect(isE2eEnvelope(sealed), isTrue); + expect(isE2eEnvelope(encodeLog(log)), isFalse); + }); + + test('legacy plaintext passes only while acceptPlaintext', () async { + final text = encodeLog(logOf({'n1': 'x'})); + expect(await openLogText(text, _cipher(), path: _path), text); + expect( + () => openLogText(text, _cipher(7, false), path: _path), + throwsA(isA()), + ); + }); + + test('a record id named v is still legacy plaintext', () async { + final text = encodeLog(logOf({'v': 'x'})); + expect(isE2eEnvelope(text), isFalse); + }); + + group('tamper and wrong key are E2eDecryptError', () { + Future sealed() async => + sealLogText(encodeLog(logOf({'n1': 'x'})), _cipher(), path: _path); + + test('wrong key', () async { + final text = await sealed(); + expect( + () => openLogText(text, _cipher(8), path: _path), + throwsA(isA()), + ); + }); + test('moved path', () async { + final text = await sealed(); + expect( + () => openLogText(text, _cipher(), path: '$_path-x'), + throwsA(isA()), + ); + }); + test('flipped clear deleted flag', () async { + final json = jsonDecode(await sealed()) as Map; + ((json['r'] as Map)['n1'] as Map)['d'] = true; + expect( + () => openLogText(jsonEncode(json), _cipher(), path: _path), + throwsA(isA()), + ); + }); + test('record swapped to another id', () async { + final json = jsonDecode(await sealed()) as Map; + final r = json['r'] as Map; + r['n2'] = r.remove('n1'); + expect( + () => openLogText(jsonEncode(json), _cipher(), path: _path), + throwsA(isA()), + ); + }); + test('mis-shaped envelope', () async { + expect( + () => openLogText('{"v":1,"r":5}', _cipher(), path: _path), + throwsA(isA()), + ); + }); + }); + + test('text that is not a log is sealed whole and opens back', () async { + final cipher = _cipher(); + final sealed = await sealLogText('{"n":{"id":1}}', cipher, path: _path); + expect(sealed, isNot(contains('"id"'))); + expect(await openLogText(sealed, cipher, path: _path), '{"n":{"id":1}}'); + }); +} diff --git a/crdt_sync_dart/test/e2e_fakes.dart b/crdt_sync_dart/test/e2e_fakes.dart new file mode 100644 index 0000000..3ff8dce --- /dev/null +++ b/crdt_sync_dart/test/e2e_fakes.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; + +const cheapArgon = Argon2Params(memoryKiB: 8, iterations: 1, parallelism: 1); + +Log logOf(Map titles, {String nodeId = 'node-a'}) => { + for (final entry in titles.entries) + entry.key: Record( + id: entry.key, + fields: { + 'title': ( + entry.value, + Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId), + ), + }, + ), +}; + +String encodeLog(Log log) => + jsonEncode(log.map((id, record) => MapEntry(id, record.toJson()))); + +Log decodeLog(String text) => (jsonDecode(text) as Map).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +/// In-memory remote; [bulk] toggles the [BulkMapReader] capability. +class MemoryRemote implements RemoteStore, BulkMapReader { + MemoryRemote([Map? files]) : files = files ?? {}; + + final Map files; + final List writes = []; + final List reads = []; + bool closed = false; + + /// Overrides the next reads of a path, oldest first, to fake a race. + final Map> scriptedReads = {}; + + @override + Future> listDirectory(String path) async => files.keys + .where((key) => key.startsWith('$path/')) + .map((key) => key.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + + @override + Future getFileText(String path) async { + reads.add(path); + final script = scriptedReads[path]; + if (script != null && script.isNotEmpty) return script.removeAt(0); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes.add(path); + files[path] = text; + } + + @override + Future> getStringMap(String path) async => { + for (final entry in files.entries) + if (entry.key.startsWith('$path/')) + entry.key.substring(path.length + 1): entry.value, + }; + + @override + Future deleteFile(String path, {String message = ''}) async => + files.remove(path); + + @override + Future canAccessRemote() async => true; + + @override + void close() => closed = true; +} + +/// Same storage without the bulk capability, like `GitHubClient`. +class PlainRemote implements RemoteStore { + PlainRemote(this.inner); + + final MemoryRemote inner; + + @override + Future> listDirectory(String path) => inner.listDirectory(path); + + @override + Future getFileText(String path) => inner.getFileText(path); + + @override + Future putFileText( + String path, + String text, { + required String message, + }) => inner.putFileText(path, text, message: message); + + @override + Future deleteFile(String path, {String message = ''}) => + inner.deleteFile(path); + + @override + Future canAccessRemote() => inner.canAccessRemote(); + + @override + void close() => inner.close(); +} diff --git a/crdt_sync_dart/test/e2e_key_test.dart b/crdt_sync_dart/test/e2e_key_test.dart new file mode 100644 index 0000000..134f1cf --- /dev/null +++ b/crdt_sync_dart/test/e2e_key_test.dart @@ -0,0 +1,181 @@ +/// Passphrase -> key, the clear header beside the data, and the open gate. +library; + +import 'dart:convert'; +import 'dart:math'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +import 'e2e_fakes.dart'; + +const _prefix = 'todo-sync/notes'; +const _headerPath = 'todo-sync/e2e'; + +Future _unlock(MemoryRemote remote, String passphrase) => unlockE2e( + store: remote, + pathPrefix: _prefix, + passphrase: passphrase, + params: cheapArgon, + random: Random(1), +); + +void main() { + test('the header sits beside the device dirs, not among them', () { + expect(defaultE2ePath(_prefix), _headerPath); + expect(defaultE2ePath('wake-alarm'), 'wake-alarm/e2e'); + }); + + test( + 'first unlock writes a clear header with salt, params and check', + () async { + final remote = MemoryRemote(); + await _unlock(remote, 'correct horse'); + final header = jsonDecode(remote.files[_headerPath]!) as Map; + expect(header['v'], 1); + expect(header['kdf'], 'argon2id'); + expect(base64.decode(header['salt'] as String), hasLength(16)); + expect(header['m'], 8); + expect(header['t'], 1); + expect(header['p'], 1); + expect(header['check'], isA()); + expect(remote.files[_headerPath], isNot(contains('correct horse'))); + }, + ); + + test('the same passphrase on another device yields the same key', () async { + final remote = MemoryRemote(); + final a = await _unlock(remote, 'pw'); + final b = await unlockE2e( + store: remote, + pathPrefix: _prefix, + passphrase: 'pw', + random: Random(99), + ); + expect(b.bytes, a.bytes); + expect(remote.writes, [_headerPath]); + }); + + test('a wrong passphrase is refused before anything is written', () async { + final remote = MemoryRemote(); + await _unlock(remote, 'pw'); + remote.writes.clear(); + await expectLater( + _unlock(remote, 'not pw'), + throwsA(isA()), + ); + expect(remote.writes, isEmpty); + }); + + test('a setup race derives from the header that won', () async { + final winner = MemoryRemote(); + await unlockE2e( + store: winner, + pathPrefix: _prefix, + passphrase: 'pw', + params: cheapArgon, + random: Random(5), + ); + final remote = MemoryRemote() + ..scriptedReads[_headerPath] = [null, winner.files[_headerPath]]; + final key = await _unlock(remote, 'pw'); + final expected = await _unlock(winner, 'pw'); + expect(key.bytes, expected.bytes); + }); + + test('an empty passphrase is refused', () { + expect(() => _unlock(MemoryRemote(), ''), throwsArgumentError); + }); + + test('a corrupt or newer header is refused, never overwritten', () async { + for (final bad in ['nope', '[]', '{"v":2}', '{"v":1,"kdf":"scrypt"}']) { + final remote = MemoryRemote({_headerPath: bad}); + await expectLater( + _unlock(remote, 'pw'), + throwsA(isA()), + reason: bad, + ); + expect(remote.files[_headerPath], bad); + } + }); + + test('E2eKey.fromBytes round-trips a cached key', () async { + final key = await _unlock(MemoryRemote(), 'pw'); + final cached = await E2eKey.fromBytes( + base64.decode(base64.encode(key.bytes)), + ); + expect(cached.bytes, key.bytes); + expect(await cached.revision('x'), await key.revision('x')); + expect(() => E2eKey.fromBytes(const [1]), throwsArgumentError); + }); + + test('revision is keyed: not sha256, and differs across keys', () async { + final a = await _unlock(MemoryRemote(), 'a'); + final b = await _unlock(MemoryRemote(), 'b'); + expect(await a.revision('{}'), isNot(revisionOf('{}'))); + expect(await a.revision('{}'), isNot(await b.revision('{}'))); + expect(await a.revision('{}'), await a.revision('{}')); + }); + + test('errors read as themselves', () { + expect(E2eWrongPassphraseError().toString(), contains('passphrase')); + expect(E2eHeaderError('x').toString(), 'E2eHeaderError: x'); + }); + + group('openE2e', () { + test('no header and no key: off, plaintext sync allowed', () async { + final state = await openE2e(store: MemoryRemote(), pathPrefix: _prefix); + expect(state, isA()); + }); + + test('header but no key: locked, needs the passphrase', () async { + final remote = MemoryRemote(); + await _unlock(remote, 'pw'); + final state = await openE2e(store: remote, pathPrefix: _prefix); + expect(state, isA()); + expect((state as E2eLocked).reason, E2eLockReason.needsPassphrase); + }); + + test('a cached key the header rejects: locked, wrong key', () async { + final remote = MemoryRemote(); + await _unlock(remote, 'pw'); + final stale = await _unlock(MemoryRemote(), 'old'); + final state = await openE2e( + store: remote, + pathPrefix: _prefix, + key: stale, + ); + expect((state as E2eLocked).reason, E2eLockReason.wrongKey); + }); + + test('a cached key with the header gone: locked, wrong key', () async { + final key = await _unlock(MemoryRemote(), 'pw'); + final state = await openE2e( + store: MemoryRemote(), + pathPrefix: _prefix, + key: key, + ); + expect((state as E2eLocked).reason, E2eLockReason.wrongKey); + }); + + test('a corrupt header: locked, unreadable header', () async { + final state = await openE2e( + store: MemoryRemote({_headerPath: 'garbage'}), + pathPrefix: _prefix, + ); + expect((state as E2eLocked).reason, E2eLockReason.badHeader); + }); + + test('a matching key: ready with an encrypting store', () async { + final remote = MemoryRemote(); + final key = await _unlock(remote, 'pw'); + final state = await openE2e(store: remote, pathPrefix: _prefix, key: key); + expect((state as E2eReady).store, isA()); + }); + }); + + test('recommended params meet the OWASP Argon2id floor', () { + expect(Argon2Params.recommended.memoryKiB, greaterThanOrEqualTo(19456)); + expect(Argon2Params.recommended.iterations, greaterThanOrEqualTo(2)); + }); +} diff --git a/crdt_sync_dart/test/e2e_migration_test.dart b/crdt_sync_dart/test/e2e_migration_test.dart new file mode 100644 index 0000000..4c40726 --- /dev/null +++ b/crdt_sync_dart/test/e2e_migration_test.dart @@ -0,0 +1,167 @@ +/// One-time copy from the plaintext prefix to `-e2e`. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +import 'e2e_fakes.dart'; + +const _old = 'app/notes'; + +Future _key(MemoryRemote remote, E2eLayout layout) => unlockE2e( + store: remote, + pathPrefix: layout.prefix, + passphrase: 'pw', + params: cheapArgon, + e2ePath: layout.e2ePath, +); + +MemoryRemote _plainRemote() => MemoryRemote({ + '$_old/phone/log.json': encodeLog(logOf({'a': 'milk', 'b': 'eggs'})), + '$_old/pc/log.json': encodeLog(logOf({'c': 'tea'}, nodeId: 'node-b')), +}); + +void main() { + final layout = E2eLayout.of(_old); + + test('layout puts ciphertext on a new prefix, revs apart from old', () { + expect(layout.prefix, 'app/notes-e2e'); + expect(layout.revsPath, isNot(defaultRevsPath(_old))); + expect(layout.e2ePath, startsWith('app/')); + }); + + test('copy seals every device log under the new prefix', () async { + final remote = _plainRemote(); + final key = await _key(remote, layout); + final report = await copyToEncrypted( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ); + expect(report.copied, 2); + final blob = remote.files['app/notes-e2e/phone/log.json']!; + expect(blob, isNot(contains('milk'))); + expect(blob, isNot(contains('"title"'))); + expect(remote.files['$_old/phone/log.json'], contains('milk')); + final enc = EncryptingStore( + remote, + key, + pathPrefix: layout.prefix, + revsPath: layout.revsPath, + e2ePath: layout.e2ePath, + ); + final merged = await syncLog( + client: enc, + deviceId: 'tablet', + pathPrefix: layout.prefix, + localLog: {}, + encode: encodeLog, + decode: decodeLog, + ); + expect(merged.keys, unorderedEquals(['a', 'b', 'c'])); + }); + + test('copying twice adds no duplicate and loses nothing', () async { + final remote = _plainRemote(); + final key = await _key(remote, layout); + Future run() => copyToEncrypted( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ); + await run(); + final first = Map.of(remote.files); + remote.writes.clear(); + final second = await run(); + expect(second.copied, 0); + expect(remote.writes, isEmpty); + expect(remote.files, first); + }); + + test('a later plaintext write by an old build is copied again', () async { + final remote = _plainRemote(); + final key = await _key(remote, layout); + await copyToEncrypted( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ); + remote.files['$_old/pc/log.json'] = encodeLog( + logOf({'c': 'tea', 'd': 'jam'}, nodeId: 'node-b'), + ); + final report = await copyToEncrypted( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ); + expect(report.copied, 1); + expect( + await verifyCopy( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ), + isTrue, + ); + }); + + test('cleanup refuses before the copy is verified, then deletes', () async { + final remote = _plainRemote(); + final key = await _key(remote, layout); + expect( + await deletePlaintextPrefix( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ), + isFalse, + ); + expect(remote.files['$_old/phone/log.json'], isNotNull); + await copyToEncrypted( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ); + expect( + await deletePlaintextPrefix( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ), + isTrue, + ); + expect(remote.files.keys.where((k) => k.startsWith('$_old/')), isEmpty); + expect(remote.files['app/notes-e2e/phone/log.json'], isNotNull); + }); + + test('a corrupt source blob is skipped and blocks cleanup', () async { + final remote = _plainRemote(); + remote.files['$_old/bad/log.json'] = '{oops'; + final key = await _key(remote, layout); + final report = await copyToEncrypted( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ); + expect(report.copied, 2); + expect(report.skipped, ['bad/log.json']); + expect( + await deletePlaintextPrefix( + store: remote, + key: key, + fromPrefix: _old, + layout: layout, + ), + isFalse, + ); + }); +} diff --git a/crdt_sync_dart/test/encrypting_store_test.dart b/crdt_sync_dart/test/encrypting_store_test.dart new file mode 100644 index 0000000..b02b89b --- /dev/null +++ b/crdt_sync_dart/test/encrypting_store_test.dart @@ -0,0 +1,290 @@ +/// syncLog over an EncryptingStore: the relay sees ciphertext, merge is +/// unchanged. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:glados/glados.dart' hide expect, group, test, setUp; +import 'package:test/test.dart'; + +import 'e2e_fakes.dart'; + +const _prefix = 'todo-sync/notes'; +const _revs = 'todo-sync/revs'; + +Future _key(MemoryRemote remote, [String passphrase = 'pw']) => + unlockE2e( + store: remote, + pathPrefix: _prefix, + passphrase: passphrase, + params: cheapArgon, + random: Random(1), + ); + +Future _tick( + RemoteStore client, + String deviceId, + Log local, { + SyncStateStore? state, + List? skipped, +}) => syncLog( + client: client, + deviceId: deviceId, + pathPrefix: _prefix, + localLog: local, + encode: encodeLog, + decode: decodeLog, + stateStore: state, + onPeerSkipped: (id, _) => skipped?.add(id), +); + +void main() { + test('pushed blob is ciphertext and a peer merges it', () async { + final remote = MemoryRemote(); + final key = await _key(remote); + final store = EncryptingStore(remote, key, pathPrefix: _prefix); + await _tick(store, 'phone', logOf({'n1': 'buy milk'})); + + final blob = remote.files['$_prefix/phone/log.json']!; + expect(blob, isNot(contains('title'))); + expect(blob, isNot(contains('buy milk'))); + + final merged = await _tick(store, 'pc', logOf({'n2': 'call mum'})); + expect(merged.keys, unorderedEquals(['n1', 'n2'])); + expect(remote.files.values.where((v) => v.contains('"title"')), isEmpty); + }); + + test('published revisions are keyed, not sha256 of the plaintext', () async { + final remote = MemoryRemote(); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + final local = logOf({'n1': 'x'}); + await _tick(store, 'phone', local, state: InMemorySyncStateStore()); + final rev = remote.files['$_revs/phone']!; + expect(rev, isNot(revisionOf(encodeLog(local)))); + expect(rev, hasLength(64)); + }); + + test('a peer that has not upgraded is still read', () async { + final remote = MemoryRemote(); + await _tick(remote, 'pc', logOf({'old': 'plain'})); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + final merged = await _tick(store, 'phone', logOf({'n1': 'x'})); + expect(merged.keys, unorderedEquals(['old', 'n1'])); + }); + + test('a tampered peer is skipped and the others still merge', () async { + final remote = MemoryRemote(); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + await _tick(store, 'b', logOf({'b1': 'y'})); + await _tick(store, 'a', logOf({'a1': 'x'})); + final blob = remote.files['$_prefix/a/log.json']!; + remote.files['$_prefix/a/log.json'] = blob.replaceRange( + 40, + 41, + blob[40] == 'A' ? 'B' : 'A', + ); + final skipped = []; + final merged = await _tick(store, 'c', {}, skipped: skipped); + expect(merged.keys, ['b1']); + expect(skipped, ['a']); + }); + + test('unparsable and mis-shaped peers are reported as skipped', () async { + final remote = MemoryRemote({ + '$_prefix/bad/log.json': '{oops', + '$_prefix/shape/log.json': '{"n":{"id":1}}', + }); + final skipped = []; + await _tick(remote, 'me', {}, skipped: skipped); + expect(skipped, unorderedEquals(['bad', 'shape'])); + }); + + test('a peer under another passphrase is skipped, not fatal', () async { + final remote = MemoryRemote(); + final other = MemoryRemote(); + final mine = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + final theirs = EncryptingStore( + remote, + await _key(other, 'other'), + pathPrefix: _prefix, + ); + await _tick(theirs, 'x', logOf({'x1': 'x'})); + expect(await _tick(mine, 'me', {}), isEmpty); + }); + + test('a prefix without a slash keeps revs out of the sealed set', () async { + final remote = MemoryRemote(); + final key = await unlockE2e( + store: remote, + pathPrefix: 'wake-alarm', + passphrase: 'pw', + params: cheapArgon, + ); + final store = EncryptingStore(remote, key, pathPrefix: 'wake-alarm'); + await syncLog( + client: store, + deviceId: 'phone', + pathPrefix: 'wake-alarm', + localLog: logOf({'n': 'x'}), + encode: encodeLog, + decode: decodeLog, + stateStore: InMemorySyncStateStore(), + ); + expect(remote.files['wake-alarm/revs/phone'], hasLength(64)); + expect(remote.files['wake-alarm/phone/log.json'], isNot(contains('"x"'))); + expect(remote.files['wake-alarm/e2e'], contains('argon2id')); + }); + + test('paths outside the prefix pass through untouched', () async { + final remote = MemoryRemote(); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + await store.putFileText('other/x', '{"a":1}', message: 'm'); + expect(remote.files['other/x'], '{"a":1}'); + expect(await store.getFileText('other/x'), '{"a":1}'); + expect(await store.getFileText('$_prefix/none/log.json'), isNull); + }); + + test('a flat /.json layout (todo) is sealed too', () async { + final remote = MemoryRemote(); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + final log = encodeLog(logOf({'n1': 'buy milk'})); + await store.putFileText('$_prefix/node-a.json', log, message: 'm'); + expect(remote.files['$_prefix/node-a.json'], isNot(contains('milk'))); + expect(isE2eEnvelope(remote.files['$_prefix/node-a.json']!), isTrue); + expect(await store.getFileText('$_prefix/node-a.json'), log); + }); + + test('a slashless prefix keeps the header and revs out of sealing', () async { + final remote = MemoryRemote(); + final key = await unlockE2e( + store: remote, + pathPrefix: 'wake-alarm', + passphrase: 'pw', + params: cheapArgon, + ); + final store = EncryptingStore(remote, key, pathPrefix: 'wake-alarm'); + final header = remote.files['wake-alarm/e2e']!; + expect(await store.getFileText('wake-alarm/e2e'), header); + await store.putFileText('wake-alarm/e2e', header, message: 'm'); + expect(remote.files['wake-alarm/e2e'], header); + }); + + test('plaintext is refused when acceptPlaintext is off', () async { + final remote = MemoryRemote({ + '$_prefix/pc/log.json': encodeLog(logOf({'a': 'b'})), + }); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + acceptPlaintext: false, + ); + expect(await _tick(store, 'phone', {}), isEmpty); + }); + + group('delegation', () { + test('bulk reads forward when the inner store has them', () async { + final remote = MemoryRemote({'$_revs/a': 'r'}); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + expect(await store.getStringMap(_revs), {'a': 'r'}); + }); + + test('bulk reads degrade to "fetch everything" without them', () async { + final remote = MemoryRemote({'$_revs/a': 'r'}); + final store = EncryptingStore( + PlainRemote(remote), + await _key(remote), + pathPrefix: _prefix, + ); + expect(await store.getStringMap(_revs), isEmpty); + }); + + test('list, delete, access and close reach the inner store', () async { + final remote = MemoryRemote({'$_prefix/a/log.json': '{}'}); + final store = EncryptingStore( + remote, + await _key(remote), + pathPrefix: _prefix, + ); + expect(await store.listDirectory(_prefix), ['a']); + await store.deleteFile('$_prefix/a/log.json', message: 'm'); + expect(remote.files.containsKey('$_prefix/a/log.json'), isFalse); + expect(await store.canAccessRemote(), isTrue); + store.close(); + expect(remote.closed, isTrue); + }); + }); + + // Differential: the same sync rounds over a plain and an encrypting relay + // must converge to the same logs. + Glados( + any.list( + any.combine3( + any.intInRange(0, 3), + any.intInRange(0, 6), + any.letterOrDigits, + (int d, int n, String t) => (d, n, t), + ), + ), + ExploreConfig(numRuns: 25), + ).test('merge is identical with and without encryption', (edits) async { + const devices = ['a', 'b', 'c']; + Log localFor(int d) => { + for (final (i, (dev, note, title)) in edits.indexed) + if (dev == d) + 'n$note': Record( + id: 'n$note', + fields: { + 'title': ( + title, + Hlc(wallTimeMs: 1000 + i, counter: 0, nodeId: devices[d]), + ), + }, + ), + }; + final plain = MemoryRemote(); + final sealedRemote = MemoryRemote(); + final sealed = EncryptingStore( + sealedRemote, + await E2eKey.fromBytes(List.filled(32, 3)), + pathPrefix: _prefix, + ); + final plainLogs = [for (var d = 0; d < 3; d++) localFor(d)]; + final sealedLogs = [for (var d = 0; d < 3; d++) localFor(d)]; + for (var round = 0; round < 2; round++) { + for (var d = 0; d < 3; d++) { + plainLogs[d] = await _tick(plain, devices[d], plainLogs[d]); + sealedLogs[d] = await _tick(sealed, devices[d], sealedLogs[d]); + } + } + expect(sealedLogs, plainLogs); + expect(sealedLogs[0], sealedLogs[2]); + }); +} diff --git a/crdt_sync_dart/tool/e2e_audit.dart b/crdt_sync_dart/tool/e2e_audit.dart new file mode 100644 index 0000000..6180635 --- /dev/null +++ b/crdt_sync_dart/tool/e2e_audit.dart @@ -0,0 +1,47 @@ +/// Read-only done check: lists plaintext blobs under an RTDB root. +/// +/// Usage: `dart run tool/e2e_audit.dart ` +/// Exit 0 when every blob is sealed, 1 when any is plaintext, 2 on misuse. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; + +Future main(List args) async { + if (args.length != 2) { + stderr.writeln('usage: e2e_audit.dart '); + exit(2); + } + final [root, sessionFile] = args; + final home = Platform.environment['HOME']; + final cfg = + jsonDecode( + File('$home/.config/crdt-sync/firebase.json').readAsStringSync(), + ) + as Map; + final session = FirebaseCredentials.fromJson( + jsonDecode(File(sessionFile).readAsStringSync()) as Map, + ); + final auth = FirebaseTokenProvider( + apiKey: cfg['apiKey'] as String, + store: InMemoryCredentialStore(session), + ); + final url = Uri.parse( + '${cfg['databaseUrl']}/${encodePath(root)}.json' + '?auth=${await auth.idToken()}', + ); + final response = await http.get(url); + if (response.statusCode != 200) { + stderr.writeln('GET $root: HTTP ${response.statusCode}'); + exit(2); + } + final plaintext = plaintextBlobPaths(jsonDecode(response.body)); + stdout.writeln('plaintext blobs under $root: ${plaintext.length}'); + for (final path in plaintext) { + stdout.writeln(' $root/$path'); + } + exit(plaintext.isEmpty ? 0 : 1); +} diff --git a/crdt_sync_dart/tool/interop_check.dart b/crdt_sync_dart/tool/interop_check.dart index e20a74d..3bb5108 100644 --- a/crdt_sync_dart/tool/interop_check.dart +++ b/crdt_sync_dart/tool/interop_check.dart @@ -12,15 +12,15 @@ import 'package:crdt_sync/crdt_sync.dart'; /// mode 0600 -- never in source, because this repository is public. final _configDir = '${Platform.environment['HOME']}/.config/crdt-sync'; -Log _decode(String t) => (jsonDecode(t) as Map) - .map((k, v) => MapEntry(k, Record.fromJson(v as Map))); -String _encode(Log l) => - jsonEncode(l.map((k, v) => MapEntry(k, v.toJson()))); +Log _decode(String t) => (jsonDecode(t) as Map).map( + (k, v) => MapEntry(k, Record.fromJson(v as Map)), +); +String _encode(Log l) => jsonEncode(l.map((k, v) => MapEntry(k, v.toJson()))); Future main(List args) async { - final cfg = jsonDecode( - File('$_configDir/firebase.json').readAsStringSync(), - ) as Map; + final cfg = + jsonDecode(File('$_configDir/firebase.json').readAsStringSync()) + as Map; final auth = FirebaseTokenProvider( apiKey: cfg['apiKey'] as String, store: InMemoryCredentialStore(), diff --git a/crdt_sync_dart/tool/live_smoke_test.dart b/crdt_sync_dart/tool/live_smoke_test.dart index bbbde7b..66d92ee 100644 --- a/crdt_sync_dart/tool/live_smoke_test.dart +++ b/crdt_sync_dart/tool/live_smoke_test.dart @@ -23,7 +23,9 @@ const _prefix = '_smoketest/devices'; var _failures = 0; void _check(String what, bool ok, [String detail = '']) { - stdout.writeln('${ok ? " ok " : " FAIL"} $what${detail.isEmpty ? '' : ' ($detail)'}'); + stdout.writeln( + '${ok ? " ok " : " FAIL"} $what${detail.isEmpty ? '' : ' ($detail)'}', + ); if (!ok) _failures++; } @@ -68,16 +70,24 @@ Future main(List args) async { _check('canAccessRemote is false before sign-in', !await auth.hasSession()); await auth.signIn(email: email, password: password); _check('signIn stored a session', await auth.hasSession()); - _check('canAccessRemote is true after sign-in', await client.canAccessRemote()); + _check( + 'canAccessRemote is true after sign-in', + await client.canAccessRemote(), + ); stdout.writeln('\n== round-trip and key escaping =='); const path = '$_prefix/pc/log.json'; await client.putFileText(path, '{"hello":"world"}', message: 'smoke'); final read = await client.getFileText(path); - _check('a .json filename round-trips byte-for-byte', - read == '{"hello":"world"}', read ?? 'null'); - _check('missing path reads as null', - await client.getFileText('$_prefix/nope/log.json') == null); + _check( + 'a .json filename round-trips byte-for-byte', + read == '{"hello":"world"}', + read ?? 'null', + ); + _check( + 'missing path reads as null', + await client.getFileText('$_prefix/nope/log.json') == null, + ); final listed = await client.listDirectory(_prefix); _check('listDirectory sees the device', listed.contains('pc'), '$listed'); @@ -96,8 +106,11 @@ Future main(List args) async { final pushed = await client.getFileText(path); _check('the log landed in the database', pushed != null); final revs = await client.getStringMap('_smoketest/revs'); - _check('a revision was published for this device', - revs['pc'] == revisionOf(_encode(pcLog)), '${revs.keys}'); + _check( + 'a revision was published for this device', + revs['pc'] == revisionOf(_encode(pcLog)), + '${revs.keys}', + ); stdout.writeln('\n== sync: an unchanged tick writes nothing =='); final before = await client.getStringMap('_smoketest/revs'); @@ -110,8 +123,10 @@ Future main(List args) async { decode: _decode, stateStore: storePc, ); - _check('revision unchanged, so nothing was re-uploaded', - (await client.getStringMap('_smoketest/revs'))['pc'] == before['pc']); + _check( + 'revision unchanged, so nothing was re-uploaded', + (await client.getStringMap('_smoketest/revs'))['pc'] == before['pc'], + ); stdout.writeln('\n== sync: two devices converge =='); final storePhone = InMemorySyncStateStore(); @@ -135,8 +150,10 @@ Future main(List args) async { stateStore: storePc, ); _check('pc merged the phone record', pcAfter.containsKey('b')); - _check('each device wrote only its own revision key', - (await client.getStringMap('_smoketest/revs')).length == 2); + _check( + 'each device wrote only its own revision key', + (await client.getStringMap('_smoketest/revs')).length == 2, + ); stdout.writeln('\n== fail-closed =='); await auth.signOut(); @@ -160,7 +177,9 @@ Future main(List args) async { client.close(); auth.close(); stdout.writeln( - _failures == 0 ? '\nALL LIVE CHECKS PASSED' : '\n$_failures LIVE CHECK(S) FAILED', + _failures == 0 + ? '\nALL LIVE CHECKS PASSED' + : '\n$_failures LIVE CHECK(S) FAILED', ); exit(_failures == 0 ? 0 : 1); }