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
|
|
@ -22,8 +22,11 @@ The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and
|
||||||
| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** |
|
| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** |
|
||||||
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
||||||
| 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** |
|
| 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** |
|
||||||
|
| 4 | Does a Duniter-style web of trust work over Nostr, and can it filter spam? | **Yes — certify/discover + threshold-and-distance membership + offer filtering all prototyped.** |
|
||||||
|
|
||||||
All of it runs with tests (20 passing). See "How to run" at the end.
|
All of it runs with tests (30 passing). All three transport contracts —
|
||||||
|
offers, messaging, trust — plus the pure trust-graph logic are exercised.
|
||||||
|
See "How to run" at the end.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -112,21 +115,22 @@ manageable**:
|
||||||
certifications behind `OfferTransport.publish/discover` would overload it.
|
certifications behind `OfferTransport.publish/discover` would overload it.
|
||||||
- **Recommendation — now demonstrated in code, not just asserted:**
|
- **Recommendation — now demonstrated in code, not just asserted:**
|
||||||
[`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is
|
[`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is
|
||||||
**one shared** socket + key + sign + REQ/EOSE lifecycle, and **two thin
|
**one shared** socket + key + sign + REQ/EOSE lifecycle, and **all three thin
|
||||||
interfaces already sit on top of the same instance** —
|
interfaces sit on top of the same instance** —
|
||||||
[`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
[`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
||||||
(NIP-99) and
|
(NIP-99),
|
||||||
[`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)
|
[`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)
|
||||||
(NIP-17). A third, `TrustTransport` (WoT), would slot in the same way. This is
|
(NIP-17) and
|
||||||
the shape to lift into `commons_core`: shared connection, per-concern
|
[`TrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)
|
||||||
contract. Trying to make `OfferTransport` also carry DMs would have overloaded
|
(custom WoT). The trust test even runs offer discovery and trust annotation
|
||||||
it — the split is real.
|
over one connection. This is the shape to lift into `commons_core`: shared
|
||||||
|
connection, per-concern contract. Trying to make `OfferTransport` also carry
|
||||||
|
DMs and certifications would have overloaded it — the split is real.
|
||||||
|
|
||||||
**Residual risk: medium → low-medium.** The abstraction is right *and* two of
|
**Residual risk: low.** The abstraction is right and all three contracts are
|
||||||
the three contracts are now built. The remaining risk is **scope, not
|
built. The remaining risk is **scope/hardening, not feasibility**: "the social
|
||||||
feasibility**: "the social layer" is offers + messaging + trust + relays
|
layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and
|
||||||
together (§D.1 "indivisible"), and trust/WoT plus production hardening of
|
that whole happy path now runs — what's left is production hardening (below).
|
||||||
messaging (below) are still ahead.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -209,6 +213,39 @@ coupling to Nostr. So the spike built it rather than hand-waving:
|
||||||
large" weight.
|
large" weight.
|
||||||
- **Spam/abuse** on DMs leans on the (unbuilt) web of trust.
|
- **Spam/abuse** on DMs leans on the (unbuilt) web of trust.
|
||||||
|
|
||||||
|
## Q4 — Web of trust (the last big unknown)
|
||||||
|
|
||||||
|
**Result: a Duniter-style WoT maps cleanly onto Nostr, computes correctly from
|
||||||
|
discovered events, and filters spam. Built and tested.**
|
||||||
|
|
||||||
|
There is no settled NIP for a web of trust, so — exactly as
|
||||||
|
[g1-integration.md](g1-integration.md) §"WoT propia pero compatible"
|
||||||
|
anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr:
|
||||||
|
|
||||||
|
- **Certifications as events**
|
||||||
|
([`NostrTrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)):
|
||||||
|
a custom addressable kind (30777) keyed by (issuer, `d`=subject), so one live
|
||||||
|
"A vouches for B" per pair; re-certifying renews, revoking replaces (tested).
|
||||||
|
Certifications expire (Ğ1 semantics) and are public (like the on-chain Ğ1 WoT).
|
||||||
|
- **The membership rule is pure and separately tested**
|
||||||
|
([`WebOfTrust`](../../spike/block2_spike/lib/src/web_of_trust.dart)) — the
|
||||||
|
`commons_core`-worthy piece: Duniter's two rules, **N certifications from
|
||||||
|
existing members** (sigQty) and **within distance D of the bootstrap
|
||||||
|
referents** (stepMax), iterated to a fixpoint (becoming a member can push
|
||||||
|
others over the line). Tests cover threshold, distance cut-off, transitive
|
||||||
|
growth, and exclusion of expired/revoked/self certs.
|
||||||
|
- **It filters spam (the payoff, network-trust.md §2):** in a test, three seed
|
||||||
|
members certify Alice; Eve stays uncertified. Bob discovers both their offers,
|
||||||
|
then annotates authors — **Alice is "known", Eve stays "unknown"** and gets
|
||||||
|
deprioritised. Trust and offers run **on the same shared connection**, closing
|
||||||
|
the loop between Q2's architecture and this filter.
|
||||||
|
|
||||||
|
**Residual risk: high → medium.** Feasibility is proven; what's left is *policy
|
||||||
|
and bootstrap*, not code: choosing sigQty/stepMax/expiry (network-trust.md §2
|
||||||
|
"a decidir"), the cold-start referent set (fairs, Ğ1 seed groups —
|
||||||
|
[g1-integration.md](g1-integration.md)), whether a collective can certify as an
|
||||||
|
entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust source.
|
||||||
|
|
||||||
## Overall recommendation
|
## Overall recommendation
|
||||||
|
|
||||||
1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified;
|
1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified;
|
||||||
|
|
@ -217,17 +254,21 @@ coupling to Nostr. So the spike built it rather than hand-waving:
|
||||||
2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try
|
2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try
|
||||||
to make `OfferTransport` also carry messaging and trust. Keep `Offer`
|
to make `OfferTransport` also carry messaging and trust. Keep `Offer`
|
||||||
agnostic and the geohash-coarsening seam exactly as prototyped.
|
agnostic and the geohash-coarsening seam exactly as prototyped.
|
||||||
3. **Q3: Nostr-first is the right bet, and now two of three contracts are
|
3. **Q3/Q4: Nostr-first is the right bet, and all three contracts are now
|
||||||
proven.** Offers (NIP-99) and private messaging (NIP-17) both work on the
|
proven.** Offers (NIP-99), private messaging (NIP-17) and the web of trust
|
||||||
shared connection. The remaining unproven weight is **WoT/trust
|
(custom, Duniter-compatible) all work on the **same shared connection** — the
|
||||||
(NIP-85/custom)** plus **production hardening of messaging** (interop-exact
|
happy path of the entire social layer runs end to end in this spike. What
|
||||||
NIP-44, offline delivery, multi-device) — that, not the happy-path flow,
|
remains is **not feasibility but hardening and policy**: interop-exact NIP-44,
|
||||||
should drive the Phase-3 estimate and the funding ask
|
offline/multi-device delivery, and the WoT parameters + cold-start
|
||||||
([open-decisions.md](open-decisions.md) §D.1, §D.6).
|
([open-decisions.md](open-decisions.md) §D.1, §D.6). That is what should drive
|
||||||
4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once
|
the Phase-3 estimate and the funding ask.
|
||||||
these findings are absorbed. The next concrete de-risking target is the **web
|
4. **Do not start Block 2 from this spike.** It is throwaway research code (not
|
||||||
of trust** (Duniter-style certifications mapped to Nostr events) — the last
|
vector-verified, no offline delivery, no persistence, single relay). Delete
|
||||||
large unknown — followed by hardening messaging for real-world delivery.
|
`spike/block2_spike/` once these findings are absorbed. The funded social
|
||||||
|
round should **rebuild on vetted client libraries in `commons_core`**, using
|
||||||
|
the shapes proven here — one `NostrConnection`, three interfaces, the pure
|
||||||
|
`WebOfTrust` rule — and spend its risk budget on hardening and bootstrap, not
|
||||||
|
on re-proving the happy path.
|
||||||
|
|
||||||
## Risk summary
|
## Risk summary
|
||||||
|
|
||||||
|
|
@ -237,15 +278,15 @@ coupling to Nostr. So the spike built it rather than hand-waving:
|
||||||
| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. |
|
| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. |
|
||||||
| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. |
|
| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. |
|
||||||
| Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. |
|
| Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. |
|
||||||
| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. The last big unknown. |
|
| Web of trust (custom, Ğ1-compatible) | **Medium** | Feasibility proven (certs + membership rule + spam filter tested). Left: parameters (sigQty/stepMax/expiry) + cold-start, not code. |
|
||||||
| Overall scope (§D.1) | **Medium-High** | Social layer is indivisible & large; 2 of 3 transport contracts now proven, WoT + hardening remain. |
|
| Overall scope (§D.1) | **Medium** | Social layer is indivisible & large, but the whole happy path now runs end to end; what remains is hardening + policy, not unknowns. |
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd spike/block2_spike
|
cd spike/block2_spike
|
||||||
dart pub get
|
dart pub get
|
||||||
dart test # derivation · privacy · roundtrip · messaging (20 tests)
|
dart test # derivation · privacy · roundtrip · messaging · trust · web_of_trust (30 tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the
|
Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,8 @@ export 'src/nostr_event.dart';
|
||||||
export 'src/nostr_key.dart';
|
export 'src/nostr_key.dart';
|
||||||
export 'src/nostr_message_transport.dart';
|
export 'src/nostr_message_transport.dart';
|
||||||
export 'src/nostr_offer_transport.dart';
|
export 'src/nostr_offer_transport.dart';
|
||||||
|
export 'src/nostr_trust_transport.dart';
|
||||||
export 'src/offer.dart';
|
export 'src/offer.dart';
|
||||||
export 'src/offer_transport.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
spike/block2_spike/test/trust_test.dart
Normal file
115
spike/block2_spike/test/trust_test.dart
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
92
spike/block2_spike/test/web_of_trust_test.dart
Normal file
92
spike/block2_spike/test/web_of_trust_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue