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.
This commit is contained in:
parent
49b872b405
commit
2cafc7fc12
10 changed files with 684 additions and 142 deletions
146
spike/block2_spike/test/messaging_test.dart
Normal file
146
spike/block2_spike/test/messaging_test.dart
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue