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:
vjrj 2026-07-10 02:17:35 +02:00
parent 2cafc7fc12
commit cc88f3d688
7 changed files with 522 additions and 27 deletions

View file

@ -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';

View 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();
}

View 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();
}

View 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;
}
}

View file

@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:block2_spike/block2_spike.dart';
import 'package:test/test.dart';
/// Q4 (last big unknown) Duniter-style web of trust over Nostr, on the SAME
/// shared connection as offers and messaging. Proves certifications publish &
/// discover, the membership rule computes from discovered events, and trust can
/// filter offers (the network-trust §2 spam defence).
void main() {
NostrKey keyFor(int fill) =>
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
late MiniRelay relay;
setUp(() async => relay = await MiniRelay.start());
tearDown(() async => relay.stop());
Future<NostrConnection> connFor(NostrKey k) => NostrConnection.connect(
relay.url,
privateKeyHex: k.privateKeyHex,
publicKeyHex: k.publicKeyHex,
);
test('certifications publish, discover, and make a newcomer known', () async {
final seeds = [for (var i = 1; i <= 3; i++) keyFor(i)];
final newcomer = keyFor(50);
// Three seed members each certify the newcomer.
for (final s in seeds) {
final t = NostrTrustTransport(await connFor(s));
await t.certify(subjectPubkey: newcomer.publicKeyHex);
await t.close();
}
// Anyone can read the certs and compute membership.
final reader = NostrTrustTransport(await connFor(keyFor(99)));
final certifiers = await reader.certifiersOf(newcomer.publicKeyHex);
expect(certifiers, hasLength(3));
final wot = WebOfTrust.fromCertifications(
await reader.allCertifications(),
now: DateTime.now(),
);
final seedSet = seeds.map((s) => s.publicKeyHex).toSet();
expect(
wot.isMember(newcomer.publicKeyHex,
seeds: seedSet, threshold: 3, maxDistance: 5),
isTrue,
);
await reader.close();
});
test('revoking a certification drops the certifier (addressable replace)',
() async {
final alice = keyFor(1);
final bob = keyFor(2);
final aliceTrust = NostrTrustTransport(await connFor(alice));
await aliceTrust.certify(subjectPubkey: bob.publicKeyHex);
var certs = await aliceTrust.certifiersOf(bob.publicKeyHex);
expect(certs, contains(alice.publicKeyHex));
await aliceTrust.revoke(subjectPubkey: bob.publicKeyHex);
certs = await aliceTrust.certifiersOf(bob.publicKeyHex);
expect(certs, isNot(contains(alice.publicKeyHex)),
reason: 'revoked cert replaces the valid one and is filtered out');
await aliceTrust.close();
});
test('trust filters offers: a known author ranks; an unknown one is flagged',
() async {
// Bootstrap: 3 seeds certify Alice. Eve is uncertified (a stranger/spammer).
final seeds = [for (var i = 1; i <= 3; i++) keyFor(i)];
final alice = keyFor(20);
final eve = keyFor(66);
for (final s in seeds) {
final t = NostrTrustTransport(await connFor(s));
await t.certify(subjectPubkey: alice.publicKeyHex);
await t.close();
}
// Both Alice and Eve publish offers in the same area.
for (final who in [alice, eve]) {
final offers = NostrOfferTransport(await connFor(who));
await offers.publish(Offer(
id: 'o-${who.publicKeyHex.substring(0, 6)}',
authorPubkeyHex: who.publicKeyHex,
summary: 'Semilla',
type: OfferType.gift,
approxGeohash: 'sp3e9',
));
await offers.close();
}
// Bob discovers offers, then annotates each author with trust over the
// SAME shared connection (one socket, offers + trust both on top of it).
final bobConn = await connFor(keyFor(7));
final found = await NostrOfferTransport(bobConn)
.discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3e9'));
final trust = NostrTrustTransport(bobConn);
final wot = WebOfTrust.fromCertifications(
await trust.allCertifications(),
now: DateTime.now(),
);
final seedSet = seeds.map((s) => s.publicKeyHex).toSet();
bool known(String pk) =>
wot.isMember(pk, seeds: seedSet, threshold: 3, maxDistance: 5);
expect(found, hasLength(2));
expect(known(alice.publicKeyHex), isTrue);
expect(known(eve.publicKeyHex), isFalse,
reason: 'uncertified author stays "unknown" and is deprioritised');
await trust.close();
});
}

View file

@ -0,0 +1,92 @@
import 'package:block2_spike/block2_spike.dart';
import 'package:test/test.dart';
/// Pure web-of-trust rules (Duniter: N certs from members + within distance D).
/// This is the `commons_core`-worthy piece: deterministic, no I/O.
void main() {
final now = DateTime(2026, 7, 10);
Certification cert(String a, String b, {DateTime? expires, bool revoked = false}) =>
Certification(
issuer: a,
subject: b,
issuedAt: DateTime(2026),
expiresAt: expires,
revoked: revoked,
);
test('seeds are always members', () {
final wot = WebOfTrust.fromCertifications([], now: now);
expect(
wot.members(seeds: {'s1'}, threshold: 5, maxDistance: 5),
contains('s1'),
);
});
test('reaches the threshold from member certifiers → becomes known', () {
final seeds = {'s1', 's2', 's3', 's4', 's5'};
final certs = [for (final s in seeds) cert(s, 'newcomer')];
final wot = WebOfTrust.fromCertifications(certs, now: now);
final members = wot.members(seeds: seeds, threshold: 5, maxDistance: 5);
expect(members, contains('newcomer'));
});
test('below the threshold stays unknown', () {
final seeds = {'s1', 's2', 's3', 's4', 's5'};
final certs = [cert('s1', 'x'), cert('s2', 'x')]; // only 2 of 5
final wot = WebOfTrust.fromCertifications(certs, now: now);
expect(
wot.members(seeds: seeds, threshold: 5, maxDistance: 5),
isNot(contains('x')),
);
});
test('membership is transitive via a fixpoint (needs a to count for b)', () {
final seeds = {'s1', 's2'};
final certs = [
cert('s1', 'a'), cert('s2', 'a'), // a: 2 member certs member
cert('a', 'b'), cert('s2', 'b'), // b: certified by a (now member) + s2
];
final wot = WebOfTrust.fromCertifications(certs, now: now);
final members = wot.members(seeds: seeds, threshold: 2, maxDistance: 5);
expect(members, containsAll(['a', 'b']));
});
test('distance rule cuts off far-away nodes even if well-certified', () {
// s a b c d e chain; only s is a seed; threshold 1.
final seeds = {'s'};
final certs = [
cert('s', 'a'),
cert('a', 'b'),
cert('b', 'c'),
cert('c', 'd'),
cert('d', 'e'),
];
final wot = WebOfTrust.fromCertifications(certs, now: now);
final members = wot.members(seeds: seeds, threshold: 1, maxDistance: 2);
expect(members, containsAll(['a', 'b'])); // dist 1 and 2
expect(members, isNot(contains('c'))); // dist 3 > maxDistance
expect(members, isNot(contains('e')));
});
test('expired and revoked certifications do not count', () {
final seeds = {'s1', 's2', 's3', 's4', 's5'};
final certs = [
cert('s1', 'x'),
cert('s2', 'x'),
cert('s3', 'x'),
cert('s4', 'x', expires: DateTime(2020)), // expired
cert('s5', 'x', revoked: true), // revoked
];
final wot = WebOfTrust.fromCertifications(certs, now: now);
expect(wot.certifierCount('x'), 3, reason: 'expired+revoked dropped');
expect(
wot.members(seeds: seeds, threshold: 5, maxDistance: 5),
isNot(contains('x')),
);
});
test('self-certification is ignored', () {
final wot = WebOfTrust.fromCertifications([cert('a', 'a')], now: now);
expect(wot.certifierCount('a'), 0);
});
}