Adds TrustTransport as the third interface on the shared NostrConnection, completing the social-layer happy path in the spike: - WebOfTrust: pure, Flutter-free Duniter membership rule (N certs from members + within distance D of bootstrap referents), iterated to a fixpoint. The commons_core-worthy piece; TDD'd (threshold, distance cutoff, transitive growth, expired/revoked/self exclusion). - NostrTrustTransport: certifications as a custom addressable kind (30777, keyed by issuer+subject) — certify renews, revoke replaces, certs expire. - Trust filters spam (network-trust §2): seeds certify Alice; Eve stays uncertified; Bob discovers both offers and annotates authors — Alice known, Eve unknown — all over ONE shared connection. Findings updated: WoT risk High -> Medium (policy/bootstrap, not feasibility); overall scope Medium-High -> Medium — all three transport contracts now proven. 30 tests green, analyzer clean, Block 1 untouched.
293 lines
17 KiB
Markdown
293 lines
17 KiB
Markdown
# Spike — Block 2 (social layer) de-risking findings
|
|
|
|
*Throwaway research spike. Language: English (findings), per code convention.
|
|
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
|
|
> [open-decisions.md](open-decisions.md) §D.1 are explicit: the social layer is
|
|
> big, indivisible, and needs its own funded round. This spike only lowers the
|
|
> unknowns before that round is committed.
|
|
|
|
## What the spike answered
|
|
|
|
The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and
|
|
[g1-integration.md](g1-integration.md) "A decidir":
|
|
|
|
| # | Open question | Verdict |
|
|
|---|---|---|
|
|
| 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** |
|
|
| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** |
|
|
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
|
| 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** |
|
|
| 4 | Does a Duniter-style web of trust work over Nostr, and can it filter spam? | **Yes — certify/discover + threshold-and-distance membership + offer filtering all prototyped.** |
|
|
|
|
All of it runs with tests (30 passing). All three transport contracts —
|
|
offers, messaging, trust — plus the pure trust-graph logic are exercised.
|
|
See "How to run" at the end.
|
|
|
|
---
|
|
|
|
## Q1 — Identity derivation (secp256k1 from the Ğ1 root seed)
|
|
|
|
**Result: works, deterministic, one-way. This unblocks "one identity, one backup".**
|
|
|
|
- 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
|
|
of the seed) → BIP340 x-only pubkey → NIP-19 `npub`/`nsec`.
|
|
- The root seed is exactly what
|
|
[`IdentityService.generateRootSeed`](../../packages/commons_core/lib/src/identity/identity_service.dart)
|
|
already produces (32 bytes). The spike test feeds a real `IdentityService`
|
|
seed straight in — no shape mismatch.
|
|
- **Reproducibility:** same seed → identical private key, pubkey, and `npub`
|
|
(test `is reproducible`). The user backs up ONE thing (the seed QR); the
|
|
secp256k1 key regenerates on demand. Confirms the [g1-integration.md](g1-integration.md)
|
|
§"Resuelto" bet.
|
|
- **One-wayness:** derivation runs the seed through HMAC-SHA256, which is not
|
|
invertible; the seed never appears in the derived bytes, and unrelated seeds
|
|
never collide (tested). So publishing an `npub` cannot expose the Ğ1 identity
|
|
or the seed. (Simultaneous *use* of both identities can still be correlated by
|
|
an observer — that residual risk is unchanged and is what the "pseudonymous
|
|
key" advanced option in [g1-integration.md](g1-integration.md) §"peor caso"
|
|
exists for.)
|
|
- **The derived key really signs:** BIP340 sign+verify round-trips (test), i.e.
|
|
a real relay/peer would accept events from this key.
|
|
|
|
**Design decisions this firms up (for the social round):**
|
|
- Fix the derivation `info` string and version it (done: `…/v1`), the same
|
|
discipline `BackupBox` already uses. Changing it later would silently rotate
|
|
everyone's Nostr identity, so it is effectively part of `schemaVersion`.
|
|
- Derivation belongs in `commons_core` (identity is generic). In production it
|
|
should reuse the `cryptography` package's HKDF already vendored there, not the
|
|
spike's hand-rolled one.
|
|
|
|
**Residual risk: low.** The only real decision left is cosmetic/standardisation:
|
|
whether to align the derivation path with an existing convention (e.g. a
|
|
SLIP-0010/BIP32-style path or Cesium's "derive encryption key from signing key"
|
|
precedent) so other tools could reproduce it. Not a blocker — our scheme is
|
|
self-consistent and one-way today.
|
|
|
|
---
|
|
|
|
## Q2 — `OfferTransport` abstraction & the privacy seam
|
|
|
|
**Result: the abstraction holds and the privacy seam is real. But messaging and
|
|
trust do NOT fit behind the same interface — they share a *connection*, not a
|
|
*contract*.**
|
|
|
|
### The seam works (offers)
|
|
- [`OfferTransport`](../../spike/block2_spike/lib/src/offer_transport.dart) =
|
|
`publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr
|
|
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`](../../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
|
|
[core-domain-boundary.md](core-domain-boundary.md) §4.3 call for.
|
|
|
|
### The privacy seam is enforced on the wire (tested)
|
|
- The codec **coarsens the geohash** to ≤5 chars (≈±2.4 km) before it ever hits
|
|
the wire, and emits a NIP-52 prefix *ladder* (`u`, `u0`, `u09`, …) so area
|
|
queries match by exact tag while nothing finer than the cap leaks.
|
|
- Tests assert, byte-wise on the serialised event, that a slipped-in precise
|
|
geohash and a secret inventory marker **never appear**, and that there is no
|
|
`location`/`address` tag. The seam has no field that *could* carry them —
|
|
privacy is structural, not a runtime check that can be forgotten.
|
|
|
|
### The caveat — coupling (this was the point of Q2)
|
|
[open-decisions.md](open-decisions.md) §D.3 worried Nostr couples messaging and
|
|
trust more than the plan implies. The spike confirms the worry is **real but
|
|
manageable**:
|
|
|
|
- Offers (NIP-99, kind 30402), **messaging** (NIP-17 DMs, kind 1059 gift-wrap),
|
|
and **trust** (NIP-85 / certifications) all reuse the *same derived key* and
|
|
the *same relay socket* (the OK-handshake, REQ/EOSE plumbing in
|
|
`NostrOfferTransport` is identical for all three).
|
|
- But their **verbs differ**: publish/browse a public listing vs. send/receive a
|
|
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`](../../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`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
|
(NIP-99),
|
|
[`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)
|
|
(NIP-17) and
|
|
[`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
|
|
DMs and certifications would have overloaded it — the split is real.
|
|
|
|
**Residual risk: low.** The abstraction is right and all three contracts are
|
|
built. The remaining risk is **scope/hardening, not feasibility**: "the social
|
|
layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and
|
|
that whole happy path now runs — what's left is production hardening (below).
|
|
|
|
---
|
|
|
|
## Q3 — Publish → discover by geohash against a relay
|
|
|
|
**Result: the flow works end to end. The real risk is NIP maturity/ecosystem,
|
|
not the mechanics.**
|
|
|
|
- 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.
|
|
- End-to-end tests (`roundtrip_test.dart`):
|
|
- Alice publishes a gift offer; **Bob (a different derived identity) discovers
|
|
it by a coarser geohash** and gets the summary + Alice's pubkey back. ✅
|
|
- A query for a **different** area returns nothing (geohash scoping works). ✅
|
|
- **Re-publishing the same offer id replaces, not duplicates** (addressable
|
|
events — how a real relay behaves). ✅
|
|
- Latency measured: **~34 ms** publish+discover, in-process, cold (includes
|
|
the OK handshake + REQ/EOSE; a floor value — a real relay adds network RTT).
|
|
|
|
### On NIP maturity and the ActivityPub fallback
|
|
- **NIP-99 (classified listings, 30402):** stable enough and used in the wild
|
|
(e.g. Shopstr). Carries title/summary/price+currency/status of the box, so
|
|
gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the
|
|
reciprocity mode NIP-99 lacks. **Low risk.**
|
|
- **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed +
|
|
gift-wrapped, per-message ephemeral keys). **The spike DID build it** — see §4
|
|
below. Verdict moves from "biggest unbuilt risk" to **medium (production
|
|
hardening), not high (feasibility)**.
|
|
- **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification
|
|
model maps onto signed events, but there is no dominant standard. Likely a
|
|
**custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT
|
|
propia pero compatible" already anticipates). **Higher risk; design work, not
|
|
just integration.**
|
|
- **ActivityPub / FEP-0837 as fallback:** worth keeping as the *second*
|
|
`OfferTransport` backend for reach/federation, but it does **not** solve the
|
|
hard parts (private DMs, WoT) any better than Nostr, and it adds instance
|
|
hosting. Recommendation: **stay Nostr-first**, keep the transport interface so
|
|
AP can be added later for offer federation, don't invest in AP now.
|
|
|
|
---
|
|
|
|
## Q3b — NIP-17 private messaging (the piece that was flagged highest-risk)
|
|
|
|
**Result: the metadata-private DM flow works end to end. Built, tested, and it
|
|
fits the shared-connection architecture.**
|
|
|
|
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`](../../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`](../../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).
|
|
- **End-to-end over the relay (tested):**
|
|
- Alice → Bob: the message arrives and Bob **authenticates the sender as
|
|
Alice** — even though the wrap was signed by an ephemeral key.
|
|
- **The relay/eavesdropper sees neither the plaintext nor the real sender:**
|
|
snooping stored kind-1059 events yields only ciphertext, an ephemeral
|
|
author, and a `p` tag for the recipient. Alice's identity is hidden.
|
|
- A third party cannot read a message not addressed to them.
|
|
|
|
**What this de-risks:** the hard, novel part (metadata-private messaging on the
|
|
*same derived key and relay* as offers) is feasible and architecturally clean.
|
|
|
|
**What remains (why it's still medium, not solved):**
|
|
- The NIP-44 implementation here is **structurally faithful but NOT verified
|
|
against the reference test vectors** — cross-client interop must be proven
|
|
before shipping (a real client library should be used, not this spike code).
|
|
- **Offline delivery, retries, and multi-device** (a recipient who's offline
|
|
when the wrap is published) are untouched — this is the real "messaging is
|
|
large" weight.
|
|
- **Spam/abuse** on DMs leans on the (unbuilt) web of trust.
|
|
|
|
## Q4 — Web of trust (the last big unknown)
|
|
|
|
**Result: a Duniter-style WoT maps cleanly onto Nostr, computes correctly from
|
|
discovered events, and filters spam. Built and tested.**
|
|
|
|
There is no settled NIP for a web of trust, so — exactly as
|
|
[g1-integration.md](g1-integration.md) §"WoT propia pero compatible"
|
|
anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr:
|
|
|
|
- **Certifications as events**
|
|
([`NostrTrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)):
|
|
a custom addressable kind (30777) keyed by (issuer, `d`=subject), so one live
|
|
"A vouches for B" per pair; re-certifying renews, revoking replaces (tested).
|
|
Certifications expire (Ğ1 semantics) and are public (like the on-chain Ğ1 WoT).
|
|
- **The membership rule is pure and separately tested**
|
|
([`WebOfTrust`](../../spike/block2_spike/lib/src/web_of_trust.dart)) — the
|
|
`commons_core`-worthy piece: Duniter's two rules, **N certifications from
|
|
existing members** (sigQty) and **within distance D of the bootstrap
|
|
referents** (stepMax), iterated to a fixpoint (becoming a member can push
|
|
others over the line). Tests cover threshold, distance cut-off, transitive
|
|
growth, and exclusion of expired/revoked/self certs.
|
|
- **It filters spam (the payoff, network-trust.md §2):** in a test, three seed
|
|
members certify Alice; Eve stays uncertified. Bob discovers both their offers,
|
|
then annotates authors — **Alice is "known", Eve stays "unknown"** and gets
|
|
deprioritised. Trust and offers run **on the same shared connection**, closing
|
|
the loop between Q2's architecture and this filter.
|
|
|
|
**Residual risk: high → medium.** Feasibility is proven; what's left is *policy
|
|
and bootstrap*, not code: choosing sigQty/stepMax/expiry (network-trust.md §2
|
|
"a decidir"), the cold-start referent set (fairs, Ğ1 seed groups —
|
|
[g1-integration.md](g1-integration.md)), whether a collective can certify as an
|
|
entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust source.
|
|
|
|
## Overall recommendation
|
|
|
|
1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified;
|
|
move it into `commons_core` (reusing the vendored HKDF) when the social round
|
|
starts. Version the `info` string as part of the schema.
|
|
2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try
|
|
to make `OfferTransport` also carry messaging and trust. Keep `Offer`
|
|
agnostic and the geohash-coarsening seam exactly as prototyped.
|
|
3. **Q3/Q4: Nostr-first is the right bet, and all three contracts are now
|
|
proven.** Offers (NIP-99), private messaging (NIP-17) and the web of trust
|
|
(custom, Duniter-compatible) all work on the **same shared connection** — the
|
|
happy path of the entire social layer runs end to end in this spike. What
|
|
remains is **not feasibility but hardening and policy**: interop-exact NIP-44,
|
|
offline/multi-device delivery, and the WoT parameters + cold-start
|
|
([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). 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
|
|
on re-proving the happy path.
|
|
|
|
## Risk summary
|
|
|
|
| Area | Risk | Why |
|
|
|---|---|---|
|
|
| Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. |
|
|
| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. |
|
|
| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. |
|
|
| Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. |
|
|
| Web of trust (custom, Ğ1-compatible) | **Medium** | Feasibility proven (certs + membership rule + spam filter tested). Left: parameters (sigQty/stepMax/expiry) + cold-start, not code. |
|
|
| Overall scope (§D.1) | **Medium** | Social layer is indivisible & large, but the whole happy path now runs end to end; what remains is hardening + policy, not unknowns. |
|
|
|
|
## How to run
|
|
|
|
```sh
|
|
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.
|