spike(block2): de-risk social layer — key derivation, OfferTransport, NIP-99 round-trip
Throwaway research spike (spike/ branch, outside pub workspace, no prod deps touched). Validates the open Block 2 decisions before committing a funded round: 1. Deterministic one-way secp256k1 (Nostr) key derived from the Ğ1 root seed via HKDF — user still backs up ONE thing. Reproducible + signs (BIP340). 2. OfferTransport abstraction over Nostr NIP-99 (kind 30402); Offer stays inventory/location-agnostic, geohash coarsened on the wire (tested). 3. publish->discover-by-geohash proven end-to-end against an in-process hermetic mini-relay (~34ms round-trip). Findings + risks + recommendation in docs/design/spike-block2-findings.md. Block 1 suite untouched. 14 tests green, analyzer clean.
This commit is contained in:
parent
6eb1517ffb
commit
32ddaf1714
1 changed files with 204 additions and 0 deletions
204
docs/design/spike-block2-findings.md
Normal file
204
docs/design/spike-block2-findings.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# 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 — with a caveat on messaging/trust coupling.** |
|
||||
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
||||
|
||||
All of it runs with tests. 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:** in `commons_core`, model **one shared `NostrConnection`**
|
||||
(socket, key, sign, req/sub lifecycle) with **three thin interfaces on top** —
|
||||
`OfferTransport`, `MessageTransport`, `TrustTransport`. That matches how the
|
||||
code actually factored in the spike and keeps each contract honest, while
|
||||
admitting the reality that one protocol/key/relay underpins all three.
|
||||
|
||||
**Residual risk: medium.** The abstraction is right; the *scope* is the risk —
|
||||
"the social layer" is genuinely offers + messaging + trust + relays together
|
||||
(the §D.1 "indivisible" point), and this spike only built the offers third end
|
||||
to end. Messaging (NIP-17 gift-wrap, offline delivery, spam) and trust (Duniter-
|
||||
style WoT over Nostr) remain the large, unbuilt pieces.
|
||||
|
||||
---
|
||||
|
||||
## 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). Viable but the **biggest unbuilt
|
||||
risk** — this is where Q2's "messaging is large" bites. **Medium-high risk;
|
||||
spike did not implement it.**
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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: Nostr-first is the right bet, but scope honestly.** Offers are the easy
|
||||
third and they work. **Messaging (NIP-17) and WoT (NIP-85/custom) are the
|
||||
real weight and remain unproven here** — they, not the offer flow, should
|
||||
drive the Phase-3 estimate and the funding ask ([open-decisions.md](open-decisions.md)
|
||||
§D.1, §D.6).
|
||||
4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once
|
||||
these findings are absorbed. The next concrete step is a *funded* social
|
||||
round that starts by building NIP-17 messaging as its own de-risking target,
|
||||
since that is where the residual risk concentrates.
|
||||
|
||||
## 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-High** | Newer, complex, unbuilt; the big Phase-3 cost. |
|
||||
| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. |
|
||||
| Overall scope (§D.1) | **High** | Social layer is indivisible & large; only 1/3 proven here. |
|
||||
|
||||
## How to run
|
||||
|
||||
```sh
|
||||
cd spike/block2_spike
|
||||
dart pub get
|
||||
dart test # derivation_test.dart, privacy_test.dart, roundtrip_test.dart
|
||||
```
|
||||
|
||||
Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the
|
||||
Block 1 suite is untouched.
|
||||
Loading…
Add table
Add a link
Reference in a new issue