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:
Krzysztof Rudnicki 2026-11-12 08:19:10 +01:00
parent b0fad2b2e5
commit 51e562a764
21 changed files with 2045 additions and 70 deletions

View File

@ -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';

View 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;
}

View 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),
];
}

View 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);
}

View 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,
),
);
}

View 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;
}

View 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();
}

View File

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

View File

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

View File

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

View File

@ -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}');
},
);
});
}

View 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);
});
}

View 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');
});
}

View 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}}');
});
}

View 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();
}

View 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));
});
}

View 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,
);
});
}

View 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]);
});
}

View 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);
}

View File

@ -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(),

View File

@ -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);
}