From 1253f0c63202695597079a51878e6927532f68a0 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 02:31:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(block2):=20commons=5Fcore=20social=20found?= =?UTF-8?q?ation=20=E2=80=94=20derivation,=20types,=20interfaces,=20WoT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/commons_core/lib/commons_core.dart | 7 ++ .../src/identity/nostr_key_derivation.dart | 79 +++++++++++++++ .../lib/src/social/certification.dart | 35 +++++++ .../lib/src/social/message_transport.dart | 27 ++++++ .../commons_core/lib/src/social/offer.dart | 89 +++++++++++++++++ .../lib/src/social/offer_transport.dart | 24 +++++ .../lib/src/social/trust_transport.dart | 26 +++++ .../lib/src/social/web_of_trust.dart | 96 +++++++++++++++++++ packages/commons_core/pubspec.yaml | 4 + .../identity/nostr_key_derivation_test.dart | 59 ++++++++++++ .../test/social/web_of_trust_test.dart | 82 ++++++++++++++++ 11 files changed, 528 insertions(+) create mode 100644 packages/commons_core/lib/src/identity/nostr_key_derivation.dart create mode 100644 packages/commons_core/lib/src/social/certification.dart create mode 100644 packages/commons_core/lib/src/social/message_transport.dart create mode 100644 packages/commons_core/lib/src/social/offer.dart create mode 100644 packages/commons_core/lib/src/social/offer_transport.dart create mode 100644 packages/commons_core/lib/src/social/trust_transport.dart create mode 100644 packages/commons_core/lib/src/social/web_of_trust.dart create mode 100644 packages/commons_core/test/identity/nostr_key_derivation_test.dart create mode 100644 packages/commons_core/test/social/web_of_trust_test.dart diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index dcb059b..6d5805b 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -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'; diff --git a/packages/commons_core/lib/src/identity/nostr_key_derivation.dart b/packages/commons_core/lib/src/identity/nostr_key_derivation.dart new file mode 100644 index 0000000..e6be6f5 --- /dev/null +++ b/packages/commons_core/lib/src/identity/nostr_key_derivation.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 deriveFromSeed(List 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 bytes) { + var result = BigInt.zero; + for (final b in bytes) { + result = (result << 8) | BigInt.from(b); + } + return result; +} diff --git a/packages/commons_core/lib/src/social/certification.dart b/packages/commons_core/lib/src/social/certification.dart new file mode 100644 index 0000000..f85606d --- /dev/null +++ b/packages/commons_core/lib/src/social/certification.dart @@ -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 get props => [issuer, subject, issuedAt, expiresAt, revoked, note]; +} diff --git a/packages/commons_core/lib/src/social/message_transport.dart b/packages/commons_core/lib/src/social/message_transport.dart new file mode 100644 index 0000000..3a86d99 --- /dev/null +++ b/packages/commons_core/lib/src/social/message_transport.dart @@ -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 send({required String toPubkey, required String text}); + + /// Streams decrypted messages addressed to this identity, until cancelled. + Stream inbox(); + + Future close(); +} diff --git a/packages/commons_core/lib/src/social/offer.dart b/packages/commons_core/lib/src/social/offer.dart new file mode 100644 index 0000000..ae5491f --- /dev/null +++ b/packages/commons_core/lib/src/social/offer.dart @@ -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 types; + final int limit; +} diff --git a/packages/commons_core/lib/src/social/offer_transport.dart b/packages/commons_core/lib/src/social/offer_transport.dart new file mode 100644 index 0000000..3dc3740 --- /dev/null +++ b/packages/commons_core/lib/src/social/offer_transport.dart @@ -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 publish(Offer offer); + + /// Streams offers matching [query]: stored matches first, then live ones, + /// until the caller cancels the subscription. + Stream discover(DiscoveryQuery query); + + /// Retracts a previously published offer (relays that already replicated it + /// drop it over time). + Future retract(String offerId); + + Future close(); +} diff --git a/packages/commons_core/lib/src/social/trust_transport.dart b/packages/commons_core/lib/src/social/trust_transport.dart new file mode 100644 index 0000000..638e43b --- /dev/null +++ b/packages/commons_core/lib/src/social/trust_transport.dart @@ -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 certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }); + + /// Revokes this identity's certification of [subjectPubkey]. + Future revoke({required String subjectPubkey}); + + /// All certifications currently visible on the relay (to build a [WebOfTrust]). + Future> allCertifications(); + + /// Distinct, currently-valid certifiers of [subjectPubkey]. + Future> certifiersOf(String subjectPubkey); + + Future close(); +} diff --git a/packages/commons_core/lib/src/social/web_of_trust.dart b/packages/commons_core/lib/src/social/web_of_trust.dart new file mode 100644 index 0000000..70a9eeb --- /dev/null +++ b/packages/commons_core/lib/src/social/web_of_trust.dart @@ -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> _out = {}; // issuer -> subjects + final Map> _in = {}; // subject -> issuers + + /// Builds a graph from the certifications valid as of [now] (drops expired / + /// revoked, ignores self-certifications). + factory WebOfTrust.fromCertifications( + Iterable 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 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 members({ + required Set 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 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 _distancesFromSeeds(Set seeds) { + final dist = {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 {}) { + if (!dist.containsKey(next)) { + dist[next] = d + 1; + queue.add(next); + } + } + } + return dist; + } +} diff --git a/packages/commons_core/pubspec.yaml b/packages/commons_core/pubspec.yaml index 5236f47..ef9d76a 100644 --- a/packages/commons_core/pubspec.yaml +++ b/packages/commons_core/pubspec.yaml @@ -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 diff --git a/packages/commons_core/test/identity/nostr_key_derivation_test.dart b/packages/commons_core/test/identity/nostr_key_derivation_test.dart new file mode 100644 index 0000000..d0b01cb --- /dev/null +++ b/packages/commons_core/test/identity/nostr_key_derivation_test.dart @@ -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}$'))); + }); + }); +} diff --git a/packages/commons_core/test/social/web_of_trust_test.dart b/packages/commons_core/test/social/web_of_trust_test.dart new file mode 100644 index 0000000..066067b --- /dev/null +++ b/packages/commons_core/test/social/web_of_trust_test.dart @@ -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); + }); +}