diff --git a/docs/design/spike-block2-findings.md b/docs/design/spike-block2-findings.md index 4ad2d02..10a37de 100644 --- a/docs/design/spike-block2-findings.md +++ b/docs/design/spike-block2-findings.md @@ -19,10 +19,11 @@ The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and | # | Open question | Verdict | |---|---|---| | 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** | -| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — with a caveat on messaging/trust coupling.** | +| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** | | 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** | +| 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** | -All of it runs with tests. See "How to run" at the end. +All of it runs with tests (20 passing). See "How to run" at the end. --- @@ -109,17 +110,23 @@ manageable**: - But their **verbs differ**: publish/browse a public listing vs. send/receive a private encrypted DM vs. assert/read a signed certification. Forcing DMs and certifications behind `OfferTransport.publish/discover` would overload it. -- **Recommendation:** in `commons_core`, model **one shared `NostrConnection`** - (socket, key, sign, req/sub lifecycle) with **three thin interfaces on top** — - `OfferTransport`, `MessageTransport`, `TrustTransport`. That matches how the - code actually factored in the spike and keeps each contract honest, while - admitting the reality that one protocol/key/relay underpins all three. +- **Recommendation — now demonstrated in code, not just asserted:** + [`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is + **one shared** socket + key + sign + REQ/EOSE lifecycle, and **two thin + interfaces already sit on top of the same instance** — + [`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart) + (NIP-99) and + [`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart) + (NIP-17). A third, `TrustTransport` (WoT), would slot in the same way. This is + the shape to lift into `commons_core`: shared connection, per-concern + contract. Trying to make `OfferTransport` also carry DMs would have overloaded + it — the split is real. -**Residual risk: medium.** The abstraction is right; the *scope* is the risk — -"the social layer" is genuinely offers + messaging + trust + relays together -(the §D.1 "indivisible" point), and this spike only built the offers third end -to end. Messaging (NIP-17 gift-wrap, offline delivery, spam) and trust (Duniter- -style WoT over Nostr) remain the large, unbuilt pieces. +**Residual risk: medium → low-medium.** The abstraction is right *and* two of +the three contracts are now built. The remaining risk is **scope, not +feasibility**: "the social layer" is offers + messaging + trust + relays +together (§D.1 "indivisible"), and trust/WoT plus production hardening of +messaging (below) are still ahead. --- @@ -147,9 +154,9 @@ not the mechanics.** gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the reciprocity mode NIP-99 lacks. **Low risk.** - **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed + - gift-wrapped, per-message ephemeral keys). Viable but the **biggest unbuilt - risk** — this is where Q2's "messaging is large" bites. **Medium-high risk; - spike did not implement it.** + gift-wrapped, per-message ephemeral keys). **The spike DID build it** — see §4 + below. Verdict moves from "biggest unbuilt risk" to **medium (production + hardening), not high (feasibility)**. - **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification model maps onto signed events, but there is no dominant standard. Likely a **custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT @@ -163,6 +170,45 @@ not the mechanics.** --- +## Q3b — NIP-17 private messaging (the piece that was flagged highest-risk) + +**Result: the metadata-private DM flow works end to end. Built, tested, and it +fits the shared-connection architecture.** + +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`](../../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`](../../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). +- **End-to-end over the relay (tested):** + - Alice → Bob: the message arrives and Bob **authenticates the sender as + Alice** — even though the wrap was signed by an ephemeral key. + - **The relay/eavesdropper sees neither the plaintext nor the real sender:** + snooping stored kind-1059 events yields only ciphertext, an ephemeral + author, and a `p` tag for the recipient. Alice's identity is hidden. + - A third party cannot read a message not addressed to them. + +**What this de-risks:** the hard, novel part (metadata-private messaging on the +*same derived key and relay* as offers) is feasible and architecturally clean. + +**What remains (why it's still medium, not solved):** +- The NIP-44 implementation here is **structurally faithful but NOT verified + against the reference test vectors** — cross-client interop must be proven + before shipping (a real client library should be used, not this spike code). +- **Offline delivery, retries, and multi-device** (a recipient who's offline + when the wrap is published) are untouched — this is the real "messaging is + large" weight. +- **Spam/abuse** on DMs leans on the (unbuilt) web of trust. + ## Overall recommendation 1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified; @@ -171,15 +217,17 @@ not the mechanics.** 2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try to make `OfferTransport` also carry messaging and trust. Keep `Offer` agnostic and the geohash-coarsening seam exactly as prototyped. -3. **Q3: Nostr-first is the right bet, but scope honestly.** Offers are the easy - third and they work. **Messaging (NIP-17) and WoT (NIP-85/custom) are the - real weight and remain unproven here** — they, not the offer flow, should - drive the Phase-3 estimate and the funding ask ([open-decisions.md](open-decisions.md) - §D.1, §D.6). +3. **Q3: Nostr-first is the right bet, and now two of three contracts are + proven.** Offers (NIP-99) and private messaging (NIP-17) both work on the + shared connection. The remaining unproven weight is **WoT/trust + (NIP-85/custom)** plus **production hardening of messaging** (interop-exact + NIP-44, offline delivery, multi-device) — that, not the happy-path flow, + should drive the Phase-3 estimate and the funding ask + ([open-decisions.md](open-decisions.md) §D.1, §D.6). 4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once - these findings are absorbed. The next concrete step is a *funded* social - round that starts by building NIP-17 messaging as its own de-risking target, - since that is where the residual risk concentrates. + these findings are absorbed. The next concrete de-risking target is the **web + of trust** (Duniter-style certifications mapped to Nostr events) — the last + large unknown — followed by hardening messaging for real-world delivery. ## Risk summary @@ -188,16 +236,16 @@ not the mechanics.** | Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. | | Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. | | Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. | -| Messaging (NIP-17) | **Medium-High** | Newer, complex, unbuilt; the big Phase-3 cost. | -| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. | -| Overall scope (§D.1) | **High** | Social layer is indivisible & large; only 1/3 proven here. | +| Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. | +| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. The last big unknown. | +| Overall scope (§D.1) | **Medium-High** | Social layer is indivisible & large; 2 of 3 transport contracts now proven, WoT + hardening remain. | ## How to run ```sh cd spike/block2_spike dart pub get -dart test # derivation_test.dart, privacy_test.dart, roundtrip_test.dart +dart test # derivation · privacy · roundtrip · messaging (20 tests) ``` Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the diff --git a/spike/block2_spike/lib/block2_spike.dart b/spike/block2_spike/lib/block2_spike.dart index 3fb663e..7fca384 100644 --- a/spike/block2_spike/lib/block2_spike.dart +++ b/spike/block2_spike/lib/block2_spike.dart @@ -3,10 +3,14 @@ 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/offer.dart'; export 'src/offer_transport.dart'; diff --git a/spike/block2_spike/lib/src/hkdf.dart b/spike/block2_spike/lib/src/hkdf.dart index 486629b..020360f 100644 --- a/spike/block2_spike/lib/src/hkdf.dart +++ b/spike/block2_spike/lib/src/hkdf.dart @@ -30,3 +30,26 @@ Uint8List hkdfSha256({ } 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/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/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_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 index 5c96c11..67d6626 100644 --- a/spike/block2_spike/lib/src/nostr_offer_transport.dart +++ b/spike/block2_spike/lib/src/nostr_offer_transport.dart @@ -1,152 +1,71 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'nip99.dart'; -import 'nostr_event.dart'; +import 'nostr_connection.dart'; import 'offer.dart'; import 'offer_transport.dart'; -/// Nostr NIP-99 backend for [OfferTransport], over a single relay connection. -/// -/// Signs every offer with the caller's derived Nostr key (Q1) and publishes it -/// as a kind-30402 classified listing (Q2/Q3). Discovery is a REQ filtered by -/// kind + `#g` geohash tag. This is the "one protocol" probe for Q3: it works, -/// but notice it needs a live socket, an OK handshake, and EOSE handling — the -/// same plumbing NIP-17 messaging and NIP-85 trust would each reuse. +/// 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._socket, this._privateKeyHex, this._pubkeyHex) { - _socket.listen(_onData, onDone: _onDone); - } + NostrOfferTransport(this._conn); - final WebSocket _socket; - final String _privateKeyHex; - final String _pubkeyHex; + final NostrConnection _conn; final Nip99Codec _codec = Nip99Codec(); - final _incoming = StreamController>.broadcast(); - int _subCounter = 0; - - /// Connects to [relayUrl] and returns a transport signing as [pubkeyHex] - /// (BIP340 x-only) with [privateKeyHex]. Both come from `NostrKey`. + /// Convenience: open a dedicated connection for this transport. static Future connect( String relayUrl, { required String privateKeyHex, required String pubkeyHex, - }) async { - final socket = await WebSocket.connect(relayUrl); - return NostrOfferTransport._(socket, privateKeyHex, pubkeyHex); - } + }) async => + NostrOfferTransport(await NostrConnection.connect( + relayUrl, + privateKeyHex: privateKeyHex, + publicKeyHex: pubkeyHex, + )); - void _onData(dynamic data) => _incoming.add(jsonDecode(data as String) as List); - void _onDone() { - if (!_incoming.isClosed) _incoming.close(); - } + 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, - ); - event.signWith(_privateKeyHex); - - final ok = _incoming.stream.firstWhere( - (m) => m[0] == 'OK' && m[1] == event.id, - ); - _socket.add(jsonEncode(['EVENT', event.toJson()])); - final response = await ok.timeout(const Duration(seconds: 5)); + )..signWith(_conn.privateKeyHex); + final r = await _conn.publish(event); return PublishResult( - accepted: response[2] as bool, + accepted: r.accepted, transportRef: event.id, - message: response.length > 3 ? response[3] as String : '', + message: r.message, ); } @override - Stream discover(DiscoveryQuery query) { - final subId = 'sub${_subCounter++}'; - final filter = { - 'kinds': [Nip99Codec.kindActive], - '#g': [query.geohashPrefix], - 'limit': query.limit, - }; + Stream discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where( + (o) => query.types.isEmpty || query.types.contains(o.type), + ); - late StreamController controller; - controller = StreamController( - onListen: () => _socket.add(jsonEncode(['REQ', subId, filter])), - onCancel: () { - _socket.add(jsonEncode(['CLOSE', subId])); - }, - ); - - final sub = _incoming.stream.listen((m) { - if (m[0] == 'EVENT' && m[1] == subId) { - final offer = _codec.decode( - NostrEvent.fromJson(m[2] as Map), - ); - if (query.types.isEmpty || query.types.contains(offer.type)) { - controller.add(offer); - } - } - // EOSE arrives here too; we keep the stream open for live offers. - }); - controller.onCancel = () { - _socket.add(jsonEncode(['CLOSE', subId])); - sub.cancel(); - }; - return controller.stream; - } - - /// Convenience for tests/metrics: collect matches up to EOSE, then stop. + /// Collect matches up to EOSE (for tests/metrics). Future> discoverUntilEose(DiscoveryQuery query) async { - final subId = 'once${_subCounter++}'; - final filter = { - 'kinds': [Nip99Codec.kindActive], - '#g': [query.geohashPrefix], - 'limit': query.limit, - }; - final results = []; - final done = Completer(); - final sub = _incoming.stream.listen((m) { - if (m[1] != subId) return; - if (m[0] == 'EVENT') { - final offer = _codec.decode( - NostrEvent.fromJson(m[2] as Map), - ); - if (query.types.isEmpty || query.types.contains(offer.type)) { - results.add(offer); - } - } 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; + 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 { - // NIP-09 deletion request referencing the addressable coordinate. - final event = NostrEvent( - pubkey: _pubkeyHex, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - kind: 5, - tags: [ - ['a', '${Nip99Codec.kindActive}:$_pubkeyHex:$offerId'], - ], - content: 'offer retracted', - ); - event.signWith(_privateKeyHex); - _socket.add(jsonEncode(['EVENT', event.toJson()])); + // 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() async { - await _socket.close(); - if (!_incoming.isClosed) await _incoming.close(); - } + Future close() => _conn.close(); } diff --git a/spike/block2_spike/pubspec.yaml b/spike/block2_spike/pubspec.yaml index 5009dc7..41688f5 100644 --- a/spike/block2_spike/pubspec.yaml +++ b/spike/block2_spike/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: # 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 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(); + }); + }); +}