# Mesh-D1 — membership key + seed-proof codec (pure)

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 1. **Pure primitive, no wiring** — no `nethost`/`ConnEntry`/gate changes (those are D2/D5). Delivers the crypto a member proves itself with. **Activates REQ-MESH-1 (impl, unit)** — this phase satisfies that activation with the codec slice; D2 (connect wiring + ConnEntry flag) and D5 (gate swap) enrich the same requirement later.

## What it is

A node proves *current-epoch seed knowledge* on a connection, symmetrically, without either side having pre-pinned the other — the mesh property ("accepts a member it never paired"). Two primitives:

1. **Membership key.** `MK = HKDF-SHA256(ikm = seed, info = domain ‖ subnet_id ‖ seed_epoch)`. The shared per-(subnet, epoch) secret every member can derive and no outsider can. Domain-separated + versioned; exact-epoch (the N-1 re-seed grace is D7, not here).
2. **Mutual channel-bound seed-proof.** Each side MACs (HMAC-SHA256 keyed by MK) a transcript binding **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role**, and constant-time-verifies the peer's tag. Channel-binding (the two QUIC pubkeys) kills cross-connection replay; the nonces give per-connection freshness; the role byte kills reflection; mutual means *both* must prove.

This is the exact sibling of `pairing::transcript` (HMAC-SHA256, length-prefixed fields, `subtle::ct_eq`, `Role`, versioned domain) — clean-room the same shape, different domain and key source.

## Where

New module `crates/spt-net/src/net/mesh/` (sibling to `pairing/`), file `seedproof.rs`, re-exported via `net/mesh.rs` + a `pub mod mesh;` in `net.rs`. Gated behind the existing `net` feature (it pulls `hmac`/`sha2`/`subtle`/`zeroize`, all already `net`-deps). Add `hkdf = { version = "0.12", optional = true }` to `spt-net` and to the `net` feature list (already transitive in `Cargo.lock` — promotion only).

## Surface (impl)

```rust
// [impl->REQ-MESH-1]
pub const MK_LEN: usize = 32;
pub struct MembershipKey([u8; MK_LEN]);          // Zeroize on Drop

impl MembershipKey {
    /// MK = HKDF-SHA256(seed, domain ‖ len-prefixed subnet_id ‖ seed_epoch_be).
    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey;
}

pub const PROOF_TAG_LEN: usize = 32;             // HMAC-SHA256

/// Which side of the connection a party plays. The DIALER initiates; the
/// ACCEPTOR responds. Bound into the tag so one side's proof can't be replayed
/// as the other's (reflection defense), exactly like pairing::transcript::Role.
pub enum ProofRole { Dialer, Acceptor }

/// The channel-bound seed-proof transcript. Both sides MUST construct an
/// identical one (same fields, same pubkey/nonce order) or the tags diverge —
/// that equality IS the membership agreement check.
pub struct SeedProofTranscript {
    pub subnet_id: String,
    pub seed_epoch: u64,
    pub dialer_pub:   PublicKey,   // handshake-proven (QUIC) — channel binding
    pub acceptor_pub: PublicKey,   // handshake-proven (QUIC) — channel binding
    pub dialer_nonce:   [u8; 32],
    pub acceptor_nonce: [u8; 32],
}

impl SeedProofTranscript {
    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN];
    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool; // ct_eq
}
```

Plus the wire codec (the frames D2 will exchange — defined here, sent here only in tests):

```rust
// Length-delimited, serde additive — mirrors net/pairing/wire.rs framing.
pub enum SeedProofFrame {
    Challenge { nonce: [u8; 32] },        // each side opens with its nonce
    Proof     { tag: [u8; PROOF_TAG_LEN] }, // then its tag over the agreed transcript
}
impl SeedProofFrame { pub fn encode(&self) -> Vec<u8>; pub fn decode(&[u8]) -> Option<Self>; }
```

Mutual exchange shape (documented; *driven* at D2): dialer→Challenge(nd), acceptor→Challenge(na), then each computes the shared transcript {pubkeys from the handshake, nd, na} and sends Proof(tag(role)); each verifies the peer's tag under its own MK. Either verify-fail ⇒ drop (D2).

Domains: `spt-core/mesh/mk/v1` (HKDF info prefix) · `spt-core/mesh/seedproof/v1` (tag prefix). Length-prefix every variable field (subnet_id, nonces, pubkeys) — no field-boundary collision (the `pairing::transcript` lesson).

## Unit tests (the REQ-MESH-1 `unit` slice)

`// [unit->REQ-MESH-1]` on each:

1. **MK derivation** — deterministic for equal inputs; *different* for any of {different seed, different subnet_id, different seed_epoch}. Length-prefix proves `subnet "a"+epoch 1` ≠ `subnet "a1"+epoch 0`-style collisions.
2. **valid** — matching MK + transcript + role verifies; the two role tags differ (no reflection).
3. **forged** — a wrong MK (outsider / wrong seed) fails verify.
4. **wrong-subnet** — same seed, different `subnet_id` ⇒ different MK ⇒ fail.
5. **wrong-epoch** — same seed, `seed_epoch+1` ⇒ different MK ⇒ fail (exact-epoch; N-1 grace is D7).
6. **cross-connection-replay** — a tag valid on transcript A (pubkeys/nonces A) fails on transcript B (different pubkeys OR different nonces) — the channel-binding assertion, the security center.
7. **mutual-fail** — if one side's tag fails, the pair is rejected (model the two-sided check: both must pass).
8. **frame round-trip** — `decode(encode(f)) == f` for Challenge + Proof; a truncated/garbage buffer ⇒ `None` (additive/robust).

## Done when

- `cargo test -p spt-net mesh::seedproof` green; all 8 units pass.
- `cargo build -p spt-net` (and `--no-default-features`) clean — module is `net`-gated, no leak into the default surface.
- `traceable-reqs check` clean with **REQ-MESH-1 activated `required_stages = ["impl","unit"]`** and the codec evidence tags present. (D2/D5 add more `impl` evidence under the same id.)
- No `nethost`/`broker`/gate file touched (purity gate — D2 owns wiring).
- Commit: `feat(mesh): membership key + mutual seed-proof codec (REQ-MESH-1) — Mesh-D1`.

## Carry-forward (not D1)

- **Open verification #1** (QUIC `keep_alive_interval` in `endpoint.rs`) is a **D2** check — D2 needs connections to stay warm so re-proof is restart/partition/rotation-only. Note it, don't chase it here.
- The nonces are 32 bytes from the OS CSPRNG at D2 connect time; D1 tests use fixed arrays.
