mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 22:50:57 +01:00
crdt_sync_dart 0.13.0: E2E envelope + migration
Todo: fda71ed1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b0fad2b2e5
commit
51e562a764
@ -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';
|
||||
|
||||
23
crdt_sync_dart/lib/src/e2e_audit.dart
Normal file
23
crdt_sync_dart/lib/src/e2e_audit.dart
Normal file
@ -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<String> plaintextBlobPaths(Object? tree) {
|
||||
final found = <String>[];
|
||||
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;
|
||||
}
|
||||
97
crdt_sync_dart/lib/src/e2e_cipher.dart
Normal file
97
crdt_sync_dart/lib/src/e2e_cipher.dart
Normal file
@ -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<int> key, {this.acceptPlaintext = true})
|
||||
: _key = SecretKey(List<int>.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<String> 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<String> open(String stored, {required String path}) async {
|
||||
if (isPlaintext(stored)) {
|
||||
if (acceptPlaintext) return stored;
|
||||
throw E2eDecryptError('plaintext blob at $path after migration');
|
||||
}
|
||||
final List<int> 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<int> _aad(int version, String path) => [
|
||||
version,
|
||||
...utf8.encode(path),
|
||||
];
|
||||
}
|
||||
123
crdt_sync_dart/lib/src/e2e_envelope.dart
Normal file
123
crdt_sync_dart/lib/src/e2e_envelope.dart
Normal file
@ -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<String> sealLogText(
|
||||
String text,
|
||||
E2eCipher cipher, {
|
||||
required String path,
|
||||
}) async {
|
||||
final Map<String, dynamic> raw;
|
||||
try {
|
||||
raw = (jsonDecode(text) as Map).cast<String, dynamic>();
|
||||
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 = <String, Object?>{};
|
||||
for (final entry in raw.entries) {
|
||||
final rec = (entry.value as Map).cast<String, dynamic>();
|
||||
final fields = (rec['fields'] as Map).cast<String, dynamic>();
|
||||
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<String> 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<String, dynamic>();
|
||||
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 = <String, Object?>{};
|
||||
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);
|
||||
}
|
||||
254
crdt_sync_dart/lib/src/e2e_key.dart
Normal file
254
crdt_sync_dart/lib/src/e2e_key.dart
Normal file
@ -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<String, dynamic>) {
|
||||
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<int> 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<E2eKey> fromBytes(List<int> bytes) {
|
||||
if (bytes.length != 32) {
|
||||
throw ArgumentError.value(bytes.length, 'bytes', 'must be 32 bytes');
|
||||
}
|
||||
return _expand(List<int>.unmodifiable(bytes));
|
||||
}
|
||||
|
||||
static Future<E2eKey> _expand(List<int> master) async {
|
||||
Future<List<int>> 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<int> bytes;
|
||||
final List<int> _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<String> 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<String> _checkFor(List<int> salt) async {
|
||||
final mac = await Hmac.sha256().calculateMac(salt, secretKey: _check);
|
||||
return base64.encode(mac.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
Future<E2eKey> _derive(
|
||||
String passphrase,
|
||||
List<int> 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<E2eKey> 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<int>.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<E2eState> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
180
crdt_sync_dart/lib/src/e2e_migration.dart
Normal file
180
crdt_sync_dart/lib/src/e2e_migration.dart
Normal file
@ -0,0 +1,180 @@
|
||||
/// The one-time copy from a plaintext prefix to `<prefix>-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);
|
||||
|
||||
/// `<oldPrefix>-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<String> skipped;
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _listBlobs(RemoteStore store, String root) async {
|
||||
if (store is BulkMapReader) {
|
||||
return (store as BulkMapReader).getStringMap(root);
|
||||
}
|
||||
final out = <String, String>{};
|
||||
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<MigrationReport> 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 = <String>[];
|
||||
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 ?? <String, Record>{}, 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<bool> 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<bool> 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;
|
||||
}
|
||||
80
crdt_sync_dart/lib/src/encrypting_store.dart
Normal file
80
crdt_sync_dart/lib/src/encrypting_store.dart
Normal file
@ -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
|
||||
/// `<device>/<file>` or flat `<node>.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<String?> 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<void> 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<Map<String, String>> getStringMap(String path) async {
|
||||
if (_inner case final BulkMapReader reader) {
|
||||
return reader.getStringMap(path);
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||
_inner.deleteFile(path, message: message);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
||||
|
||||
@override
|
||||
void close() => _inner.close();
|
||||
}
|
||||
@ -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<Log> syncLog({
|
||||
required RemoteStore client,
|
||||
required String deviceId,
|
||||
@ -56,6 +60,7 @@ Future<Log> 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<Log> 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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 `<file>.<pid>.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 `<file>.<pid>.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<FileSystemException>()));
|
||||
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<FileSystemException>()),
|
||||
);
|
||||
|
||||
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}');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
56
crdt_sync_dart/test/e2e_audit_test.dart
Normal file
56
crdt_sync_dart/test/e2e_audit_test.dart
Normal file
@ -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 /<root>.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<int>.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);
|
||||
});
|
||||
}
|
||||
130
crdt_sync_dart/test/e2e_cipher_test.dart
Normal file
130
crdt_sync_dart/test/e2e_cipher_test.dart
Normal file
@ -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<int>.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<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<E2eDecryptError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('update the app'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('text that is not base64', () {
|
||||
expect(
|
||||
() => _cipher().open('not base64 !!', path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a blob too short to hold nonce and tag', () {
|
||||
expect(
|
||||
() => _cipher().open(base64.encode([1, 2, 3]), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty blob', () {
|
||||
expect(
|
||||
() => _cipher().open('', path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
156
crdt_sync_dart/test/e2e_envelope_test.dart
Normal file
156
crdt_sync_dart/test/e2e_envelope_test.dart
Normal file
@ -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<int>.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 = <String, Record>{};
|
||||
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<Log> 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<String, dynamic>;
|
||||
expect(json['v'], 1);
|
||||
final rec = (json['r'] as Map<String, dynamic>)['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<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<String> 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<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('moved path', () async {
|
||||
final text = await sealed();
|
||||
expect(
|
||||
() => openLogText(text, _cipher(), path: '$_path-x'),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('flipped clear deleted flag', () async {
|
||||
final json = jsonDecode(await sealed()) as Map<String, dynamic>;
|
||||
((json['r'] as Map)['n1'] as Map)['d'] = true;
|
||||
expect(
|
||||
() => openLogText(jsonEncode(json), _cipher(), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('record swapped to another id', () async {
|
||||
final json = jsonDecode(await sealed()) as Map<String, dynamic>;
|
||||
final r = json['r'] as Map;
|
||||
r['n2'] = r.remove('n1');
|
||||
expect(
|
||||
() => openLogText(jsonEncode(json), _cipher(), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('mis-shaped envelope', () async {
|
||||
expect(
|
||||
() => openLogText('{"v":1,"r":5}', _cipher(), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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}}');
|
||||
});
|
||||
}
|
||||
110
crdt_sync_dart/test/e2e_fakes.dart
Normal file
110
crdt_sync_dart/test/e2e_fakes.dart
Normal file
@ -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<String, String> 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<String, dynamic>).map(
|
||||
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||
);
|
||||
|
||||
/// In-memory remote; [bulk] toggles the [BulkMapReader] capability.
|
||||
class MemoryRemote implements RemoteStore, BulkMapReader {
|
||||
MemoryRemote([Map<String, String>? files]) : files = files ?? {};
|
||||
|
||||
final Map<String, String> files;
|
||||
final List<String> writes = [];
|
||||
final List<String> reads = [];
|
||||
bool closed = false;
|
||||
|
||||
/// Overrides the next reads of a path, oldest first, to fake a race.
|
||||
final Map<String, List<String?>> scriptedReads = {};
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) async => files.keys
|
||||
.where((key) => key.startsWith('$path/'))
|
||||
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<String?> 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<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async {
|
||||
writes.add(path);
|
||||
files[path] = text;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> 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<void> deleteFile(String path, {String message = ''}) async =>
|
||||
files.remove(path);
|
||||
|
||||
@override
|
||||
Future<bool> 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<List<String>> listDirectory(String path) => inner.listDirectory(path);
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) => inner.getFileText(path);
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) => inner.putFileText(path, text, message: message);
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||
inner.deleteFile(path);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() => inner.canAccessRemote();
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
181
crdt_sync_dart/test/e2e_key_test.dart
Normal file
181
crdt_sync_dart/test/e2e_key_test.dart
Normal file
@ -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<E2eKey> _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<String>());
|
||||
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<E2eWrongPassphraseError>()),
|
||||
);
|
||||
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<E2eHeaderError>()),
|
||||
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<E2eOff>());
|
||||
});
|
||||
|
||||
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<E2eLocked>());
|
||||
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<EncryptingStore>());
|
||||
});
|
||||
});
|
||||
|
||||
test('recommended params meet the OWASP Argon2id floor', () {
|
||||
expect(Argon2Params.recommended.memoryKiB, greaterThanOrEqualTo(19456));
|
||||
expect(Argon2Params.recommended.iterations, greaterThanOrEqualTo(2));
|
||||
});
|
||||
}
|
||||
167
crdt_sync_dart/test/e2e_migration_test.dart
Normal file
167
crdt_sync_dart/test/e2e_migration_test.dart
Normal file
@ -0,0 +1,167 @@
|
||||
/// One-time copy from the plaintext prefix to `<prefix>-e2e`.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'e2e_fakes.dart';
|
||||
|
||||
const _old = 'app/notes';
|
||||
|
||||
Future<E2eKey> _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<MigrationReport> 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
290
crdt_sync_dart/test/encrypting_store_test.dart
Normal file
290
crdt_sync_dart/test/encrypting_store_test.dart
Normal file
@ -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<E2eKey> _key(MemoryRemote remote, [String passphrase = 'pw']) =>
|
||||
unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: _prefix,
|
||||
passphrase: passphrase,
|
||||
params: cheapArgon,
|
||||
random: Random(1),
|
||||
);
|
||||
|
||||
Future<Log> _tick(
|
||||
RemoteStore client,
|
||||
String deviceId,
|
||||
Log local, {
|
||||
SyncStateStore? state,
|
||||
List<String>? 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 = <String>[];
|
||||
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 = <String>[];
|
||||
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 <prefix>/<node>.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<int>.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]);
|
||||
});
|
||||
}
|
||||
47
crdt_sync_dart/tool/e2e_audit.dart
Normal file
47
crdt_sync_dart/tool/e2e_audit.dart
Normal file
@ -0,0 +1,47 @@
|
||||
/// Read-only done check: lists plaintext blobs under an RTDB root.
|
||||
///
|
||||
/// Usage: `dart run tool/e2e_audit.dart <root> <firebase_auth.json>`
|
||||
/// 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<void> main(List<String> args) async {
|
||||
if (args.length != 2) {
|
||||
stderr.writeln('usage: e2e_audit.dart <root> <firebase_auth.json>');
|
||||
exit(2);
|
||||
}
|
||||
final [root, sessionFile] = args;
|
||||
final home = Platform.environment['HOME'];
|
||||
final cfg =
|
||||
jsonDecode(
|
||||
File('$home/.config/crdt-sync/firebase.json').readAsStringSync(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
final session = FirebaseCredentials.fromJson(
|
||||
jsonDecode(File(sessionFile).readAsStringSync()) as Map<String, dynamic>,
|
||||
);
|
||||
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);
|
||||
}
|
||||
@ -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<String, dynamic>)
|
||||
.map((k, v) => MapEntry(k, Record.fromJson(v as Map<String, dynamic>)));
|
||||
String _encode(Log l) =>
|
||||
jsonEncode(l.map((k, v) => MapEntry(k, v.toJson())));
|
||||
Log _decode(String t) => (jsonDecode(t) as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(k, Record.fromJson(v as Map<String, dynamic>)),
|
||||
);
|
||||
String _encode(Log l) => jsonEncode(l.map((k, v) => MapEntry(k, v.toJson())));
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final cfg = jsonDecode(
|
||||
File('$_configDir/firebase.json').readAsStringSync(),
|
||||
) as Map<String, dynamic>;
|
||||
final cfg =
|
||||
jsonDecode(File('$_configDir/firebase.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: cfg['apiKey'] as String,
|
||||
store: InMemoryCredentialStore(),
|
||||
|
||||
@ -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<void> main(List<String> 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<void> main(List<String> 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<void> main(List<String> 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<void> main(List<String> 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<void> main(List<String> 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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user