tane/docs/design/spike-block2-findings.md
vjrj 8a81aaf442 spike(block2): de-risk NIP-17 messaging + validate shared-connection shape
Extends the Block 2 spike to attack the highest-risk unbuilt piece (private
messaging) and to prove the Q2 architecture recommendation with running code:

- NostrConnection: one shared socket+key+sign+REQ/EOSE lifecycle. Refactored
  NostrOfferTransport onto it; added NostrMessageTransport — two thin contracts
  on ONE connection (the 'one connection, three interfaces' shape).
- NIP-44 v2 encryption (secp256k1 ECDH -> ChaCha20 + HMAC, length-hiding pad).
- NIP-17/NIP-59 gift-wrap: rumor(14) -> seal(13) -> wrap(1059, ephemeral key).
  Alice->Bob DM round-trips through the relay: encrypted, sender authenticated,
  and metadata-private (relay/eavesdropper sees only ciphertext, an ephemeral
  author and the recipient p-tag — Alice stays hidden).

Findings updated: NIP-17 risk drops Medium-High -> Medium (hardening, not
feasibility); next de-risking target is the web of trust. NIP-44 not yet
vector-verified (noted). 20 tests green, analyzer clean, Block 1 untouched.
2026-07-10 02:11:54 +02:00

14 KiB

Spike — Block 2 (social layer) de-risking findings

Throwaway research spike. Language: English (findings), per code convention. Code lives in 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 §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 §D.3 and 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".

All of it runs with tests (20 passing). 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: 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 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 §"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 §"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 = publish(Offer) / discover(DiscoveryQuery) / retract(id). The Nostr NIP-99 backend (NostrOfferTransport
    • Nip99Codec) sits entirely behind it. A second backend (ActivityPub/FEP-0837) could replace it without the domain noticing.
  • Offer 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 §2 and 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 §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 is one shared socket + key + sign + REQ/EOSE lifecycle, and two thin interfaces already sit on top of the same instanceOfferTransport (NIP-99) and MessageTransport (NIP-17). A third, TrustTransport (WoT), would slot in the same way. This is the shape to lift into commons_core: shared connection, per-concern contract. Trying to make OfferTransport also carry DMs would have overloaded it — the split is real.

Residual risk: medium → low-medium. The abstraction is right and two of the three contracts are now built. The remaining risk is scope, not feasibility: "the social layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and trust/WoT plus production hardening of messaging (below) are still ahead.


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 (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 §4) calls messaging "grande" and 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): 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): 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.

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, and now two of three contracts are proven. Offers (NIP-99) and private messaging (NIP-17) both work on the shared connection. The remaining unproven weight is WoT/trust (NIP-85/custom) plus production hardening of messaging (interop-exact NIP-44, offline delivery, multi-device) — that, not the happy-path flow, should drive the Phase-3 estimate and the funding ask (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 de-risking target is the web of trust (Duniter-style certifications mapped to Nostr events) — the last large unknown — followed by hardening messaging for real-world delivery.

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 (NIP-85/custom) High No settled standard; genuine design work. The last big unknown.
Overall scope (§D.1) Medium-High Social layer is indivisible & large; 2 of 3 transport contracts now proven, WoT + hardening remain.

How to run

cd spike/block2_spike
dart pub get
dart test   # derivation · privacy · roundtrip · messaging  (20 tests)

Nothing here is wired into app_seeds or commons_core's production graph; the Block 1 suite is untouched.