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