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
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue