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