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
95
spike/block2_spike/lib/src/nostr_connection.dart
Normal file
95
spike/block2_spike/lib/src/nostr_connection.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue