Compare commits
186 commits
5b64fe14b3
...
a0302632a3
| Author | SHA1 | Date | |
|---|---|---|---|
| a0302632a3 | |||
| 0d4d19658c | |||
| f844d7c4f6 | |||
| ea17ced152 | |||
| 8ed1387792 | |||
| a511db9062 | |||
| 15385a26cf | |||
| ab027a8779 | |||
| 416fd7153b | |||
| 6292c741be | |||
| eb8c75cf44 | |||
| f10db611e8 | |||
| 36e44fef00 | |||
| 05947e03ee | |||
| 67f1c0da0c | |||
| 485d35c916 | |||
| dc20104c87 | |||
| 4ecf9b9811 | |||
| 3fa50a7a46 | |||
| 8d471507be | |||
| 7030f4a0bd | |||
| 621902612d | |||
| 13a9bead15 | |||
| f9c3dc018d | |||
| 2e25e34d11 | |||
| bec0f78fa1 | |||
| 516f1f50bc | |||
| baa8867287 | |||
| 41ea6735d2 | |||
| 7ef3de04d7 | |||
| dd36c3ac81 | |||
| f1e4fe377a | |||
| 37761a029c | |||
| 435b4db101 | |||
| 7869a7163b | |||
| b41dfd4248 | |||
| cd9f09048d | |||
| 668ed28246 | |||
| 8417a545a2 | |||
| 6b154ec717 | |||
| 184c15bcec | |||
| c4421f768a | |||
| 9560d0d18f | |||
| d4a9142ee2 | |||
| d85b557f1e | |||
| f31f58cc7e | |||
| 51f8177f70 | |||
| 9913d03c7a | |||
| 5d0da128b1 | |||
| 2f7095ca59 | |||
| ec5dca1e1c | |||
| adbe6b00fb | |||
| 064248144a | |||
| d42ed0df1a | |||
| b22db7d0cb | |||
| be5977d688 | |||
| 514d3c7364 | |||
| 461bc3cb36 | |||
| d28454b010 | |||
| ceb606ae57 | |||
| 248b239c48 | |||
| 456cc8154d | |||
| 2c42b1022a | |||
| 7e7340d643 | |||
| a24831530d | |||
| fef174e8c4 | |||
| 3c5200f235 | |||
| 9f01c3893a | |||
| 491e079b50 | |||
| e6e70e4b97 | |||
| 1103b6ce15 | |||
| 5ecdd1222a | |||
| 4a1e72bc7e | |||
| 805f1dc535 | |||
| 9e083d8b7f | |||
| 4bb94db3e0 | |||
| 4ebfdca2fd | |||
| a461106dbf | |||
| d481e62ff7 | |||
| 73e96a3ce5 | |||
| a5f50314e4 | |||
| dcda349878 | |||
| aec14f6518 | |||
| 68b04ea409 | |||
| 44337497d0 | |||
| 5b487ba1ec | |||
| e2665c27d4 | |||
| 833ddaed4d | |||
| c75fb59926 | |||
| 68ffb55f47 | |||
| d89ee64bc7 | |||
| 2c33172ba4 | |||
| caf9d2b96e | |||
| b15f912ad8 | |||
| d0dbed9bc2 | |||
| bf15992a97 | |||
| d9c2f46b79 | |||
| 1666f5b31f | |||
| b9492cb65a | |||
| 2c8869ea0d | |||
| a2ce4dbb45 | |||
| f951b05e0a | |||
| 2b419e6516 | |||
| d2b98af36d | |||
| 5017ea51e0 | |||
| d026376993 | |||
| 7fce1ce6f0 | |||
| e715b129f6 | |||
| b56ce68167 | |||
| f767ccd70e | |||
| 0cdeee50f8 | |||
| a109a84714 | |||
| 4b71859416 | |||
| 4d110e8082 | |||
| 6c8a1ad129 | |||
| 3ea1bfbf55 | |||
| 33c45bcab1 | |||
| efb369eb81 | |||
| a71493bfb1 | |||
| 0213152bfb | |||
| 09b7575df3 | |||
| 21ec836bd8 | |||
| 3dca732da9 | |||
| 138027a8ef | |||
| e641f8e0fa | |||
| 5ab505bc94 | |||
| 1af0282e00 | |||
| 5dd0f243ce | |||
| ea5ab0342d | |||
| c9c764e624 | |||
| 8d1a66e7b8 | |||
| 5cb1c33d1b | |||
| a14065db3f | |||
| 3e84e9bb3f | |||
| 3fe5397675 | |||
| cc762a139c | |||
| a070d866e6 | |||
| c6b91b7666 | |||
| a447149a74 | |||
| 5b52298160 | |||
| a9c6030dde | |||
| 267896430f | |||
| 3067d9ca7f | |||
| e32fdc9d38 | |||
| 48228c0bc6 | |||
| 856736c8c5 | |||
| 458502b12b | |||
| 30246f4292 | |||
| 432acded29 | |||
| abc2d37402 | |||
| 867bf38c5a | |||
| 7bf80d2031 | |||
| 6d8c6cf7e1 | |||
| f3a7ad198d | |||
| 320b9b6dba | |||
| 46b5537a11 | |||
| c18314d8c5 | |||
| c513a6ce4b | |||
| 49a43722eb | |||
| d369015e01 | |||
| a1a2f1230a | |||
| a72224f95a | |||
| 6fa5b9bdc5 | |||
| a85e71dc8d | |||
| efd64c4da4 | |||
| 0dd7c28039 | |||
| 08d8dc1172 | |||
| ee78b0ed62 | |||
| 15bd689a99 | |||
| bf0e770aae | |||
| 6195bece4a | |||
| 39adc423b9 | |||
| 2d4e14e0da | |||
| 6d2d0517cc | |||
| 0e7eb22c26 | |||
| 12f48ea318 | |||
| 2c358ec33a | |||
| 801a846d6e | |||
| a59adcca6b | |||
| 51f9338433 | |||
| 2b58fe40ce | |||
| edb480b21b | |||
| 05a9d45a82 | |||
| cc88f3d688 | |||
| 2cafc7fc12 | |||
| 49b872b405 |
26 changed files with 2051 additions and 25 deletions
|
|
@ -1,9 +1,9 @@
|
|||
# Spike — Block 2 (social layer) de-risking findings
|
||||
|
||||
*Throwaway research spike. Language: English (findings), per code convention.
|
||||
The throwaway spike code (`spike/block2_spike/`) has been moved out of this
|
||||
public repo and preserved privately — it was **not production, not on the pub
|
||||
workspace, no deps added to Block 1**. This document is the deliverable: what we learned, the risks, and a
|
||||
Code lives in [`spike/block2_spike/`](../../spike/block2_spike/) and is marked
|
||||
throwaway — **not production, not on the pub workspace, no deps added to Block
|
||||
1**. This document is the deliverable: what we learned, the risks, and a
|
||||
recommendation on whether/how to take on the social round (Phase 3).*
|
||||
|
||||
> **Status: this is a spike, not a green light to build Block 2.** CLAUDE.md and
|
||||
|
|
@ -34,7 +34,7 @@ See "How to run" at the end.
|
|||
|
||||
**Result: works, deterministic, one-way. This unblocks "one identity, one backup".**
|
||||
|
||||
- Implemented `NostrKey.deriveFromSeed`:
|
||||
- Implemented [`NostrKey.deriveFromSeed`](../../spike/block2_spike/lib/src/nostr_key.dart):
|
||||
`HKDF-SHA256(rootSeed, info: "org.comunes.tane/nostr/secp256k1/v1")` → reduce
|
||||
to a valid secp256k1 scalar `d ∈ [1, n-1]` by rejection (bump a counter in the
|
||||
HKDF info on the astronomically-rare miss, so the result stays a pure function
|
||||
|
|
@ -80,13 +80,13 @@ trust do NOT fit behind the same interface — they share a *connection*, not a
|
|||
*contract*.**
|
||||
|
||||
### The seam works (offers)
|
||||
- `OfferTransport` =
|
||||
- [`OfferTransport`](../../spike/block2_spike/lib/src/offer_transport.dart) =
|
||||
`publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr
|
||||
NIP-99 backend (`NostrOfferTransport`
|
||||
+ `Nip99Codec`) sits entirely
|
||||
NIP-99 backend ([`NostrOfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
||||
+ [`Nip99Codec`](../../spike/block2_spike/lib/src/nip99.dart)) sits entirely
|
||||
behind it. A second backend (ActivityPub/FEP-0837) could replace it without
|
||||
the domain noticing.
|
||||
- `Offer` is agnostic by
|
||||
- [`Offer`](../../spike/block2_spike/lib/src/offer.dart) is agnostic by
|
||||
construction: a chosen *summary* + coarse geohash, **no FK into seed tables,
|
||||
no full inventory, no exact address** — exactly the Offer↔Lot split
|
||||
[sharing-model.md](sharing-model.md) §2 and
|
||||
|
|
@ -114,14 +114,14 @@ manageable**:
|
|||
private encrypted DM vs. assert/read a signed certification. Forcing DMs and
|
||||
certifications behind `OfferTransport.publish/discover` would overload it.
|
||||
- **Recommendation — now demonstrated in code, not just asserted:**
|
||||
`NostrConnection` is
|
||||
[`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is
|
||||
**one shared** socket + key + sign + REQ/EOSE lifecycle, and **all three thin
|
||||
interfaces sit on top of the same instance** —
|
||||
`OfferTransport`
|
||||
[`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
||||
(NIP-99),
|
||||
`MessageTransport`
|
||||
[`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)
|
||||
(NIP-17) and
|
||||
`TrustTransport`
|
||||
[`TrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)
|
||||
(custom WoT). The trust test even runs offer discovery and trust annotation
|
||||
over one connection. This is the shape to lift into `commons_core`: shared
|
||||
connection, per-concern contract. Trying to make `OfferTransport` also carry
|
||||
|
|
@ -139,7 +139,7 @@ that whole happy path now runs — what's left is production hardening (below).
|
|||
**Result: the flow works end to end. The real risk is NIP maturity/ecosystem,
|
||||
not the mechanics.**
|
||||
|
||||
- Built a hermetic in-process `MiniRelay`
|
||||
- Built a hermetic in-process [`MiniRelay`](../../spike/block2_spike/lib/src/mini_relay.dart)
|
||||
(NIP-01 subset: EVENT/REQ/EOSE/CLOSE, filter by `kinds`/`authors`/`#g`,
|
||||
addressable-event replacement for kind 30402). No network → CI-safe, no flaky
|
||||
public-relay dependency.
|
||||
|
|
@ -183,13 +183,13 @@ The plan ([network-trust.md](network-trust.md) §4) calls messaging "grande" and
|
|||
[open-decisions.md](open-decisions.md) §D.3 singled it out as the tightest
|
||||
coupling to Nostr. So the spike built it rather than hand-waving:
|
||||
|
||||
- **NIP-44 v2 encryption** (`Nip44`):
|
||||
- **NIP-44 v2 encryption** ([`Nip44`](../../spike/block2_spike/lib/src/nip44.dart)):
|
||||
secp256k1 ECDH → HKDF conversation key → ChaCha20 + HMAC-SHA256 with
|
||||
length-hiding padding. Conversation key is symmetric (A→B == B→A, tested); a
|
||||
wrong key cannot decrypt (throws, tested); short messages pad to the same
|
||||
bucket so ciphertext length doesn't leak plaintext length (tested).
|
||||
- **NIP-17 / NIP-59 gift-wrap onion**
|
||||
(`NostrMessageTransport`):
|
||||
([`NostrMessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)):
|
||||
rumor (kind 14, unsigned) → seal (kind 13, signed by sender, NIP-44 to
|
||||
recipient) → gift wrap (kind 1059, signed by a **throwaway ephemeral key**,
|
||||
NIP-44 to recipient).
|
||||
|
|
@ -223,12 +223,12 @@ There is no settled NIP for a web of trust, so — exactly as
|
|||
anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr:
|
||||
|
||||
- **Certifications as events**
|
||||
(`NostrTrustTransport`):
|
||||
([`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`) — the
|
||||
([`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
|
||||
|
|
@ -263,9 +263,8 @@ entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust sourc
|
|||
([open-decisions.md](open-decisions.md) §D.1, §D.6). That is what should drive
|
||||
the Phase-3 estimate and the funding ask.
|
||||
4. **Do not start Block 2 from this spike.** It is throwaway research code (not
|
||||
vector-verified, no offline delivery, no persistence, single relay). The
|
||||
`spike/block2_spike/` code has been moved out of this public repo (preserved
|
||||
privately) now these findings are captured. The funded social
|
||||
vector-verified, no offline delivery, no persistence, single relay). Delete
|
||||
`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
|
||||
|
|
@ -284,12 +283,11 @@ entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust sourc
|
|||
|
||||
## How to run
|
||||
|
||||
The throwaway spike code has been moved out of this public repo and is preserved
|
||||
privately; nothing here was wired into `app_seeds` or `commons_core`'s production
|
||||
graph, and the Block 1 suite is untouched. For the record, it ran as:
|
||||
|
||||
```sh
|
||||
cd spike/block2_spike # in the private archive
|
||||
cd spike/block2_spike
|
||||
dart pub get
|
||||
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
|
||||
Block 1 suite is untouched.
|
||||
|
|
|
|||
3
spike/block2_spike/.gitignore
vendored
Normal file
3
spike/block2_spike/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Throwaway spike — don't commit build artifacts or the resolved lockfile.
|
||||
.dart_tool/
|
||||
pubspec.lock
|
||||
19
spike/block2_spike/lib/block2_spike.dart
Normal file
19
spike/block2_spike/lib/block2_spike.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/// THROWAWAY Block 2 de-risking spike. Not production; not on the pub workspace.
|
||||
library;
|
||||
|
||||
export 'src/bech32.dart';
|
||||
export 'src/hkdf.dart';
|
||||
export 'src/message_transport.dart';
|
||||
export 'src/mini_relay.dart';
|
||||
export 'src/nip44.dart';
|
||||
export 'src/nip99.dart';
|
||||
export 'src/nostr_connection.dart';
|
||||
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';
|
||||
54
spike/block2_spike/lib/src/bech32.dart
Normal file
54
spike/block2_spike/lib/src/bech32.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/// Self-contained bech32 encode (BIP-173 checksum), enough for NIP-19
|
||||
/// `npub`/`nsec`. Written inline to avoid a dependency's version quirks; NIP-19
|
||||
/// is plain bech32 (checksum constant 1), not bech32m.
|
||||
const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
|
||||
int _polymod(List<int> values) {
|
||||
const gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
var chk = 1;
|
||||
for (final v in values) {
|
||||
final top = chk >> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
if ((top >> i) & 1 == 1) chk ^= gen[i];
|
||||
}
|
||||
}
|
||||
return chk;
|
||||
}
|
||||
|
||||
List<int> _hrpExpand(String hrp) => [
|
||||
...hrp.codeUnits.map((c) => c >> 5),
|
||||
0,
|
||||
...hrp.codeUnits.map((c) => c & 31),
|
||||
];
|
||||
|
||||
List<int> _createChecksum(String hrp, List<int> data) {
|
||||
final values = [..._hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
|
||||
final mod = _polymod(values) ^ 1;
|
||||
return List.generate(6, (i) => (mod >> (5 * (5 - i))) & 31);
|
||||
}
|
||||
|
||||
/// Converts 8-bit bytes to 5-bit groups (bech32 payload), padding with zeros.
|
||||
List<int> _convertBits8to5(List<int> data) {
|
||||
var acc = 0;
|
||||
var bits = 0;
|
||||
final out = <int>[];
|
||||
for (final value in data) {
|
||||
acc = (acc << 8) | value;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out.add((acc >> bits) & 31);
|
||||
}
|
||||
}
|
||||
if (bits > 0) out.add((acc << (5 - bits)) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// bech32-encodes [data] (raw bytes) under [hrp] (e.g. `npub`).
|
||||
String bech32Encode(String hrp, List<int> data) {
|
||||
final five = _convertBits8to5(data);
|
||||
final checksum = _createChecksum(hrp, five);
|
||||
final combined = [...five, ...checksum];
|
||||
return '${hrp}1${combined.map((v) => _charset[v]).join()}';
|
||||
}
|
||||
55
spike/block2_spike/lib/src/hkdf.dart
Normal file
55
spike/block2_spike/lib/src/hkdf.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Minimal, self-contained HKDF-SHA256 (RFC 5869), synchronous.
|
||||
///
|
||||
/// Spike-local so the derivation stays pure Dart and easy to reason about. In
|
||||
/// production this would reuse `commons_core`'s `Hkdf` (the `cryptography`
|
||||
/// package already ships one — see backup_box.dart), keyed by a domain string.
|
||||
Uint8List hkdfSha256({
|
||||
required List<int> ikm,
|
||||
required String info,
|
||||
List<int> salt = const [],
|
||||
int length = 32,
|
||||
}) {
|
||||
// Extract.
|
||||
final actualSalt = salt.isEmpty ? Uint8List(32) : salt; // HashLen zeros
|
||||
final prk = Hmac(sha256, actualSalt).convert(ikm).bytes;
|
||||
|
||||
// Expand.
|
||||
final infoBytes = info.codeUnits;
|
||||
final out = <int>[];
|
||||
var previous = <int>[];
|
||||
var counter = 1;
|
||||
while (out.length < length) {
|
||||
final input = <int>[...previous, ...infoBytes, counter];
|
||||
previous = Hmac(sha256, prk).convert(input).bytes;
|
||||
out.addAll(previous);
|
||||
counter++;
|
||||
}
|
||||
return Uint8List.fromList(out.sublist(0, length));
|
||||
}
|
||||
|
||||
/// HKDF-Extract (RFC 5869 §2.2): `PRK = HMAC(salt, ikm)`. NIP-44 uses this
|
||||
/// alone to turn the ECDH secret into the conversation key.
|
||||
Uint8List hkdfExtract({required List<int> salt, required List<int> ikm}) =>
|
||||
Uint8List.fromList(Hmac(sha256, salt).convert(ikm).bytes);
|
||||
|
||||
/// HKDF-Expand (RFC 5869 §2.3) with a *byte* info (NIP-44 uses the nonce as
|
||||
/// info), producing [length] bytes.
|
||||
Uint8List hkdfExpandBytes({
|
||||
required List<int> prk,
|
||||
required List<int> info,
|
||||
required int length,
|
||||
}) {
|
||||
final out = <int>[];
|
||||
var previous = <int>[];
|
||||
var counter = 1;
|
||||
while (out.length < length) {
|
||||
previous = Hmac(sha256, prk).convert([...previous, ...info, counter]).bytes;
|
||||
out.addAll(previous);
|
||||
counter++;
|
||||
}
|
||||
return Uint8List.fromList(out.sublist(0, length));
|
||||
}
|
||||
28
spike/block2_spike/lib/src/message_transport.dart
Normal file
28
spike/block2_spike/lib/src/message_transport.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/// One decrypted direct message handed to the app.
|
||||
class DirectMessage {
|
||||
const DirectMessage({
|
||||
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;
|
||||
}
|
||||
|
||||
/// The second interface over the shared `NostrConnection` (Q2): private 1:1
|
||||
/// messaging. Deliberately SEPARATE from `OfferTransport` — its verbs (send a
|
||||
/// private 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.
|
||||
Stream<DirectMessage> inbox();
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
124
spike/block2_spike/lib/src/mini_relay.dart
Normal file
124
spike/block2_spike/lib/src/mini_relay.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'nostr_event.dart';
|
||||
|
||||
/// A tiny, in-process Nostr relay (NIP-01 subset): EVENT / REQ / EOSE / CLOSE,
|
||||
/// filtering by `kinds`, `authors`, `ids` and `#g` (exact tag). Enough to prove
|
||||
/// the publish→discover round-trip WITHOUT touching the network, so the test is
|
||||
/// hermetic and CI-safe. Not a real relay — no persistence, no NIP-11, no auth.
|
||||
///
|
||||
/// Addressable events (kind 30000–39999, NIP-99's 30402) replace by
|
||||
/// (kind, pubkey, `d`) like a real relay, so re-publishing an offer updates it.
|
||||
class MiniRelay {
|
||||
MiniRelay._(this._server);
|
||||
|
||||
final HttpServer _server;
|
||||
final List<NostrEvent> _events = [];
|
||||
final Set<_Sub> _subs = {};
|
||||
|
||||
int get port => _server.port;
|
||||
String get url => 'ws://127.0.0.1:$port';
|
||||
|
||||
/// Number of events currently stored (for assertions/metrics).
|
||||
int get storedCount => _events.length;
|
||||
|
||||
static Future<MiniRelay> start() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final relay = MiniRelay._(server);
|
||||
relay._accept();
|
||||
return relay;
|
||||
}
|
||||
|
||||
void _accept() {
|
||||
_server.listen((req) async {
|
||||
if (!WebSocketTransformer.isUpgradeRequest(req)) {
|
||||
req.response.statusCode = HttpStatus.badRequest;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final ws = await WebSocketTransformer.upgrade(req);
|
||||
final socketSubs = <_Sub>{};
|
||||
ws.listen(
|
||||
(data) => _onMessage(ws, socketSubs, data as String),
|
||||
onDone: () => _subs.removeAll(socketSubs),
|
||||
onError: (_) => _subs.removeAll(socketSubs),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onMessage(WebSocket ws, Set<_Sub> socketSubs, String data) {
|
||||
final msg = jsonDecode(data) as List;
|
||||
switch (msg[0]) {
|
||||
case 'EVENT':
|
||||
_handleEvent(ws, NostrEvent.fromJson(msg[1] as Map<String, dynamic>));
|
||||
case 'REQ':
|
||||
final subId = msg[1] as String;
|
||||
final filters = msg.sublist(2).cast<Map<String, dynamic>>();
|
||||
final sub = _Sub(ws, subId, filters);
|
||||
_subs.add(sub);
|
||||
socketSubs.add(sub);
|
||||
for (final e in _events) {
|
||||
if (sub.matches(e)) _send(ws, ['EVENT', subId, e.toJson()]);
|
||||
}
|
||||
_send(ws, ['EOSE', subId]);
|
||||
case 'CLOSE':
|
||||
final subId = msg[1] as String;
|
||||
socketSubs.removeWhere((s) => s.id == subId);
|
||||
_subs.removeWhere((s) => s.ws == ws && s.id == subId);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(WebSocket ws, NostrEvent event) {
|
||||
if (!event.verify()) {
|
||||
_send(ws, ['OK', event.id, false, 'invalid: bad signature']);
|
||||
return;
|
||||
}
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
final d = event.tag('d') ?? '';
|
||||
_events.removeWhere(
|
||||
(e) => e.kind == event.kind && e.pubkey == event.pubkey && (e.tag('d') ?? '') == d,
|
||||
);
|
||||
}
|
||||
_events.add(event);
|
||||
_send(ws, ['OK', event.id, true, '']);
|
||||
for (final sub in _subs) {
|
||||
if (sub.matches(event)) _send(sub.ws, ['EVENT', sub.id, event.toJson()]);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(WebSocket ws, Object message) => ws.add(jsonEncode(message));
|
||||
|
||||
Future<void> stop() => _server.close(force: true);
|
||||
}
|
||||
|
||||
class _Sub {
|
||||
_Sub(this.ws, this.id, this.filters);
|
||||
|
||||
final WebSocket ws;
|
||||
final String id;
|
||||
final List<Map<String, dynamic>> filters;
|
||||
|
||||
bool matches(NostrEvent e) => filters.any((f) => _matchesFilter(e, f));
|
||||
|
||||
bool _matchesFilter(NostrEvent e, Map<String, dynamic> f) {
|
||||
if (f['kinds'] != null && !(f['kinds'] as List).contains(e.kind)) {
|
||||
return false;
|
||||
}
|
||||
if (f['authors'] != null && !(f['authors'] as List).contains(e.pubkey)) {
|
||||
return false;
|
||||
}
|
||||
if (f['ids'] != null && !(f['ids'] as List).contains(e.id)) return false;
|
||||
for (final entry in f.entries) {
|
||||
if (!entry.key.startsWith('#')) continue;
|
||||
final tagName = entry.key.substring(1);
|
||||
final wanted = (entry.value as List).cast<String>();
|
||||
final present = e.tags
|
||||
.where((t) => t.isNotEmpty && t[0] == tagName && t.length > 1)
|
||||
.map((t) => t[1]);
|
||||
if (!wanted.any(present.contains)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
142
spike/block2_spike/lib/src/nip44.dart
Normal file
142
spike/block2_spike/lib/src/nip44.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
import 'hkdf.dart';
|
||||
|
||||
/// NIP-44 v2 payload encryption (secp256k1 ECDH → conversation key → ChaCha20 +
|
||||
/// HMAC-SHA256, with length-hiding padding). This is the crypto NIP-17 private
|
||||
/// DMs ride on. Spike-grade: follows the NIP-44 *construction* faithfully but is
|
||||
/// NOT cross-checked against the reference test vectors — see findings §4. Good
|
||||
/// enough to prove the messaging flow and architecture round-trip.
|
||||
class Nip44 {
|
||||
static final _params = ECCurve_secp256k1();
|
||||
|
||||
/// secp256k1 field prime.
|
||||
static final BigInt _p = BigInt.parse(
|
||||
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F',
|
||||
radix: 16,
|
||||
);
|
||||
|
||||
static final _salt = ascii.encode('nip44-v2');
|
||||
|
||||
/// ECDH conversation key shared by [privHexA] and x-only [pubHexB]:
|
||||
/// `HKDF-Extract(salt="nip44-v2", ikm = (d·P).x)`. Symmetric: A→B and B→A
|
||||
/// yield the same key.
|
||||
static Uint8List conversationKey(String privHexA, String pubHexB) {
|
||||
final d = _bi(privHexA);
|
||||
final point = _liftX(_bi(pubHexB));
|
||||
final shared = (point * d)!;
|
||||
final x = shared.x!.toBigInteger()!;
|
||||
return hkdfExtract(salt: _salt, ikm: _to32(x));
|
||||
}
|
||||
|
||||
/// Encrypts [plaintext] under [convKey]; returns base64 NIP-44 payload.
|
||||
static String encrypt(Uint8List convKey, String plaintext, {Uint8List? nonce}) {
|
||||
final n = nonce ?? _randomBytes(32);
|
||||
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
|
||||
final chachaKey = keys.sublist(0, 32);
|
||||
final chachaNonce = keys.sublist(32, 44);
|
||||
final hmacKey = keys.sublist(44, 76);
|
||||
|
||||
final padded = _pad(utf8.encode(plaintext));
|
||||
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
|
||||
final mac = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
|
||||
|
||||
return base64.encode([2, ...n, ...ciphertext, ...mac]);
|
||||
}
|
||||
|
||||
/// Decrypts a base64 NIP-44 payload under [convKey]. Throws on version/MAC
|
||||
/// failure (so an eavesdropper with the wrong key cannot read it).
|
||||
static String decrypt(Uint8List convKey, String payload) {
|
||||
final raw = base64.decode(payload);
|
||||
if (raw[0] != 2) throw const FormatException('unsupported nip44 version');
|
||||
final n = raw.sublist(1, 33);
|
||||
final ciphertext = raw.sublist(33, raw.length - 32);
|
||||
final mac = raw.sublist(raw.length - 32);
|
||||
|
||||
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
|
||||
final chachaKey = keys.sublist(0, 32);
|
||||
final chachaNonce = keys.sublist(32, 44);
|
||||
final hmacKey = keys.sublist(44, 76);
|
||||
|
||||
final expected = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
|
||||
if (!_constantTimeEquals(expected, mac)) {
|
||||
throw const FormatException('nip44 MAC mismatch (wrong key or tampered)');
|
||||
}
|
||||
final padded = _chacha20(chachaKey, chachaNonce, Uint8List.fromList(ciphertext));
|
||||
final len = (padded[0] << 8) | padded[1];
|
||||
return utf8.decode(padded.sublist(2, 2 + len));
|
||||
}
|
||||
|
||||
// --- padding (NIP-44 length hiding) ---
|
||||
|
||||
static Uint8List _pad(List<int> plaintext) {
|
||||
final unpadded = plaintext.length;
|
||||
final paddedLen = _calcPaddedLen(unpadded);
|
||||
final buf = Uint8List(2 + paddedLen);
|
||||
buf[0] = (unpadded >> 8) & 0xff;
|
||||
buf[1] = unpadded & 0xff;
|
||||
buf.setRange(2, 2 + unpadded, plaintext);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int _calcPaddedLen(int len) {
|
||||
if (len <= 32) return 32;
|
||||
final nextPower = 1 << ((len - 1).bitLength);
|
||||
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
|
||||
return chunk * ((len - 1) ~/ chunk + 1);
|
||||
}
|
||||
|
||||
// --- primitives ---
|
||||
|
||||
static Uint8List _chacha20(List<int> key, List<int> nonce, Uint8List input) {
|
||||
final engine = ChaCha7539Engine()
|
||||
..init(
|
||||
true,
|
||||
ParametersWithIV(
|
||||
KeyParameter(Uint8List.fromList(key)),
|
||||
Uint8List.fromList(nonce),
|
||||
),
|
||||
);
|
||||
final out = Uint8List(input.length);
|
||||
engine.processBytes(input, 0, input.length, out, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// BIP340 lift_x: reconstruct the curve point with even y from x-only [x].
|
||||
static ECPoint _liftX(BigInt x) {
|
||||
final ySq = (x.modPow(BigInt.from(3), _p) + BigInt.from(7)) % _p;
|
||||
var y = ySq.modPow((_p + BigInt.one) ~/ BigInt.from(4), _p);
|
||||
if (y.isOdd) y = _p - y;
|
||||
return _params.curve.createPoint(x, y);
|
||||
}
|
||||
|
||||
static BigInt _bi(String hex) => BigInt.parse(hex, radix: 16);
|
||||
|
||||
static Uint8List _to32(BigInt v) {
|
||||
final out = Uint8List(32);
|
||||
var x = v;
|
||||
for (var i = 31; i >= 0; i--) {
|
||||
out[i] = (x & BigInt.from(0xff)).toInt();
|
||||
x = x >> 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool _constantTimeEquals(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
var diff = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
static final _rng = Random.secure();
|
||||
static Uint8List _randomBytes(int n) =>
|
||||
Uint8List.fromList(List.generate(n, (_) => _rng.nextInt(256)));
|
||||
}
|
||||
118
spike/block2_spike/lib/src/nip99.dart
Normal file
118
spike/block2_spike/lib/src/nip99.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'nostr_event.dart';
|
||||
import 'offer.dart';
|
||||
|
||||
/// Maps [Offer] ↔ Nostr NIP-99 "Classified Listing" events (kind 30402), the
|
||||
/// first concrete `OfferTransport` backend (sharing-model.md §2).
|
||||
///
|
||||
/// This codec is where the privacy contract is *enforced on the wire*: the only
|
||||
/// location that ever leaves the device is a coarse geohash, truncated here to
|
||||
/// [maxGeohashChars]. There is no field that could carry the inventory or an
|
||||
/// exact address, because [Offer] itself has none — the seam does the work.
|
||||
class Nip99Codec {
|
||||
/// NIP-99 addressable classified listing.
|
||||
static const kindActive = 30402;
|
||||
|
||||
/// A geohash of 5 chars ≈ ±2.4 km cell — the coarse "near you" of the mocks.
|
||||
/// Anything finer is refused so a slip in the caller can't leak a precise fix.
|
||||
static const maxGeohashChars = 5;
|
||||
|
||||
/// Builds an UNSIGNED event from [offer]. Caller signs with the derived key.
|
||||
NostrEvent encode(Offer offer, {required int createdAt}) {
|
||||
final coarse = _coarsen(offer.approxGeohash);
|
||||
|
||||
final tags = <List<String>>[
|
||||
['d', offer.id],
|
||||
['title', offer.summary],
|
||||
['status', offer.status == OfferStatus.active ? 'active' : 'sold'],
|
||||
// Reciprocity mode is a Tanemaki concept NIP-99 lacks; carry it explicitly.
|
||||
['offer_type', offer.type.name],
|
||||
];
|
||||
// NIP-52 geohash ladder: one `g` tag per prefix length so an area query
|
||||
// matches by *exact* tag (how Nostr relays filter) yet still finds coarser
|
||||
// searches. Coarse only — NO "location"/address/exact-fix tag ever.
|
||||
for (var i = 1; i <= coarse.length; i++) {
|
||||
tags.add(['g', coarse.substring(0, i)]);
|
||||
}
|
||||
if (offer.category != null) tags.add(['t', offer.category!]);
|
||||
if (offer.radiusKm != null) {
|
||||
tags.add(['radius_km', '${offer.radiusKm}']);
|
||||
}
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null) {
|
||||
tags.add([
|
||||
'price',
|
||||
'${offer.priceAmount}',
|
||||
offer.priceCurrency ?? 'EUR',
|
||||
]);
|
||||
}
|
||||
if (offer.type == OfferType.exchange && offer.exchangeTerms != null) {
|
||||
tags.add(['exchange_terms', offer.exchangeTerms!]);
|
||||
}
|
||||
if (offer.imageUrl != null) tags.add(['image', offer.imageUrl!]);
|
||||
if (offer.expiresAt != null) {
|
||||
tags.add([
|
||||
'expiration',
|
||||
'${offer.expiresAt!.millisecondsSinceEpoch ~/ 1000}',
|
||||
]);
|
||||
}
|
||||
|
||||
return NostrEvent(
|
||||
pubkey: offer.authorPubkeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: kindActive,
|
||||
tags: tags,
|
||||
content: offer.summary,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconstructs an [Offer] from a received event.
|
||||
Offer decode(NostrEvent event) {
|
||||
final priceTag = _fullTag(event, 'price');
|
||||
return Offer(
|
||||
id: event.tag('d') ?? event.id,
|
||||
authorPubkeyHex: event.pubkey,
|
||||
summary: event.tag('title') ?? event.content,
|
||||
type: _parseType(event.tag('offer_type')),
|
||||
status: event.tag('status') == 'sold'
|
||||
? OfferStatus.closed
|
||||
: OfferStatus.active,
|
||||
category: event.tag('t'),
|
||||
approxGeohash: _longestGeohash(event),
|
||||
radiusKm: int.tryParse(event.tag('radius_km') ?? ''),
|
||||
priceAmount: priceTag != null && priceTag.length > 1
|
||||
? num.tryParse(priceTag[1])
|
||||
: null,
|
||||
priceCurrency: priceTag != null && priceTag.length > 2
|
||||
? priceTag[2]
|
||||
: null,
|
||||
exchangeTerms: event.tag('exchange_terms'),
|
||||
imageUrl: event.tag('image'),
|
||||
);
|
||||
}
|
||||
|
||||
String _coarsen(String geohash) => geohash.length > maxGeohashChars
|
||||
? geohash.substring(0, maxGeohashChars)
|
||||
: geohash;
|
||||
|
||||
/// The finest `g` tag present (top of the prefix ladder).
|
||||
String _longestGeohash(NostrEvent event) {
|
||||
var best = '';
|
||||
for (final t in event.tags) {
|
||||
if (t.isNotEmpty && t[0] == 'g' && t.length > 1 && t[1].length > best.length) {
|
||||
best = t[1];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
List<String>? _fullTag(NostrEvent event, String name) {
|
||||
for (final t in event.tags) {
|
||||
if (t.isNotEmpty && t[0] == name) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
OfferType _parseType(String? name) => OfferType.values.firstWhere(
|
||||
(t) => t.name == name,
|
||||
orElse: () => OfferType.gift,
|
||||
);
|
||||
}
|
||||
95
spike/block2_spike/lib/src/nostr_connection.dart
Normal file
95
spike/block2_spike/lib/src/nostr_connection.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'nostr_event.dart';
|
||||
|
||||
/// The ONE piece shared by every Nostr-backed transport (Q2 recommendation): a
|
||||
/// single relay socket + the signing identity + the EVENT/OK and REQ/EOSE
|
||||
/// lifecycle. `OfferTransport`, `MessageTransport` and a future `TrustTransport`
|
||||
/// each sit on top of this — they share this *connection*, not a *contract*.
|
||||
class NostrConnection {
|
||||
NostrConnection._(this._socket, this.privateKeyHex, this.publicKeyHex) {
|
||||
_socket.listen(_onData, onDone: _onDone);
|
||||
}
|
||||
|
||||
final WebSocket _socket;
|
||||
|
||||
/// The derived (Q1) identity this connection signs with.
|
||||
final String privateKeyHex;
|
||||
final String publicKeyHex;
|
||||
|
||||
final _incoming = StreamController<List<dynamic>>.broadcast();
|
||||
int _subCounter = 0;
|
||||
|
||||
static Future<NostrConnection> connect(
|
||||
String relayUrl, {
|
||||
required String privateKeyHex,
|
||||
required String publicKeyHex,
|
||||
}) async {
|
||||
final socket = await WebSocket.connect(relayUrl);
|
||||
return NostrConnection._(socket, privateKeyHex, publicKeyHex);
|
||||
}
|
||||
|
||||
void _onData(dynamic d) => _incoming.add(jsonDecode(d as String) as List);
|
||||
void _onDone() {
|
||||
if (!_incoming.isClosed) _incoming.close();
|
||||
}
|
||||
|
||||
/// Publishes an already-signed [event]; resolves with the relay's OK verdict.
|
||||
Future<({bool accepted, String message})> publish(NostrEvent event) async {
|
||||
final ok = _incoming.stream.firstWhere(
|
||||
(m) => m[0] == 'OK' && m[1] == event.id,
|
||||
);
|
||||
_socket.add(jsonEncode(['EVENT', event.toJson()]));
|
||||
final r = await ok.timeout(const Duration(seconds: 5));
|
||||
return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : '');
|
||||
}
|
||||
|
||||
/// Streams events matching [filter] (live), until the caller cancels.
|
||||
Stream<NostrEvent> subscribe(Map<String, dynamic> filter) {
|
||||
final subId = 'sub${_subCounter++}';
|
||||
late StreamController<NostrEvent> controller;
|
||||
StreamSubscription? sub;
|
||||
controller = StreamController<NostrEvent>(
|
||||
onListen: () {
|
||||
sub = _incoming.stream.listen((m) {
|
||||
if (m[0] == 'EVENT' && m[1] == subId) {
|
||||
controller.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
|
||||
}
|
||||
});
|
||||
_socket.add(jsonEncode(['REQ', subId, filter]));
|
||||
},
|
||||
onCancel: () async {
|
||||
_socket.add(jsonEncode(['CLOSE', subId]));
|
||||
await sub?.cancel();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
/// Collects stored events matching [filter] up to EOSE, then closes the sub.
|
||||
Future<List<NostrEvent>> reqOnce(Map<String, dynamic> filter) async {
|
||||
final subId = 'once${_subCounter++}';
|
||||
final results = <NostrEvent>[];
|
||||
final done = Completer<void>();
|
||||
final sub = _incoming.stream.listen((m) {
|
||||
if (m[1] != subId) return;
|
||||
if (m[0] == 'EVENT') {
|
||||
results.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
|
||||
} else if (m[0] == 'EOSE' && !done.isCompleted) {
|
||||
done.complete();
|
||||
}
|
||||
});
|
||||
_socket.add(jsonEncode(['REQ', subId, filter]));
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
_socket.add(jsonEncode(['CLOSE', subId]));
|
||||
await sub.cancel();
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _socket.close();
|
||||
if (!_incoming.isClosed) await _incoming.close();
|
||||
}
|
||||
}
|
||||
77
spike/block2_spike/lib/src/nostr_event.dart
Normal file
77
spike/block2_spike/lib/src/nostr_event.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A minimal Nostr event (NIP-01): enough to build, id, sign and serialise
|
||||
/// NIP-99 classified listings. Throwaway — production would use a vetted client.
|
||||
class NostrEvent {
|
||||
NostrEvent({
|
||||
required this.pubkey,
|
||||
required this.createdAt,
|
||||
required this.kind,
|
||||
required this.tags,
|
||||
required this.content,
|
||||
this.id = '',
|
||||
this.sig = '',
|
||||
});
|
||||
|
||||
final String pubkey; // x-only hex
|
||||
final int createdAt; // unix seconds
|
||||
final int kind;
|
||||
final List<List<String>> tags;
|
||||
final String content;
|
||||
String id;
|
||||
String sig;
|
||||
|
||||
/// NIP-01 id: sha256 of the compact JSON array
|
||||
/// `[0, pubkey, created_at, kind, tags, content]`.
|
||||
String computeId() {
|
||||
final serialized = jsonEncode([0, pubkey, createdAt, kind, tags, content]);
|
||||
final digest = sha256.convert(utf8.encode(serialized));
|
||||
return digest.toString(); // lower-case hex
|
||||
}
|
||||
|
||||
/// Fills [id] and [sig] (BIP340 Schnorr over the id) with [privateKeyHex].
|
||||
void signWith(String privateKeyHex, {String? auxRandHex}) {
|
||||
id = computeId();
|
||||
// Deterministic aux in the spike (derived from id) so round-trip tests are
|
||||
// reproducible; production uses fresh randomness per BIP340.
|
||||
final aux = auxRandHex ?? id;
|
||||
sig = bip340.sign(privateKeyHex, id, aux);
|
||||
}
|
||||
|
||||
/// Verifies the signature against [pubkey]. Cross-checks that a real relay /
|
||||
/// peer would accept what we produced.
|
||||
bool verify() => id == computeId() && bip340.verify(pubkey, id, sig);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'pubkey': pubkey,
|
||||
'created_at': createdAt,
|
||||
'kind': kind,
|
||||
'tags': tags,
|
||||
'content': content,
|
||||
'sig': sig,
|
||||
};
|
||||
|
||||
static NostrEvent fromJson(Map<String, dynamic> j) => NostrEvent(
|
||||
id: j['id'] as String,
|
||||
pubkey: j['pubkey'] as String,
|
||||
createdAt: j['created_at'] as int,
|
||||
kind: j['kind'] as int,
|
||||
tags: (j['tags'] as List)
|
||||
.map((t) => (t as List).map((e) => e as String).toList())
|
||||
.toList(),
|
||||
content: j['content'] as String,
|
||||
sig: j['sig'] as String,
|
||||
);
|
||||
|
||||
/// First value of the first tag named [name], or null.
|
||||
String? tag(String name) {
|
||||
for (final t in tags) {
|
||||
if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
77
spike/block2_spike/lib/src/nostr_key.dart
Normal file
77
spike/block2_spike/lib/src/nostr_key.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
|
||||
import 'bech32.dart';
|
||||
import 'hkdf.dart';
|
||||
|
||||
/// A Nostr identity (secp256k1 / BIP340 x-only) deterministically derived from
|
||||
/// the Duniter/Ğ1-style root seed. This is Spike Question 1: prove we can keep
|
||||
/// "one identity" (user backs up ONE seed) while still having a viable Nostr
|
||||
/// transport key.
|
||||
///
|
||||
/// Derivation is one-way by construction: the root seed feeds HKDF, and HKDF's
|
||||
/// PRF (HMAC-SHA256) is not invertible, so neither the private nor the public
|
||||
/// Nostr key leaks the root seed. See spike-block2-findings.md §1.
|
||||
class NostrKey {
|
||||
NostrKey._({required this.privateKeyHex, required this.publicKeyHex});
|
||||
|
||||
/// 32-byte secp256k1 scalar, lower-case hex (64 chars).
|
||||
final String privateKeyHex;
|
||||
|
||||
/// 32-byte BIP340 x-only public key, lower-case hex (64 chars). This is the
|
||||
/// Nostr identity that appears on events and in `npub`.
|
||||
final String publicKeyHex;
|
||||
|
||||
/// NIP-19 `npub…` encoding of [publicKeyHex] — what a user would share.
|
||||
String get npub => bech32Encode('npub', _hexToBytes(publicKeyHex));
|
||||
|
||||
/// NIP-19 `nsec…` encoding of [privateKeyHex] — secret, never shared.
|
||||
String get nsec => bech32Encode('nsec', _hexToBytes(privateKeyHex));
|
||||
|
||||
/// Domain-separated derivation label. Versioned so a future scheme can coexist
|
||||
/// with old backups (same discipline as BackupBox's HKDF info string).
|
||||
static const derivationInfo = 'org.comunes.tane/nostr/secp256k1/v1';
|
||||
|
||||
/// secp256k1 group order n.
|
||||
static final BigInt _n = BigInt.parse(
|
||||
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141',
|
||||
radix: 16,
|
||||
);
|
||||
|
||||
/// Deterministically derives the Nostr key from a root [seed] (the 32 bytes
|
||||
/// [IdentityService.generateRootSeed] produces).
|
||||
///
|
||||
/// Reduces to a valid scalar in `[1, n-1]` by rejection: on the (astronomically
|
||||
/// rare) miss it bumps a counter in the HKDF info, so the result stays a pure
|
||||
/// function of the seed.
|
||||
factory NostrKey.deriveFromSeed(List<int> seed) {
|
||||
var counter = 0;
|
||||
while (true) {
|
||||
final okm = hkdfSha256(ikm: seed, info: '$derivationInfo/$counter');
|
||||
final d = _bytesToBigInt(okm);
|
||||
if (d != BigInt.zero && d < _n) {
|
||||
final privHex = d.toRadixString(16).padLeft(64, '0');
|
||||
final pubHex = bip340.getPublicKey(privHex);
|
||||
return NostrKey._(privateKeyHex: privHex, publicKeyHex: pubHex);
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BigInt _bytesToBigInt(List<int> bytes) {
|
||||
var result = BigInt.zero;
|
||||
for (final b in bytes) {
|
||||
result = (result << 8) | BigInt.from(b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List _hexToBytes(String hex) {
|
||||
final out = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
136
spike/block2_spike/lib/src/nostr_message_transport.dart
Normal file
136
spike/block2_spike/lib/src/nostr_message_transport.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
|
||||
import 'message_transport.dart';
|
||||
import 'nip44.dart';
|
||||
import 'nostr_connection.dart';
|
||||
import 'nostr_event.dart';
|
||||
|
||||
/// NIP-17 private DM backend for [MessageTransport], on the shared
|
||||
/// [NostrConnection]. This is the spike's answer to the highest-risk Q3 piece:
|
||||
/// does the metadata-private messaging flow actually work, and does it fit the
|
||||
/// "one connection, three interfaces" shape?
|
||||
///
|
||||
/// The NIP-17 / NIP-59 gift-wrap onion:
|
||||
/// rumor (kind 14, UNSIGNED) — the real message; author = real sender
|
||||
/// seal (kind 13, signed by sender) — NIP-44(rumor) to recipient
|
||||
/// wrap (kind 1059, signed by a throwaway EPHEMERAL key) — NIP-44(seal)
|
||||
/// The relay and any observer see only the wrap: a random author, a `p` tag for
|
||||
/// the recipient, and opaque ciphertext. The real sender is hidden until the
|
||||
/// recipient unwraps.
|
||||
class NostrMessageTransport implements MessageTransport {
|
||||
NostrMessageTransport(this._conn);
|
||||
|
||||
final NostrConnection _conn;
|
||||
|
||||
static const kindRumor = 14;
|
||||
static const kindSeal = 13;
|
||||
static const kindGiftWrap = 1059;
|
||||
|
||||
final _rng = Random.secure();
|
||||
|
||||
@override
|
||||
Future<void> send({required String toPubkey, required String text}) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// 1. Rumor — the real, UNSIGNED message from the real sender.
|
||||
final rumor = NostrEvent(
|
||||
pubkey: _conn.publicKeyHex,
|
||||
createdAt: now,
|
||||
kind: kindRumor,
|
||||
tags: [
|
||||
['p', toPubkey],
|
||||
],
|
||||
content: text,
|
||||
);
|
||||
rumor.id = rumor.computeId(); // id but no signature (NIP-59)
|
||||
|
||||
// 2. Seal — sender-signed, NIP-44 to recipient.
|
||||
final sealKey = Nip44.conversationKey(_conn.privateKeyHex, toPubkey);
|
||||
final seal = NostrEvent(
|
||||
pubkey: _conn.publicKeyHex,
|
||||
createdAt: now,
|
||||
kind: kindSeal,
|
||||
tags: const [],
|
||||
content: Nip44.encrypt(sealKey, jsonEncode(rumor.toJson())),
|
||||
)..signWith(_conn.privateKeyHex);
|
||||
|
||||
// 3. Gift wrap — signed by a THROWAWAY ephemeral key, NIP-44 to recipient.
|
||||
final eph = _ephemeralKey();
|
||||
final wrapKey = Nip44.conversationKey(eph.priv, toPubkey);
|
||||
final wrap = NostrEvent(
|
||||
pubkey: eph.pub,
|
||||
createdAt: now,
|
||||
kind: kindGiftWrap,
|
||||
tags: [
|
||||
['p', toPubkey],
|
||||
],
|
||||
content: Nip44.encrypt(wrapKey, jsonEncode(seal.toJson())),
|
||||
)..signWith(eph.priv);
|
||||
|
||||
final r = await _conn.publish(wrap);
|
||||
if (!r.accepted) {
|
||||
throw StateError('relay rejected gift wrap: ${r.message}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<DirectMessage> inbox() {
|
||||
final filter = {
|
||||
'kinds': [kindGiftWrap],
|
||||
'#p': [_conn.publicKeyHex],
|
||||
};
|
||||
return _conn.subscribe(filter).map(_unwrap).where((m) => m != null).cast();
|
||||
}
|
||||
|
||||
/// Collect the inbox up to EOSE (tests/metrics).
|
||||
Future<List<DirectMessage>> inboxUntilEose() async {
|
||||
final wraps = await _conn.reqOnce({
|
||||
'kinds': [kindGiftWrap],
|
||||
'#p': [_conn.publicKeyHex],
|
||||
});
|
||||
return wraps.map(_unwrap).whereType<DirectMessage>().toList();
|
||||
}
|
||||
|
||||
DirectMessage? _unwrap(NostrEvent wrap) {
|
||||
try {
|
||||
// Unwrap with our key + the ephemeral (wrap) author.
|
||||
final wrapKey = Nip44.conversationKey(_conn.privateKeyHex, wrap.pubkey);
|
||||
final seal = NostrEvent.fromJson(
|
||||
jsonDecode(Nip44.decrypt(wrapKey, wrap.content)) as Map<String, dynamic>,
|
||||
);
|
||||
// Open the seal with our key + the seal (real sender) author.
|
||||
final sealKey = Nip44.conversationKey(_conn.privateKeyHex, seal.pubkey);
|
||||
final rumor = NostrEvent.fromJson(
|
||||
jsonDecode(Nip44.decrypt(sealKey, seal.content)) as Map<String, dynamic>,
|
||||
);
|
||||
// Authenticity: the seal is signed by the sender, and the inner rumor
|
||||
// must claim the SAME author. Otherwise someone re-wrapped a foreign seal.
|
||||
if (rumor.pubkey != seal.pubkey || !seal.verify()) return null;
|
||||
return DirectMessage(
|
||||
fromPubkey: rumor.pubkey,
|
||||
text: rumor.content,
|
||||
at: DateTime.fromMillisecondsSinceEpoch(rumor.createdAt * 1000),
|
||||
);
|
||||
} catch (_) {
|
||||
return null; // not for us / wrong key / tampered
|
||||
}
|
||||
}
|
||||
|
||||
({String priv, String pub}) _ephemeralKey() {
|
||||
final bytes = Uint8List.fromList(
|
||||
List.generate(32, (_) => _rng.nextInt(256)),
|
||||
);
|
||||
final priv = bytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return (priv: priv, pub: bip340.getPublicKey(priv));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _conn.close();
|
||||
}
|
||||
71
spike/block2_spike/lib/src/nostr_offer_transport.dart
Normal file
71
spike/block2_spike/lib/src/nostr_offer_transport.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'nip99.dart';
|
||||
import 'nostr_connection.dart';
|
||||
import 'offer.dart';
|
||||
import 'offer_transport.dart';
|
||||
|
||||
/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection]
|
||||
/// (Q2/Q3). Signs offers with the derived key (Q1) and publishes them as
|
||||
/// kind-30402 classified listings; discovery is a REQ filtered by kind + `#g`.
|
||||
class NostrOfferTransport implements OfferTransport {
|
||||
NostrOfferTransport(this._conn);
|
||||
|
||||
final NostrConnection _conn;
|
||||
final Nip99Codec _codec = Nip99Codec();
|
||||
|
||||
/// Convenience: open a dedicated connection for this transport.
|
||||
static Future<NostrOfferTransport> connect(
|
||||
String relayUrl, {
|
||||
required String privateKeyHex,
|
||||
required String pubkeyHex,
|
||||
}) async =>
|
||||
NostrOfferTransport(await NostrConnection.connect(
|
||||
relayUrl,
|
||||
privateKeyHex: privateKeyHex,
|
||||
publicKeyHex: pubkeyHex,
|
||||
));
|
||||
|
||||
Map<String, dynamic> _filter(DiscoveryQuery q) => {
|
||||
'kinds': [Nip99Codec.kindActive],
|
||||
'#g': [q.geohashPrefix],
|
||||
'limit': q.limit,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<PublishResult> publish(Offer offer) async {
|
||||
final event = _codec.encode(
|
||||
offer,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
)..signWith(_conn.privateKeyHex);
|
||||
final r = await _conn.publish(event);
|
||||
return PublishResult(
|
||||
accepted: r.accepted,
|
||||
transportRef: event.id,
|
||||
message: r.message,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Offer> discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where(
|
||||
(o) => query.types.isEmpty || query.types.contains(o.type),
|
||||
);
|
||||
|
||||
/// Collect matches up to EOSE (for tests/metrics).
|
||||
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
||||
final events = await _conn.reqOnce(_filter(query));
|
||||
return events
|
||||
.map(_codec.decode)
|
||||
.where((o) => query.types.isEmpty || query.types.contains(o.type))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> retract(String offerId) async {
|
||||
// A NIP-09 deletion of the addressable coordinate would go here; the OK-path
|
||||
// is identical to publish. Omitted from the spike surface.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _conn.close();
|
||||
}
|
||||
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();
|
||||
}
|
||||
90
spike/block2_spike/lib/src/offer.dart
Normal file
90
spike/block2_spike/lib/src/offer.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/// The four (well, five) reciprocity modes from sharing-model.md §3, plus the
|
||||
/// generic `lend` the core reserves for the 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 Lot /
|
||||
/// inventory (sharing-model.md §2, core-domain-boundary.md §4.3).
|
||||
///
|
||||
/// It carries only a chosen, denormalised *summary* of what's offered — never a
|
||||
/// foreign key into the seed tables, never the full inventory, never an exact
|
||||
/// address. This is the privacy seam: the core can publish and sync 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). Derived, per Q1.
|
||||
final String authorPubkeyHex;
|
||||
|
||||
/// Human summary the author chose to publish (e.g. "Tomate rosa de Barbastro").
|
||||
final String summary;
|
||||
|
||||
final OfferType type;
|
||||
final OfferStatus status;
|
||||
|
||||
/// Free-text category, prefilled in-app from Species.family but opaque here.
|
||||
final String? category;
|
||||
|
||||
/// LOW-PRECISION geohash only (sharing-model.md §2: "10 km near you", never
|
||||
/// exact coordinates). The spike enforces a max length in the transport.
|
||||
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. Note there is NO way to
|
||||
/// ask for "everything from person X's inventory" — you can only 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;
|
||||
}
|
||||
29
spike/block2_spike/lib/src/offer_transport.dart
Normal file
29
spike/block2_spike/lib/src/offer_transport.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import 'offer.dart';
|
||||
|
||||
/// The abstraction the plan promises lives in `commons_core` (core-domain-
|
||||
/// boundary.md §2, PLAN §4): publish an [Offer], discover offers by area. The
|
||||
/// concrete Nostr NIP-99 backend sits behind this so the domain never talks to
|
||||
/// a relay directly, and a second backend (ActivityPub / FEP-0837) could slot
|
||||
/// in without the app noticing.
|
||||
///
|
||||
/// Spike Question 2 asks whether messaging (NIP-17) and trust (NIP-85) fit
|
||||
/// behind this same seam, or whether they couple more than the plan implies.
|
||||
/// The finding: they do NOT belong here — they share the *key* and the *relay
|
||||
/// connection*, but their verbs differ (send/receive a private DM; assert/read
|
||||
/// a certification). Forcing them behind `OfferTransport` would overload it.
|
||||
/// See spike-block2-findings.md §2 for the proposed 3-interface split over one
|
||||
/// shared `NostrConnection`.
|
||||
abstract interface class OfferTransport {
|
||||
/// Publishes (or replaces) [offer]. Idempotent on the offer id.
|
||||
Future<PublishResult> publish(Offer offer);
|
||||
|
||||
/// Streams offers matching [query]. Emits already-stored matches, then live
|
||||
/// ones as they arrive, until the caller cancels the subscription.
|
||||
Stream<Offer> discover(DiscoveryQuery query);
|
||||
|
||||
/// Retracts a previously published offer (NIP-99: publish a `closed` status /
|
||||
/// NIP-09 deletion). Relays that already replicated it drop it over time.
|
||||
Future<void> retract(String offerId);
|
||||
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
25
spike/block2_spike/pubspec.yaml
Normal file
25
spike/block2_spike/pubspec.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# THROWAWAY de-risking spike for Block 2 (social layer). NOT production.
|
||||
# Deliberately OUTSIDE the pub workspace (no `resolution: workspace`) so it can
|
||||
# pull network/crypto deps without touching Block 1's dependency graph.
|
||||
# See docs/design/spike-block2-findings.md. Delete this whole dir after the spike.
|
||||
name: block2_spike
|
||||
description: Throwaway prototype validating Block 2 open decisions. Do not ship.
|
||||
version: 0.0.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.5
|
||||
|
||||
dependencies:
|
||||
# The one real dependency on production code: we consume the root identity
|
||||
# seed exactly as IdentityService produces it, to prove derivation works.
|
||||
commons_core:
|
||||
path: ../../packages/commons_core
|
||||
# Throwaway crypto/transport deps — would NOT be added like this to prod.
|
||||
crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids
|
||||
bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures)
|
||||
pointycastle: ^3.9.0 # secp256k1 ECDH + ChaCha20 for NIP-44 (messaging spike)
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.25.6
|
||||
lints: ^6.0.0
|
||||
76
spike/block2_spike/test/derivation_test.dart
Normal file
76
spike/block2_spike/test/derivation_test.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q1 — Identity derivation: a deterministic, one-way secp256k1 (Nostr) key from
|
||||
/// the Duniter/Ğ1-style root seed, so the user still backs up ONE thing.
|
||||
void main() {
|
||||
group('NostrKey.deriveFromSeed', () {
|
||||
// A fixed seed so expectations are reproducible across runs/machines.
|
||||
final seed = Uint8List.fromList(List.generate(32, (i) => i));
|
||||
|
||||
test('is reproducible: same seed → identical key', () {
|
||||
final a = NostrKey.deriveFromSeed(seed);
|
||||
final b = NostrKey.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', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
expect(key.privateKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
// Cross-check: bip340 derives the same pubkey from our private hex.
|
||||
expect(bip340.getPublicKey(key.privateKeyHex), key.publicKeyHex);
|
||||
expect(key.npub, startsWith('npub1'));
|
||||
expect(key.nsec, startsWith('nsec1'));
|
||||
});
|
||||
|
||||
test('the derived key actually signs & verifies (BIP340)', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
final message = 'a' * 64; // 32-byte hex message
|
||||
final sig = bip340.sign(key.privateKeyHex, message, 'b' * 64);
|
||||
expect(bip340.verify(key.publicKeyHex, message, sig), isTrue);
|
||||
});
|
||||
|
||||
test('different seeds → different keys (avalanche on a 1-bit flip)', () {
|
||||
final flipped = Uint8List.fromList(seed)..[0] ^= 0x01;
|
||||
final a = NostrKey.deriveFromSeed(seed);
|
||||
final b = NostrKey.deriveFromSeed(flipped);
|
||||
expect(a.privateKeyHex, isNot(b.privateKeyHex));
|
||||
expect(a.publicKeyHex, isNot(b.publicKeyHex));
|
||||
});
|
||||
|
||||
test('one-wayness: neither key contains the seed, and HKDF is not '
|
||||
'invertible', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
final seedHex = seed
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
// The seed does not appear verbatim in the derived material.
|
||||
expect(key.privateKeyHex, isNot(contains(seedHex)));
|
||||
expect(key.publicKeyHex, isNot(contains(seedHex)));
|
||||
// Structural argument (documented in findings §1): recovery of the seed
|
||||
// would require inverting HMAC-SHA256. We assert the property we CAN test:
|
||||
// two unrelated seeds never collide to the same key.
|
||||
final other = NostrKey.deriveFromSeed(
|
||||
Uint8List.fromList(List.generate(32, (i) => 255 - i)),
|
||||
);
|
||||
expect(key.privateKeyHex, isNot(other.privateKeyHex));
|
||||
});
|
||||
|
||||
test('integrates with the real IdentityService seed length', () {
|
||||
final rootSeed = IdentityService(
|
||||
random: Random(42),
|
||||
).generateRootSeed();
|
||||
expect(rootSeed.length, IdentityService.rootSeedLengthBytes);
|
||||
final key = NostrKey.deriveFromSeed(rootSeed);
|
||||
expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
146
spike/block2_spike/test/messaging_test.dart
Normal file
146
spike/block2_spike/test/messaging_test.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q3 (hardest piece) — NIP-17 private messaging: does the gift-wrap flow work
|
||||
/// end to end, stay unreadable to the relay, and hide the sender's identity?
|
||||
void main() {
|
||||
NostrKey keyFor(int fill) =>
|
||||
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
|
||||
|
||||
group('NIP-44 encryption', () {
|
||||
test('conversation key is symmetric (A→B == B→A)', () {
|
||||
final a = keyFor(1);
|
||||
final b = keyFor(2);
|
||||
final ab = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
|
||||
final ba = Nip44.conversationKey(b.privateKeyHex, a.publicKeyHex);
|
||||
expect(ab, ba);
|
||||
});
|
||||
|
||||
test('round-trips text, and a wrong key cannot decrypt', () {
|
||||
final a = keyFor(1);
|
||||
final b = keyFor(2);
|
||||
final eve = keyFor(9);
|
||||
final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
|
||||
final payload = Nip44.encrypt(key, 'tengo tomate rosa, ¿te va bien?');
|
||||
expect(Nip44.decrypt(key, payload), 'tengo tomate rosa, ¿te va bien?');
|
||||
|
||||
final eveKey = Nip44.conversationKey(eve.privateKeyHex, b.publicKeyHex);
|
||||
expect(() => Nip44.decrypt(eveKey, payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('ciphertext length hides the exact plaintext length (padding)', () {
|
||||
final a = keyFor(1);
|
||||
final b = keyFor(2);
|
||||
final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
|
||||
final short = base64.decode(Nip44.encrypt(key, 'hi')).length;
|
||||
final alsoShort = base64.decode(Nip44.encrypt(key, 'hello!')).length;
|
||||
// Both pad to the same 32-byte bucket → equal payload sizes.
|
||||
expect(short, alsoShort);
|
||||
});
|
||||
});
|
||||
|
||||
group('NIP-17 gift-wrapped DM over the relay', () {
|
||||
late MiniRelay relay;
|
||||
setUp(() async => relay = await MiniRelay.start());
|
||||
tearDown(() async => relay.stop());
|
||||
|
||||
test('Alice → Bob: message arrives, sender is authenticated', () async {
|
||||
final alice = keyFor(1);
|
||||
final bob = keyFor(2);
|
||||
|
||||
final aliceConn = await NostrConnection.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
publicKeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final bobConn = await NostrConnection.connect(
|
||||
relay.url,
|
||||
privateKeyHex: bob.privateKeyHex,
|
||||
publicKeyHex: bob.publicKeyHex,
|
||||
);
|
||||
final aliceMsg = NostrMessageTransport(aliceConn);
|
||||
final bobMsg = NostrMessageTransport(bobConn);
|
||||
|
||||
await aliceMsg.send(
|
||||
toPubkey: bob.publicKeyHex,
|
||||
text: '¿cambiamos semilla de calabaza?',
|
||||
);
|
||||
|
||||
final inbox = await bobMsg.inboxUntilEose();
|
||||
expect(inbox, hasLength(1));
|
||||
expect(inbox.single.text, '¿cambiamos semilla de calabaza?');
|
||||
// Authenticated as Alice, even though the wrap was signed by an ephemeral.
|
||||
expect(inbox.single.fromPubkey, alice.publicKeyHex);
|
||||
|
||||
await aliceMsg.close();
|
||||
await bobMsg.close();
|
||||
});
|
||||
|
||||
test('the relay/eavesdropper sees neither plaintext nor the real sender',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final bob = keyFor(2);
|
||||
final aliceConn = await NostrConnection.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
publicKeyHex: alice.publicKeyHex,
|
||||
);
|
||||
await NostrMessageTransport(aliceConn).send(
|
||||
toPubkey: bob.publicKeyHex,
|
||||
text: 'SECRET-PLAINTEXT-MARKER',
|
||||
);
|
||||
|
||||
// Snoop the relay's stored events directly (as a relay operator would).
|
||||
final snoop = await NostrConnection.connect(
|
||||
relay.url,
|
||||
privateKeyHex: keyFor(7).privateKeyHex,
|
||||
publicKeyHex: keyFor(7).publicKeyHex,
|
||||
);
|
||||
final wraps = await snoop.reqOnce({'kinds': [1059]});
|
||||
expect(wraps, hasLength(1));
|
||||
final wire = jsonEncode(wraps.single.toJson());
|
||||
expect(wire, isNot(contains('SECRET-PLAINTEXT-MARKER')));
|
||||
// The wrap's author is the ephemeral key, NOT Alice.
|
||||
expect(wraps.single.pubkey, isNot(alice.publicKeyHex));
|
||||
// Only the recipient is addressable (the `p` tag), never the sender.
|
||||
expect(wraps.single.tag('p'), bob.publicKeyHex);
|
||||
|
||||
await aliceConn.close();
|
||||
await snoop.close();
|
||||
});
|
||||
|
||||
test('a third party cannot unwrap a message not addressed to them',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final bob = keyFor(2);
|
||||
final mallory = keyFor(5);
|
||||
final aliceConn = await NostrConnection.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
publicKeyHex: alice.publicKeyHex,
|
||||
);
|
||||
await NostrMessageTransport(aliceConn).send(
|
||||
toPubkey: bob.publicKeyHex,
|
||||
text: 'solo para Bob',
|
||||
);
|
||||
// Mallory subscribes to 1059 with no p-filter and tries to unwrap.
|
||||
final malloryConn = await NostrConnection.connect(
|
||||
relay.url,
|
||||
privateKeyHex: mallory.privateKeyHex,
|
||||
publicKeyHex: mallory.publicKeyHex,
|
||||
);
|
||||
final malloryMsg = NostrMessageTransport(malloryConn);
|
||||
final got = await malloryMsg.inboxUntilEose();
|
||||
// Her inbox filters on her own `p` tag, so the relay never even hands her
|
||||
// Bob's wrap; and the eavesdropper test proves that even raw snooping
|
||||
// yields only ciphertext. Two layers, same outcome: she sees nothing.
|
||||
expect(got, isEmpty, reason: 'not addressed to Mallory (p-tag scoping)');
|
||||
|
||||
await aliceConn.close();
|
||||
await malloryConn.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
80
spike/block2_spike/test/privacy_test.dart
Normal file
80
spike/block2_spike/test/privacy_test.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q2 — the `OfferTransport` seam must not leak inventory or an exact location.
|
||||
/// The offer carries only a chosen summary; the wire event is checked byte-wise.
|
||||
void main() {
|
||||
final key = NostrKey.deriveFromSeed(
|
||||
Uint8List.fromList(List.generate(32, (i) => i)),
|
||||
);
|
||||
final codec = Nip99Codec();
|
||||
|
||||
// A realistic private-ish context: the caller ACCIDENTALLY passes a very
|
||||
// precise geohash and there is secret inventory the offer must never expose.
|
||||
const secretInventoryMarker = 'SECRET-LOT-12-KG-IN-SHED';
|
||||
const preciseGeohash = 'u09tunqmabc'; // ~sub-metre precision
|
||||
|
||||
Offer sampleOffer() => Offer(
|
||||
id: 'offer-1',
|
||||
authorPubkeyHex: key.publicKeyHex,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: preciseGeohash,
|
||||
);
|
||||
|
||||
test('exact location is coarsened on the wire (never full precision)', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]);
|
||||
// No geohash tag exceeds the coarse cap...
|
||||
expect(gTags.every((g) => g.length <= Nip99Codec.maxGeohashChars), isTrue);
|
||||
// ...and the precise fix never appears anywhere in the serialised event.
|
||||
final wire = jsonEncode(event.toJson());
|
||||
expect(wire, isNot(contains(preciseGeohash)));
|
||||
expect(wire, contains('u09t')); // coarse prefix is present
|
||||
});
|
||||
|
||||
test('no inventory / address field can ride along (seam has none)', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final wire = jsonEncode(event.toJson());
|
||||
expect(wire, isNot(contains(secretInventoryMarker)));
|
||||
// The event has no "location", "address" or "geo-exact" tag by construction.
|
||||
final tagNames = event.tags.map((t) => t[0]).toSet();
|
||||
expect(tagNames, isNot(contains('location')));
|
||||
expect(tagNames, isNot(contains('address')));
|
||||
});
|
||||
|
||||
test('geohash ladder lets an area query match by exact tag', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]).toList();
|
||||
expect(gTags, containsAll(['u', 'u0', 'u09', 'u09t']));
|
||||
});
|
||||
|
||||
test('round-trips Offer ↔ NIP-99 event, only chosen fields survive', () {
|
||||
final original = Offer(
|
||||
id: 'offer-2',
|
||||
authorPubkeyHex: key.publicKeyHex,
|
||||
summary: 'Judía del ganxet',
|
||||
type: OfferType.sale,
|
||||
priceAmount: 3,
|
||||
priceCurrency: 'G1',
|
||||
category: 'Fabaceae',
|
||||
approxGeohash: 'sp3e9',
|
||||
);
|
||||
final event = codec.encode(original, createdAt: 1000)
|
||||
..signWith(key.privateKeyHex);
|
||||
expect(event.verify(), isTrue);
|
||||
|
||||
final decoded = codec.decode(event);
|
||||
expect(decoded.id, original.id);
|
||||
expect(decoded.summary, original.summary);
|
||||
expect(decoded.type, OfferType.sale);
|
||||
expect(decoded.priceAmount, 3);
|
||||
expect(decoded.priceCurrency, 'G1');
|
||||
expect(decoded.category, 'Fabaceae');
|
||||
expect(decoded.approxGeohash, 'sp3e9');
|
||||
});
|
||||
}
|
||||
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q3 — publish → discover a real offer by geohash against a running relay
|
||||
/// (in-process, hermetic). Proves the flow works end to end and measures it.
|
||||
void main() {
|
||||
late MiniRelay relay;
|
||||
|
||||
setUp(() async => relay = await MiniRelay.start());
|
||||
tearDown(() async => relay.stop());
|
||||
|
||||
NostrKey keyFor(int fill) =>
|
||||
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
|
||||
|
||||
test('offer published by one identity is discovered by another via geohash',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final bob = keyFor(2);
|
||||
|
||||
final aliceTransport = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final bobTransport = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: bob.privateKeyHex,
|
||||
pubkeyHex: bob.publicKeyHex,
|
||||
);
|
||||
|
||||
final offer = Offer(
|
||||
id: 'tomate-1',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: 'sp3e9xyz', // will be coarsened to sp3e9
|
||||
);
|
||||
|
||||
final published = await aliceTransport.publish(offer);
|
||||
expect(published.accepted, isTrue,
|
||||
reason: 'relay OK: ${published.message}');
|
||||
|
||||
// Bob queries a coarser area and still finds it (ladder match).
|
||||
final found = await bobTransport.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3'),
|
||||
);
|
||||
expect(found, hasLength(1));
|
||||
expect(found.single.summary, 'Tomate rosa de Barbastro');
|
||||
expect(found.single.authorPubkeyHex, alice.publicKeyHex);
|
||||
|
||||
await aliceTransport.close();
|
||||
await bobTransport.close();
|
||||
});
|
||||
|
||||
test('a query for a different area returns nothing (geohash scoping works)',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
await t.publish(Offer(
|
||||
id: 'x',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Pimiento',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
final elsewhere = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'u09'),
|
||||
);
|
||||
expect(elsewhere, isEmpty);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('re-publishing the same offer id replaces (addressable), not duplicates',
|
||||
() async {
|
||||
final alice = keyFor(3);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
Offer o(String summary) => Offer(
|
||||
id: 'lot-42',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: summary,
|
||||
type: OfferType.exchange,
|
||||
exchangeTerms: 'a cambio de legumbre',
|
||||
approxGeohash: 'sp3e9',
|
||||
);
|
||||
await t.publish(o('v1'));
|
||||
await t.publish(o('v2'));
|
||||
final found = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3e9'),
|
||||
);
|
||||
expect(found, hasLength(1), reason: 'addressable replace by (kind,pubkey,d)');
|
||||
expect(found.single.summary, 'v2');
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('measures publish→discover latency (informational)', () async {
|
||||
final alice = keyFor(4);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final sw = Stopwatch()..start();
|
||||
await t.publish(Offer(
|
||||
id: 'latency',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Calabaza',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
final found = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3e9'),
|
||||
);
|
||||
sw.stop();
|
||||
expect(found, hasLength(1));
|
||||
// Not an assertion on wall-time (CI is noisy); just surface the number.
|
||||
printOnFailure('publish+discover round-trip: ${sw.elapsedMilliseconds} ms');
|
||||
// ignore: avoid_print
|
||||
print('[metric] publish+discover round-trip: ${sw.elapsedMilliseconds} ms');
|
||||
await t.close();
|
||||
});
|
||||
}
|
||||
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