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:
vjrj 2026-07-10 02:11:54 +02:00
parent 49b872b405
commit 2cafc7fc12
10 changed files with 684 additions and 142 deletions

View file

@ -3,10 +3,14 @@ library;
export 'src/bech32.dart';
export 'src/hkdf.dart';
export 'src/message_transport.dart';
export 'src/mini_relay.dart';
export 'src/nip44.dart';
export 'src/nip99.dart';
export 'src/nostr_connection.dart';
export 'src/nostr_event.dart';
export 'src/nostr_key.dart';
export 'src/nostr_message_transport.dart';
export 'src/nostr_offer_transport.dart';
export 'src/offer.dart';
export 'src/offer_transport.dart';

View file

@ -30,3 +30,26 @@ Uint8List hkdfSha256({
}
return Uint8List.fromList(out.sublist(0, length));
}
/// HKDF-Extract (RFC 5869 §2.2): `PRK = HMAC(salt, ikm)`. NIP-44 uses this
/// alone to turn the ECDH secret into the conversation key.
Uint8List hkdfExtract({required List<int> salt, required List<int> ikm}) =>
Uint8List.fromList(Hmac(sha256, salt).convert(ikm).bytes);
/// HKDF-Expand (RFC 5869 §2.3) with a *byte* info (NIP-44 uses the nonce as
/// info), producing [length] bytes.
Uint8List hkdfExpandBytes({
required List<int> prk,
required List<int> info,
required int length,
}) {
final out = <int>[];
var previous = <int>[];
var counter = 1;
while (out.length < length) {
previous = Hmac(sha256, prk).convert([...previous, ...info, counter]).bytes;
out.addAll(previous);
counter++;
}
return Uint8List.fromList(out.sublist(0, length));
}

View file

@ -0,0 +1,28 @@
/// One decrypted direct message handed to the app.
class DirectMessage {
const DirectMessage({
required this.fromPubkey,
required this.text,
required this.at,
});
/// The authenticated sender (from the inner rumor). NOT the gift-wrap author,
/// which is a throwaway ephemeral key that's the metadata-privacy point.
final String fromPubkey;
final String text;
final DateTime at;
}
/// The second interface over the shared `NostrConnection` (Q2): private 1:1
/// messaging. Deliberately SEPARATE from `OfferTransport` its verbs (send a
/// private DM / read an inbox) don't fit publish/discover. Backed by NIP-17
/// gift-wrapped, NIP-44-encrypted events.
abstract interface class MessageTransport {
/// Sends [text] privately to [toPubkey].
Future<void> send({required String toPubkey, required String text});
/// Streams decrypted messages addressed to this identity.
Stream<DirectMessage> inbox();
Future<void> close();
}

View file

@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:pointycastle/export.dart';
import 'hkdf.dart';
/// NIP-44 v2 payload encryption (secp256k1 ECDH conversation key ChaCha20 +
/// HMAC-SHA256, with length-hiding padding). This is the crypto NIP-17 private
/// DMs ride on. Spike-grade: follows the NIP-44 *construction* faithfully but is
/// NOT cross-checked against the reference test vectors see findings §4. Good
/// enough to prove the messaging flow and architecture round-trip.
class Nip44 {
static final _params = ECCurve_secp256k1();
/// secp256k1 field prime.
static final BigInt _p = BigInt.parse(
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F',
radix: 16,
);
static final _salt = ascii.encode('nip44-v2');
/// ECDH conversation key shared by [privHexA] and x-only [pubHexB]:
/// `HKDF-Extract(salt="nip44-v2", ikm = (d·P).x)`. Symmetric: AB and BA
/// yield the same key.
static Uint8List conversationKey(String privHexA, String pubHexB) {
final d = _bi(privHexA);
final point = _liftX(_bi(pubHexB));
final shared = (point * d)!;
final x = shared.x!.toBigInteger()!;
return hkdfExtract(salt: _salt, ikm: _to32(x));
}
/// Encrypts [plaintext] under [convKey]; returns base64 NIP-44 payload.
static String encrypt(Uint8List convKey, String plaintext, {Uint8List? nonce}) {
final n = nonce ?? _randomBytes(32);
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
final chachaKey = keys.sublist(0, 32);
final chachaNonce = keys.sublist(32, 44);
final hmacKey = keys.sublist(44, 76);
final padded = _pad(utf8.encode(plaintext));
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
final mac = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
return base64.encode([2, ...n, ...ciphertext, ...mac]);
}
/// Decrypts a base64 NIP-44 payload under [convKey]. Throws on version/MAC
/// failure (so an eavesdropper with the wrong key cannot read it).
static String decrypt(Uint8List convKey, String payload) {
final raw = base64.decode(payload);
if (raw[0] != 2) throw const FormatException('unsupported nip44 version');
final n = raw.sublist(1, 33);
final ciphertext = raw.sublist(33, raw.length - 32);
final mac = raw.sublist(raw.length - 32);
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
final chachaKey = keys.sublist(0, 32);
final chachaNonce = keys.sublist(32, 44);
final hmacKey = keys.sublist(44, 76);
final expected = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
if (!_constantTimeEquals(expected, mac)) {
throw const FormatException('nip44 MAC mismatch (wrong key or tampered)');
}
final padded = _chacha20(chachaKey, chachaNonce, Uint8List.fromList(ciphertext));
final len = (padded[0] << 8) | padded[1];
return utf8.decode(padded.sublist(2, 2 + len));
}
// --- padding (NIP-44 length hiding) ---
static Uint8List _pad(List<int> plaintext) {
final unpadded = plaintext.length;
final paddedLen = _calcPaddedLen(unpadded);
final buf = Uint8List(2 + paddedLen);
buf[0] = (unpadded >> 8) & 0xff;
buf[1] = unpadded & 0xff;
buf.setRange(2, 2 + unpadded, plaintext);
return buf;
}
static int _calcPaddedLen(int len) {
if (len <= 32) return 32;
final nextPower = 1 << ((len - 1).bitLength);
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
return chunk * ((len - 1) ~/ chunk + 1);
}
// --- primitives ---
static Uint8List _chacha20(List<int> key, List<int> nonce, Uint8List input) {
final engine = ChaCha7539Engine()
..init(
true,
ParametersWithIV(
KeyParameter(Uint8List.fromList(key)),
Uint8List.fromList(nonce),
),
);
final out = Uint8List(input.length);
engine.processBytes(input, 0, input.length, out, 0);
return out;
}
/// BIP340 lift_x: reconstruct the curve point with even y from x-only [x].
static ECPoint _liftX(BigInt x) {
final ySq = (x.modPow(BigInt.from(3), _p) + BigInt.from(7)) % _p;
var y = ySq.modPow((_p + BigInt.one) ~/ BigInt.from(4), _p);
if (y.isOdd) y = _p - y;
return _params.curve.createPoint(x, y);
}
static BigInt _bi(String hex) => BigInt.parse(hex, radix: 16);
static Uint8List _to32(BigInt v) {
final out = Uint8List(32);
var x = v;
for (var i = 31; i >= 0; i--) {
out[i] = (x & BigInt.from(0xff)).toInt();
x = x >> 8;
}
return out;
}
static bool _constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
static final _rng = Random.secure();
static Uint8List _randomBytes(int n) =>
Uint8List.fromList(List.generate(n, (_) => _rng.nextInt(256)));
}

View file

