diff --git a/docs/design/spike-block2-findings.md b/docs/design/spike-block2-findings.md index 1102749..dbd8ec2 100644 --- a/docs/design/spike-block2-findings.md +++ b/docs/design/spike-block2-findings.md @@ -1,9 +1,9 @@ # Spike — Block 2 (social layer) de-risking findings *Throwaway research spike. Language: English (findings), per code convention. -The throwaway spike code (`spike/block2_spike/`) has been moved out of this -public repo and preserved privately — it was **not production, not on the pub -workspace, no deps added to Block 1**. This document is the deliverable: what we learned, the risks, and a +Code lives in [`spike/block2_spike/`](../../spike/block2_spike/) and is marked +throwaway — **not production, not on the pub workspace, no deps added to Block +1**. This document is the deliverable: what we learned, the risks, and a recommendation on whether/how to take on the social round (Phase 3).* > **Status: this is a spike, not a green light to build Block 2.** CLAUDE.md and @@ -34,7 +34,7 @@ See "How to run" at the end. **Result: works, deterministic, one-way. This unblocks "one identity, one backup".** -- Implemented `NostrKey.deriveFromSeed`: +- Implemented [`NostrKey.deriveFromSeed`](../../spike/block2_spike/lib/src/nostr_key.dart): `HKDF-SHA256(rootSeed, info: "org.comunes.tane/nostr/secp256k1/v1")` → reduce to a valid secp256k1 scalar `d ∈ [1, n-1]` by rejection (bump a counter in the HKDF info on the astronomically-rare miss, so the result stays a pure function @@ -80,13 +80,13 @@ trust do NOT fit behind the same interface — they share a *connection*, not a *contract*.** ### The seam works (offers) -- `OfferTransport` = +- [`OfferTransport`](../../spike/block2_spike/lib/src/offer_transport.dart) = `publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr - NIP-99 backend (`NostrOfferTransport` - + `Nip99Codec`) sits entirely + NIP-99 backend ([`NostrOfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart) + + [`Nip99Codec`](../../spike/block2_spike/lib/src/nip99.dart)) sits entirely behind it. A second backend (ActivityPub/FEP-0837) could replace it without the domain noticing. -- `Offer` is agnostic by +- [`Offer`](../../spike/block2_spike/lib/src/offer.dart) is agnostic by construction: a chosen *summary* + coarse geohash, **no FK into seed tables, no full inventory, no exact address** — exactly the Offer↔Lot split [sharing-model.md](sharing-model.md) §2 and @@ -114,14 +114,14 @@ manageable**: private encrypted DM vs. assert/read a signed certification. Forcing DMs and certifications behind `OfferTransport.publish/discover` would overload it. - **Recommendation — now demonstrated in code, not just asserted:** - `NostrConnection` is + [`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is **one shared** socket + key + sign + REQ/EOSE lifecycle, and **all three thin interfaces sit on top of the same instance** — - `OfferTransport` + [`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart) (NIP-99), - `MessageTransport` + [`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart) (NIP-17) and - `TrustTransport` + [`TrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart) (custom WoT). The trust test even runs offer discovery and trust annotation over one connection. This is the shape to lift into `commons_core`: shared connection, per-concern contract. Trying to make `OfferTransport` also carry @@ -139,7 +139,7 @@ that whole happy path now runs — what's left is production hardening (below). **Result: the flow works end to end. The real risk is NIP maturity/ecosystem, not the mechanics.** -- Built a hermetic in-process `MiniRelay` +- Built a hermetic in-process [`MiniRelay`](../../spike/block2_spike/lib/src/mini_relay.dart) (NIP-01 subset: EVENT/REQ/EOSE/CLOSE, filter by `kinds`/`authors`/`#g`, addressable-event replacement for kind 30402). No network → CI-safe, no flaky public-relay dependency. @@ -183,13 +183,13 @@ The plan ([network-trust.md](network-trust.md) §4) calls messaging "grande" and [open-decisions.md](open-decisions.md) §D.3 singled it out as the tightest coupling to Nostr. So the spike built it rather than hand-waving: -- **NIP-44 v2 encryption** (`Nip44`): +- **NIP-44 v2 encryption** ([`Nip44`](../../spike/block2_spike/lib/src/nip44.dart)): secp256k1 ECDH → HKDF conversation key → ChaCha20 + HMAC-SHA256 with length-hiding padding. Conversation key is symmetric (A→B == B→A, tested); a wrong key cannot decrypt (throws, tested); short messages pad to the same bucket so ciphertext length doesn't leak plaintext length (tested). - **NIP-17 / NIP-59 gift-wrap onion** - (`NostrMessageTransport`): + ([`NostrMessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)): rumor (kind 14, unsigned) → seal (kind 13, signed by sender, NIP-44 to recipient) → gift wrap (kind 1059, signed by a **throwaway ephemeral key**, NIP-44 to recipient). @@ -223,12 +223,12 @@ There is no settled NIP for a web of trust, so — exactly as anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr: - **Certifications as events** - (`NostrTrustTransport`): + ([`NostrTrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)): a custom addressable kind (30777) keyed by (issuer, `d`=subject), so one live "A vouches for B" per pair; re-certifying renews, revoking replaces (tested). Certifications expire (Ğ1 semantics) and are public (like the on-chain Ğ1 WoT). - **The membership rule is pure and separately tested** - (`WebOfTrust`) — the + ([`WebOfTrust`](../../spike/block2_spike/lib/src/web_of_trust.dart)) — the `commons_core`-worthy piece: Duniter's two rules, **N certifications from existing members** (sigQty) and **within distance D of the bootstrap referents** (stepMax), iterated to a fixpoint (becoming a member can push @@ -263,9 +263,8 @@ entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust sourc ([open-decisions.md](open-decisions.md) §D.1, §D.6). That is what should drive the Phase-3 estimate and the funding ask. 4. **Do not start Block 2 from this spike.** It is throwaway research code (not - vector-verified, no offline delivery, no persistence, single relay). The - `spike/block2_spike/` code has been moved out of this public repo (preserved - privately) now these findings are captured. The funded social + vector-verified, no offline delivery, no persistence, single relay). Delete + `spike/block2_spike/` once these findings are absorbed. The funded social round should **rebuild on vetted client libraries in `commons_core`**, using the shapes proven here — one `NostrConnection`, three interfaces, the pure `WebOfTrust` rule — and spend its risk budget on hardening and bootstrap, not @@ -284,12 +283,11 @@ entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust sourc ## How to run -The throwaway spike code has been moved out of this public repo and is preserved -privately; nothing here was wired into `app_seeds` or `commons_core`'s production -graph, and the Block 1 suite is untouched. For the record, it ran as: - ```sh -cd spike/block2_spike # in the private archive +cd spike/block2_spike dart pub get dart test # derivation · privacy · roundtrip · messaging · trust · web_of_trust (30 tests) ``` + +Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the +Block 1 suite is untouched. diff --git a/spike/block2_spike/.gitignore b/spike/block2_spike/.gitignore new file mode 100644 index 0000000..d202eb0 --- /dev/null +++ b/spike/block2_spike/.gitignore @@ -0,0 +1,3 @@ +# Throwaway spike — don't commit build artifacts or the resolved lockfile. +.dart_tool/ +pubspec.lock diff --git a/spike/block2_spike/lib/block2_spike.dart b/spike/block2_spike/lib/block2_spike.dart new file mode 100644 index 0000000..5b8a346 --- /dev/null +++ b/spike/block2_spike/lib/block2_spike.dart @@ -0,0 +1,19 @@ +/// THROWAWAY Block 2 de-risking spike. Not production; not on the pub workspace. +library; + +export 'src/bech32.dart'; +export 'src/hkdf.dart'; +export 'src/message_transport.dart'; +export 'src/mini_relay.dart'; +export 'src/nip44.dart'; +export 'src/nip99.dart'; +export 'src/nostr_connection.dart'; +export 'src/nostr_event.dart'; +export 'src/nostr_key.dart'; +export 'src/nostr_message_transport.dart'; +export 'src/nostr_offer_transport.dart'; +export 'src/nostr_trust_transport.dart'; +export 'src/offer.dart'; +export 'src/offer_transport.dart'; +export 'src/trust_transport.dart'; +export 'src/web_of_trust.dart'; diff --git a/spike/block2_spike/lib/src/bech32.dart b/spike/block2_spike/lib/src/bech32.dart new file mode 100644 index 0000000..7d5c033 --- /dev/null +++ b/spike/block2_spike/lib/src/bech32.dart @@ -0,0 +1,54 @@ +/// Self-contained bech32 encode (BIP-173 checksum), enough for NIP-19 +/// `npub`/`nsec`. Written inline to avoid a dependency's version quirks; NIP-19 +/// is plain bech32 (checksum constant 1), not bech32m. +const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + +int _polymod(List values) { + const gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + var chk = 1; + for (final v in values) { + final top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (var i = 0; i < 5; i++) { + if ((top >> i) & 1 == 1) chk ^= gen[i]; + } + } + return chk; +} + +List _hrpExpand(String hrp) => [ + ...hrp.codeUnits.map((c) => c >> 5), + 0, + ...hrp.codeUnits.map((c) => c & 31), +]; + +List _createChecksum(String hrp, List data) { + final values = [..._hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]; + final mod = _polymod(values) ^ 1; + return List.generate(6, (i) => (mod >> (5 * (5 - i))) & 31); +} + +/// Converts 8-bit bytes to 5-bit groups (bech32 payload), padding with zeros. +List _convertBits8to5(List data) { + var acc = 0; + var bits = 0; + final out = []; + for (final value in data) { + acc = (acc << 8) | value; + bits += 8; + while (bits >= 5) { + bits -= 5; + out.add((acc >> bits) & 31); + } + } + if (bits > 0) out.add((acc << (5 - bits)) & 31); + return out; +} + +/// bech32-encodes [data] (raw bytes) under [hrp] (e.g. `npub`). +String bech32Encode(String hrp, List data) { + final five = _convertBits8to5(data); + final checksum = _createChecksum(hrp, five); + final combined = [...five, ...checksum]; + return '${hrp}1${combined.map((v) => _charset[v]).join()}'; +} diff --git a/spike/block2_spike/lib/src/hkdf.dart b/spike/block2_spike/lib/src/hkdf.dart new file mode 100644 index 0000000..020360f --- /dev/null +++ b/spike/block2_spike/lib/src/hkdf.dart @@ -0,0 +1,55 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Minimal, self-contained HKDF-SHA256 (RFC 5869), synchronous. +/// +/// Spike-local so the derivation stays pure Dart and easy to reason about. In +/// production this would reuse `commons_core`'s `Hkdf` (the `cryptography` +/// package already ships one — see backup_box.dart), keyed by a domain string. +Uint8List hkdfSha256({ + required List ikm, + required String info, + List salt = const [], + int length = 32, +}) { + // Extract. + final actualSalt = salt.isEmpty ? Uint8List(32) : salt; // HashLen zeros + final prk = Hmac(sha256, actualSalt).convert(ikm).bytes; + + // Expand. + final infoBytes = info.codeUnits; + final out = []; + var previous = []; + var counter = 1; + while (out.length < length) { + final input = [...previous, ...infoBytes, counter]; + previous = Hmac(sha256, prk).convert(input).bytes; + out.addAll(previous); + counter++; + } + return Uint8List.fromList(out.sublist(0, length)); +} + +/// HKDF-Extract (RFC 5869 §2.2): `PRK = HMAC(salt, ikm)`. NIP-44 uses this +/// alone to turn the ECDH secret into the conversation key. +Uint8List hkdfExtract({required List salt, required List ikm}) => + Uint8List.fromList(Hmac(sha256, salt).convert(ikm).bytes); + +/// HKDF-Expand (RFC 5869 §2.3) with a *byte* info (NIP-44 uses the nonce as +/// info), producing [length] bytes. +Uint8List hkdfExpandBytes({ + required List prk, + required List info, + required int length, +}) { + final out = []; + var previous = []; + var counter = 1; + while (out.length < length) { + previous = Hmac(sha256, prk).convert([...previous, ...info, counter]).bytes; + out.addAll(previous); + counter++; + } + return Uint8List.fromList(out.sublist(0, length)); +} diff --git a/spike/block2_spike/lib/src/message_transport.dart b/spike/block2_spike/lib/src/message_transport.dart new file mode 100644 index 0000000..811cd2e --- /dev/null +++ b/spike/block2_spike/lib/src/message_transport.dart @@ -0,0 +1,28 @@ +/// One decrypted direct message handed to the app. +class DirectMessage { + const DirectMessage({ + required this.fromPubkey, + required this.text, + required this.at, + }); + + /// The authenticated sender (from the inner rumor). NOT the gift-wrap author, + /// which is a throwaway ephemeral key — that's the metadata-privacy point. + final String fromPubkey; + final String text; + final DateTime at; +} + +/// The second interface over the shared `NostrConnection` (Q2): private 1:1 +/// messaging. Deliberately SEPARATE from `OfferTransport` — its verbs (send a +/// private DM / read an inbox) don't fit publish/discover. Backed by NIP-17 +/// gift-wrapped, NIP-44-encrypted events. +abstract interface class MessageTransport { + /// Sends [text] privately to [toPubkey]. + Future send({required String toPubkey, required String text}); + + /// Streams decrypted messages addressed to this identity. + Stream inbox(); + + Future close(); +} diff --git a/spike/block2_spike/lib/src/mini_relay.dart b/spike/block2_spike/lib/src/mini_relay.dart new file mode 100644 index 0000000..7770fb1 --- /dev/null +++ b/spike/block2_spike/lib/src/mini_relay.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'nostr_event.dart'; + +/// A tiny, in-process Nostr relay (NIP-01 subset): EVENT / REQ / EOSE / CLOSE, +/// filtering by `kinds`, `authors`, `ids` and `#g` (exact tag). Enough to prove +/// the publish→discover round-trip WITHOUT touching the network, so the test is +/// hermetic and CI-safe. Not a real relay — no persistence, no NIP-11, no auth. +/// +/// Addressable events (kind 30000–39999, NIP-99's 30402) replace by +/// (kind, pubkey, `d`) like a real relay, so re-publishing an offer updates it. +class MiniRelay { + MiniRelay._(this._server); + + final HttpServer _server; + final List _events = []; + final Set<_Sub> _subs = {}; + + int get port => _server.port; + String get url => 'ws://127.0.0.1:$port'; + + /// Number of events currently stored (for assertions/metrics). + int get storedCount => _events.length; + + static Future start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final relay = MiniRelay._(server); + relay._accept(); + return relay; + } + + void _accept() { + _server.listen((req) async { + if (!WebSocketTransformer.isUpgradeRequest(req)) { + req.response.statusCode = HttpStatus.badRequest; + await req.response.close(); + return; + } + final ws = await WebSocketTransformer.upgrade(req); + final socketSubs = <_Sub>{}; + ws.listen( + (data) => _onMessage(ws, socketSubs, data as String), + onDone: () => _subs.removeAll(socketSubs), + onError: (_) => _subs.removeAll(socketSubs), + ); + }); + } + + void _onMessage(WebSocket ws, Set<_Sub> socketSubs, String data) { + final msg = jsonDecode(data) as List; + switch (msg[0]) { + case 'EVENT': + _handleEvent(ws, NostrEvent.fromJson(msg[1] as Map)); + case 'REQ': + final subId = msg[1] as String; + final filters = msg.sublist(2).cast>(); + final sub = _Sub(ws, subId, filters); + _subs.add(sub); + socketSubs.add(sub); + for (final e in _events) { + if (sub.matches(e)) _send(ws, ['EVENT', subId, e.toJson()]); + } + _send(ws, ['EOSE', subId]); + case 'CLOSE': + final subId = msg[1] as String; + socketSubs.removeWhere((s) => s.id == subId); + _subs.removeWhere((s) => s.ws == ws && s.id == subId); + } + } + + void _handleEvent(WebSocket ws, NostrEvent event) { + if (!event.verify()) { + _send(ws, ['OK', event.id, false, 'invalid: bad signature']); + return; + } + if (event.kind >= 30000 && event.kind < 40000) { + final d = event.tag('d') ?? ''; + _events.removeWhere( + (e) => e.kind == event.kind && e.pubkey == event.pubkey && (e.tag('d') ?? '') == d, + ); + } + _events.add(event); + _send(ws, ['OK', event.id, true, '']); + for (final sub in _subs) { + if (sub.matches(event)) _send(sub.ws, ['EVENT', sub.id, event.toJson()]); + } + } + + void _send(WebSocket ws, Object message) => ws.add(jsonEncode(message)); + + Future stop() => _server.close(force: true); +} + +class _Sub { + _Sub(this.ws, this.id, this.filters); + + final WebSocket ws; + final String id; + final List> filters; + + bool matches(NostrEvent e) => filters.any((f) => _matchesFilter(e, f)); + + bool _matchesFilter(NostrEvent e, Map f) { + if (f['kinds'] != null && !(f['kinds'] as List).contains(e.kind)) { + return false; + } + if (f['authors'] != null && !(f['authors'] as List).contains(e.pubkey)) { + return false; + } + if (f['ids'] != null && !(f['ids'] as List).contains(e.id)) return false; + for (final entry in f.entries) { + if (!entry.key.startsWith('#')) continue; + final tagName = entry.key.substring(1); + final wanted = (entry.value as List).cast(); + final present = e.tags + .where((t) => t.isNotEmpty && t[0] == tagName && t.length > 1) + .map((t) => t[1]); + if (!wanted.any(present.contains)) return false; + } + return true; + } +} diff --git a/spike/block2_spike/lib/src/nip44.dart b/spike/block2_spike/lib/src/nip44.dart new file mode 100644 index 0000000..3a71fa0 --- /dev/null +++ b/spike/block2_spike/lib/src/nip44.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; + +import 'hkdf.dart'; + +/// NIP-44 v2 payload encryption (secp256k1 ECDH → conversation key → ChaCha20 + +/// HMAC-SHA256, with length-hiding padding). This is the crypto NIP-17 private +/// DMs ride on. Spike-grade: follows the NIP-44 *construction* faithfully but is +/// NOT cross-checked against the reference test vectors — see findings §4. Good +/// enough to prove the messaging flow and architecture round-trip. +class Nip44 { + static final _params = ECCurve_secp256k1(); + + /// secp256k1 field prime. + static final BigInt _p = BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', + radix: 16, + ); + + static final _salt = ascii.encode('nip44-v2'); + + /// ECDH conversation key shared by [privHexA] and x-only [pubHexB]: + /// `HKDF-Extract(salt="nip44-v2", ikm = (d·P).x)`. Symmetric: A→B and B→A + /// yield the same key. + static Uint8List conversationKey(String privHexA, String pubHexB) { + final d = _bi(privHexA); + final point = _liftX(_bi(pubHexB)); + final shared = (point * d)!; + final x = shared.x!.toBigInteger()!; + return hkdfExtract(salt: _salt, ikm: _to32(x)); + } + + /// Encrypts [plaintext] under [convKey]; returns base64 NIP-44 payload. + static String encrypt(Uint8List convKey, String plaintext, {Uint8List? nonce}) { + final n = nonce ?? _randomBytes(32); + final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76); + final chachaKey = keys.sublist(0, 32); + final chachaNonce = keys.sublist(32, 44); + final hmacKey = keys.sublist(44, 76); + + final padded = _pad(utf8.encode(plaintext)); + final ciphertext = _chacha20(chachaKey, chachaNonce, padded); + final mac = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes; + + return base64.encode([2, ...n, ...ciphertext, ...mac]); + } + + /// Decrypts a base64 NIP-44 payload under [convKey]. Throws on version/MAC + /// failure (so an eavesdropper with the wrong key cannot read it). + static String decrypt(Uint8List convKey, String payload) { + final raw = base64.decode(payload); + if (raw[0] != 2) throw const FormatException('unsupported nip44 version'); + final n = raw.sublist(1, 33); + final ciphertext = raw.sublist(33, raw.length - 32); + final mac = raw.sublist(raw.length - 32); + + final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76); + final chachaKey = keys.sublist(0, 32); + final chachaNonce = keys.sublist(32, 44); + final hmacKey = keys.sublist(44, 76); + + final expected = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes; + if (!_constantTimeEquals(expected, mac)) { + throw const FormatException('nip44 MAC mismatch (wrong key or tampered)'); + } + final padded = _chacha20(chachaKey, chachaNonce, Uint8List.fromList(ciphertext)); + final len = (padded[0] << 8) | padded[1]; + return utf8.decode(padded.sublist(2, 2 + len)); + } + + // --- padding (NIP-44 length hiding) --- + + static Uint8List _pad(List plaintext) { + final unpadded = plaintext.length; + final paddedLen = _calcPaddedLen(unpadded); + final buf = Uint8List(2 + paddedLen); + buf[0] = (unpadded >> 8) & 0xff; + buf[1] = unpadded & 0xff; + buf.setRange(2, 2 + unpadded, plaintext); + return buf; + } + + static int _calcPaddedLen(int len) { + if (len <= 32) return 32; + final nextPower = 1 << ((len - 1).bitLength); + final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8; + return chunk * ((len - 1) ~/ chunk + 1); + } + + // --- primitives --- + + static Uint8List _chacha20(List key, List nonce, Uint8List input) { + final engine = ChaCha7539Engine() + ..init( + true, + ParametersWithIV( + KeyParameter(Uint8List.fromList(key)), + Uint8List.fromList(nonce), + ), + ); + final out = Uint8List(input.length); + engine.processBytes(input, 0, input.length, out, 0); + return out; + } + + /// BIP340 lift_x: reconstruct the curve point with even y from x-only [x]. + static ECPoint _liftX(BigInt x) { + final ySq = (x.modPow(BigInt.from(3), _p) + BigInt.from(7)) % _p; + var y = ySq.modPow((_p + BigInt.one) ~/ BigInt.from(4), _p); + if (y.isOdd) y = _p - y; + return _params.curve.createPoint(x, y); + } + + static BigInt _bi(String hex) => BigInt.parse(hex, radix: 16); + + static Uint8List _to32(BigInt v) { + final out = Uint8List(32); + var x = v; + for (var i = 31; i >= 0; i--) { + out[i] = (x & BigInt.from(0xff)).toInt(); + x = x >> 8; + } + return out; + } + + static bool _constantTimeEquals(List a, List b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } + + static final _rng = Random.secure(); + static Uint8List _randomBytes(int n) => + Uint8List.fromList(List.generate(n, (_) => _rng.nextInt(256))); +} diff --git a/spike/block2_spike/lib/src/nip99.dart b/spike/block2_spike/lib/src/nip99.dart new file mode 100644 index 0000000..7cee39e --- /dev/null +++ b/spike/block2_spike/lib/src/nip99.dart @@ -0,0 +1,118 @@ +import 'nostr_event.dart'; +import 'offer.dart'; + +/// Maps [Offer] ↔ Nostr NIP-99 "Classified Listing" events (kind 30402), the +/// first concrete `OfferTransport` backend (sharing-model.md §2). +/// +/// This codec is where the privacy contract is *enforced on the wire*: the only +/// location that ever leaves the device is a coarse geohash, truncated here to +/// [maxGeohashChars]. There is no field that could carry the inventory or an +/// exact address, because [Offer] itself has none — the seam does the work. +class Nip99Codec { + /// NIP-99 addressable classified listing. + static const kindActive = 30402; + + /// A geohash of 5 chars ≈ ±2.4 km cell — the coarse "near you" of the mocks. + /// Anything finer is refused so a slip in the caller can't leak a precise fix. + static const maxGeohashChars = 5; + + /// Builds an UNSIGNED event from [offer]. Caller signs with the derived key. + NostrEvent encode(Offer offer, {required int createdAt}) { + final coarse = _coarsen(offer.approxGeohash); + + final tags = >[ + ['d', offer.id], + ['title', offer.summary], + ['status', offer.status == OfferStatus.active ? 'active' : 'sold'], + // Reciprocity mode is a Tanemaki concept NIP-99 lacks; carry it explicitly. + ['offer_type', offer.type.name], + ]; + // NIP-52 geohash ladder: one `g` tag per prefix length so an area query + // matches by *exact* tag (how Nostr relays filter) yet still finds coarser + // searches. Coarse only — NO "location"/address/exact-fix tag ever. + for (var i = 1; i <= coarse.length; i++) { + tags.add(['g', coarse.substring(0, i)]); + } + if (offer.category != null) tags.add(['t', offer.category!]); + if (offer.radiusKm != null) { + tags.add(['radius_km', '${offer.radiusKm}']); + } + if (offer.type == OfferType.sale && offer.priceAmount != null) { + tags.add([ + 'price', + '${offer.priceAmount}', + offer.priceCurrency ?? 'EUR', + ]); + } + if (offer.type == OfferType.exchange && offer.exchangeTerms != null) { + tags.add(['exchange_terms', offer.exchangeTerms!]); + } + if (offer.imageUrl != null) tags.add(['image', offer.imageUrl!]); + if (offer.expiresAt != null) { + tags.add([ + 'expiration', + '${offer.expiresAt!.millisecondsSinceEpoch ~/ 1000}', + ]); + } + + return NostrEvent( + pubkey: offer.authorPubkeyHex, + createdAt: createdAt, + kind: kindActive, + tags: tags, + content: offer.summary, + ); + } + + /// Reconstructs an [Offer] from a received event. + Offer decode(NostrEvent event) { + final priceTag = _fullTag(event, 'price'); + return Offer( + id: event.tag('d') ?? event.id, + authorPubkeyHex: event.pubkey, + summary: event.tag('title') ?? event.content, + type: _parseType(event.tag('offer_type')), + status: event.tag('status') == 'sold' + ? OfferStatus.closed + : OfferStatus.active, + category: event.tag('t'), + approxGeohash: _longestGeohash(event), + radiusKm: int.tryParse(event.tag('radius_km') ?? ''), + priceAmount: priceTag != null && priceTag.length > 1 + ? num.tryParse(priceTag[1]) + : null, + priceCurrency: priceTag != null && priceTag.length > 2 + ? priceTag[2] + : null, + exchangeTerms: event.tag('exchange_terms'), + imageUrl: event.tag('image'), + ); + } + + String _coarsen(String geohash) => geohash.length > maxGeohashChars + ? geohash.substring(0, maxGeohashChars) + : geohash; + + /// The finest `g` tag present (top of the prefix ladder). + String _longestGeohash(NostrEvent event) { + var best = ''; + for (final t in event.tags) { + if (t.isNotEmpty && t[0] == 'g' && t.length > 1 && t[1].length > best.length) { + best = t[1]; + } + } + return best; + } + + List? _fullTag(NostrEvent event, String name) { + for (final t in event.tags) { + if (t.isNotEmpty && t[0] == name) return t; + } + return null; + } + + OfferType _parseType(String? name) => OfferType.values.firstWhere( + (t) => t.name == name, + orElse: () => OfferType.gift, + ); +} diff --git a/spike/block2_spike/lib/src/nostr_connection.dart b/spike/block2_spike/lib/src/nostr_connection.dart new file mode 100644 index 0000000..0ef3ec0 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_connection.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'nostr_event.dart'; + +/// The ONE piece shared by every Nostr-backed transport (Q2 recommendation): a +/// single relay socket + the signing identity + the EVENT/OK and REQ/EOSE +/// lifecycle. `OfferTransport`, `MessageTransport` and a future `TrustTransport` +/// each sit on top of this — they share this *connection*, not a *contract*. +class NostrConnection { + NostrConnection._(this._socket, this.privateKeyHex, this.publicKeyHex) { + _socket.listen(_onData, onDone: _onDone); + } + + final WebSocket _socket; + + /// The derived (Q1) identity this connection signs with. + final String privateKeyHex; + final String publicKeyHex; + + final _incoming = StreamController>.broadcast(); + int _subCounter = 0; + + static Future connect( + String relayUrl, { + required String privateKeyHex, + required String publicKeyHex, + }) async { + final socket = await WebSocket.connect(relayUrl); + return NostrConnection._(socket, privateKeyHex, publicKeyHex); + } + + void _onData(dynamic d) => _incoming.add(jsonDecode(d as String) as List); + void _onDone() { + if (!_incoming.isClosed) _incoming.close(); + } + + /// Publishes an already-signed [event]; resolves with the relay's OK verdict. + Future<({bool accepted, String message})> publish(NostrEvent event) async { + final ok = _incoming.stream.firstWhere( + (m) => m[0] == 'OK' && m[1] == event.id, + ); + _socket.add(jsonEncode(['EVENT', event.toJson()])); + final r = await ok.timeout(const Duration(seconds: 5)); + return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : ''); + } + + /// Streams events matching [filter] (live), until the caller cancels. + Stream subscribe(Map filter) { + final subId = 'sub${_subCounter++}'; + late StreamController controller; + StreamSubscription? sub; + controller = StreamController( + onListen: () { + sub = _incoming.stream.listen((m) { + if (m[0] == 'EVENT' && m[1] == subId) { + controller.add(NostrEvent.fromJson(m[2] as Map)); + } + }); + _socket.add(jsonEncode(['REQ', subId, filter])); + }, + onCancel: () async { + _socket.add(jsonEncode(['CLOSE', subId])); + await sub?.cancel(); + }, + ); + return controller.stream; + } + + /// Collects stored events matching [filter] up to EOSE, then closes the sub. + Future> reqOnce(Map filter) async { + final subId = 'once${_subCounter++}'; + final results = []; + final done = Completer(); + final sub = _incoming.stream.listen((m) { + if (m[1] != subId) return; + if (m[0] == 'EVENT') { + results.add(NostrEvent.fromJson(m[2] as Map)); + } else if (m[0] == 'EOSE' && !done.isCompleted) { + done.complete(); + } + }); + _socket.add(jsonEncode(['REQ', subId, filter])); + await done.future.timeout(const Duration(seconds: 5)); + _socket.add(jsonEncode(['CLOSE', subId])); + await sub.cancel(); + return results; + } + + Future close() async { + await _socket.close(); + if (!_incoming.isClosed) await _incoming.close(); + } +} diff --git a/spike/block2_spike/lib/src/nostr_event.dart b/spike/block2_spike/lib/src/nostr_event.dart new file mode 100644 index 0000000..3323e44 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_event.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:crypto/crypto.dart'; + +/// A minimal Nostr event (NIP-01): enough to build, id, sign and serialise +/// NIP-99 classified listings. Throwaway — production would use a vetted client. +class NostrEvent { + NostrEvent({ + required this.pubkey, + required this.createdAt, + required this.kind, + required this.tags, + required this.content, + this.id = '', + this.sig = '', + }); + + final String pubkey; // x-only hex + final int createdAt; // unix seconds + final int kind; + final List> tags; + final String content; + String id; + String sig; + + /// NIP-01 id: sha256 of the compact JSON array + /// `[0, pubkey, created_at, kind, tags, content]`. + String computeId() { + final serialized = jsonEncode([0, pubkey, createdAt, kind, tags, content]); + final digest = sha256.convert(utf8.encode(serialized)); + return digest.toString(); // lower-case hex + } + + /// Fills [id] and [sig] (BIP340 Schnorr over the id) with [privateKeyHex]. + void signWith(String privateKeyHex, {String? auxRandHex}) { + id = computeId(); + // Deterministic aux in the spike (derived from id) so round-trip tests are + // reproducible; production uses fresh randomness per BIP340. + final aux = auxRandHex ?? id; + sig = bip340.sign(privateKeyHex, id, aux); + } + + /// Verifies the signature against [pubkey]. Cross-checks that a real relay / + /// peer would accept what we produced. + bool verify() => id == computeId() && bip340.verify(pubkey, id, sig); + + Map toJson() => { + 'id': id, + 'pubkey': pubkey, + 'created_at': createdAt, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + }; + + static NostrEvent fromJson(Map j) => NostrEvent( + id: j['id'] as String, + pubkey: j['pubkey'] as String, + createdAt: j['created_at'] as int, + kind: j['kind'] as int, + tags: (j['tags'] as List) + .map((t) => (t as List).map((e) => e as String).toList()) + .toList(), + content: j['content'] as String, + sig: j['sig'] as String, + ); + + /// First value of the first tag named [name], or null. + String? tag(String name) { + for (final t in tags) { + if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1]; + } + return null; + } +} diff --git a/spike/block2_spike/lib/src/nostr_key.dart b/spike/block2_spike/lib/src/nostr_key.dart new file mode 100644 index 0000000..5e5edf7 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_key.dart @@ -0,0 +1,77 @@ +import 'dart:typed_data'; + +import 'package:bip340/bip340.dart' as bip340; + +import 'bech32.dart'; +import 'hkdf.dart'; + +/// A Nostr identity (secp256k1 / BIP340 x-only) deterministically derived from +/// the Duniter/Ğ1-style root seed. This is Spike Question 1: prove we can keep +/// "one identity" (user backs up ONE seed) while still having a viable Nostr +/// transport key. +/// +/// Derivation is one-way by construction: the root seed feeds HKDF, and HKDF's +/// PRF (HMAC-SHA256) is not invertible, so neither the private nor the public +/// Nostr key leaks the root seed. See spike-block2-findings.md §1. +class NostrKey { + NostrKey._({required this.privateKeyHex, required this.publicKeyHex}); + + /// 32-byte secp256k1 scalar, lower-case hex (64 chars). + final String privateKeyHex; + + /// 32-byte BIP340 x-only public key, lower-case hex (64 chars). This is the + /// Nostr identity that appears on events and in `npub`. + final String publicKeyHex; + + /// NIP-19 `npub…` encoding of [publicKeyHex] — what a user would share. + String get npub => bech32Encode('npub', _hexToBytes(publicKeyHex)); + + /// NIP-19 `nsec…` encoding of [privateKeyHex] — secret, never shared. + String get nsec => bech32Encode('nsec', _hexToBytes(privateKeyHex)); + + /// Domain-separated derivation label. Versioned so a future scheme can coexist + /// with old backups (same discipline as BackupBox's HKDF info string). + static const derivationInfo = 'org.comunes.tane/nostr/secp256k1/v1'; + + /// secp256k1 group order n. + static final BigInt _n = BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', + radix: 16, + ); + + /// Deterministically derives the Nostr key from a root [seed] (the 32 bytes + /// [IdentityService.generateRootSeed] produces). + /// + /// Reduces to a valid scalar in `[1, n-1]` by rejection: on the (astronomically + /// rare) miss it bumps a counter in the HKDF info, so the result stays a pure + /// function of the seed. + factory NostrKey.deriveFromSeed(List seed) { + var counter = 0; + while (true) { + final okm = hkdfSha256(ikm: seed, info: '$derivationInfo/$counter'); + final d = _bytesToBigInt(okm); + if (d != BigInt.zero && d < _n) { + final privHex = d.toRadixString(16).padLeft(64, '0'); + final pubHex = bip340.getPublicKey(privHex); + return NostrKey._(privateKeyHex: privHex, publicKeyHex: pubHex); + } + counter++; + } + } +} + +BigInt _bytesToBigInt(List bytes) { + var result = BigInt.zero; + for (final b in bytes) { + result = (result << 8) | BigInt.from(b); + } + return result; +} + +Uint8List _hexToBytes(String hex) { + final out = Uint8List(hex.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} diff --git a/spike/block2_spike/lib/src/nostr_message_transport.dart b/spike/block2_spike/lib/src/nostr_message_transport.dart new file mode 100644 index 0000000..0bb0f2c --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_message_transport.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:bip340/bip340.dart' as bip340; + +import 'message_transport.dart'; +import 'nip44.dart'; +import 'nostr_connection.dart'; +import 'nostr_event.dart'; + +/// NIP-17 private DM backend for [MessageTransport], on the shared +/// [NostrConnection]. This is the spike's answer to the highest-risk Q3 piece: +/// does the metadata-private messaging flow actually work, and does it fit the +/// "one connection, three interfaces" shape? +/// +/// The NIP-17 / NIP-59 gift-wrap onion: +/// rumor (kind 14, UNSIGNED) — the real message; author = real sender +/// seal (kind 13, signed by sender) — NIP-44(rumor) to recipient +/// wrap (kind 1059, signed by a throwaway EPHEMERAL key) — NIP-44(seal) +/// The relay and any observer see only the wrap: a random author, a `p` tag for +/// the recipient, and opaque ciphertext. The real sender is hidden until the +/// recipient unwraps. +class NostrMessageTransport implements MessageTransport { + NostrMessageTransport(this._conn); + + final NostrConnection _conn; + + static const kindRumor = 14; + static const kindSeal = 13; + static const kindGiftWrap = 1059; + + final _rng = Random.secure(); + + @override + Future send({required String toPubkey, required String text}) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // 1. Rumor — the real, UNSIGNED message from the real sender. + final rumor = NostrEvent( + pubkey: _conn.publicKeyHex, + createdAt: now, + kind: kindRumor, + tags: [ + ['p', toPubkey], + ], + content: text, + ); + rumor.id = rumor.computeId(); // id but no signature (NIP-59) + + // 2. Seal — sender-signed, NIP-44 to recipient. + final sealKey = Nip44.conversationKey(_conn.privateKeyHex, toPubkey); + final seal = NostrEvent( + pubkey: _conn.publicKeyHex, + createdAt: now, + kind: kindSeal, + tags: const [], + content: Nip44.encrypt(sealKey, jsonEncode(rumor.toJson())), + )..signWith(_conn.privateKeyHex); + + // 3. Gift wrap — signed by a THROWAWAY ephemeral key, NIP-44 to recipient. + final eph = _ephemeralKey(); + final wrapKey = Nip44.conversationKey(eph.priv, toPubkey); + final wrap = NostrEvent( + pubkey: eph.pub, + createdAt: now, + kind: kindGiftWrap, + tags: [ + ['p', toPubkey], + ], + content: Nip44.encrypt(wrapKey, jsonEncode(seal.toJson())), + )..signWith(eph.priv); + + final r = await _conn.publish(wrap); + if (!r.accepted) { + throw StateError('relay rejected gift wrap: ${r.message}'); + } + } + + @override + Stream inbox() { + final filter = { + 'kinds': [kindGiftWrap], + '#p': [_conn.publicKeyHex], + }; + return _conn.subscribe(filter).map(_unwrap).where((m) => m != null).cast(); + } + + /// Collect the inbox up to EOSE (tests/metrics). + Future> inboxUntilEose() async { + final wraps = await _conn.reqOnce({ + 'kinds': [kindGiftWrap], + '#p': [_conn.publicKeyHex], + }); + return wraps.map(_unwrap).whereType().toList(); + } + + DirectMessage? _unwrap(NostrEvent wrap) { + try { + // Unwrap with our key + the ephemeral (wrap) author. + final wrapKey = Nip44.conversationKey(_conn.privateKeyHex, wrap.pubkey); + final seal = NostrEvent.fromJson( + jsonDecode(Nip44.decrypt(wrapKey, wrap.content)) as Map, + ); + // Open the seal with our key + the seal (real sender) author. + final sealKey = Nip44.conversationKey(_conn.privateKeyHex, seal.pubkey); + final rumor = NostrEvent.fromJson( + jsonDecode(Nip44.decrypt(sealKey, seal.content)) as Map, + ); + // Authenticity: the seal is signed by the sender, and the inner rumor + // must claim the SAME author. Otherwise someone re-wrapped a foreign seal. + if (rumor.pubkey != seal.pubkey || !seal.verify()) return null; + return DirectMessage( + fromPubkey: rumor.pubkey, + text: rumor.content, + at: DateTime.fromMillisecondsSinceEpoch(rumor.createdAt * 1000), + ); + } catch (_) { + return null; // not for us / wrong key / tampered + } + } + + ({String priv, String pub}) _ephemeralKey() { + final bytes = Uint8List.fromList( + List.generate(32, (_) => _rng.nextInt(256)), + ); + final priv = bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return (priv: priv, pub: bip340.getPublicKey(priv)); + } + + @override + Future close() => _conn.close(); +} diff --git a/spike/block2_spike/lib/src/nostr_offer_transport.dart b/spike/block2_spike/lib/src/nostr_offer_transport.dart new file mode 100644 index 0000000..67d6626 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_offer_transport.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'nip99.dart'; +import 'nostr_connection.dart'; +import 'offer.dart'; +import 'offer_transport.dart'; + +/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection] +/// (Q2/Q3). Signs offers with the derived key (Q1) and publishes them as +/// kind-30402 classified listings; discovery is a REQ filtered by kind + `#g`. +class NostrOfferTransport implements OfferTransport { + NostrOfferTransport(this._conn); + + final NostrConnection _conn; + final Nip99Codec _codec = Nip99Codec(); + + /// Convenience: open a dedicated connection for this transport. + static Future connect( + String relayUrl, { + required String privateKeyHex, + required String pubkeyHex, + }) async => + NostrOfferTransport(await NostrConnection.connect( + relayUrl, + privateKeyHex: privateKeyHex, + publicKeyHex: pubkeyHex, + )); + + Map _filter(DiscoveryQuery q) => { + 'kinds': [Nip99Codec.kindActive], + '#g': [q.geohashPrefix], + 'limit': q.limit, + }; + + @override + Future publish(Offer offer) async { + final event = _codec.encode( + offer, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + )..signWith(_conn.privateKeyHex); + final r = await _conn.publish(event); + return PublishResult( + accepted: r.accepted, + transportRef: event.id, + message: r.message, + ); + } + + @override + Stream discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where( + (o) => query.types.isEmpty || query.types.contains(o.type), + ); + + /// Collect matches up to EOSE (for tests/metrics). + Future> discoverUntilEose(DiscoveryQuery query) async { + final events = await _conn.reqOnce(_filter(query)); + return events + .map(_codec.decode) + .where((o) => query.types.isEmpty || query.types.contains(o.type)) + .toList(); + } + + @override + Future retract(String offerId) async { + // A NIP-09 deletion of the addressable coordinate would go here; the OK-path + // is identical to publish. Omitted from the spike surface. + } + + @override + Future close() => _conn.close(); +} diff --git a/spike/block2_spike/lib/src/nostr_trust_transport.dart b/spike/block2_spike/lib/src/nostr_trust_transport.dart new file mode 100644 index 0000000..371f869 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_trust_transport.dart @@ -0,0 +1,97 @@ +import 'nostr_connection.dart'; +import 'nostr_event.dart'; +import 'trust_transport.dart'; +import 'web_of_trust.dart'; + +/// Duniter-style certifications carried as Nostr events, on the shared +/// [NostrConnection]. There is no settled NIP for a web of trust (NIP-85 is +/// unsettled), so the spike uses a **custom addressable kind** mapped to the +/// Duniter model — exactly the "own WoT, Duniter-compatible" path +/// g1-integration.md §"WoT propia pero compatible" anticipates. +/// +/// Addressable by (kind, issuer, `d`=subject): one live certification per +/// (certifier, subject), so re-certifying renews and revoking replaces — the +/// relay keeps only the latest, like Ğ1 keeps one active cert per pair. +class NostrTrustTransport implements TrustTransport { + NostrTrustTransport(this._conn); + + final NostrConnection _conn; + + /// Custom Tanemaki certification kind (addressable range). + static const kindCertification = 30777; + + int get _now => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + @override + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) => + _publishCert(subjectPubkey, note: note, validity: validity, revoked: false); + + @override + Future revoke({required String subjectPubkey}) => + _publishCert(subjectPubkey, note: '', validity: Duration.zero, revoked: true); + + Future _publishCert( + String subject, { + required String note, + required Duration validity, + required bool revoked, + }) async { + final event = NostrEvent( + pubkey: _conn.publicKeyHex, + createdAt: _now, + kind: kindCertification, + tags: [ + ['p', subject], + ['d', subject], // one addressable cert per (issuer, subject) + ['status', revoked ? 'revoked' : 'valid'], + if (!revoked) ['expiration', '${_now + validity.inSeconds}'], + ], + content: note, + )..signWith(_conn.privateKeyHex); + final r = await _conn.publish(event); + if (!r.accepted) throw StateError('relay rejected cert: ${r.message}'); + } + + @override + Future> allCertifications() async { + final events = await _conn.reqOnce({ + 'kinds': [kindCertification], + }); + return events.map(_toCertification).toList(); + } + + @override + Future> certifiersOf(String subjectPubkey) async { + final events = await _conn.reqOnce({ + 'kinds': [kindCertification], + '#p': [subjectPubkey], + }); + final now = DateTime.now(); + return events + .map(_toCertification) + .where((c) => c.subject == subjectPubkey && c.isValidAt(now)) + .map((c) => c.issuer) + .toSet(); + } + + Certification _toCertification(NostrEvent e) { + final exp = e.tag('expiration'); + return Certification( + issuer: e.pubkey, + subject: e.tag('p') ?? e.tag('d') ?? '', + issuedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000), + expiresAt: exp == null + ? null + : DateTime.fromMillisecondsSinceEpoch(int.parse(exp) * 1000), + revoked: e.tag('status') == 'revoked', + note: e.content, + ); + } + + @override + Future close() => _conn.close(); +} diff --git a/spike/block2_spike/lib/src/offer.dart b/spike/block2_spike/lib/src/offer.dart new file mode 100644 index 0000000..3793f07 --- /dev/null +++ b/spike/block2_spike/lib/src/offer.dart @@ -0,0 +1,90 @@ +/// The four (well, five) reciprocity modes from sharing-model.md §3, plus the +/// generic `lend` the core reserves for the future "library of things". +enum OfferType { gift, exchange, sale, wanted, lend } + +enum OfferStatus { active, reserved, closed } + +/// A publishable offer — the "shop window", deliberately DECOUPLED from Lot / +/// inventory (sharing-model.md §2, core-domain-boundary.md §4.3). +/// +/// It carries only a chosen, denormalised *summary* of what's offered — never a +/// foreign key into the seed tables, never the full inventory, never an exact +/// address. This is the privacy seam: the core can publish and sync offers +/// WITHOUT understanding seeds. Everything here is what the person elected to +/// reveal. +class Offer { + const Offer({ + required this.id, + required this.authorPubkeyHex, + required this.summary, + required this.type, + required this.approxGeohash, + this.status = OfferStatus.active, + this.category, + this.priceAmount, + this.priceCurrency, + this.exchangeTerms, + this.radiusKm, + this.imageUrl, + this.expiresAt, + }); + + /// Stable per-offer identifier (NIP-99 `d` tag → addressable event). + final String id; + + /// The publishing identity (BIP340 x-only pubkey hex). Derived, per Q1. + final String authorPubkeyHex; + + /// Human summary the author chose to publish (e.g. "Tomate rosa de Barbastro"). + final String summary; + + final OfferType type; + final OfferStatus status; + + /// Free-text category, prefilled in-app from Species.family but opaque here. + final String? category; + + /// LOW-PRECISION geohash only (sharing-model.md §2: "10 km near you", never + /// exact coordinates). The spike enforces a max length in the transport. + final String approxGeohash; + final int? radiusKm; + + final num? priceAmount; // only for sale + final String? priceCurrency; // ISO 4217 or a community currency (e.g. "G1") + final String? exchangeTerms; // only for exchange + + final String? imageUrl; + final DateTime? expiresAt; +} + +/// Result of publishing to a transport. +class PublishResult { + const PublishResult({ + required this.accepted, + required this.transportRef, + this.message = '', + }); + + /// Whether the relay/instance accepted the event. + final bool accepted; + + /// The transport-side id (Nostr event id / ActivityPub object id). + final String transportRef; + final String message; +} + +/// A discovery query: coarse location + optional facets. Note there is NO way to +/// ask for "everything from person X's inventory" — you can only browse the +/// public shop window by area and kind. +class DiscoveryQuery { + const DiscoveryQuery({ + required this.geohashPrefix, + this.types = const {}, + this.limit = 100, + }); + + /// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km). + final String geohashPrefix; + final Set types; + final int limit; +} diff --git a/spike/block2_spike/lib/src/offer_transport.dart b/spike/block2_spike/lib/src/offer_transport.dart new file mode 100644 index 0000000..348057d --- /dev/null +++ b/spike/block2_spike/lib/src/offer_transport.dart @@ -0,0 +1,29 @@ +import 'offer.dart'; + +/// The abstraction the plan promises lives in `commons_core` (core-domain- +/// boundary.md §2, PLAN §4): publish an [Offer], discover offers by area. The +/// concrete Nostr NIP-99 backend sits behind this so the domain never talks to +/// a relay directly, and a second backend (ActivityPub / FEP-0837) could slot +/// in without the app noticing. +/// +/// Spike Question 2 asks whether messaging (NIP-17) and trust (NIP-85) fit +/// behind this same seam, or whether they couple more than the plan implies. +/// The finding: they do NOT belong here — they share the *key* and the *relay +/// connection*, but their verbs differ (send/receive a private DM; assert/read +/// a certification). Forcing them behind `OfferTransport` would overload it. +/// See spike-block2-findings.md §2 for the proposed 3-interface split over one +/// shared `NostrConnection`. +abstract interface class OfferTransport { + /// Publishes (or replaces) [offer]. Idempotent on the offer id. + Future publish(Offer offer); + + /// Streams offers matching [query]. Emits already-stored matches, then live + /// ones as they arrive, until the caller cancels the subscription. + Stream discover(DiscoveryQuery query); + + /// Retracts a previously published offer (NIP-99: publish a `closed` status / + /// NIP-09 deletion). Relays that already replicated it drop it over time. + Future retract(String offerId); + + Future close(); +} diff --git a/spike/block2_spike/lib/src/trust_transport.dart b/spike/block2_spike/lib/src/trust_transport.dart new file mode 100644 index 0000000..e99be98 --- /dev/null +++ b/spike/block2_spike/lib/src/trust_transport.dart @@ -0,0 +1,28 @@ +import 'web_of_trust.dart'; + +/// The third interface over the shared `NostrConnection` (Q2): the web of trust. +/// Its verbs — certify / revoke / read certifications — are again distinct from +/// offers and messaging, confirming the three-contract split. Certifications are +/// public signed events (like the on-chain Ğ1 WoT), so this transport publishes +/// and discovers rather than encrypts. +abstract interface class TrustTransport { + /// Publishes a signed "I vouch for [subjectPubkey]" certification, valid for + /// [validity] (Duniter certifications expire and must be renewed). + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }); + + /// Revokes this identity's certification of [subjectPubkey] (replaces the + /// addressable event with a revoked one). + Future revoke({required String subjectPubkey}); + + /// All certifications currently visible on the relay (to build a [WebOfTrust]). + Future> allCertifications(); + + /// Distinct, currently-valid certifiers of [subjectPubkey]. + Future> certifiersOf(String subjectPubkey); + + Future close(); +} diff --git a/spike/block2_spike/lib/src/web_of_trust.dart b/spike/block2_spike/lib/src/web_of_trust.dart new file mode 100644 index 0000000..8fbcafa --- /dev/null +++ b/spike/block2_spike/lib/src/web_of_trust.dart @@ -0,0 +1,119 @@ +/// One "A vouches for B" certification (Duniter/Ğ1 style). Public by design — +/// like the on-chain Ğ1 WoT — and signed by the issuer. +class Certification { + const Certification({ + required this.issuer, + required this.subject, + required this.issuedAt, + this.expiresAt, + this.revoked = false, + this.note = '', + }); + + final String issuer; // certifier pubkey + final String subject; // certified pubkey + final DateTime issuedAt; + final DateTime? expiresAt; + final bool revoked; + final String note; + + bool isValidAt(DateTime now) => + !revoked && (expiresAt == null || expiresAt!.isAfter(now)); +} + +/// Pure, Flutter-free web-of-trust computation. This is the piece that would +/// live in `commons_core` (identity/trust is generic). It answers "who counts +/// as a known member" from a set of certification edges, using Duniter's two +/// rules: **N certifications from existing members** and **within distance D of +/// the bootstrap referents**. TDD target — no I/O, deterministic. +class WebOfTrust { + WebOfTrust(); + + final Map> _out = {}; // issuer -> subjects + final Map> _in = {}; // subject -> issuers + + /// Builds a graph from valid certifications as of [now] (drops expired / + /// revoked, ignores self-certifications). + factory WebOfTrust.fromCertifications( + Iterable certs, { + required DateTime now, + }) { + final wot = WebOfTrust(); + for (final c in certs) { + if (c.issuer == c.subject) continue; + if (!c.isValidAt(now)) continue; + wot.addEdge(c.issuer, c.subject); + } + return wot; + } + + void addEdge(String issuer, String subject) { + (_out[issuer] ??= {}).add(subject); + (_in[subject] ??= {}).add(issuer); + } + + /// Distinct certifiers of [subject]. + Set certifiersOf(String subject) => _in[subject] ?? const {}; + + int certifierCount(String subject) => certifiersOf(subject).length; + + /// The set of known members: [seeds] (bootstrap referents, certified face to + /// face at a fair) plus everyone reachable by the rules. Iterated to a + /// fixpoint, because becoming a member can let you push others over the + /// threshold. + /// + /// - [threshold]: min certifications **from current members** (Duniter sigQty, + /// ~5). + /// - [maxDistance]: max certification hops from any seed (Duniter stepMax, ~5). + Set members({ + required Set seeds, + required int threshold, + required int maxDistance, + }) { + final members = {...seeds}; + final distance = _distancesFromSeeds(seeds); + var changed = true; + while (changed) { + changed = false; + for (final subject in _in.keys) { + if (members.contains(subject)) continue; + final d = distance[subject]; + if (d == null || d > maxDistance) continue; + final memberCertifiers = + certifiersOf(subject).where(members.contains).length; + if (memberCertifiers >= threshold) { + members.add(subject); + changed = true; + } + } + } + return members; + } + + bool isMember( + String pubkey, { + required Set seeds, + required int threshold, + required int maxDistance, + }) => + members(seeds: seeds, threshold: threshold, maxDistance: maxDistance) + .contains(pubkey); + + /// BFS hop-distance from the nearest seed over certification edges. + Map _distancesFromSeeds(Set seeds) { + final dist = {for (final s in seeds) s: 0}; + final queue = [...seeds]; + var head = 0; + while (head < queue.length) { + final node = queue[head++]; + final d = dist[node]!; + for (final next in _out[node] ?? const {}) { + if (!dist.containsKey(next)) { + dist[next] = d + 1; + queue.add(next); + } + } + } + return dist; + } +} diff --git a/spike/block2_spike/pubspec.yaml b/spike/block2_spike/pubspec.yaml new file mode 100644 index 0000000..41688f5 --- /dev/null +++ b/spike/block2_spike/pubspec.yaml @@ -0,0 +1,25 @@ +# THROWAWAY de-risking spike for Block 2 (social layer). NOT production. +# Deliberately OUTSIDE the pub workspace (no `resolution: workspace`) so it can +# pull network/crypto deps without touching Block 1's dependency graph. +# See docs/design/spike-block2-findings.md. Delete this whole dir after the spike. +name: block2_spike +description: Throwaway prototype validating Block 2 open decisions. Do not ship. +version: 0.0.0 +publish_to: none + +environment: + sdk: ^3.11.5 + +dependencies: + # The one real dependency on production code: we consume the root identity + # seed exactly as IdentityService produces it, to prove derivation works. + commons_core: + path: ../../packages/commons_core + # Throwaway crypto/transport deps — would NOT be added like this to prod. + crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids + bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures) + pointycastle: ^3.9.0 # secp256k1 ECDH + ChaCha20 for NIP-44 (messaging spike) + +dev_dependencies: + test: ^1.25.6 + lints: ^6.0.0 diff --git a/spike/block2_spike/test/derivation_test.dart b/spike/block2_spike/test/derivation_test.dart new file mode 100644 index 0000000..708657d --- /dev/null +++ b/spike/block2_spike/test/derivation_test.dart @@ -0,0 +1,76 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:block2_spike/block2_spike.dart'; +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +/// Q1 — Identity derivation: a deterministic, one-way secp256k1 (Nostr) key from +/// the Duniter/Ğ1-style root seed, so the user still backs up ONE thing. +void main() { + group('NostrKey.deriveFromSeed', () { + // A fixed seed so expectations are reproducible across runs/machines. + final seed = Uint8List.fromList(List.generate(32, (i) => i)); + + test('is reproducible: same seed → identical key', () { + final a = NostrKey.deriveFromSeed(seed); + final b = NostrKey.deriveFromSeed(seed); + expect(a.privateKeyHex, b.privateKeyHex); + expect(a.publicKeyHex, b.publicKeyHex); + expect(a.npub, b.npub); + }); + + test('produces a well-formed x-only key and npub/nsec', () { + final key = NostrKey.deriveFromSeed(seed); + expect(key.privateKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + // Cross-check: bip340 derives the same pubkey from our private hex. + expect(bip340.getPublicKey(key.privateKeyHex), key.publicKeyHex); + expect(key.npub, startsWith('npub1')); + expect(key.nsec, startsWith('nsec1')); + }); + + test('the derived key actually signs & verifies (BIP340)', () { + final key = NostrKey.deriveFromSeed(seed); + final message = 'a' * 64; // 32-byte hex message + final sig = bip340.sign(key.privateKeyHex, message, 'b' * 64); + expect(bip340.verify(key.publicKeyHex, message, sig), isTrue); + }); + + test('different seeds → different keys (avalanche on a 1-bit flip)', () { + final flipped = Uint8List.fromList(seed)..[0] ^= 0x01; + final a = NostrKey.deriveFromSeed(seed); + final b = NostrKey.deriveFromSeed(flipped); + expect(a.privateKeyHex, isNot(b.privateKeyHex)); + expect(a.publicKeyHex, isNot(b.publicKeyHex)); + }); + + test('one-wayness: neither key contains the seed, and HKDF is not ' + 'invertible', () { + final key = NostrKey.deriveFromSeed(seed); + final seedHex = seed + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + // The seed does not appear verbatim in the derived material. + expect(key.privateKeyHex, isNot(contains(seedHex))); + expect(key.publicKeyHex, isNot(contains(seedHex))); + // Structural argument (documented in findings §1): recovery of the seed + // would require inverting HMAC-SHA256. We assert the property we CAN test: + // two unrelated seeds never collide to the same key. + final other = NostrKey.deriveFromSeed( + Uint8List.fromList(List.generate(32, (i) => 255 - i)), + ); + expect(key.privateKeyHex, isNot(other.privateKeyHex)); + }); + + test('integrates with the real IdentityService seed length', () { + final rootSeed = IdentityService( + random: Random(42), + ).generateRootSeed(); + expect(rootSeed.length, IdentityService.rootSeedLengthBytes); + final key = NostrKey.deriveFromSeed(rootSeed); + expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + }); + }); +} diff --git a/spike/block2_spike/test/messaging_test.dart b/spike/block2_spike/test/messaging_test.dart new file mode 100644 index 0000000..c550826 --- /dev/null +++ b/spike/block2_spike/test/messaging_test.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Q3 (hardest piece) — NIP-17 private messaging: does the gift-wrap flow work +/// end to end, stay unreadable to the relay, and hide the sender's identity? +void main() { + NostrKey keyFor(int fill) => + NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill)); + + group('NIP-44 encryption', () { + test('conversation key is symmetric (A→B == B→A)', () { + final a = keyFor(1); + final b = keyFor(2); + final ab = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex); + final ba = Nip44.conversationKey(b.privateKeyHex, a.publicKeyHex); + expect(ab, ba); + }); + + test('round-trips text, and a wrong key cannot decrypt', () { + final a = keyFor(1); + final b = keyFor(2); + final eve = keyFor(9); + final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex); + final payload = Nip44.encrypt(key, 'tengo tomate rosa, ¿te va bien?'); + expect(Nip44.decrypt(key, payload), 'tengo tomate rosa, ¿te va bien?'); + + final eveKey = Nip44.conversationKey(eve.privateKeyHex, b.publicKeyHex); + expect(() => Nip44.decrypt(eveKey, payload), throwsFormatException); + }); + + test('ciphertext length hides the exact plaintext length (padding)', () { + final a = keyFor(1); + final b = keyFor(2); + final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex); + final short = base64.decode(Nip44.encrypt(key, 'hi')).length; + final alsoShort = base64.decode(Nip44.encrypt(key, 'hello!')).length; + // Both pad to the same 32-byte bucket → equal payload sizes. + expect(short, alsoShort); + }); + }); + + group('NIP-17 gift-wrapped DM over the relay', () { + late MiniRelay relay; + setUp(() async => relay = await MiniRelay.start()); + tearDown(() async => relay.stop()); + + test('Alice → Bob: message arrives, sender is authenticated', () async { + final alice = keyFor(1); + final bob = keyFor(2); + + final aliceConn = await NostrConnection.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + publicKeyHex: alice.publicKeyHex, + ); + final bobConn = await NostrConnection.connect( + relay.url, + privateKeyHex: bob.privateKeyHex, + publicKeyHex: bob.publicKeyHex, + ); + final aliceMsg = NostrMessageTransport(aliceConn); + final bobMsg = NostrMessageTransport(bobConn); + + await aliceMsg.send( + toPubkey: bob.publicKeyHex, + text: '¿cambiamos semilla de calabaza?', + ); + + final inbox = await bobMsg.inboxUntilEose(); + expect(inbox, hasLength(1)); + expect(inbox.single.text, '¿cambiamos semilla de calabaza?'); + // Authenticated as Alice, even though the wrap was signed by an ephemeral. + expect(inbox.single.fromPubkey, alice.publicKeyHex); + + await aliceMsg.close(); + await bobMsg.close(); + }); + + test('the relay/eavesdropper sees neither plaintext nor the real sender', + () async { + final alice = keyFor(1); + final bob = keyFor(2); + final aliceConn = await NostrConnection.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + publicKeyHex: alice.publicKeyHex, + ); + await NostrMessageTransport(aliceConn).send( + toPubkey: bob.publicKeyHex, + text: 'SECRET-PLAINTEXT-MARKER', + ); + + // Snoop the relay's stored events directly (as a relay operator would). + final snoop = await NostrConnection.connect( + relay.url, + privateKeyHex: keyFor(7).privateKeyHex, + publicKeyHex: keyFor(7).publicKeyHex, + ); + final wraps = await snoop.reqOnce({'kinds': [1059]}); + expect(wraps, hasLength(1)); + final wire = jsonEncode(wraps.single.toJson()); + expect(wire, isNot(contains('SECRET-PLAINTEXT-MARKER'))); + // The wrap's author is the ephemeral key, NOT Alice. + expect(wraps.single.pubkey, isNot(alice.publicKeyHex)); + // Only the recipient is addressable (the `p` tag), never the sender. + expect(wraps.single.tag('p'), bob.publicKeyHex); + + await aliceConn.close(); + await snoop.close(); + }); + + test('a third party cannot unwrap a message not addressed to them', + () async { + final alice = keyFor(1); + final bob = keyFor(2); + final mallory = keyFor(5); + final aliceConn = await NostrConnection.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + publicKeyHex: alice.publicKeyHex, + ); + await NostrMessageTransport(aliceConn).send( + toPubkey: bob.publicKeyHex, + text: 'solo para Bob', + ); + // Mallory subscribes to 1059 with no p-filter and tries to unwrap. + final malloryConn = await NostrConnection.connect( + relay.url, + privateKeyHex: mallory.privateKeyHex, + publicKeyHex: mallory.publicKeyHex, + ); + final malloryMsg = NostrMessageTransport(malloryConn); + final got = await malloryMsg.inboxUntilEose(); + // Her inbox filters on her own `p` tag, so the relay never even hands her + // Bob's wrap; and the eavesdropper test proves that even raw snooping + // yields only ciphertext. Two layers, same outcome: she sees nothing. + expect(got, isEmpty, reason: 'not addressed to Mallory (p-tag scoping)'); + + await aliceConn.close(); + await malloryConn.close(); + }); + }); +} diff --git a/spike/block2_spike/test/privacy_test.dart b/spike/block2_spike/test/privacy_test.dart new file mode 100644 index 0000000..d18154d --- /dev/null +++ b/spike/block2_spike/test/privacy_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Q2 — the `OfferTransport` seam must not leak inventory or an exact location. +/// The offer carries only a chosen summary; the wire event is checked byte-wise. +void main() { + final key = NostrKey.deriveFromSeed( + Uint8List.fromList(List.generate(32, (i) => i)), + ); + final codec = Nip99Codec(); + + // A realistic private-ish context: the caller ACCIDENTALLY passes a very + // precise geohash and there is secret inventory the offer must never expose. + const secretInventoryMarker = 'SECRET-LOT-12-KG-IN-SHED'; + const preciseGeohash = 'u09tunqmabc'; // ~sub-metre precision + + Offer sampleOffer() => Offer( + id: 'offer-1', + authorPubkeyHex: key.publicKeyHex, + summary: 'Tomate rosa de Barbastro', + type: OfferType.gift, + category: 'Solanaceae', + approxGeohash: preciseGeohash, + ); + + test('exact location is coarsened on the wire (never full precision)', () { + final event = codec.encode(sampleOffer(), createdAt: 1000); + final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]); + // No geohash tag exceeds the coarse cap... + expect(gTags.every((g) => g.length <= Nip99Codec.maxGeohashChars), isTrue); + // ...and the precise fix never appears anywhere in the serialised event. + final wire = jsonEncode(event.toJson()); + expect(wire, isNot(contains(preciseGeohash))); + expect(wire, contains('u09t')); // coarse prefix is present + }); + + test('no inventory / address field can ride along (seam has none)', () { + final event = codec.encode(sampleOffer(), createdAt: 1000); + final wire = jsonEncode(event.toJson()); + expect(wire, isNot(contains(secretInventoryMarker))); + // The event has no "location", "address" or "geo-exact" tag by construction. + final tagNames = event.tags.map((t) => t[0]).toSet(); + expect(tagNames, isNot(contains('location'))); + expect(tagNames, isNot(contains('address'))); + }); + + test('geohash ladder lets an area query match by exact tag', () { + final event = codec.encode(sampleOffer(), createdAt: 1000); + final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]).toList(); + expect(gTags, containsAll(['u', 'u0', 'u09', 'u09t'])); + }); + + test('round-trips Offer ↔ NIP-99 event, only chosen fields survive', () { + final original = Offer( + id: 'offer-2', + authorPubkeyHex: key.publicKeyHex, + summary: 'Judía del ganxet', + type: OfferType.sale, + priceAmount: 3, + priceCurrency: 'G1', + category: 'Fabaceae', + approxGeohash: 'sp3e9', + ); + final event = codec.encode(original, createdAt: 1000) + ..signWith(key.privateKeyHex); + expect(event.verify(), isTrue); + + final decoded = codec.decode(event); + expect(decoded.id, original.id); + expect(decoded.summary, original.summary); + expect(decoded.type, OfferType.sale); + expect(decoded.priceAmount, 3); + expect(decoded.priceCurrency, 'G1'); + expect(decoded.category, 'Fabaceae'); + expect(decoded.approxGeohash, 'sp3e9'); + }); +} diff --git a/spike/block2_spike/test/roundtrip_test.dart b/spike/block2_spike/test/roundtrip_test.dart new file mode 100644 index 0000000..f71b57b --- /dev/null +++ b/spike/block2_spike/test/roundtrip_test.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Q3 — publish → discover a real offer by geohash against a running relay +/// (in-process, hermetic). Proves the flow works end to end and measures it. +void main() { + late MiniRelay relay; + + setUp(() async => relay = await MiniRelay.start()); + tearDown(() async => relay.stop()); + + NostrKey keyFor(int fill) => + NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill)); + + test('offer published by one identity is discovered by another via geohash', + () async { + final alice = keyFor(1); + final bob = keyFor(2); + + final aliceTransport = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + final bobTransport = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: bob.privateKeyHex, + pubkeyHex: bob.publicKeyHex, + ); + + final offer = Offer( + id: 'tomate-1', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Tomate rosa de Barbastro', + type: OfferType.gift, + category: 'Solanaceae', + approxGeohash: 'sp3e9xyz', // will be coarsened to sp3e9 + ); + + final published = await aliceTransport.publish(offer); + expect(published.accepted, isTrue, + reason: 'relay OK: ${published.message}'); + + // Bob queries a coarser area and still finds it (ladder match). + final found = await bobTransport.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'sp3'), + ); + expect(found, hasLength(1)); + expect(found.single.summary, 'Tomate rosa de Barbastro'); + expect(found.single.authorPubkeyHex, alice.publicKeyHex); + + await aliceTransport.close(); + await bobTransport.close(); + }); + + test('a query for a different area returns nothing (geohash scoping works)', + () async { + final alice = keyFor(1); + final t = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + await t.publish(Offer( + id: 'x', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Pimiento', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + final elsewhere = await t.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'u09'), + ); + expect(elsewhere, isEmpty); + await t.close(); + }); + + test('re-publishing the same offer id replaces (addressable), not duplicates', + () async { + final alice = keyFor(3); + final t = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + Offer o(String summary) => Offer( + id: 'lot-42', + authorPubkeyHex: alice.publicKeyHex, + summary: summary, + type: OfferType.exchange, + exchangeTerms: 'a cambio de legumbre', + approxGeohash: 'sp3e9', + ); + await t.publish(o('v1')); + await t.publish(o('v2')); + final found = await t.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'sp3e9'), + ); + expect(found, hasLength(1), reason: 'addressable replace by (kind,pubkey,d)'); + expect(found.single.summary, 'v2'); + await t.close(); + }); + + test('measures publish→discover latency (informational)', () async { + final alice = keyFor(4); + final t = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + final sw = Stopwatch()..start(); + await t.publish(Offer( + id: 'latency', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Calabaza', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + final found = await t.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'sp3e9'), + ); + sw.stop(); + expect(found, hasLength(1)); + // Not an assertion on wall-time (CI is noisy); just surface the number. + printOnFailure('publish+discover round-trip: ${sw.elapsedMilliseconds} ms'); + // ignore: avoid_print + print('[metric] publish+discover round-trip: ${sw.elapsedMilliseconds} ms'); + await t.close(); + }); +} diff --git a/spike/block2_spike/test/trust_test.dart b/spike/block2_spike/test/trust_test.dart new file mode 100644 index 0000000..4b3e371 --- /dev/null +++ b/spike/block2_spike/test/trust_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Q4 (last big unknown) — Duniter-style web of trust over Nostr, on the SAME +/// shared connection as offers and messaging. Proves certifications publish & +/// discover, the membership rule computes from discovered events, and trust can +/// filter offers (the network-trust §2 spam defence). +void main() { + NostrKey keyFor(int fill) => + NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill)); + + late MiniRelay relay; + setUp(() async => relay = await MiniRelay.start()); + tearDown(() async => relay.stop()); + + Future connFor(NostrKey k) => NostrConnection.connect( + relay.url, + privateKeyHex: k.privateKeyHex, + publicKeyHex: k.publicKeyHex, + ); + + test('certifications publish, discover, and make a newcomer known', () async { + final seeds = [for (var i = 1; i <= 3; i++) keyFor(i)]; + final newcomer = keyFor(50); + + // Three seed members each certify the newcomer. + for (final s in seeds) { + final t = NostrTrustTransport(await connFor(s)); + await t.certify(subjectPubkey: newcomer.publicKeyHex); + await t.close(); + } + + // Anyone can read the certs and compute membership. + final reader = NostrTrustTransport(await connFor(keyFor(99))); + final certifiers = await reader.certifiersOf(newcomer.publicKeyHex); + expect(certifiers, hasLength(3)); + + final wot = WebOfTrust.fromCertifications( + await reader.allCertifications(), + now: DateTime.now(), + ); + final seedSet = seeds.map((s) => s.publicKeyHex).toSet(); + expect( + wot.isMember(newcomer.publicKeyHex, + seeds: seedSet, threshold: 3, maxDistance: 5), + isTrue, + ); + await reader.close(); + }); + + test('revoking a certification drops the certifier (addressable replace)', + () async { + final alice = keyFor(1); + final bob = keyFor(2); + final aliceTrust = NostrTrustTransport(await connFor(alice)); + + await aliceTrust.certify(subjectPubkey: bob.publicKeyHex); + var certs = await aliceTrust.certifiersOf(bob.publicKeyHex); + expect(certs, contains(alice.publicKeyHex)); + + await aliceTrust.revoke(subjectPubkey: bob.publicKeyHex); + certs = await aliceTrust.certifiersOf(bob.publicKeyHex); + expect(certs, isNot(contains(alice.publicKeyHex)), + reason: 'revoked cert replaces the valid one and is filtered out'); + await aliceTrust.close(); + }); + + test('trust filters offers: a known author ranks; an unknown one is flagged', + () async { + // Bootstrap: 3 seeds certify Alice. Eve is uncertified (a stranger/spammer). + final seeds = [for (var i = 1; i <= 3; i++) keyFor(i)]; + final alice = keyFor(20); + final eve = keyFor(66); + for (final s in seeds) { + final t = NostrTrustTransport(await connFor(s)); + await t.certify(subjectPubkey: alice.publicKeyHex); + await t.close(); + } + + // Both Alice and Eve publish offers in the same area. + for (final who in [alice, eve]) { + final offers = NostrOfferTransport(await connFor(who)); + await offers.publish(Offer( + id: 'o-${who.publicKeyHex.substring(0, 6)}', + authorPubkeyHex: who.publicKeyHex, + summary: 'Semilla', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + await offers.close(); + } + + // Bob discovers offers, then annotates each author with trust — over the + // SAME shared connection (one socket, offers + trust both on top of it). + final bobConn = await connFor(keyFor(7)); + final found = await NostrOfferTransport(bobConn) + .discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3e9')); + final trust = NostrTrustTransport(bobConn); + final wot = WebOfTrust.fromCertifications( + await trust.allCertifications(), + now: DateTime.now(), + ); + final seedSet = seeds.map((s) => s.publicKeyHex).toSet(); + bool known(String pk) => + wot.isMember(pk, seeds: seedSet, threshold: 3, maxDistance: 5); + + expect(found, hasLength(2)); + expect(known(alice.publicKeyHex), isTrue); + expect(known(eve.publicKeyHex), isFalse, + reason: 'uncertified author stays "unknown" and is deprioritised'); + await trust.close(); + }); +} diff --git a/spike/block2_spike/test/web_of_trust_test.dart b/spike/block2_spike/test/web_of_trust_test.dart new file mode 100644 index 0000000..2deb8e6 --- /dev/null +++ b/spike/block2_spike/test/web_of_trust_test.dart @@ -0,0 +1,92 @@ +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Pure web-of-trust rules (Duniter: N certs from members + within distance D). +/// This is the `commons_core`-worthy piece: deterministic, no I/O. +void main() { + final now = DateTime(2026, 7, 10); + Certification cert(String a, String b, {DateTime? expires, bool revoked = false}) => + Certification( + issuer: a, + subject: b, + issuedAt: DateTime(2026), + expiresAt: expires, + revoked: revoked, + ); + + test('seeds are always members', () { + final wot = WebOfTrust.fromCertifications([], now: now); + expect( + wot.members(seeds: {'s1'}, threshold: 5, maxDistance: 5), + contains('s1'), + ); + }); + + test('reaches the threshold from member certifiers → becomes known', () { + final seeds = {'s1', 's2', 's3', 's4', 's5'}; + final certs = [for (final s in seeds) cert(s, 'newcomer')]; + final wot = WebOfTrust.fromCertifications(certs, now: now); + final members = wot.members(seeds: seeds, threshold: 5, maxDistance: 5); + expect(members, contains('newcomer')); + }); + + test('below the threshold stays unknown', () { + final seeds = {'s1', 's2', 's3', 's4', 's5'}; + final certs = [cert('s1', 'x'), cert('s2', 'x')]; // only 2 of 5 + final wot = WebOfTrust.fromCertifications(certs, now: now); + expect( + wot.members(seeds: seeds, threshold: 5, maxDistance: 5), + isNot(contains('x')), + ); + }); + + test('membership is transitive via a fixpoint (needs a to count for b)', () { + final seeds = {'s1', 's2'}; + final certs = [ + cert('s1', 'a'), cert('s2', 'a'), // a: 2 member certs → member + cert('a', 'b'), cert('s2', 'b'), // b: certified by a (now member) + s2 + ]; + final wot = WebOfTrust.fromCertifications(certs, now: now); + final members = wot.members(seeds: seeds, threshold: 2, maxDistance: 5); + expect(members, containsAll(['a', 'b'])); + }); + + test('distance rule cuts off far-away nodes even if well-certified', () { + // s → a → b → c → d → e chain; only s is a seed; threshold 1. + final seeds = {'s'}; + final certs = [ + cert('s', 'a'), + cert('a', 'b'), + cert('b', 'c'), + cert('c', 'd'), + cert('d', 'e'), + ]; + final wot = WebOfTrust.fromCertifications(certs, now: now); + final members = wot.members(seeds: seeds, threshold: 1, maxDistance: 2); + expect(members, containsAll(['a', 'b'])); // dist 1 and 2 + expect(members, isNot(contains('c'))); // dist 3 > maxDistance + expect(members, isNot(contains('e'))); + }); + + test('expired and revoked certifications do not count', () { + final seeds = {'s1', 's2', 's3', 's4', 's5'}; + final certs = [ + cert('s1', 'x'), + cert('s2', 'x'), + cert('s3', 'x'), + cert('s4', 'x', expires: DateTime(2020)), // expired + cert('s5', 'x', revoked: true), // revoked + ]; + final wot = WebOfTrust.fromCertifications(certs, now: now); + expect(wot.certifierCount('x'), 3, reason: 'expired+revoked dropped'); + expect( + wot.members(seeds: seeds, threshold: 5, maxDistance: 5), + isNot(contains('x')), + ); + }); + + test('self-certification is ignored', () { + final wot = WebOfTrust.fromCertifications([cert('a', 'a')], now: now); + expect(wot.certifierCount('a'), 0); + }); +}