feat(block2): commons_core social foundation — derivation, types, interfaces, WoT
First production slice of Block 2 (transport foundation), on the vetted pure-Dart nostr package (LGPL-3.0, AGPL-compatible, Flutter-free): - NostrKeyDerivation: seed -> secp256k1 Nostr identity via domain-separated HKDF (reuses cryptography's Hkdf like BackupBox), one-way, versioned. Wraps nostr's Keys for signing. Tested (reproducible, avalanche, one-way, integrates with IdentityService seed). - Agnostic value types: Offer/OfferType/OfferStatus/DiscoveryQuery/PublishResult, Certification. - Transport interfaces: OfferTransport, MessageTransport (+PrivateMessage), TrustTransport — the three sibling contracts. - WebOfTrust: pure Duniter membership rule (threshold + distance, fixpoint). Promoted from the spike; TDD (7 cases). No network yet (that's the next slice). commons_core: 50 tests green, analyzer clean. Block 1 APIs unchanged.
This commit is contained in:
parent
a03154030b
commit
1253f0c632
11 changed files with 528 additions and 0 deletions
|
|
@ -9,5 +9,12 @@ export 'src/crypto/backup_box.dart';
|
|||
export 'src/crypto/random_bytes.dart';
|
||||
export 'src/crypto/recovery_code.dart';
|
||||
export 'src/identity/identity_service.dart';
|
||||
export 'src/identity/nostr_key_derivation.dart';
|
||||
export 'src/ids/id_gen.dart';
|
||||
export 'src/social/certification.dart';
|
||||
export 'src/social/message_transport.dart';
|
||||
export 'src/social/offer.dart';
|
||||
export 'src/social/offer_transport.dart';
|
||||
export 'src/social/trust_transport.dart';
|
||||
export 'src/social/web_of_trust.dart';
|
||||
export 'src/value/quantity.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:nostr/nostr.dart';
|
||||
|
||||
/// A Nostr identity (secp256k1 / BIP340) deterministically derived from the
|
||||
/// Duniter/Ğ1-style root seed. Keeps "one identity, one backup": the user backs
|
||||
/// up only the root seed (as the printable recovery QR); this key regenerates on
|
||||
/// demand.
|
||||
///
|
||||
/// Derivation is one-way by construction — the seed feeds HKDF-SHA256, whose PRF
|
||||
/// (HMAC-SHA256) is not invertible, so neither the private nor the public Nostr
|
||||
/// key can leak the root seed. Validated by the Block 2 spike; see
|
||||
/// docs/design/spike-block2-findings.md §1.
|
||||
class NostrIdentity {
|
||||
NostrIdentity._(this._keys);
|
||||
|
||||
final Keys _keys;
|
||||
|
||||
/// 32-byte secp256k1 secret, lower-case hex. Secret — never shared.
|
||||
String get privateKeyHex => _keys.secret;
|
||||
|
||||
/// 32-byte BIP340 x-only public key, lower-case hex — the Nostr identity.
|
||||
String get publicKeyHex => _keys.public;
|
||||
|
||||
/// NIP-19 `npub…` (shareable) and `nsec…` (secret) encodings.
|
||||
String get npub => _keys.npub;
|
||||
String get nsec => _keys.nsec;
|
||||
|
||||
/// The underlying `nostr` key pair, for signing/encryption.
|
||||
Keys get keys => _keys;
|
||||
}
|
||||
|
||||
/// Derives the Nostr identity from a root [seed] (the 32 bytes
|
||||
/// [IdentityService.generateRootSeed] produces).
|
||||
///
|
||||
/// Domain-separated and versioned so a future scheme can coexist with old
|
||||
/// backups — the same discipline `BackupBox` uses. Changing [derivationInfo]
|
||||
/// silently rotates every user's Nostr identity, so it is effectively part of
|
||||
/// `schemaVersion`.
|
||||
///
|
||||
/// Reduces to a valid secp256k1 scalar in `[1, n-1]` by rejection (bumps a
|
||||
/// counter in the HKDF info on the astronomically rare miss), so the result
|
||||
/// stays a pure function of the seed.
|
||||
class NostrKeyDerivation {
|
||||
static const derivationInfo = 'org.comunes.tane/nostr/secp256k1/v1';
|
||||
|
||||
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||
|
||||
/// secp256k1 group order n.
|
||||
static final BigInt _n = BigInt.parse(
|
||||
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141',
|
||||
radix: 16,
|
||||
);
|
||||
|
||||
static Future<NostrIdentity> deriveFromSeed(List<int> seed) async {
|
||||
var counter = 0;
|
||||
while (true) {
|
||||
final key = await _hkdf.deriveKey(
|
||||
secretKey: SecretKey(seed),
|
||||
info: '$derivationInfo/$counter'.codeUnits,
|
||||
nonce: const [],
|
||||
);
|
||||
final bytes = await key.extractBytes();
|
||||
final d = _bytesToBigInt(bytes);
|
||||
if (d != BigInt.zero && d < _n) {
|
||||
final privHex = d.toRadixString(16).padLeft(64, '0');
|
||||
return NostrIdentity._(Keys(privHex));
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BigInt _bytesToBigInt(List<int> bytes) {
|
||||
var result = BigInt.zero;
|
||||
for (final b in bytes) {
|
||||
result = (result << 8) | BigInt.from(b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
35
packages/commons_core/lib/src/social/certification.dart
Normal file
35
packages/commons_core/lib/src/social/certification.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// One "A vouches for B" certification, Duniter/Ğ1 style. Public by design (like
|
||||
/// the on-chain Ğ1 web of trust) and signed by the issuer. Generic core type —
|
||||
/// no seed specifics.
|
||||
class Certification extends Equatable {
|
||||
const Certification({
|
||||
required this.issuer,
|
||||
required this.subject,
|
||||
required this.issuedAt,
|
||||
this.expiresAt,
|
||||
this.revoked = false,
|
||||
this.note = '',
|
||||
});
|
||||
|
||||
/// Certifier public key (hex).
|
||||
final String issuer;
|
||||
|
||||
/// Certified public key (hex).
|
||||
final String subject;
|
||||
|
||||
final DateTime issuedAt;
|
||||
|
||||
/// Duniter certifications expire and must be renewed; null means no expiry.
|
||||
final DateTime? expiresAt;
|
||||
final bool revoked;
|
||||
final String note;
|
||||
|
||||
/// A certification counts only while valid: not revoked and not expired.
|
||||
bool isValidAt(DateTime now) =>
|
||||
!revoked && (expiresAt == null || expiresAt!.isAfter(now));
|
||||
|
||||
@override
|
||||
List<Object?> get props => [issuer, subject, issuedAt, expiresAt, revoked, note];
|
||||
}
|
||||
27
packages/commons_core/lib/src/social/message_transport.dart
Normal file
27
packages/commons_core/lib/src/social/message_transport.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/// One decrypted private message handed to the app.
|
||||
class PrivateMessage {
|
||||
const PrivateMessage({
|
||||
required this.fromPubkey,
|
||||
required this.text,
|
||||
required this.at,
|
||||
});
|
||||
|
||||
/// The authenticated sender (from the inner rumor) — NOT the gift-wrap author,
|
||||
/// which is a throwaway ephemeral key. That's the metadata-privacy point.
|
||||
final String fromPubkey;
|
||||
final String text;
|
||||
final DateTime at;
|
||||
}
|
||||
|
||||
/// Private 1:1 messaging over the shared connection. Deliberately separate from
|
||||
/// `OfferTransport` — its verbs (send a DM / read an inbox) don't fit
|
||||
/// publish/discover. Backed by NIP-17 gift-wrapped, NIP-44-encrypted events.
|
||||
abstract interface class MessageTransport {
|
||||
/// Sends [text] privately to [toPubkey].
|
||||
Future<void> send({required String toPubkey, required String text});
|
||||
|
||||
/// Streams decrypted messages addressed to this identity, until cancelled.
|
||||
Stream<PrivateMessage> inbox();
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
89
packages/commons_core/lib/src/social/offer.dart
Normal file
89
packages/commons_core/lib/src/social/offer.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/// Reciprocity modes (sharing-model.md §3), plus the generic `lend` the core
|
||||
/// reserves for a future "library of things".
|
||||
enum OfferType { gift, exchange, sale, wanted, lend }
|
||||
|
||||
enum OfferStatus { active, reserved, closed }
|
||||
|
||||
/// A publishable offer — the "shop window", deliberately DECOUPLED from the
|
||||
/// domain's inventory (sharing-model.md §2, core-domain-boundary.md §4.3).
|
||||
///
|
||||
/// It carries only a chosen, denormalised *summary* — never a foreign key into
|
||||
/// domain tables, never the full inventory, never an exact address. This is the
|
||||
/// privacy seam: the core publishes and syncs offers without understanding
|
||||
/// seeds. Everything here is what the person elected to reveal.
|
||||
class Offer {
|
||||
const Offer({
|
||||
required this.id,
|
||||
required this.authorPubkeyHex,
|
||||
required this.summary,
|
||||
required this.type,
|
||||
required this.approxGeohash,
|
||||
this.status = OfferStatus.active,
|
||||
this.category,
|
||||
this.priceAmount,
|
||||
this.priceCurrency,
|
||||
this.exchangeTerms,
|
||||
this.radiusKm,
|
||||
this.imageUrl,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
/// Stable per-offer identifier (NIP-99 `d` tag → addressable event).
|
||||
final String id;
|
||||
|
||||
/// The publishing identity (BIP340 x-only pubkey hex).
|
||||
final String authorPubkeyHex;
|
||||
|
||||
/// Human summary the author chose to publish.
|
||||
final String summary;
|
||||
|
||||
final OfferType type;
|
||||
final OfferStatus status;
|
||||
|
||||
/// Free-text category, prefilled in-app from the domain but opaque here.
|
||||
final String? category;
|
||||
|
||||
/// LOW-PRECISION geohash only (sharing-model.md §2: "near you", never exact
|
||||
/// coordinates). The Nostr transport coarsens it further on the wire.
|
||||
final String approxGeohash;
|
||||
final int? radiusKm;
|
||||
|
||||
final num? priceAmount; // only for sale
|
||||
final String? priceCurrency; // ISO 4217 or a community currency (e.g. "G1")
|
||||
final String? exchangeTerms; // only for exchange
|
||||
|
||||
final String? imageUrl;
|
||||
final DateTime? expiresAt;
|
||||
}
|
||||
|
||||
/// Result of publishing to a transport.
|
||||
class PublishResult {
|
||||
const PublishResult({
|
||||
required this.accepted,
|
||||
required this.transportRef,
|
||||
this.message = '',
|
||||
});
|
||||
|
||||
/// Whether the relay/instance accepted the event.
|
||||
final bool accepted;
|
||||
|
||||
/// The transport-side id (Nostr event id / ActivityPub object id).
|
||||
final String transportRef;
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// A discovery query: coarse location + optional facets. There is no way to ask
|
||||
/// for "everything person X holds" — you browse the public shop window by area
|
||||
/// and kind.
|
||||
class DiscoveryQuery {
|
||||
const DiscoveryQuery({
|
||||
required this.geohashPrefix,
|
||||
this.types = const {},
|
||||
this.limit = 100,
|
||||
});
|
||||
|
||||
/// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km).
|
||||
final String geohashPrefix;
|
||||
final Set<OfferType> types;
|
||||
final int limit;
|
||||
}
|
||||
24
packages/commons_core/lib/src/social/offer_transport.dart
Normal file
24
packages/commons_core/lib/src/social/offer_transport.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import 'offer.dart';
|
||||
|
||||
/// Publish/discover offers, transport-agnostic (core-domain-boundary.md §2,
|
||||
/// PLAN §4). The Nostr NIP-99 backend sits behind this; a second backend
|
||||
/// (ActivityPub / FEP-0837) could slot in without the app noticing.
|
||||
///
|
||||
/// One of three sibling contracts — [OfferTransport], `MessageTransport`,
|
||||
/// `TrustTransport` — that share one `NostrConnection` but keep distinct verbs.
|
||||
/// The Block 2 spike proved forcing DMs/certifications behind publish/discover
|
||||
/// would overload it; the split is deliberate.
|
||||
abstract interface class OfferTransport {
|
||||
/// Publishes (or replaces) [offer]. Idempotent on the offer id.
|
||||
Future<PublishResult> publish(Offer offer);
|
||||
|
||||
/// Streams offers matching [query]: stored matches first, then live ones,
|
||||
/// until the caller cancels the subscription.
|
||||
Stream<Offer> discover(DiscoveryQuery query);
|
||||
|
||||
/// Retracts a previously published offer (relays that already replicated it
|
||||
/// drop it over time).
|
||||
Future<void> retract(String offerId);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
26
packages/commons_core/lib/src/social/trust_transport.dart
Normal file
26
packages/commons_core/lib/src/social/trust_transport.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'certification.dart';
|
||||
|
||||
/// The web-of-trust contract over the shared connection: certify / revoke / read
|
||||
/// certifications. Its verbs are again distinct from offers and messaging.
|
||||
/// Certifications are public signed events (like the on-chain Ğ1 WoT), so this
|
||||
/// transport publishes and discovers rather than encrypts. Feeds [WebOfTrust].
|
||||
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].
|
||||
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();
|
||||
}
|
||||
96
packages/commons_core/lib/src/social/web_of_trust.dart
Normal file
96
packages/commons_core/lib/src/social/web_of_trust.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'certification.dart';
|
||||
|
||||
/// Pure web-of-trust computation (Duniter/Ğ1 rules), Flutter-free and I/O-free.
|
||||
/// Answers "who counts as a known member" from certification edges, using two
|
||||
/// rules: **N certifications from existing members** (sigQty) and **within
|
||||
/// distance D of the bootstrap referents** (stepMax). Validated by the Block 2
|
||||
/// spike; the transport that discovers certifications feeds this.
|
||||
class WebOfTrust {
|
||||
WebOfTrust();
|
||||
|
||||
final Map<String, Set<String>> _out = {}; // issuer -> subjects
|
||||
final Map<String, Set<String>> _in = {}; // subject -> issuers
|
||||
|
||||
/// Builds a graph from the certifications valid 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) plus everyone reachable by the rules. Iterated to a fixpoint, because
|
||||
/// becoming a member can push others over the threshold.
|
||||
///
|
||||
/// - [threshold]: min certifications **from current members** (sigQty, ~5).
|
||||
/// - [maxDistance]: max certification hops from any seed (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;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ dependencies:
|
|||
meta: ^1.15.0
|
||||
# Pure-Dart AEAD + HKDF (Apache-2.0) for sealed backups. No FFI, host-testable.
|
||||
cryptography: ^2.7.0
|
||||
# Pure-Dart Nostr protocol (LGPL-3.0, AGPL-compatible, Flutter-free): events,
|
||||
# signing, NIP-19, NIP-44, gift wrap — the vetted crypto for the social layer
|
||||
# transport. We keep the relay/websocket layer and our derivation on top.
|
||||
nostr: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^6.0.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('NostrKeyDerivation.deriveFromSeed', () {
|
||||
final seed = Uint8List.fromList(List.generate(32, (i) => i));
|
||||
|
||||
test('is reproducible: same seed → identical key', () async {
|
||||
final a = await NostrKeyDerivation.deriveFromSeed(seed);
|
||||
final b = await NostrKeyDerivation.deriveFromSeed(seed);
|
||||
expect(a.privateKeyHex, b.privateKeyHex);
|
||||
expect(a.publicKeyHex, b.publicKeyHex);
|
||||
expect(a.npub, b.npub);
|
||||
});
|
||||
|
||||
test('produces a well-formed x-only key and npub/nsec', () async {
|
||||
final id = await NostrKeyDerivation.deriveFromSeed(seed);
|
||||
expect(id.privateKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
expect(id.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
expect(id.npub, startsWith('npub1'));
|
||||
expect(id.nsec, startsWith('nsec1'));
|
||||
});
|
||||
|
||||
test('the derived key signs & verifies (BIP340, via nostr)', () async {
|
||||
final id = await NostrKeyDerivation.deriveFromSeed(seed);
|
||||
final message = 'a' * 64; // 32-byte hex
|
||||
final sig = id.keys.sign(message: message);
|
||||
expect(sig, matches(RegExp(r'^[0-9a-f]{128}$')));
|
||||
});
|
||||
|
||||
test('different seeds → different keys (avalanche on a 1-bit flip)',
|
||||
() async {
|
||||
final flipped = Uint8List.fromList(seed)..[0] ^= 0x01;
|
||||
final a = await NostrKeyDerivation.deriveFromSeed(seed);
|
||||
final b = await NostrKeyDerivation.deriveFromSeed(flipped);
|
||||
expect(a.privateKeyHex, isNot(b.privateKeyHex));
|
||||
expect(a.publicKeyHex, isNot(b.publicKeyHex));
|
||||
});
|
||||
|
||||
test('one-wayness: the seed does not appear in the derived material',
|
||||
() async {
|
||||
final id = await NostrKeyDerivation.deriveFromSeed(seed);
|
||||
final seedHex =
|
||||
seed.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
expect(id.privateKeyHex, isNot(contains(seedHex)));
|
||||
expect(id.publicKeyHex, isNot(contains(seedHex)));
|
||||
});
|
||||
|
||||
test('integrates with the real IdentityService root seed', () async {
|
||||
final rootSeed = IdentityService(random: Random(42)).generateRootSeed();
|
||||
expect(rootSeed.length, IdentityService.rootSeedLengthBytes);
|
||||
final id = await NostrKeyDerivation.deriveFromSeed(rootSeed);
|
||||
expect(id.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
82
packages/commons_core/test/social/web_of_trust_test.dart
Normal file
82
packages/commons_core/test/social/web_of_trust_test.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Pure web-of-trust rules (Duniter: N certs from members + within distance D).
|
||||
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);
|
||||
expect(wot.members(seeds: seeds, threshold: 5, maxDistance: 5),
|
||||
contains('newcomer'));
|
||||
});
|
||||
|
||||
test('below the threshold stays unknown', () {
|
||||
final seeds = {'s1', 's2', 's3', 's4', 's5'};
|
||||
final certs = [cert('s1', 'x'), cert('s2', 'x')];
|
||||
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'),
|
||||
cert('a', 'b'), cert('s2', 'b'),
|
||||
];
|
||||
final wot = WebOfTrust.fromCertifications(certs, now: now);
|
||||
expect(wot.members(seeds: seeds, threshold: 2, maxDistance: 5),
|
||||
containsAll(['a', 'b']));
|
||||
});
|
||||
|
||||
test('distance rule cuts off far-away nodes even if well-certified', () {
|
||||
final seeds = {'s'};
|
||||
final certs = [
|
||||
cert('s', 'a'),
|
||||
cert('a', 'b'),
|
||||
cert('b', 'c'),
|
||||
cert('c', 'd'),
|
||||
];
|
||||
final wot = WebOfTrust.fromCertifications(certs, now: now);
|
||||
final members = wot.members(seeds: seeds, threshold: 1, maxDistance: 2);
|
||||
expect(members, containsAll(['a', 'b']));
|
||||
expect(members, isNot(contains('c')));
|
||||
});
|
||||
|
||||
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)),
|
||||
cert('s5', 'x', revoked: true),
|
||||
];
|
||||
final wot = WebOfTrust.fromCertifications(certs, now: now);
|
||||
expect(wot.certifierCount('x'), 3);
|
||||
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