Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
---
module: spoke-connect
date: 2026-08-04
last_updated: 2026-09-22
problem_type: architecture_pattern
category: architecture-patterns
severity: high
applies_when: ["porting the Noise XX transport to another language or runtime", "shipping an opt-in crypto subpath inside a published package", "proving wire interop against rust-libp2p without a live peer in CI"]
tags: [spoke-connect, noise-xx, libp2p, golden-transcript, interop, subpath, bundle-isolation, snow]
applies_when:
- "porting the Noise XX transport to another language or runtime"
- "shipping an opt-in crypto subpath inside a published package"
- "proving wire interop against rust-libp2p without a live peer in CI"
tags:
- spoke-connect
- noise-xx
- libp2p
- golden-transcript
- interop
- subpath
- bundle-isolation
- snow
---

# Pure-TS Noise XX stack: rust-libp2p interop and opt-in subpath isolation

## Context

`@42ch/spoke-connect` ships a thin default client (WebSocket ordered-stream transport, JCS + Ed25519 identity, the session-core parity rules). Integrators that need to join a libp2p-noise mesh — the transport of the Rust reference `crates/spoke-connect`, which composes `libp2p::noise::Config::new` over libp2p 0.56.0 — get a first-party pure-TypeScript `Noise_XX_25519_ChaChaPoly_SHA256` stack behind the opt-in `./noise` subpath. Two hard problems were solved: (1) proving byte-level wire interop with rust-libp2p deterministically in CI without a live Rust peer; (2) shipping the crypto stack without widening the default bundle's dependency surface. Both solutions generalize to every future language Noise port and every future opt-in crypto subpath.
`@42ch/spoke-connect` ships a thin default client (WebSocket ordered-stream transport, JCS + Ed25519 identity, the session-core parity rules). Integrators that need to join a libp2p-noise mesh — the transport of the Rust reference `crates/spoke-connect`, which composes `libp2p::noise::Config::new` over libp2p 0.57.0 — get a first-party pure-TypeScript `Noise_XX_25519_ChaChaPoly_SHA256` stack behind the opt-in `./noise` subpath. Two hard problems were solved: (1) proving byte-level wire interop with rust-libp2p deterministically in CI without a live Rust peer; (2) shipping the crypto stack without widening the default bundle's dependency surface. Both solutions generalize to every future language Noise port and every future opt-in crypto subpath.

## Guidance

### 1. Golden-transcript interop via a snow-engine recorder

The interop gate replays a **recorded rust-libp2p Noise XX initiator transcript** against the TS responder. The recording is produced by a dev-only Rust example binary, `crates/spoke-connect/examples/noise_recorder.rs`:

