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.
71 lines
2.2 KiB
Dart
71 lines
2.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'nip99.dart';
|
|
import 'nostr_connection.dart';
|
|
import 'offer.dart';
|
|
import 'offer_transport.dart';
|
|
|
|
/// 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._conn);
|
|
|
|
final NostrConnection _conn;
|
|
final Nip99Codec _codec = Nip99Codec();
|
|
|
|
/// Convenience: open a dedicated connection for this transport.
|
|
static Future<NostrOfferTransport> connect(
|
|
String relayUrl, {
|
|
required String privateKeyHex,
|
|
required String pubkeyHex,
|
|
}) async =>
|
|
NostrOfferTransport(await NostrConnection.connect(
|
|
relayUrl,
|
|
privateKeyHex: privateKeyHex,
|
|
publicKeyHex: pubkeyHex,
|
|
));
|
|
|
|
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,
|
|
)..signWith(_conn.privateKeyHex);
|
|
final r = await _conn.publish(event);
|
|
return PublishResult(
|
|
accepted: r.accepted,
|
|
transportRef: event.id,
|
|
message: r.message,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Stream<Offer> discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where(
|
|
(o) => query.types.isEmpty || query.types.contains(o.type),
|
|
);
|
|
|
|
/// Collect matches up to EOSE (for tests/metrics).
|
|
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
|
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 {
|
|
// 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() => _conn.close();
|
|
}
|