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.
146 lines
5.4 KiB
Dart
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();
|
|
});
|
|
});
|
|
}
|