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 b9492cb65a
commit bf15992a97
5 changed files with 262 additions and 1 deletions

View file

@ -48,7 +48,7 @@ Estas fijan `schemaVersion = 1` y el arranque técnico:
3. **Apuesta fuerte por Nostr.** Ofertas, mensajes y confianza dependerían de NIPs en evolución (99 / 17 / 85). La abstracción `OfferTransport` amortigua las ofertas, pero mensajería y confianza también se apoyan en Nostr → más acoplamiento del que sugería el plan. Revisar si un solo protocolo lo cubre bien o si conviene aislar también mensajería y confianza tras interfaces. 3. **Apuesta fuerte por Nostr.** Ofertas, mensajes y confianza dependerían de NIPs en evolución (99 / 17 / 85). La abstracción `OfferTransport` amortigua las ofertas, pero mensajería y confianza también se apoyan en Nostr → más acoplamiento del que sugería el plan. Revisar si un solo protocolo lo cubre bien o si conviene aislar también mensajería y confianza tras interfaces.
4. **Cifrado en reposo + sync CRDT + multidispositivo.** Hay que asegurar que la sincronización **no filtra la llave ni datos en claro**, y que la llave (QR) llega bien al segundo dispositivo. Encaja, pero es un punto técnico fino que conviene prototipar pronto. 4. **Cifrado en reposo + sync CRDT + multidispositivo — EN CURSO (base landed 2026-07-10).** El modelo ya está listo para CRDT (HLC, tombstones, LWW, Movement append-only) y el reconciliador de import prueba el merge; faltaba el **transporte de sync**. Primera rebanada en `commons_core`: `SyncTransport` (mueve snapshots opacos y cifrados entre los dispositivos de UNA identidad) + `NostrSyncTransport` (NIP-78 kind 30078 addressable/reemplazable por dispositivo, contenido **NIP-44 cifrado a sí mismo** → el relay solo ve cifrado; filtrado a tu propia clave de autor). Tests con MiniRelay: replica al otro dispositivo, el cable solo ve cifrado, otra identidad no recibe ni podría descifrar, re-push reemplaza. **Confirma la garantía de §4**: no filtra llave ni datos en claro. PENDIENTE (siguiente rebanada, app): `SyncService` que serialice el inventario (reusa `ExportImportService`), empuje en mutaciones, y en recepción corra `importInventory` (LWW idempotente ya existente); id de dispositivo estable por instalación (keystore, NO el nodeId derivado de la semilla, que es común a los dispositivos); integrar `sync` en `SocialSession` (5ª interfaz sobre la conexión compartida); UI de conflictos (raro).
5. **La "varilla" y los datos de conservación** necesitan una fuente y una curación que **aún no tenemos**. Puede ser más trabajo del que parece (y es, a la vez, de lo que más valor humano aporta). 5. **La "varilla" y los datos de conservación** necesitan una fuente y una curación que **aún no tenemos**. Puede ser más trabajo del que parece (y es, a la vez, de lo que más valor humano aporta).

View file

@ -16,6 +16,7 @@ export 'src/social/geohash.dart';
export 'src/social/message_transport.dart'; export 'src/social/message_transport.dart';
export 'src/social/nostr_ids.dart'; export 'src/social/nostr_ids.dart';
export 'src/social/nostr/nostr_channel.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_connection.dart';
export 'src/social/nostr/nostr_message_transport.dart'; export 'src/social/nostr/nostr_message_transport.dart';
export 'src/social/nostr/nostr_offer_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.dart';
export 'src/social/offer_transport.dart'; export 'src/social/offer_transport.dart';
export 'src/social/profile_transport.dart'; export 'src/social/profile_transport.dart';
export 'src/social/sync_transport.dart';
export 'src/social/trust_transport.dart'; export 'src/social/trust_transport.dart';
export 'src/social/web_of_trust.dart'; export 'src/social/web_of_trust.dart';
export 'src/value/quantity.dart'; export 'src/value/quantity.dart';

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

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