tane/spike/block2_spike/lib/src/nostr_trust_transport.dart
vjrj cc88f3d688 spike(block2): de-risk web of trust — the last big unknown
Adds TrustTransport as the third interface on the shared NostrConnection,
completing the social-layer happy path in the spike:

- WebOfTrust: pure, Flutter-free Duniter membership rule (N certs from members +
  within distance D of bootstrap referents), iterated to a fixpoint. The
  commons_core-worthy piece; TDD'd (threshold, distance cutoff, transitive
  growth, expired/revoked/self exclusion).
- NostrTrustTransport: certifications as a custom addressable kind (30777,
  keyed by issuer+subject) — certify renews, revoke replaces, certs expire.
- Trust filters spam (network-trust §2): seeds certify Alice; Eve stays
  uncertified; Bob discovers both offers and annotates authors — Alice known,
  Eve unknown — all over ONE shared connection.

Findings updated: WoT risk High -> Medium (policy/bootstrap, not feasibility);
overall scope Medium-High -> Medium — all three transport contracts now proven.
30 tests green, analyzer clean, Block 1 untouched.
2026-07-10 02:17:35 +02:00

97 lines
3.1 KiB
Dart

import 'nostr_connection.dart';
import 'nostr_event.dart';
import 'trust_transport.dart';
import 'web_of_trust.dart';
/// Duniter-style certifications carried as Nostr events, on the shared
/// [NostrConnection]. There is no settled NIP for a web of trust (NIP-85 is
/// unsettled), so the spike uses a **custom addressable kind** mapped to the
/// Duniter model — exactly the "own WoT, Duniter-compatible" path
/// g1-integration.md §"WoT propia pero compatible" anticipates.
///
/// Addressable by (kind, issuer, `d`=subject): one live certification per
/// (certifier, subject), so re-certifying renews and revoking replaces — the
/// relay keeps only the latest, like Ğ1 keeps one active cert per pair.
class NostrTrustTransport implements TrustTransport {
NostrTrustTransport(this._conn);
final NostrConnection _conn;
/// Custom Tanemaki certification kind (addressable range).
static const kindCertification = 30777;
int get _now => DateTime.now().millisecondsSinceEpoch ~/ 1000;
@override
Future<void> certify({
required String subjectPubkey,
Duration validity = const Duration(days: 365),
String note = '',
}) =>
_publishCert(subjectPubkey, note: note, validity: validity, revoked: false);
@override
Future<void> revoke({required String subjectPubkey}) =>
_publishCert(subjectPubkey, note: '', validity: Duration.zero, revoked: true);
Future<void> _publishCert(
String subject, {
required String note,
required Duration validity,
required bool revoked,
}) async {
final event = NostrEvent(
pubkey: _conn.publicKeyHex,
createdAt: _now,
kind: kindCertification,
tags: [
['p', subject],
['d', subject], // one addressable cert per (issuer, subject)
['status', revoked ? 'revoked' : 'valid'],
if (!revoked) ['expiration', '${_now + validity.inSeconds}'],
],
content: note,
)..signWith(_conn.privateKeyHex);
final r = await _conn.publish(event);
if (!r.accepted) throw StateError('relay rejected cert: ${r.message}');
}
@override
Future<List<Certification>> allCertifications() async {
final events = await _conn.reqOnce({
'kinds': [kindCertification],
});
return events.map(_toCertification).toList();
}
@override
Future<Set<String>> certifiersOf(String subjectPubkey) async {
final events = await _conn.reqOnce({
'kinds': [kindCertification],
'#p': [subjectPubkey],
});
final now = DateTime.now();
return events
.map(_toCertification)
.where((c) => c.subject == subjectPubkey && c.isValidAt(now))
.map((c) => c.issuer)
.toSet();
}
Certification _toCertification(NostrEvent e) {
final exp = e.tag('expiration');
return Certification(
issuer: e.pubkey,
subject: e.tag('p') ?? e.tag('d') ?? '',
issuedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000),
expiresAt: exp == null
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(exp) * 1000),
revoked: e.tag('status') == 'revoked',
note: e.content,
);
}
@override
Future<void> close() => _conn.close();
}