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
|
|
@ -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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue