tane/spike/block2_spike/test/messaging_test.dart
vjrj 2cafc7fc12 spike(block2): de-risk NIP-17 messaging + validate shared-connection shape
Extends the Block 2 spike to attack the highest-risk unbuilt piece (private
messaging) and to prove the Q2 architecture recommendation with running code:

- NostrConnection: one shared socket+key+sign+REQ/EOSE lifecycle. Refactored
  NostrOfferTransport onto it; added NostrMessageTransport — two thin contracts
  on ONE connection (the 'one connection, three interfaces' shape).
- NIP-44 v2 encryption (secp256k1 ECDH -> ChaCha20 + HMAC, length-hiding pad).
- NIP-17/NIP-59 gift-wrap: rumor(14) -> seal(13) -> wrap(1059, ephemeral key).
  Alice->Bob DM round-trips through the relay: encrypted, sender authenticated,
  and metadata-private (relay/eavesdropper sees only ciphertext, an ephemeral
  author and the recipient p-tag — Alice stays hidden).

Findings updated: NIP-17 risk drops Medium-High -> Medium (hardening, not
feasibility); next de-risking target is the web of trust. NIP-44 not yet
vector-verified (noted). 20 tests green, analyzer clean, Block 1 untouched.
2026-07-10 02:11:54 +02:00

146 lines
5.4 KiB
Dart

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