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