@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'nostr_event.dart';
/// The ONE piece shared by every Nostr-backed transport (Q2 recommendation): a
/// single relay socket + the signing identity + the EVENT/OK and REQ/EOSE
/// lifecycle. `OfferTransport`, `MessageTransport` and a future `TrustTransport`
/// each sit on top of this they share this *connection*, not a *contract*.
class NostrConnection {
NostrConnection._(this._socket, this.privateKeyHex, this.publicKeyHex) {
_socket.listen(_onData, onDone: _onDone);
}
final WebSocket _socket;
/// The derived (Q1) identity this connection signs with.
final String privateKeyHex;
final String publicKeyHex;
final _incoming = StreamController<List<dynamic>>.broadcast();
int _subCounter = 0;
static Future<NostrConnection> connect(
String relayUrl, {
required String privateKeyHex,
required String publicKeyHex,
}) async {
final socket = await WebSocket.connect(relayUrl);
return NostrConnection._(socket, privateKeyHex, publicKeyHex);
}
void _onData(dynamic d) => _incoming.add(jsonDecode(d as String) as List);
void _onDone() {
if (!_incoming.isClosed) _incoming.close();
}
/// Publishes an already-signed [event]; resolves with the relay's OK verdict.
Future<({bool accepted, String message})> publish(NostrEvent event) async {
final ok = _incoming.stream.firstWhere(
(m) => m[0] == 'OK' && m[1] == event.id,
);
_socket.add(jsonEncode(['EVENT', event.toJson()]));
final r = await ok.timeout(const Duration(seconds: 5));
return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : '');
}
/// Streams events matching [filter] (live), until the caller cancels.
Stream<NostrEvent> subscribe(Map<String, dynamic> filter) {
final subId = 'sub${_subCounter++}';
late StreamController<NostrEvent> controller;
StreamSubscription? sub;
controller = StreamController<NostrEvent>(
onListen: () {
sub = _incoming.stream.listen((m) {
if (m[0] == 'EVENT' && m[1] == subId) {
controller.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
}
});
_socket.add(jsonEncode(['REQ', subId, filter]));
},
onCancel: () async {
_socket.add(jsonEncode(['CLOSE', subId]));
await sub?.cancel();
},
);
return controller.stream;
}
/// Collects stored events matching [filter] up to EOSE, then closes the sub.
Future<List<NostrEvent>> reqOnce(Map<String, dynamic> filter) async {
final subId = 'once${_subCounter++}';
final results = <NostrEvent>[];
final done = Completer<void>();
final sub = _incoming.stream.listen((m) {
if (m[1] != subId) return;
if (m[0] == 'EVENT') {
results.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
} else if (m[0] == 'EOSE' && !done.isCompleted) {
done.complete();
}
});
_socket.add(jsonEncode(['REQ', subId, filter]));
await done.future.timeout(const Duration(seconds: 5));
_socket.add(jsonEncode(['CLOSE', subId]));
await sub.cancel();
return results;
}
Future<void> close() async {
await _socket.close();
if (!_incoming.isClosed) await _incoming.close();
}
}

View file

@ -0,0 +1,136 @@
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<void> 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<DirectMessage> 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<List<DirectMessage>> inboxUntilEose() async {
final wraps = await _conn.reqOnce({
'kinds': [kindGiftWrap],
'#p': [_conn.publicKeyHex],
});
return wraps.map(_unwrap).whereType<DirectMessage>().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<String, dynamic>,
);
// 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<String, dynamic>,
);
// 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<void> close() => _conn.close();
}

View file

