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.
This commit is contained in:
parent
2cafc7fc12
commit
cc88f3d688
7 changed files with 522 additions and 27 deletions
|
|
@ -12,5 +12,8 @@ export 'src/nostr_event.dart';
|
|||
export 'src/nostr_key.dart';
|
||||
export 'src/nostr_message_transport.dart';
|
||||
export 'src/nostr_offer_transport.dart';
|
||||
export 'src/nostr_trust_transport.dart';
|
||||
export 'src/offer.dart';
|
||||
export 'src/offer_transport.dart';
|
||||
export 'src/trust_transport.dart';
|
||||
export 'src/web_of_trust.dart';
|
||||
|
|
|
|||
97
spike/block2_spike/lib/src/nostr_trust_transport.dart
Normal file
97
spike/block2_spike/lib/src/nostr_trust_transport.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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();
|
||||
}
|
||||
28
spike/block2_spike/lib/src/trust_transport.dart
Normal file
28
spike/block2_spike/lib/src/trust_transport.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'web_of_trust.dart';
|
||||
|
||||
/// The third interface over the shared `NostrConnection` (Q2): the web of trust.
|
||||
/// Its verbs — certify / revoke / read certifications — are again distinct from
|
||||
/// offers and messaging, confirming the three-contract split. Certifications are
|
||||
/// public signed events (like the on-chain Ğ1 WoT), so this transport publishes
|
||||
/// and discovers rather than encrypts.
|
||||
abstract interface class TrustTransport {
|
||||
/// Publishes a signed "I vouch for [subjectPubkey]" certification, valid for
|
||||
/// [validity] (Duniter certifications expire and must be renewed).
|
||||
Future<void> certify({
|
||||
required String subjectPubkey,
|
||||
Duration validity = const Duration(days: 365),
|
||||
String note = '',
|
||||
});
|
||||
|
||||
/// Revokes this identity's certification of [subjectPubkey] (replaces the
|
||||
/// addressable event with a revoked one).
|
||||
Future<void> revoke({required String subjectPubkey});
|
||||
|
||||
/// All certifications currently visible on the relay (to build a [WebOfTrust]).
|
||||
Future<List<Certification>> allCertifications();
|
||||
|
||||
/// Distinct, currently-valid certifiers of [subjectPubkey].
|
||||
Future<Set<String>> certifiersOf(String subjectPubkey);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
119
spike/block2_spike/lib/src/web_of_trust.dart
Normal file
119
spike/block2_spike/lib/src/web_of_trust.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/// One "A vouches for B" certification (Duniter/Ğ1 style). Public by design —
|
||||
/// like the on-chain Ğ1 WoT — and signed by the issuer.
|
||||
class Certification {
|
||||
const Certification({
|
||||
required this.issuer,
|
||||
required this.subject,
|
||||
required this.issuedAt,
|
||||
this.expiresAt,
|
||||
this.revoked = false,
|
||||
this.note = '',
|
||||
});
|
||||
|
||||
final String issuer; // certifier pubkey
|
||||
final String subject; // certified pubkey
|
||||
final DateTime issuedAt;
|
||||
final DateTime? expiresAt;
|
||||
final bool revoked;
|
||||
final String note;
|
||||
|
||||
bool isValidAt(DateTime now) =>
|
||||
!revoked && (expiresAt == null || expiresAt!.isAfter(now));
|
||||
}
|
||||
|
||||
/// Pure, Flutter-free web-of-trust computation. This is the piece that would
|
||||
/// live in `commons_core` (identity/trust is generic). It answers "who counts
|
||||
/// as a known member" from a set of certification edges, using Duniter's two
|
||||
/// rules: **N certifications from existing members** and **within distance D of
|
||||
/// the bootstrap referents**. TDD target — no I/O, deterministic.
|
||||
class WebOfTrust {
|
||||
WebOfTrust();
|
||||
|
||||
final Map<String, Set<String>> _out = {}; // issuer -> subjects
|
||||
final Map<String, Set<String>> _in = {}; // subject -> issuers
|
||||
|
||||
/// Builds a graph from valid certifications as of [now] (drops expired /
|
||||
/// revoked, ignores self-certifications).
|
||||
factory WebOfTrust.fromCertifications(
|
||||
Iterable<Certification> certs, {
|
||||
required DateTime now,
|
||||
}) {
|
||||
final wot = WebOfTrust();
|
||||
for (final c in certs) {
|
||||
if (c.issuer == c.subject) continue;
|
||||
if (!c.isValidAt(now)) continue;
|
||||
wot.addEdge(c.issuer, c.subject);
|
||||
}
|
||||
return wot;
|
||||
}
|
||||
|
||||
void addEdge(String issuer, String subject) {
|
||||
(_out[issuer] ??= {}).add(subject);
|
||||
(_in[subject] ??= {}).add(issuer);
|
||||
}
|
||||
|
||||
/// Distinct certifiers of [subject].
|
||||
Set<String> certifiersOf(String subject) => _in[subject] ?? const {};
|
||||
|
||||
int certifierCount(String subject) => certifiersOf(subject).length;
|
||||
|
||||
/// The set of known members: [seeds] (bootstrap referents, certified face to
|
||||
/// face at a fair) plus everyone reachable by the rules. Iterated to a
|
||||
/// fixpoint, because becoming a member can let you push others over the
|
||||
/// threshold.
|
||||
///
|
||||
/// - [threshold]: min certifications **from current members** (Duniter sigQty,
|
||||
/// ~5).
|
||||
/// - [maxDistance]: max certification hops from any seed (Duniter stepMax, ~5).
|
||||
Set<String> members({
|
||||
required Set<String> seeds,
|
||||
required int threshold,
|
||||
required int maxDistance,
|
||||
}) {
|
||||
final members = {...seeds};
|
||||
final distance = _distancesFromSeeds(seeds);
|
||||
var changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (final subject in _in.keys) {
|
||||
if (members.contains(subject)) continue;
|
||||
final d = distance[subject];
|
||||
if (d == null || d > maxDistance) continue;
|
||||
final memberCertifiers =
|
||||
certifiersOf(subject).where(members.contains).length;
|
||||
if (memberCertifiers >= threshold) {
|
||||
members.add(subject);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
bool isMember(
|
||||
String pubkey, {
|
||||
required Set<String> seeds,
|
||||
required int threshold,
|
||||
required int maxDistance,
|
||||
}) =>
|
||||
members(seeds: seeds, threshold: threshold, maxDistance: maxDistance)
|
||||
.contains(pubkey);
|
||||
|
||||
/// BFS hop-distance from the nearest seed over certification edges.
|
||||
Map<String, int> _distancesFromSeeds(Set<String> seeds) {
|
||||
final dist = <String, int>{for (final s in seeds) s: 0};
|
||||
final queue = [...seeds];
|
||||
var head = 0;
|
||||
while (head < queue.length) {
|
||||
final node = queue[head++];
|
||||
final d = dist[node]!;
|
||||
for (final next in _out[node] ?? const <String>{}) {
|
||||
if (!dist.containsKey(next)) {
|
||||
dist[next] = d + 1;
|
||||
queue.add(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue