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:
vjrj 2026-07-10 23:16:07 +02:00
parent bb4ee2fd89
commit 182c334883
5 changed files with 262 additions and 1 deletions

View file

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

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