@ -1,152 +1,71 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'nip99.dart';
import 'nostr_event.dart';
import 'nostr_connection.dart';
import 'offer.dart';
import 'offer_transport.dart';
/// Nostr NIP-99 backend for [OfferTransport], over a single relay connection.
///
/// Signs every offer with the caller's derived Nostr key (Q1) and publishes it
/// as a kind-30402 classified listing (Q2/Q3). Discovery is a REQ filtered by
/// kind + `#g` geohash tag. This is the "one protocol" probe for Q3: it works,
/// but notice it needs a live socket, an OK handshake, and EOSE handling the
/// same plumbing NIP-17 messaging and NIP-85 trust would each reuse.
/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection]
/// (Q2/Q3). Signs offers with the derived key (Q1) and publishes them as
/// kind-30402 classified listings; discovery is a REQ filtered by kind + `#g`.
class NostrOfferTransport implements OfferTransport {
NostrOfferTransport._(this._socket, this._privateKeyHex, this._pubkeyHex) {
_socket.listen(_onData, onDone: _onDone);
}
NostrOfferTransport(this._conn);
final WebSocket _socket;
final String _privateKeyHex;
final String _pubkeyHex;
final NostrConnection _conn;
final Nip99Codec _codec = Nip99Codec();
final _incoming = StreamController<List<dynamic>>.broadcast();
int _subCounter = 0;
/// Connects to [relayUrl] and returns a transport signing as [pubkeyHex]
/// (BIP340 x-only) with [privateKeyHex]. Both come from `NostrKey`.
/// Convenience: open a dedicated connection for this transport.
static Future<NostrOfferTransport> connect(
String relayUrl, {
required String privateKeyHex,
required String pubkeyHex,
}) async {
final socket = await WebSocket.connect(relayUrl);
return NostrOfferTransport._(socket, privateKeyHex, pubkeyHex);
}
}) async =>
NostrOfferTransport(await NostrConnection.connect(
relayUrl,
privateKeyHex: privateKeyHex,
publicKeyHex: pubkeyHex,
));
void _onData(dynamic data) => _incoming.add(jsonDecode(data as String) as List);
void _onDone() {
if (!_incoming.isClosed) _incoming.close();
}
Map<String, dynamic> _filter(DiscoveryQuery q) => {
'kinds': [Nip99Codec.kindActive],
'#g': [q.geohashPrefix],
'limit': q.limit,
};
@override
Future<PublishResult> publish(Offer offer) async {
final event = _codec.encode(
offer,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
event.signWith(_privateKeyHex);
final ok = _incoming.stream.firstWhere(
(m) => m[0] == 'OK' && m[1] == event.id,
);
_socket.add(jsonEncode(['EVENT', event.toJson()]));
final response = await ok.timeout(const Duration(seconds: 5));
)..signWith(_conn.privateKeyHex);
final r = await _conn.publish(event);
return PublishResult(
accepted: response[2] as bool,
accepted: r.accepted,
transportRef: event.id,
message: response.length > 3 ? response[3] as String : '',
message: r.message,
);
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
final subId = 'sub${_subCounter++}';
final filter = <String, dynamic>{
'kinds': [Nip99Codec.kindActive],
'#g': [query.geohashPrefix],
'limit': query.limit,
};
Stream<Offer> discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where(
(o) => query.types.isEmpty || query.types.contains(o.type),
);
late StreamController<Offer> controller;
controller = StreamController<Offer>(
onListen: () => _socket.add(jsonEncode(['REQ', subId, filter])),
onCancel: () {
_socket.add(jsonEncode(['CLOSE', subId]));
},
);
final sub = _incoming.stream.listen((m) {
if (m[0] == 'EVENT' && m[1] == subId) {
final offer = _codec.decode(
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
);
if (query.types.isEmpty || query.types.contains(offer.type)) {
controller.add(offer);
}
}
// EOSE arrives here too; we keep the stream open for live offers.
});
controller.onCancel = () {
_socket.add(jsonEncode(['CLOSE', subId]));
sub.cancel();
};
return controller.stream;
}
/// Convenience for tests/metrics: collect matches up to EOSE, then stop.
/// Collect matches up to EOSE (for tests/metrics).
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
final subId = 'once${_subCounter++}';
final filter = <String, dynamic>{
'kinds': [Nip99Codec.kindActive],
'#g': [query.geohashPrefix],
'limit': query.limit,
};
final results = <Offer>[];
final done = Completer<void>();
final sub = _incoming.stream.listen((m) {
if (m[1] != subId) return;
if (m[0] == 'EVENT') {
final offer = _codec.decode(
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
);
if (query.types.isEmpty || query.types.contains(offer.type)) {
results.add(offer);
}
} else if (m[0] == 'EOSE' && !done.isCompleted) {
done.complete();
}
});
_socket.add(jsonEncode(['REQ', subId, filter]));
await done.future.timeout(const Duration(seconds: 5));
_socket.add(jsonEncode(['CLOSE', subId]));
await sub.cancel();
return results;
final events = await _conn.reqOnce(_filter(query));
return events
.map(_codec.decode)
.where((o) => query.types.isEmpty || query.types.contains(o.type))
.toList();
}
@override
Future<void> retract(String offerId) async {
// NIP-09 deletion request referencing the addressable coordinate.
final event = NostrEvent(
pubkey: _pubkeyHex,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
kind: 5,
tags: [
['a', '${Nip99Codec.kindActive}:$_pubkeyHex:$offerId'],
],
content: 'offer retracted',
);
event.signWith(_privateKeyHex);
_socket.add(jsonEncode(['EVENT', event.toJson()]));
// A NIP-09 deletion of the addressable coordinate would go here; the OK-path
// is identical to publish. Omitted from the spike surface.
}
@override
Future<void> close() async {
await _socket.close();
if (!_incoming.isClosed) await _incoming.close();
}
Future<void> close() => _conn.close();
}

View file

@ -18,6 +18,7 @@ dependencies:
# Throwaway crypto/transport deps — would NOT be added like this to prod.
crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids
bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures)
pointycastle: ^3.9.0 # secp256k1 ECDH + ChaCha20 for NIP-44 (messaging spike)
dev_dependencies:
test: ^1.25.6

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