feat(sync): encrypted device-to-device sync transport (core foundation)
The most foundational Block-2+ gap: the data model was built CRDT-ready (HLC, tombstones, LWW, append-only Movement) and the import reconciler proves the merge — but nothing replicated a device's inventory to your OTHER devices. Only manual file backup/restore moved data between devices. This lands the transport foundation in commons_core (seed-agnostic, on the shared connection — the 'one connection, N interfaces' shape): - SyncTransport: moves an opaque, encrypted snapshot blob between ONE identity's own devices. The core carries bytes + does the crypto; the app decides what the bytes are and how to merge them. - NostrSyncTransport: NIP-78 app-data (kind 30078, addressable/replaceable per device via the d-tag), content NIP-44-encrypted TO SELF, discovery filtered to your own author key. Each device keeps one replaceable record. Tests (MiniRelay, pure Dart): a snapshot replicates to the identity's other device; the relay only ever sees ciphertext; another identity neither receives nor could decrypt; re-push replaces; two devices keep their own. Confirms the open-decisions §D.4 guarantee (sync leaks neither the key nor plaintext). NEXT slice (app): a SyncService that serializes the inventory (reusing ExportImportService), pushes on mutation, and runs the existing idempotent importInventory on receive; a stable per-install device id; wire sync into SocialSession.
This commit is contained in:
parent
bb4ee2fd89
commit
182c334883
5 changed files with 262 additions and 1 deletions
110
packages/commons_core/test/social/nostr_sync_transport_test.dart
Normal file
110
packages/commons_core/test/social/nostr_sync_transport_test.dart
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/mini_relay.dart';
|
||||
|
||||
/// Device-to-device sync (NIP-78 app data, NIP-44 encrypted to self) over a
|
||||
/// hermetic in-process relay: one identity's snapshot replicates to its OTHER
|
||||
/// device, encrypted end-to-end, with each device replacing only its own record.
|
||||
void main() {
|
||||
const ns = 'org.comunes.tane/inventory';
|
||||
|
||||
late MiniRelay relay;
|
||||
setUp(() async => relay = await MiniRelay.start());
|
||||
tearDown(() async => relay.stop());
|
||||
|
||||
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
|
||||
Uint8List(32)..fillRange(0, 32, fill),
|
||||
);
|
||||
Future<NostrSyncTransport> deviceFor(NostrIdentity id, String deviceId) async =>
|
||||
NostrSyncTransport(
|
||||
await NostrConnection.connect(relay.url, identity: id),
|
||||
namespace: ns,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
|
||||
test('a snapshot replicates to the same identity\'s other device', () async {
|
||||
final me = await idFor(7);
|
||||
final deviceA = await deviceFor(me, 'A');
|
||||
final deviceB = await deviceFor(me, 'B');
|
||||
final payload = utf8.encode('{"varieties":[{"id":"v1","label":"Tomate"}]}');
|
||||
|
||||
await deviceA.pushSnapshot(payload);
|
||||
final got = await deviceB.snapshotsUntilEose();
|
||||
|
||||
expect(got, hasLength(1));
|
||||
expect(got.single.deviceId, 'A');
|
||||
expect(got.single.data, payload);
|
||||
|
||||
await deviceA.close();
|
||||
await deviceB.close();
|
||||
});
|
||||
|
||||
test('the relay only ever sees ciphertext, never the snapshot', () async {
|
||||
final me = await idFor(7);
|
||||
final deviceA = await deviceFor(me, 'A');
|
||||
final payload = utf8.encode('secret inventory');
|
||||
|
||||
await deviceA.pushSnapshot(payload);
|
||||
|
||||
final stored = relay.eventsOfKind(NostrSyncTransport.kindAppData);
|
||||
expect(stored, hasLength(1));
|
||||
final content = stored.single['content'] as String;
|
||||
expect(content, isNot(equals(base64.encode(payload))));
|
||||
expect(content, isNot(contains('secret inventory')));
|
||||
await deviceA.close();
|
||||
});
|
||||
|
||||
test('another identity neither receives nor could read it', () async {
|
||||
final me = await idFor(7);
|
||||
final stranger = await idFor(9);
|
||||
final deviceA = await deviceFor(me, 'A');
|
||||
final strangerDevice = await deviceFor(stranger, 'A');
|
||||
|
||||
await deviceA.pushSnapshot(utf8.encode('mine only'));
|
||||
|
||||
// Filtered to the stranger's own author key → nothing of mine arrives.
|
||||
expect(await strangerDevice.snapshotsUntilEose(), isEmpty);
|
||||
await deviceA.close();
|
||||
await strangerDevice.close();
|
||||
});
|
||||
|
||||
test('re-pushing replaces this device\'s record (one snapshot per device)',
|
||||
() async {
|
||||
final me = await idFor(7);
|
||||
final deviceA = await deviceFor(me, 'A');
|
||||
final deviceB = await deviceFor(me, 'B');
|
||||
|
||||
await deviceA.pushSnapshot(utf8.encode('v1'));
|
||||
await deviceA.pushSnapshot(utf8.encode('v2'));
|
||||
final got = await deviceB.snapshotsUntilEose();
|
||||
|
||||
expect(got, hasLength(1));
|
||||
expect(utf8.decode(got.single.data), 'v2');
|
||||
await deviceA.close();
|
||||
await deviceB.close();
|
||||
});
|
||||
|
||||
test('two devices each keep their own snapshot', () async {
|
||||
final me = await idFor(7);
|
||||
final deviceA = await deviceFor(me, 'A');
|
||||
final deviceB = await deviceFor(me, 'B');
|
||||
final reader = await deviceFor(me, 'C');
|
||||
|
||||
await deviceA.pushSnapshot(utf8.encode('from A'));
|
||||
await deviceB.pushSnapshot(utf8.encode('from B'));
|
||||
final got = await reader.snapshotsUntilEose();
|
||||
|
||||
expect(got, hasLength(2));
|
||||
expect(
|
||||
{for (final s in got) s.deviceId: utf8.decode(s.data)},
|
||||
{'A': 'from A', 'B': 'from B'},
|
||||
);
|
||||
await deviceA.close();
|
||||
await deviceB.close();
|
||||
await reader.close();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue