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