- Recorder dependencies are **dev-dependencies only** (`snow 0.9.6` with `ring-resolver`, `libp2p-identity 0.2`, `x25519-dalek 2`), and `exclude = ["examples/**"]` keeps the binary out of the published crate tarball (`cargo package --list` verified). No Rust artifacts ship to consumers — only the committed JSON fixture under the TS test tree.
- The recorder drives `snow::HandshakeState` directly with the **exact engine behind libp2p-noise 0.46.1** (the crate behind the reference's `noise::Config::new`): same `snow` version and `ring-resolver` feature, same builder parameters libp2p-noise composes (`prologue([])`, `local_private_key(static_secret)`), and a `RecorderResolver` mirroring libp2p-noise's `protocol.rs::Resolver` (hash/cipher from `snow::resolvers::RingResolver`; X25519 DH over `x25519_dalek`).
- Recorder dependencies are **dev-dependencies only** (`snow 0.10` with `ring-resolver`, `libp2p-identity 0.3`, `x25519-dalek 3`), and `exclude = ["examples/**"]` keeps the binary out of the published crate tarball (`cargo package --list` verified). No Rust artifacts ship to consumers — only the committed JSON fixture under the TS test tree.
- The recorder drives `snow::HandshakeState` directly with the **exact engine behind libp2p-noise 0.47.0** (the crate behind the reference's `noise::Config::new`): same `snow` version and `ring-resolver` feature, same builder parameters libp2p-noise composes (`prologue([])`, `local_private_key(static_secret)`), and a `RecorderResolver` mirroring libp2p-noise's `protocol.rs::Resolver` (hash/cipher from `snow::resolvers::RingResolver`; X25519 DH over `x25519_dalek`).
- Ephemeral keys are pinned via snow's `fixed_ephemeral_key_for_testing_only` — the only pinning hook; libp2p-noise never exposes ephemerals, which is why the recorder drives `snow::HandshakeState` directly instead of the crate-private framed codec.
- Payload and signature replicate libp2p-noise `send_identity` exactly: `NoiseHandshakePayload { identity_key = libp2p-identity PublicKey protobuf, identity_sig = Ed25519 over "noise-libp2p-static-key:" || static_x25519_pub }`, no extensions, quick-protobuf field order.
- Payload and signature replicate libp2p-noise `send_identity` exactly: `NoiseHandshakePayload { identity_key = libp2p-identity PublicKey protobuf, identity_sig = Ed25519 over "noise-libp2p-static-key:" || static_x25519_pub }`, no extensions; prost encodes the `payload.proto` fields by their declared tags (`identity_key` tag 1, `identity_sig` tag 2).
- The recorder **self-verifies before emitting**: flight-1 payload empty, payloads cross-read intact, handshake hashes equal, remote statics crossed, both identity signatures verified exactly like libp2p-noise `finish()`, transport round-trip opens in both directions. Any mismatch panics — no fixture is written.
- Rerun is **byte-identical** (deterministic; verified by `diff`). The fixture (`tests/noise/fixtures/noise-xx-golden.json`) records **pure Noise frames after multistream** — u16-BE length-prefixed wire bytes with the `/noise` negotiation outside the Noise messages.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
---
module: spoke-connect
date: 2026-07-31
last_updated: 2026-08-01
last_updated: 2026-09-22
problem_type: architecture_pattern
category: architecture-patterns
severity: high
applies_when: ["building a libp2p-based spoke-connect runtime", "binding spoke-connect to a foreign language via uniffi", "hardening a p2p handshake/allowlist implementation"]
tags: [spoke-connect, libp2p, rust, noise, identify, allowlist, pending-dial]
applies_when:
- "building a libp2p-based spoke-connect runtime"
- "binding spoke-connect to a foreign language via uniffi"
- "hardening a p2p handshake/allowlist implementation"
tags:
- spoke-connect
- libp2p
- rust
- noise
- identify
- allowlist
- pending-dial
---

# spoke-connect Rust libp2p spike: transport, auth binding, and event-loop pitfalls
Expand All @@ -19,7 +29,7 @@ The `crates/spoke-connect` crate (published on crates.io) implements the `spoke-

### Transport composition (locked minimal feature set)

Pin a single rust-libp2p version (e.g. `=0.56.0`); enable only `noise`, `yamux`, `request-response`, `identify`, `macros`, `tokio`, `ed25519` (+ `tcp`, `json` as required by the composition). Avoid QUIC, relay, kad, gossipsub, tls unless a real behaviour is wired — a capability-named feature flag with no runtime behaviour must not ship.
Pin a single rust-libp2p version (e.g. `=0.57.0`); enable only `noise`, `yamux`, `request-response`, `identify`, `macros`, `tokio`, `ed25519` (+ `tcp`, `json` as required by the composition). Avoid QUIC, relay, kad, gossipsub, tls unless a real behaviour is wired — a capability-named feature flag with no runtime behaviour must not ship.

### Identify key <-> noise PeerId binding (defense in depth)

Expand Down
2 changes: 1 addition & 1 deletion .mstar/specs/connect-publish-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Bindings under `crates/spoke-connect/bindings/*` are packaged per language: unif
| Item | Value |
|------|--------|
| **Conclusion** | **Remain fallback route only** — does not overturn pure-TS-minimal primary ([`spoke-connect-ts-route.md`](spoke-connect-ts-route.md)) |
| **Evidence** | **When mesh interop matters:** only for **direct libp2p-network participation** — a product host that must dial/listen on the same Noise/yamux mesh as the Rust reference spike (`crates/spoke-connect`). Envelope-level interop — the v1 goal — requires only the same hello / `peer_id` / session rules over **any ordered stream** (WebSocket today); Noise multistream is not required for it ([`spoke-connect-ts-route.md`](spoke-connect-ts-route.md) evaluation criterion 10). **Dependency weight:** js-libp2p is a deep `@libp2p/*` monorepo tree — large supply-chain and bundle surface (criterion 7), inverse of the thin `@42ch/*` helper pattern; Noise/multistream interop with rust-libp2p 0.56 additionally needs version pinning and periodic re-verify (criterion 8). Current package reality: `@42ch/spoke-connect` dependencies are `@42ch/spoke-schemas`, `@noble/ed25519`, `@noble/hashes`, `canonicalize`, `ws` — no libp2p deps. **Session-core ownership:** js-libp2p does **not** replace the TS session-core port — sequence, nonce, allowlist, `request_id` correlation, and the dispatch gate remain SDK-owned (criterion 6); the SPOKE hello is JCS-signed, not a libp2p native hello (criterion 4) |
| **Evidence** | **When mesh interop matters:** only for **direct libp2p-network participation** — a product host that must dial/listen on the same Noise/yamux mesh as the Rust reference spike (`crates/spoke-connect`). Envelope-level interop — the v1 goal — requires only the same hello / `peer_id` / session rules over **any ordered stream** (WebSocket today); Noise multistream is not required for it ([`spoke-connect-ts-route.md`](spoke-connect-ts-route.md) evaluation criterion 10). **Dependency weight:** js-libp2p is a deep `@libp2p/*` monorepo tree — large supply-chain and bundle surface (criterion 7), inverse of the thin `@42ch/*` helper pattern; Noise/multistream interop with rust-libp2p 0.57 additionally needs version pinning and periodic re-verify (criterion 10). **Maintenance:** …
Comment thread
auto-wood marked this conversation as resolved.
| **Recommend** | Do **not** add `js-libp2p` to `@42ch/spoke-connect` default dependencies; keep the pure-TS-minimal primary intact |
| **Defer** | Shipping a mesh helper inside the default package |
| **Trigger to ship a mesh helper** | Product requires shared libp2p network with the Rust spike; then prefer an **optional companion package or consumer-repo adapter**, not a forced default export |
Expand Down
2 changes: 1 addition & 1 deletion .mstar/specs/noise-xx-libp2p-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Status:** Frozen normative spec — the wire contract for the Noise XX mesh
transport in the `@42ch/spoke-connect` TypeScript client (`./noise` subpath)
and the Rust reference `spoke-connect`. Grounded in rust-libp2p
`libp2p-noise` 0.46.x (pulled by workspace `libp2p = 0.56.0`) as used by
`libp2p-noise` 0.47.x (pulled by workspace `libp2p = 0.57.0`) as used by
`crates/spoke-connect` (`noise::Config::new` in `src/node.rs`).

## 1. Interop target
Expand Down
2 changes: 1 addition & 1 deletion .mstar/specs/spoke-connect-ts-route.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Scoring is High / Med / Low fitness for SPOKE Path A browser+Node clients that m
| 5 | **JCS (RFC 8785)** | **Med** — external JCS dependency still required; must match `serde_jcs` | **High** — `serde_jcs` in-core | **High** — pure JCS subset (or lib) matches golden hex vector (proof); omit absent optionals (no `"authority": null`) |
| 6 | **Session-core port cost** | **Med** — session rules still ported in TS; libp2p does not replace sequence/nonce/allowlist/correlation | **High** — reuse pure Rust core behind WASM | **Med** — small pure port (sequence, nonce, allowlist, correlation, dispatch gate); intentional and bounded |
| 7 | **Dependency weight** | **Low** — deep `@libp2p/*` tree, large supply-chain and bundle surface for a future thin `@42ch/*` helper | **Low** — WASM binary + wasm-bindgen glue + JS transport | **High** — WebCrypto (platform) + optional noble + thin WS; no swarm stack |
| 8 | **Ecosystem / maintenance** | **Med** — js-libp2p remains active (e.g. 3.x releases); Noise/multistream interop with rust-libp2p 0.56 needs version pinning and periodic re-verify | **Low** — wasm-pack/wasm-bindgen CI and artifact policy inside a protocol repo is ongoing cost | **High** — platform crypto + small pure modules; low churn surface |
| 8 | **Ecosystem / maintenance** | **Med** — js-libp2p remains active (e.g. 3.x releases); Noise/multistream interop with rust-libp2p 0.57 needs version pinning and periodic re-verify | **Low** — wasm-pack/wasm-bindgen CI and artifact policy inside a protocol repo is ongoing cost | **High** — platform crypto + small pure modules; low churn surface |
| 9 | **Fit with P0 embedding model** | **Med** — valid Path A host, but nudges integrators toward “connect = libp2p mesh” rather than envelope-on-stream | **Low** — Path B-ish reuse of Rust; browsers that refuse WASM still need a pure path | **High** — Path A purity: no Rust required; no daemon; transport-adapter-owned framing |
| 10 | **Interop with Rust reference spike** | **Med–High** — Noise mesh peer possible when versions align; envelope interop still needs SPOKE hello bytes | **High** — same core bytes as the spike | **High** for **envelope-level** interop (same hello/`peer_id`/session rules over any ordered stream). Noise multistream is **not** required for v1 product goals that only need connect envelopes |

Expand Down
Loading
Loading