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
b9492cb65a
commit
bf15992a97
5 changed files with 262 additions and 1 deletions
|
|
@ -16,6 +16,7 @@ export 'src/social/geohash.dart';
|
|||
export 'src/social/message_transport.dart';
|
||||
export 'src/social/nostr_ids.dart';
|
||||
export 'src/social/nostr/nostr_channel.dart';
|
||||
export 'src/social/nostr/nostr_sync_transport.dart';
|
||||
export 'src/social/nostr/nostr_connection.dart';
|
||||
export 'src/social/nostr/nostr_message_transport.dart';
|
||||
export 'src/social/nostr/nostr_offer_transport.dart';
|
||||
|
|
@ -25,6 +26,7 @@ export 'src/social/nostr/relay_pool.dart';
|
|||
export 'src/social/offer.dart';
|
||||
export 'src/social/offer_transport.dart';
|
||||
export 'src/social/profile_transport.dart';
|
||||
export 'src/social/sync_transport.dart';
|
||||
export 'src/social/trust_transport.dart';
|
||||
export 'src/social/web_of_trust.dart';
|
||||
export 'src/value/quantity.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:nostr/nostr.dart';
|
||||
|
||||
import '../sync_transport.dart';
|
||||
import 'nostr_channel.dart';
|
||||
|
||||
/// Nostr backend for [SyncTransport], on the shared channel.
|
||||
///
|
||||
/// Uses NIP-78 application-data (kind 30078, addressable/replaceable): each
|
||||
/// device keeps ONE record keyed by `d = "<namespace>/<deviceId>"`, so a new
|
||||
/// push replaces that device's previous snapshot. The snapshot bytes are
|
||||
/// NIP-44-encrypted TO SELF (ECDH of the identity with its own key), so the
|
||||
/// relay only ever sees ciphertext and only this identity's devices can read it.
|
||||
/// Discovery filters to this identity's own author key, so nobody else's data
|
||||
/// enters the stream.
|
||||
class NostrSyncTransport implements SyncTransport {
|
||||
NostrSyncTransport(
|
||||
this._conn, {
|
||||
required this.namespace,
|
||||
required this.deviceId,
|
||||
});
|
||||
|
||||
final NostrChannel _conn;
|
||||
|
||||
/// The app's data namespace, e.g. `org.comunes.tane/inventory` — kept out of
|
||||
/// the core so `commons_core` stays seed-agnostic.
|
||||
final String namespace;
|
||||
|
||||
/// A stable per-install id so each device replaces only its OWN record (NOT
|
||||
/// the seed-derived CRDT node id, which is shared across a user's devices).
|
||||
final String deviceId;
|
||||
|
||||
/// NIP-78 arbitrary application data (parameterized-replaceable).
|
||||
static const kindAppData = 30078;
|
||||
|
||||
String get _dTag => '$namespace/$deviceId';
|
||||
|
||||
@override
|
||||
Future<void> pushSnapshot(List<int> snapshot) async {
|
||||
final ciphertext = await Encryption.encrypt(
|
||||
plaintext: base64.encode(snapshot),
|
||||
senderSecretKey: _conn.privateKeyHex,
|
||||
recipientPubkey: _conn.publicKeyHex, // to self
|
||||
);
|
||||
final event = Event.from(
|
||||
kind: kindAppData,
|
||||
content: ciphertext,
|
||||
tags: [
|
||||
['d', _dTag],
|
||||
],
|
||||
secretKey: _conn.privateKeyHex,
|
||||
);
|
||||
final r = await _conn.publish(event);
|
||||
if (!r.accepted) throw StateError('relay rejected snapshot: ${r.message}');
|
||||
}
|
||||
|
||||
Filter get _filter =>
|
||||
Filter(kinds: const [kindAppData], authors: [_conn.publicKeyHex]);
|
||||
|
||||
@override
|
||||
Stream<SyncSnapshot> snapshots() =>
|
||||
_conn.subscribe(_filter).asyncMap(_decode).where((s) => s != null).cast();
|
||||
|
||||
@override
|
||||
Future<List<SyncSnapshot>> snapshotsUntilEose() async {
|
||||
final events = await _conn.reqOnce(_filter);
|
||||
final out = <SyncSnapshot>[];
|
||||
for (final e in events) {
|
||||
final s = await _decode(e);
|
||||
if (s != null) out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Decodes one event into a snapshot, or null if it isn't one of ours / can't
|
||||
/// be read (wrong namespace, tampered, not decryptable).
|
||||
Future<SyncSnapshot?> _decode(Event event) async {
|
||||
final d = _tag(event, 'd');
|
||||
if (d == null || !d.startsWith('$namespace/')) return null;
|
||||
try {
|
||||
final plain = await Encryption.decrypt(
|
||||
payload: event.content,
|
||||
recipientSecretKey: _conn.privateKeyHex,
|
||||
senderPubkey: _conn.publicKeyHex, // from self
|
||||
);
|
||||
return SyncSnapshot(
|
||||
deviceId: d.substring(namespace.length + 1),
|
||||
data: base64.decode(plain),
|
||||
at: DateTime.fromMillisecondsSinceEpoch(event.createdAt * 1000),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _tag(Event e, String name) {
|
||||
for (final t in e.tags) {
|
||||
if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _conn.close();
|
||||
}
|
||||
43
packages/commons_core/lib/src/social/sync_transport.dart
Normal file
43
packages/commons_core/lib/src/social/sync_transport.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/// One device's encrypted snapshot, as handed back to the app after decryption.
|
||||
class SyncSnapshot {
|
||||
const SyncSnapshot({
|
||||
required this.deviceId,
|
||||
required this.data,
|
||||
required this.at,
|
||||
});
|
||||
|
||||
/// Which of the identity's devices published this snapshot.
|
||||
final String deviceId;
|
||||
|
||||
/// The opaque snapshot bytes the app pushed (already decrypted). The core
|
||||
/// never inspects them — it only moves them, encrypted, between your devices.
|
||||
final List<int> data;
|
||||
|
||||
/// When the publishing device stamped it.
|
||||
final DateTime at;
|
||||
}
|
||||
|
||||
/// Moves an opaque, ENCRYPTED snapshot blob between one identity's OWN devices,
|
||||
/// so a CRDT-ready store (HLC + tombstones + LWW) can replicate itself without a
|
||||
/// central server. Seed-agnostic: the core carries bytes and does the
|
||||
/// encryption; the app decides what the bytes are (e.g. the inventory) and how
|
||||
/// to merge them on receipt (its existing reconciler).
|
||||
///
|
||||
/// Deliberately separate from offers/messaging/trust — its verb is "replicate my
|
||||
/// own state to my other devices", not publish/discover or DM. Each device keeps
|
||||
/// ONE replaceable record (re-pushing replaces it), so the relay holds at most
|
||||
/// one snapshot per device.
|
||||
abstract interface class SyncTransport {
|
||||
/// Publishes this device's latest [snapshot] (opaque bytes), encrypted so only
|
||||
/// this identity's devices can read it. Replaces this device's previous one.
|
||||
Future<void> pushSnapshot(List<int> snapshot);
|
||||
|
||||
/// Streams the identity's device snapshots (decrypted) as they arrive, live.
|
||||
/// May include this device's own pushes — the app's merge must be idempotent.
|
||||
Stream<SyncSnapshot> snapshots();
|
||||
|
||||
/// One-shot fetch of every device's current snapshot, up to EOSE.
|
||||
Future<List<SyncSnapshot>> snapshotsUntilEose();
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
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