<task-notification>
<task-id>ac0f8fa9fd60f940a</task-id>
<tool-use-id>toolu_013htDkHgRx9KQkNdjyyVEe3</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\ac0f8fa9fd60f940a.output</output-file>
<status>completed</status>
<summary>Agent "Map mesh D7 code surfaces" completed</summary>
<result>Perfect! Now let me create my comprehensive findings report.

## Final Report: Mesh-D7 Code Surface Mapping

Based on my thorough exploration of the spt-core workspace, here are the exact code surfaces needed to implement Mesh-D7 (subnet revoke + timeboxed seed rotation + re-seed grace):

---

### 1. **THE SUBNET CLI COMMAND GROUP**

**File:** `C:\Users\decid\Documents\projects\spt-core\crates\spt\src\cli.rs`

- **SubnetCmd enum definition (lines 535-643):** Defines all subnet subcommands including `Status`, `Create`, `ShowCode`, `Join`, `Leave`, `Prune`, `Detach`, `Attach`, `Notify`. The `Prune` variant at line 597-600 is the foundation for the new `Revoke` variant.

- **cmd_subnet_prune handler (lines 2959-3040):** The elevation-gated command that tombstones a dead identity locally (not propagating). Key elements:
  - `trust_mutation_refusal` gate call at line 2960
  - `prune_candidates` resolution at line 2968 (hex/prefix/label matching)
  - `roster.tombstone()` calls at line 3004
  - Registry snapshot eviction at lines 3014-3030

- **cmd_subnet_leave handler (lines 2883-2929):** The elevation-gated leave command showing the dual structure: subnet removal + roster cleanup. Shows how to persist changes atomically.

- **trust_mutation_refusal function (lines 2866-2874):** The elevation gate reused by leave/prune. Returns `None` on success (Gate::Show), Some(message) on refusal (Gate::Fallback). Must be reused for `revoke`.

- **prune_candidates function (lines 2936-2948):** Pure node resolution logic matching hex (exact or prefix) or label (case-insensitive). Returns Vec of matching pubkey_hex. **Reuse directly for revoke.**

---

### 2. **SUBNETSTORE — SEED ROTATION + EPOCH**

**File:** `C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs`

- **SubnetRecord struct (lines 68-90):** Holds `name`, `seed_hex`, `epoch` (u64, monotonic), `user`, `created`, `hide_new_endpoints`. The epoch field is what D7 bumps during rotation.

- **SubnetStore struct (lines 102-106):** Container holding `Vec<SubnetRecord>`. All operations are on this store.

- **rotate_seed method (lines 201-210):** **THE CORE ROTATION LOGIC.** Takes subnet name, bumps epoch by 1, re-mints seed via `encode_hex(&random_seed())`. This is what the pump fires on deadline. Pure function on a borrowed &mut record.

- **add_joined method (lines 174-192):** Shows the seed+epoch adopt pattern that the re-seed push will mirror (line 178-186: writes seed_hex + epoch into a new record). The daemon's re-seed push will use similar adoption.

- **random_seed function (lines 240-244):** Generates 20 random bytes for a fresh seed. Uses Ed25519 CSPRNG via spt-proto (no extra rand dependency).

- **encode_hex / decode_hex (lines 247-262):** Hex codec for seed bytes ↔ string. D7's re-seed push will use encode on the new seed.

**Tests (lines 272-434):** Unit tests at lines 296-307 prove rotation bumps epoch and changes seed. Line 337-348 show multi-subnet persistence.

---

### 3. **ROSTERSTORE — TOMBSTONE + MEMBER GATES**

**File:** `C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\roster.rs`

- **Tombstone struct (lines 111-119):** The revocation marker. Holds `pubkey_hex`, `subnet`, `stamp`. Grows in a separate vector, not paired with entries.

- **RosterStore struct (lines 124-131):** Holds `members: Vec<RosterEntry>` and `tombstones: Vec<Tombstone>`. Two separate arrays for grow-set CRDT semantics.

- **is_member method (lines 185-187):** The admission predicate: entry present ∧ ¬tombstoned. **D7 unchanged** (exact-epoch membership check happens in seedproofx, not here).

- **is_member_any method (lines 194-198):** Cross-subnet gate. Returns true if pubkey is a (non-tombstoned) member of ANY subnet. Shows the intersection of `members` iterator + tombstone check.

- **members_in method (lines 202-206):** Filter method returning all non-tombstoned members of one subnet. Used by the pump's push target (Mesh-D6 widening).

- **tombstone method (lines 276-284):** Idempotent write of `(pubkey_hex, subnet, stamp)` tombstone. This is the propagating grow-set element. **D7 reuses this for revoke's propagating tombstone** (vs prune's local-only tombstone).

- **clear_tombstone method (lines 290-295):** Drops the tombstone (local elevated act, re-pair only). Shows the bool return (whether one was cleared).

- **merge method (lines 301-308):** Union grow-set merge. Merges both entries (strictly-greater-wins on lease) and tombstones (union). Reachable via on-connect roster exchange.

- **roster_for method (lines 314-328):** Projects one subnet's full slice (members + tombstones, offline included) — what the pairing ceremony seeds a joiner with, and what Mesh-D4 exchanges per subnet.

**Tests (lines 331-571):** Extensive CRDT and tombstone truth table tests. Line 436-460 show tombstone dominates/suppresses-reinsert/clears behavior.

---

### 4. **SEEDPROOFX / MEMBERSHIP — SEED-PROOF VERIFY + PROVEN_SUBNETS**

**File:** `C:\Users\decid\Documents\projects\spt-daemon\src\seedproofx.rs`

- **SubnetCred struct (lines 56-63):** One subnet's credential as the proof needs it: `name` (label), `seed` (raw bytes, never on wire), `epoch` (local current, never on wire).

- **production_membership function (lines 78-92):** Fresh-load membership source. Re-reads SubnetStore per connect so rotation is picked up without restart. Returns Vec<SubnetCred> with all member subnets.

- **prove_membership async function (lines 121-168):** **THE TOP-LEVEL ORCHESTRATOR.** Takes connection, local_id, role (Dialer/Acceptor), creds (Vec<SubnetCred>), roster exchange, self_addr. Returns `Option<HashSet<String>>` — the set of subnets BOTH sides proved. Calls prove_as_dialer or prove_as_acceptor. **This is where D7's three-valued grading hooks in** (exact vs N-1 vs denied).

- **prove_as_dialer (lines 171-223):** Four-leg ping-pong (Hello, Hello, Proof x N, Proof x N). Line 197 builds transcripts, line 203 sends proofs, line 208-215 verifies acceptor's proofs. **D7 swaps line 212's verify to return (Proven{full} | Proven{reseed_only} | Denied).**

- **prove_as_acceptor (lines 226-282):** Mirror structure. Lines 264-271 read+verify dialer's proofs, 273-276 send acceptor's.

- **build_transcripts (lines 287-310):** Constructs `(name, SeedProofTranscript, MembershipKey)` for each shared subnet. Line 298 derives MK from seed+subnet_id+epoch. **D7 consults transcript.seed_epoch here to grade.**

- **RosterExchange type (line 348-350):** Closure type for roster provider. Takes proven subnets (HashSet<String>) and self_addr (JSON), returns (Vec<RosterEntry>, Vec<Tombstone>). The D4 roster leg's callback shape.

- **exchange_roster_dialer / exchange_roster_acceptor (not shown, but referenced at lines 220, 279):** Best-effort roster legs running after both proofs verify. These will be unchanged for D7 (roster carries no seed).

---

### 5. **THE SEED-PROOF TRANSCRIPT + MK DERIVATION**

**File:** `C:\Users\decid\Documents\projects\spt-net\src\net\mesh\seedproof.rs`

- **MembershipKey struct (lines 57-90):** Wraps `[u8; MK_LEN]` (32 bytes). Zeroizes on drop. Derived via HKDF-SHA-256(ikm=seed, info=domain‖len-prefixed(subnet_id)‖seed_epoch_be).

- **MembershipKey::derive method (lines 65-77):** **THE EXACT-EPOCH BINDING.** `seed_epoch` is baked into the HKDF info at line 70: `info.extend_from_slice(&seed_epoch.to_be_bytes())`. A different epoch derives a different MK. **D7 reads the bound epoch to grade.**

- **SeedProofTranscript struct (lines 119-135):** The context bound by the HMAC tag. Holds `subnet_id`, `seed_epoch`, `dialer_pub`, `acceptor_pub`, `dialer_nonce`, `acceptor_nonce`.

- **SeedProofTranscript::tag method (lines 143-150+):** Computes HMAC-SHA-256 tag over the transcript. Field order is fixed regardless of prover; only the `prover.label()` (role byte) differs. **D7 uses this same tag generation, but verifies against N-1 when grading.**

- **ProofRole enum (lines 97-111):** Dialer / Acceptor. Role bytes are bound into the tag (reflection defense).

**Constants:**
- `MK_DOMAIN = b"spt-core/mesh/mk/v1"` (line 38)
- `PROOF_DOMAIN = b"spt-core/mesh/seedproof/v1"` (line 43)
- `NONCE_LEN = 32` (line 52)

---

### 6. **NETHOST — CONNECTION DISPATCHER + PROVEN_SUBNETS CACHING**

**File:** `C:\Users\decid\Documents\projects\spt-daemon\src\nethost.rs`

- **NetConfig struct (lines 58-76):** Holds `identity`, `relay`, `discovery`, `scope`, `membership: Option<MembershipSource>`. The membership source is lazy-loaded per dial/accept (line 75 comment explains this design for D7 picks up rotation).

- **ConnEntry struct (lines 87-100):** One live QUIC connection. Holds:
  - `conn: Connection` — the iroh handle (dropping it closes the QUIC conn)
  - `remote_id_hex: String` — the peer's pubkey (proven by handshake)
  - `proven_subnets: HashSet<String>` — **the caching site for Mesh-D2** (line 99). At D7, this becomes `HashMap<String, ProofGrade>` (or similar) to cache full/reseed_only/denied per subnet.

- **NetShared struct (lines 281-302):** Shared tables:
  - `next_conn_id: AtomicU64` — monotonic minting
  - `conns: Mutex<HashMap<u64, ConnEntry>>` — **the connection table D7 force-drops from**
  - `streams: Mutex<HashMap<u64, Arc<StreamEntry>>>` — stream table
  - `presence: Mutex<PresenceLog>` — liveness events

- **register_conn function (lines 307-363):** Registers a new connection:
  - Line 310: mints broker conn_id
  - Line 312-314: extracts peer pubkey (hex)
  - Line 316-323: inserts ConnEntry
  - Line 327-331: appends presence event
  - Line 338-349: spawns closed-watcher that removes the row on conn end
  - Line 354-360: spawns inbound-stream acceptor

**D7 force-drop mechanism:** To drop a connection, call `.remove(&id)` on `shared.conns` (line 343 pattern). The connection value (ConnEntry) is dropped, closing the QUIC conn.

- **NetHost::start method (lines 449-550+):** Binds the endpoint, starts the runtime, starts the accept loop. Lines 490-536 show the accept loop spawning:
  - Line 505: checks ALPN (pairing vs trusted SPT_NET_ALPN)
  - Line 506: routes pairing to responder
  - Lines 513-532: routes trusted to prove_membership (Mesh-D2) then register_conn
  - Line 521-522: snapshots roster_rx and self_addr per connect

- **NetHost::conn_proven_subnets method (lines 681-689):** Reads the cached proven subnets from a ConnEntry. Returns empty set if conn is gone. **D7 changes return type to per-subnet grade.**

- **NetHost::node_id_hex method (line 592-594):** Returns this node's pubkey hex.

- **NetHost::addr method (line 597-599):** Returns the endpoint's dialable address.

**No explicit drop-connection method today**, but Dropping logic is in the closed-watcher (line 343: `sh_close.conns.lock().unwrap().remove(&id)`). **D7 adds a public method** `pub fn drop_conn(&self, conn_id: u64) -> bool` that removes from the table synchronously (or spawns an async task to drop it).

---

### 7. **THE DAEMON PUMP CADENCE — REGISTRY TICK / EVICTION**

**File:** `C:\Users\decid\Documents\projects\spt-daemon\src\peerloop.rs`

- **TICK constant (line 74):** `Duration::from_millis(200)` — the loop's sleep granularity.

- **run_peer_pump function (lines 245-300+):** The main loop. Called from daemon startup. Runs until `stop` is set. Each iteration:
  - Line 274: snapshot current `Instant`
  - Lines 281-284: write heartbeat if due
  - Lines 291-292: check registry advertise-now marker (event-driven)
  - Lines 293, 300-301: check cadences for notif/sync/update
  - Lines 302-305: sleep or dispatch a round

- **PumpPaths struct (lines 108-149):** Paths for pump-side stores:
  - `subnets: PathBuf` → subnet.json
  - `ctx_root` → context store
  - `notif_db` → notifications
  - `releases` → staged releases
  - `heartbeat` → last-tick marker (M8 decision 23)
  - **D7 adds** `rotations: PathBuf` → rotation-pending.json

- **PumpCadence struct (lines 73+, implied from config):** Holds cadence periods (registry, notif, sync, update). Loaded from DaemonConfig.

**Registry pump / eviction (lines 340+):** After the round computes adverts:
  - Line 340-+: eviction sweep logic (M8 decision 13, commented at line 341)
  - **D7 fires rotation scheduling here**: after eviction, read rotation-pending.json, fire due rotations

**The D9 cadence pattern:** The pump is the sole loop. Each tick can dispatch any phase. Rotating seed happens **within the same tick as the eviction sweep**, within the already-running daemon, no restart needed.

---

### 8. **PAIRING CEREMONY FRAME::SEED CODEC**

**File:** `C:\Users\decid\Documents\projects\spt-net\src\net\pairing\wire.rs`

- **Frame enum (lines 523-564):** The wire format for pairing handshake. Key variant:
  - `Seed { seed: [u8; TOTP_SEED_LEN], epoch: u64, roster: Vec<RosterEntry>, tombstones: Vec<Tombstone> }` (lines 548-559)
    - `seed` and `epoch` are the TOTP material for the subnet (20 bytes + u64)
    - `roster` and `tombstones` are trailing/additive (REQ-MESH-2) — new field, legacy frames omit them
    - Entries carry **empty** `subnet` field on the wire; joiner stamps the real name on adopt

- **Frame::Seed kind identifier (line 575):** Returns `"Seed"` string.

**Encoder/decoder (not shown in excerpt, but referenced):** Length-delimited TLV-style encoding. The Seed is one variant in the frame enum.

**D7 re-seed push uses a similar shape:** A new `SeedTransfer` record (or reuse Frame::Seed?) with `seed` + `epoch` fields, sent over a restricted control stream after both sides prove (or after N-1 grace proves).

---

### 9. **MESH INTEGRATION TEST RIG**

**File:** `C:\Users\decid\Documents\projects\spt-daemon\tests\mesh.rs`

- **Module docs (lines 1-27):** Mesh-D6 staggered + star test. Tests full-mesh convergence: A and C (never directly paired, only through B) reach each other when B is offline. Uses real `run_peer_pump` with explicit roster as push target.

- **init_home / HOME (lines 55-69):** Canonical SPT_HOME (process-global). The two tests serialize on LOCK to share it.

- **hermetic function (lines 77-85):** NetConfig with membership=None (seed-proof disabled, so roster exchange doesn't run). Used for hermetic testing.

- **net_broker function (lines 87-96):** Spawns a NetHost and Broker on a named socket.

- **connect_retry function (lines 98-100+):** Brain cold-start with retries.

- **SUBNET constant (line 53):** `"mesh0"` — the test subnet name.

**Key test structure:** Per-node temp dirs for registries/owleries, seeded roster, real pump loop, real dispatch loop, permissive gates. No real address discovery (omniscient addresses injected).

**D7 integration test:** Multi-daemon (mesh.rs sibling), staggered start:
1. Create subnet on A, B joins
2. B goes offline
3. A revokes C (or a third identity)
4. B comes back online, proves N-1, receives reseed_only
5. B pulls new seed, reconnects full
6. Assert B is a full member; C (if tombstoned) is never re-admitted without fresh re-pair

---

### 10. **KNOWN-HAZARDS REFERENCES (KH 4.10 / 7.5)**

These are regression tests that must stay green:

- **KH 4.10** (KNOWN-HAZARDS line 4.10): Stale registry rows linger until eviction. Prune (D6) clears them explicitly.
- **KH 7.5** (line 7.5): Conflicting entries persisted safely during a reconcile turn (ADR-0013). Conflict artifacts must not brick the daemon.

Both are already tested in the existing suite. D7 must not break them.

---

## Summary of File Paths and Key Functions

| Surface | File | Key Items |
|---------|------|-----------|
| **CLI command group** | `crates/spt/src/cli.rs` | `SubnetCmd` enum (535), `cmd_subnet_prune` (2959), `cmd_subnet_leave` (2883), `trust_mutation_refusal` (2866), `prune_candidates` (2936) |
| **SubnetStore** | `crates/spt-store/src/subnet.rs` | `SubnetRecord` (68), `SubnetStore` (102), `rotate_seed` (201), `add_joined` (174), `random_seed` (240), `encode_hex`/`decode_hex` (247, 253) |
| **RosterStore** | `crates/spt-store/src/roster.rs` | `Tombstone` (111), `RosterStore` (124), `is_member` (185), `is_member_any` (194), `members_in` (202), `tombstone` (276), `clear_tombstone` (290), `merge` (301), `roster_for` (314) |
| **Seedproofx** | `crates/spt-daemon/src/seedproofx.rs` | `SubnetCred` (56), `production_membership` (78), `prove_membership` (121), `prove_as_dialer` (171), `prove_as_acceptor` (226), `build_transcripts` (287), `RosterExchange` type (348) |
| **Seedproof codec** | `crates/spt-net/src/net/mesh/seedproof.rs` | `MembershipKey` (57), `MembershipKey::derive` (65), `SeedProofTranscript` (119), `SeedProofTranscript::tag` (143), `ProofRole` enum (97), constants `MK_DOMAIN`, `PROOF_DOMAIN`, `NONCE_LEN` |
| **Nethost** | `crates/spt-daemon/src/nethost.rs` | `NetConfig` (58), `ConnEntry` (87), `NetShared` (281), `register_conn` (307), `NetHost::start` (449), `NetHost::conn_proven_subnets` (681), `NetHost::node_id_hex` (592), `NetHost::addr` (597) |
| **Pump cadence** | `crates/spt-daemon/src/peerloop.rs` | `TICK` (74), `run_peer_pump` (245), `PumpPaths` (108), `PumpCadence` (implicit from config), registry tick loop (340+) |
| **Pairing Frame** | `crates/spt-net/src/net/pairing/wire.rs` | `Frame` enum (523), `Frame::Seed` variant (548-559), codec shape (length-delimited TLV) |
| **Mesh integration test** | `crates/spt-daemon/tests/mesh.rs` | Module docs (1-27), `init_home` (62), `hermetic` (77), `net_broker` (87), `SUBNET` (53) |

All paths are absolute Windows paths under `C:\Users\decid\Documents\projects\spt-core\`.</result>
<usage><subagent_tokens>93263</subagent_tokens><tool_uses>48</tool_uses><duration_ms>213838</duration_ms></usage>
</task-notification>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_messages>
<owl_message from="doyle-w354" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w354
Agent type: Explore
To message it: $OWL send doyle-w354 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.

<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


Map complete. Read core files before implementing. Start parallel reads of load-bearing surfaces.


1	//! Membership key + mutual channel-bound seed-proof (Mesh-D1, REQ-MESH-1).
2	//!
3	//! Authorization in the subnet mesh is a **live per-connection proof of
4	//! current-epoch seed knowledge**, not a stored pairwise pin. The two primitives:
5	//!
6	//! 1. [`MembershipKey`] — `MK = HKDF-SHA256(ikm = seed, info = domain ‖
7	//!    subnet_id ‖ seed_epoch)`. The shared per-(subnet, epoch) secret every
8	//!    member can derive and no outsider can. Exact-epoch by construction; the
9	//!    one-deep N-1 re-seed grace is Mesh-D7's concern, not this codec's.
10	//! 2. [`SeedProofTranscript`] — each side computes an HMAC-SHA-256 tag keyed by
11	//!    `MK` over a transcript binding **both handshake-proven node pubkeys, both
12	//!    nonces, subnet_id, seed_epoch, and role**, and constant-time-verifies the
13	//!    peer's tag. The two pubkeys are the channel binding (a tag is worthless on
14	//!    any other connection); the nonces give per-connection freshness; the role
15	//!    byte stops one side's tag being replayed as the other's; *mutual* means
16	//!    both sides must prove.
17	//!
18	//! This is the deliberate sibling of [`crate::net::pairing::transcript`] — same
19	//! HMAC-SHA-256 + length-prefixed-field + `subtle::ct_eq` shape, a different
20	//! domain and a different key source (the derived `MK`, not a PAKE secret).
21	//! Clean-room the structure; do not share code (the two bindings must be free to
22	//! diverge).
23	//
24	// [impl->REQ-MESH-1]
25
26	use hkdf::Hkdf;
27	use hmac::{Hmac, Mac};
28	use sha2::Sha256;
29	use spt_proto::identity::{PublicKey, PUBLIC_KEY_LEN};
30	use subtle::ConstantTimeEq;
31	use zeroize::Zeroize;
32
33	type HmacSha256 = Hmac<Sha256>;
34
35	/// Domain separation for the HKDF `info` that derives `MK` — versioned so a
36	/// future derivation change is a clean break, never a silent cross-version key
37	/// collision.
38	const MK_DOMAIN: &[u8] = b"spt-core/mesh/mk/v1";
39
40	/// Domain separation for the seed-proof confirmation MAC — distinct from
41	/// [`MK_DOMAIN`] (a tag and a key derivation must never share a transcript) and
42	/// from the pairing confirm domain.
43	const PROOF_DOMAIN: &[u8] = b"spt-core/mesh/seedproof/v1";
44
45	/// Membership-key length (HKDF-SHA-256 output).
46	pub const MK_LEN: usize = 32;
47
48	/// Seed-proof tag length (HMAC-SHA-256 output).
49	pub const PROOF_TAG_LEN: usize = 32;
50
51	/// Per-connection challenge nonce length.
52	pub const NONCE_LEN: usize = 32;
53
54	/// The subnet membership key — the shared secret for one `(subnet_id,
55	/// seed_epoch)` generation. Wiped on drop: a leaked `MK` is a leaked
56	/// subnet-wide credential, so it must not linger in freed memory.
57	pub struct MembershipKey([u8; MK_LEN]);
58
59	impl MembershipKey {
60	    /// Derive `MK = HKDF-SHA-256(ikm = seed, info = domain ‖ ⟨subnet_id⟩ ‖
61	    /// seed_epoch_be)`. `salt = None`: the seed is already high-entropy keying
62	    /// material and all separation rides the domain-tagged `info`. The
63	    /// length-prefixed `subnet_id` makes `("a", epoch 1)` and `("a1", epoch 0)`
64	    /// derive distinct keys (no field-boundary ambiguity).
65	    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey {
66	        let mut info = Vec::with_capacity(MK_DOMAIN.len() + 4 + subnet_id.len() + 8);
67	        info.extend_from_slice(MK_DOMAIN);
68	        info.extend_from_slice(&(subnet_id.len() as u32).to_be_bytes());
69	        info.extend_from_slice(subnet_id.as_bytes());
70	        info.extend_from_slice(&seed_epoch.to_be_bytes());
71
72	        let hk = Hkdf::<Sha256>::new(None, seed);
73	        let mut okm = [0u8; MK_LEN];
74	        hk.expand(&info, &mut okm)
75	            .expect("MK_LEN is a valid HKDF-SHA-256 output length");
76	        MembershipKey(okm)
77	    }
78
79	    /// The raw key bytes — for the HMAC key only. Not `Clone`/`Debug` on the
80	    /// outer type: keep the surface that can copy this secret minimal.
81	    fn key_bytes(&self) -> &[u8; MK_LEN] {
82	        &self.0
83	    }
84	}
85
86	impl Drop for MembershipKey {
87	    fn drop(&mut self) {
88	        self.0.zeroize();
89	    }
90	}
91
92	/// Which side of the connection a party plays. The **dialer** opened the
93	/// connection; the **acceptor** received it. Bound into the tag so the dialer's
94	/// proof can never be replayed as the acceptor's (reflection defense) — exactly
95	/// the role pinning [`crate::net::pairing::transcript::Role`] uses.
96	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97	pub enum ProofRole {
98	    /// The node that opened the connection.
99	    Dialer,
100	    /// The node that accepted the connection.
101	    Acceptor,
102	}
103
104	impl ProofRole {
105	    fn label(self) -> &'static [u8] {
106	        match self {
107	            ProofRole::Dialer => b"dialer",
108	            ProofRole::Acceptor => b"acceptor",
109	        }
110	    }
111	}
112
113	/// The context bound by the seed-proof MAC. Both sides MUST build an identical
114	/// transcript — same field values, same pubkey/nonce order (dialer first) — or
115	/// the tags diverge, and that divergence *is* the rejection. The pubkeys are the
116	/// connection's two handshake-proven identities (the channel binding); a tag is
117	/// therefore valid on exactly one connection.
118	#[derive(Debug, Clone, PartialEq, Eq)]
119	pub struct SeedProofTranscript {
120	    /// The subnet whose membership is being proven (binds the proof to one
121	    /// subnet — a member of X cannot prove into Y).
122	    pub subnet_id: String,
123	    /// The seed generation the proof is for. Exact-match: a tag for epoch N does
124	    /// not verify against epoch N±1 (the N-1 re-seed grace is a separate
125	    /// admission path at D7, not a relaxation of this codec).
126	    pub seed_epoch: u64,
127	    /// The dialer's handshake-proven Ed25519 public key (channel binding).
128	    pub dialer_pub: PublicKey,
129	    /// The acceptor's handshake-proven Ed25519 public key (channel binding).
130	    pub acceptor_pub: PublicKey,
131	    /// The dialer's per-connection challenge nonce (freshness).
132	    pub dialer_nonce: [u8; NONCE_LEN],
133	    /// The acceptor's per-connection challenge nonce (freshness).
134	    pub acceptor_nonce: [u8; NONCE_LEN],
135	}
136
137	impl SeedProofTranscript {
138	    /// The tag a party of role `prover` sends, keyed by `mk`, over the canonical
139	    /// transcript. Fields are bound in a fixed order regardless of prover, so
140	    /// both sides hash the same transcript and only the role byte differs
141	    /// between the two tags.
142	    // [impl->REQ-MESH-1]
143	    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN] {
144	        let mut mac = HmacSha256::new_from_slice(mk.key_bytes())
145	            .expect("HMAC accepts any key length");
146	        mac.update(PROOF_DOMAIN);
147	        mac.update(prover.label());
148	        // subnet_id is variable-length → length-prefix it. The rest are
149	        // fixed-width (epoch 8, pubkeys 32, nonces 32) and unambiguous raw.
150	        mac.update(&(self.subnet_id.len() as u32).to_be_bytes());
151	        mac.update(self.subnet_id.as_bytes());
152	        mac.update(&self.seed_epoch.to_be_bytes());
153	        mac.update(&self.dialer_pub.to_bytes());
154	        mac.update(&self.acceptor_pub.to_bytes());
155	        mac.update(&self.dialer_nonce);
156	        mac.update(&self.acceptor_nonce);
157	        mac.finalize().into_bytes().into()
158	    }
159
160	    /// Constant-time verify a peer's `claimed` tag for role `prover`. `true` only
161	    /// on an exact match — no early-exit timing oracle (`subtle::ct_eq`).
162	    // [impl->REQ-MESH-1]
163	    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool {
164	        let want = self.tag(mk, prover);
165	        want.ct_eq(claimed).into()
166	    }
167	}
168
169	/// The wire frames of the mutual exchange.
170	///
171	/// Mesh-D2 drives the exchange with [`Hello`](SeedProofFrame::Hello) (a party's
172	/// nonce **plus** the subnet names it offers — the acceptor replies with the
173	/// intersection only) followed by one [`Proof`](SeedProofFrame::Proof) per shared
174	/// subnet. [`Challenge`](SeedProofFrame::Challenge) is the D1 nonce-only opener,
175	/// retained for the codec's stability tests; `Hello` subsumes it on the wire.
176	///
177	/// Encoding: a 1-byte tag followed by the payload. `Challenge` (`0`) and `Proof`
178	/// (`1`) are fixed-width; `Hello` (`2`) is `nonce ‖ u32 count ‖ {u32 len ‖ utf8}*`
179	/// — every variable field length-prefixed (the `pairing::wire` discipline).
180	/// Decoding is total: any malformed buffer (unknown tag, wrong length, bad
181	/// count, non-UTF-8 name, trailing/short bytes) decodes to `None`.
182	#[derive(Debug, Clone, PartialEq, Eq)]
183	pub enum SeedProofFrame {
184	    /// A party's per-connection nonce, sent first. (D1 opener; `Hello` subsumes it.)
185	    Challenge { nonce: [u8; NONCE_LEN] },
186	    /// A party's seed-proof tag over the agreed transcript.
187	    Proof { tag: [u8; PROOF_TAG_LEN] },
188	    /// A party's per-connection nonce together with the subnet names it offers.
189	    /// The dialer sends its full local list; the acceptor replies with only the
190	    /// intersection (minimizing name disclosure). The proven set is established
191	    /// by the per-subnet [`Proof`](SeedProofFrame::Proof)s that follow.
192	    Hello {
193	        /// This party's fresh per-connection nonce.
194	        nonce: [u8; NONCE_LEN],
195	        /// Offered subnet names (dialer: local set; acceptor: intersection).
196	        subnets: Vec<String>,
197	    },
198	}
199
200	const FRAME_CHALLENGE: u8 = 0;
201	const FRAME_PROOF: u8 = 1;
202	const FRAME_HELLO: u8 = 2;
203
204	impl SeedProofFrame {
205	    /// Serialize to the wire form.
206	    pub fn encode(&self) -> Vec<u8> {
207	        match self {
208	            SeedProofFrame::Challenge { nonce } => {
209	                let mut out = Vec::with_capacity(1 + NONCE_LEN);
210	                out.push(FRAME_CHALLENGE);
211	                out.extend_from_slice(nonce);
212	                out
213	            }
214	            SeedProofFrame::Proof { tag } => {
215	                let mut out = Vec::with_capacity(1 + PROOF_TAG_LEN);
216	                out.push(FRAME_PROOF);
217	                out.extend_from_slice(tag);
218	                out
219	            }
220	            SeedProofFrame::Hello { nonce, subnets } => {
221	                let mut out = Vec::with_capacity(1 + NONCE_LEN + 4 + subnets.len() * 16);
222	                out.push(FRAME_HELLO);
223	                out.extend_from_slice(nonce);
224	                out.extend_from_slice(&(subnets.len() as u32).to_be_bytes());
225	                for name in subnets {
226	                    out.extend_from_slice(&(name.len() as u32).to_be_bytes());
227	                    out.extend_from_slice(name.as_bytes());
228	                }
229	                out
230	            }
231	        }
232	    }
233
234	    /// Parse from the wire form. `None` on an unknown tag byte, a wrong-length
235	    /// payload, a name-count/length that overruns the buffer, or a non-UTF-8
236	    /// name — never panics, never partial-reads.
237	    pub fn decode(bytes: &[u8]) -> Option<SeedProofFrame> {
238	        let (&tag, rest) = bytes.split_first()?;
239	        match tag {
240	            FRAME_CHALLENGE if rest.len() == NONCE_LEN => {
241	                let mut nonce = [0u8; NONCE_LEN];
242	                nonce.copy_from_slice(rest);
243	                Some(SeedProofFrame::Challenge { nonce })
244	            }
245	            FRAME_PROOF if rest.len() == PROOF_TAG_LEN => {
246	                let mut t = [0u8; PROOF_TAG_LEN];
247	                t.copy_from_slice(rest);
248	                Some(SeedProofFrame::Proof { tag: t })
249	            }
250	            FRAME_HELLO => Self::decode_hello(rest),
251	            _ => None,
252	        }
253	    }
254
255	    /// Decode the `Hello` payload (everything after the tag byte). Total: any
256	    /// truncation or non-UTF-8 name yields `None`.
257	    fn decode_hello(rest: &[u8]) -> Option<SeedProofFrame> {
258	        if rest.len() < NONCE_LEN + 4 {
259	            return None;
260	        }
261	        let mut nonce = [0u8; NONCE_LEN];
262	        nonce.copy_from_slice(&rest[..NONCE_LEN]);
263	        let mut pos = NONCE_LEN;
264	        let count = u32::from_be_bytes(rest[pos..pos + 4].try_into().ok()?) as usize;
265	        pos += 4;
266	        let mut subnets = Vec::with_capacity(count.min(64));
267	        for _ in 0..count {
268	            if pos + 4 > rest.len() {
269	                return None;
270	            }
271	            let len = u32::from_be_bytes(rest[pos..pos + 4].try_into().ok()?) as usize;
272	            pos += 4;
273	            if pos + len > rest.len() {
274	                return None;
275	            }
276	            let name = std::str::from_utf8(&rest[pos..pos + len]).ok()?;
277	            subnets.push(name.to_string());
278	            pos += len;
279	        }
280	        // No trailing bytes beyond the declared names.
281	        if pos != rest.len() {
282	            return None;
283	        }
284	        Some(SeedProofFrame::Hello { nonce, subnets })
285	    }
286	}
287
288	// A pubkey-length cross-check so a future PublicKey size change trips the test
289	// suite rather than silently reshaping the transcript.
290	const _: () = assert!(PUBLIC_KEY_LEN == 32);
291
292	#[cfg(test)]
293	mod tests {
294	    use super::*;
295	    use spt_proto::identity::Identity;
296
297	    const SEED: &[u8] = b"a-32-byte-ish-subnet-seed-value!";
298
299	    fn pubs() -> (PublicKey, PublicKey) {
300	        (
301	            Identity::from_seed(&[1u8; 32]).public_key(),
302	            Identity::from_seed(&[2u8; 32]).public_key(),
303	        )
304	    }
305
306	    fn transcript() -> SeedProofTranscript {
307	        let (d, a) = pubs();
308	        SeedProofTranscript {
309	            subnet_id: "home".into(),
310	            seed_epoch: 1,
311	            dialer_pub: d,
312	            acceptor_pub: a,
313	            dialer_nonce: [7u8; NONCE_LEN],
314	            acceptor_nonce: [9u8; NONCE_LEN],
315	        }
316	    }
317
318	    // [unit->REQ-MESH-1] MK is deterministic for equal inputs and diverges for
319	    // a different seed, subnet, OR epoch — and the length-prefix closes the
320	    // ("a",1) vs ("a1",0) field-boundary collision.
321	    #[test]
322	    fn mk_derivation_is_deterministic_and_input_sensitive() {
323	        let base = MembershipKey::derive(SEED, "home", 1);
324	        assert_eq!(
325	            base.0,
326	            MembershipKey::derive(SEED, "home", 1).0,
327	            "deterministic"
328	        );
329	        assert_ne!(
330	            base.0,
331	            MembershipKey::derive(b"other-seed-other-seed-other-seed", "home", 1).0,
332	            "seed-sensitive"
333	        );
334	        assert_ne!(
335	            base.0,
336	            MembershipKey::derive(SEED, "work", 1).0,
337	            "subnet-sensitive"
338	        );
339	        assert_ne!(
340	            base.0,
341	            MembershipKey::derive(SEED, "home", 2).0,
342	            "epoch-sensitive"
343	        );
344	        // Field-boundary: ("a", epoch 1) must not equal ("a1", epoch 0)…0x01.
345	        // (to_be_bytes of 1 ends in 0x01; without the length prefix the two
346	        // info strings would be identical bytewise.)
347	        assert_ne!(
348	            MembershipKey::derive(SEED, "a", 1).0,
349	            MembershipKey::derive(SEED, "a1", 0).0,
350	            "length-prefix prevents subnet/epoch boundary collision"
351	        );
352	    }
353
354	    // [unit->REQ-MESH-1] a matching MK + transcript verifies, and the two role
355	    // tags differ so the dialer's proof can't be replayed as the acceptor's.
356	    #[test]
357	    fn valid_proof_verifies_and_roles_differ() {
358	        let mk = MembershipKey::derive(SEED, "home", 1);
359	        let t = transcript();
360	        let d = t.tag(&mk, ProofRole::Dialer);
361	        let a = t.tag(&mk, ProofRole::Acceptor);
362	        assert_ne!(d, a, "role byte distinguishes the two tags");
363	        assert!(t.verify(&mk, ProofRole::Dialer, &d));
364	        assert!(t.verify(&mk, ProofRole::Acceptor, &a));
365	        // No reflection: an acceptor tag must not verify as a dialer tag.
366	        assert!(!t.verify(&mk, ProofRole::Dialer, &a));
367	    }
368
369	    // [unit->REQ-MESH-1] a forged proof — a non-member without the seed, modeled
370	    // as a wrong MK — fails verification.
371	    #[test]
372	    fn forged_proof_fails() {
373	        let real = MembershipKey::derive(SEED, "home", 1);
374	        let t = transcript();
375	        let tag = t.tag(&real, ProofRole::Dialer);
376	        let outsider = MembershipKey::derive(b"the-outsider-has-the-wrong-seed!", "home", 1);
377	        assert!(!t.verify(&outsider, ProofRole::Dialer, &tag));
378	    }
379
380	    // [unit->REQ-MESH-1] wrong-subnet: a tag made under subnet "home"'s MK does
381	    // not verify under subnet "work"'s MK (same seed, different MK).
382	    #[test]
383	    fn wrong_subnet_fails() {
384	        let home = MembershipKey::derive(SEED, "home", 1);
385	        let work = MembershipKey::derive(SEED, "work", 1);
386	        let t = transcript();
387	        let tag = t.tag(&home, ProofRole::Dialer);
388	        assert!(!t.verify(&work, ProofRole::Dialer, &tag));
389	    }
390
391	    // [unit->REQ-MESH-1] wrong-epoch: exact-epoch match — a tag for epoch 1 does
392	    // not verify under epoch 2's MK (the N-1 grace is D7, not this codec).
393	    #[test]
394	    fn wrong_epoch_fails() {
395	        let e1 = MembershipKey::derive(SEED, "home", 1);
396	        let e2 = MembershipKey::derive(SEED, "home", 2);
397	        let t = transcript();
398	        let tag = t.tag(&e1, ProofRole::Dialer);
399	        assert!(!t.verify(&e2, ProofRole::Dialer, &tag));
400	    }
401
402	    // [unit->REQ-MESH-1] the channel-binding center: a tag valid on connection A
403	    // (its pubkeys + nonces) fails on connection B that differs in ANY of the
404	    // four channel fields — no cross-connection replay.
405	    #[test]
406	    fn cross_connection_replay_fails() {
407	        let mk = MembershipKey::derive(SEED, "home", 1);
408	        let base = transcript();
409	        let tag = base.tag(&mk, ProofRole::Dialer);
410
411	        let other_pub = Identity::from_seed(&[42u8; 32]).public_key();
412	        let variants = [
413	            SeedProofTranscript {
414	                dialer_pub: other_pub,
415	                ..base.clone()
416	            },
417	            SeedProofTranscript {
418	                acceptor_pub: other_pub,
419	                ..base.clone()
420	            },
421	            SeedProofTranscript {
422	                dialer_nonce: [0xABu8; NONCE_LEN],
423	                ..base.clone()
424	            },
425	            SeedProofTranscript {
426	                acceptor_nonce: [0xCDu8; NONCE_LEN],
427	                ..base.clone()
428	            },
429	        ];
430	        for (i, v) in variants.iter().enumerate() {
431	            assert!(
432	                !v.verify(&mk, ProofRole::Dialer, &tag),
433	                "channel field {i} must bind the tag"
434	            );
435	        }
436	    }
437
438	    // [unit->REQ-MESH-1] mutual: the pair is admitted only when BOTH sides
439	    // verify; if either side proves under the wrong MK, the mutual check fails.
440	    #[test]
441	    fn mutual_requires_both_sides() {
442	        let t = transcript();
443	        let good = MembershipKey::derive(SEED, "home", 1);
444	        let bad = MembershipKey::derive(b"impostor-impostor-impostor-impo!", "home", 1);
445
446	        // Each side sends its own-role tag; the peer verifies it.
447	        let dialer_tag = t.tag(&good, ProofRole::Dialer);
448	        let acceptor_tag_good = t.tag(&good, ProofRole::Acceptor);
449	        let acceptor_tag_bad = t.tag(&bad, ProofRole::Acceptor);
450
451	        let mutual = |dt: &[u8], at: &[u8]| {
452	            t.verify(&good, ProofRole::Dialer, dt) && t.verify(&good, ProofRole::Acceptor, at)
453	        };
454	        assert!(mutual(&dialer_tag, &acceptor_tag_good), "both prove → admit");
455	        assert!(
456	            !mutual(&dialer_tag, &acceptor_tag_bad),
457	            "one impostor → reject"
458	        );
459	    }
460
461	    // [unit->REQ-MESH-1] frame codec round-trips both variants, and a malformed
462	    // buffer (unknown tag / wrong length / empty) decodes to None.
463	    #[test]
464	    fn frame_codec_round_trips_and_rejects_garbage() {
465	        let ch = SeedProofFrame::Challenge {
466	            nonce: [3u8; NONCE_LEN],
467	        };
468	        let pf = SeedProofFrame::Proof {
469	            tag: [5u8; PROOF_TAG_LEN],
470	        };
471	        assert_eq!(SeedProofFrame::decode(&ch.encode()), Some(ch));
472	        assert_eq!(SeedProofFrame::decode(&pf.encode()), Some(pf));
473
474	        assert_eq!(SeedProofFrame::decode(&[]), None, "empty");
475	        assert_eq!(SeedProofFrame::decode(&[9u8; 33]), None, "unknown tag");
476	        assert_eq!(
477	            SeedProofFrame::decode(&[FRAME_CHALLENGE, 1, 2, 3]),
478	            None,
479	            "short challenge"
480	        );
481	        assert_eq!(
482	            SeedProofFrame::decode(&[FRAME_PROOF; 8]),
483	            None,
484	            "short proof"
485	        );
486	    }
487
488	    // [unit->REQ-MESH-1] the Mesh-D2 Hello frame (nonce + offered subnet names)
489	    // round-trips for the empty list, a multi-name list, and a unicode name; and
490	    // every malformed shape (truncated count, short name, name overrun, non-UTF-8,
491	    // trailing bytes) decodes to None — the connect-time negotiation can't be
492	    // tricked into a partial read.
493	    #[test]
494	    fn hello_frame_round_trips_and_rejects_malformed() {
495	        let nonce = [4u8; NONCE_LEN];
496	        for subnets in [
497	            vec![],
498	            vec!["home".to_string()],
499	            vec!["home".to_string(), "work".to_string(), "".to_string()],
500	            vec!["réseau-maison".to_string()],
501	        ] {
502	            let f = SeedProofFrame::Hello {
503	                nonce,
504	                subnets: subnets.clone(),
505	            };
506	            assert_eq!(
507	                SeedProofFrame::decode(&f.encode()),
508	                Some(f),
509	                "round-trip {subnets:?}"
510	            );
511	        }
512
513	        // Truncated: tag + short-of-(nonce+count).
514	        assert_eq!(
515	            SeedProofFrame::decode(&[FRAME_HELLO, 0, 0, 0]),
516	            None,
517	            "short hello header"
518	        );
519	        // Count claims 1 name but no length follows.
520	        let mut overrun = vec![FRAME_HELLO];
521	        overrun.extend_from_slice(&nonce);
522	        overrun.extend_from_slice(&1u32.to_be_bytes());
523	        assert_eq!(SeedProofFrame::decode(&overrun), None, "missing name len");
524	        // Name length overruns the buffer.
525	        let mut nameover = overrun.clone();
526	        nameover.extend_from_slice(&99u32.to_be_bytes());
527	        nameover.extend_from_slice(b"hi");
528	        assert_eq!(SeedProofFrame::decode(&nameover), None, "name overrun");
529	        // Non-UTF-8 name body.
530	        let mut badutf = vec![FRAME_HELLO];
531	        badutf.extend_from_slice(&nonce);
532	        badutf.extend_from_slice(&1u32.to_be_bytes());
533	        badutf.extend_from_slice(&2u32.to_be_bytes());
534	        badutf.extend_from_slice(&[0xff, 0xfe]);
535	        assert_eq!(SeedProofFrame::decode(&badutf), None, "non-utf8 name");
536	        // Trailing bytes past the declared (empty) name list.
537	        let mut trailing = vec![FRAME_HELLO];
538	        trailing.extend_from_slice(&nonce);
539	        trailing.extend_from_slice(&0u32.to_be_bytes());
540	        trailing.push(0x00);
541	        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
542	    }
543	}
544

//! Membership key + mutual channel-bound seed-proof (Mesh-D1, REQ-MESH-1).
//!
//! Authorization in the subnet mesh is a **live per-connection proof of
//! current-epoch seed knowledge**, not a stored pairwise pin. The two primitives:
//!
//! 1. [`MembershipKey`] — `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. Exact-epoch by construction; the
//!    one-deep N-1 re-seed grace is Mesh-D7's concern, not this codec's.
//! 2. [`SeedProofTranscript`] — each side computes an HMAC-SHA-256 tag keyed by
//!    `MK` over a transcript binding **both handshake-proven node pubkeys, both
//!    nonces, subnet_id, seed_epoch, and role**, and constant-time-verifies the
//!    peer's tag. The two pubkeys are the channel binding (a tag is worthless on
//!    any other connection); the nonces give per-connection freshness; the role
//!    byte stops one side's tag being replayed as the other's; *mutual* means
//!    both sides must prove.
//!
//! This is the deliberate sibling of [`crate::net::pairing::transcript`] — same
//! HMAC-SHA-256 + length-prefixed-field + `subtle::ct_eq` shape, a different
//! domain and a different key source (the derived `MK`, not a PAKE secret).
//! Clean-room the structure; do not share code (the two bindings must be free to
//! diverge).
//
// [impl->REQ-MESH-1]

use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use spt_proto::identity::{PublicKey, PUBLIC_KEY_LEN};
use subtle::ConstantTimeEq;
use zeroize::Zeroize;

type HmacSha256 = Hmac<Sha256>;

/// Domain separation for the HKDF `info` that derives `MK` — versioned so a
/// future derivation change is a clean break, never a silent cross-version key
/// collision.
const MK_DOMAIN: &[u8] = b"spt-core/mesh/mk/v1";

/// Domain separation for the seed-proof confirmation MAC — distinct from
/// [`MK_DOMAIN`] (a tag and a key derivation must never share a transcript) and
/// from the pairing confirm domain.
const PROOF_DOMAIN: &[u8] = b"spt-core/mesh/seedproof/v1";

/// Membership-key length (HKDF-SHA-256 output).
pub const MK_LEN: usize = 32;

/// Seed-proof tag length (HMAC-SHA-256 output).
pub const PROOF_TAG_LEN: usize = 32;

/// Per-connection challenge nonce length.
pub const NONCE_LEN: usize = 32;

/// The subnet membership key — the shared secret for one `(subnet_id,
/// seed_epoch)` generation. Wiped on drop: a leaked `MK` is a leaked
/// subnet-wide credential, so it must not linger in freed memory.
pub struct MembershipKey([u8; MK_LEN]);

impl MembershipKey {
    /// Derive `MK = HKDF-SHA-256(ikm = seed, info = domain ‖ ⟨subnet_id⟩ ‖
    /// seed_epoch_be)`. `salt = None`: the seed is already high-entropy keying
    /// material and all separation rides the domain-tagged `info`. The
    /// length-prefixed `subnet_id` makes `("a", epoch 1)` and `("a1", epoch 0)`
    /// derive distinct keys (no field-boundary ambiguity).
    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey {
        let mut info = Vec::with_capacity(MK_DOMAIN.len() + 4 + subnet_id.len() + 8);
        info.extend_from_slice(MK_DOMAIN);
        info.extend_from_slice(&(subnet_id.len() as u32).to_be_bytes());
        info.extend_from_slice(subnet_id.as_bytes());
        info.extend_from_slice(&seed_epoch.to_be_bytes());

        let hk = Hkdf::<Sha256>::new(None, seed);
        let mut okm = [0u8; MK_LEN];
        hk.expand(&info, &mut okm)
            .expect("MK_LEN is a valid HKDF-SHA-256 output length");
        MembershipKey(okm)
    }

    /// The raw key bytes — for the HMAC key only. Not `Clone`/`Debug` on the
    /// outer type: keep the surface that can copy this secret minimal.
    fn key_bytes(&self) -> &[u8; MK_LEN] {
        &self.0
    }
}

impl Drop for MembershipKey {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

/// Which side of the connection a party plays. The **dialer** opened the
/// connection; the **acceptor** received it. Bound into the tag so the dialer's
/// proof can never be replayed as the acceptor's (reflection defense) — exactly
/// the role pinning [`crate::net::pairing::transcript::Role`] uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProofRole {
    /// The node that opened the connection.
    Dialer,
    /// The node that accepted the connection.
    Acceptor,
}

impl ProofRole {
    fn label(self) -> &'static [u8] {
        match self {
            ProofRole::Dialer => b"dialer",
            ProofRole::Acceptor => b"acceptor",
        }
    }
}

/// The context bound by the seed-proof MAC. Both sides MUST build an identical
/// transcript — same field values, same pubkey/nonce order (dialer first) — or
/// the tags diverge, and that divergence *is* the rejection. The pubkeys are the
/// connection's two handshake-proven identities (the channel binding); a tag is
/// therefore valid on exactly one connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeedProofTranscript {
    /// The subnet whose membership is being proven (binds the proof to one
    /// subnet — a member of X cannot prove into Y).
    pub subnet_id: String,
    /// The seed generation the proof is for. Exact-match: a tag for epoch N does
    /// not verify against epoch N±1 (the N-1 re-seed grace is a separate
    /// admission path at D7, not a relaxation of this codec).
    pub seed_epoch: u64,
    /// The dialer's handshake-proven Ed25519 public key (channel binding).
    pub dialer_pub: PublicKey,
    /// The acceptor's handshake-proven Ed25519 public key (channel binding).
    pub acceptor_pub: PublicKey,
    /// The dialer's per-connection challenge nonce (freshness).
    pub dialer_nonce: [u8; NONCE_LEN],
    /// The acceptor's per-connection challenge nonce (freshness).
    pub acceptor_nonce: [u8; NONCE_LEN],
}

impl SeedProofTranscript {
    /// The tag a party of role `prover` sends, keyed by `mk`, over the canonical
    /// transcript. Fields are bound in a fixed order regardless of prover, so
    /// both sides hash the same transcript and only the role byte differs
    /// between the two tags.
    // [impl->REQ-MESH-1]
    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN] {
        let mut mac = HmacSha256::new_from_slice(mk.key_bytes())
            .expect("HMAC accepts any key length");
        mac.update(PROOF_DOMAIN);
        mac.update(prover.label());
        // subnet_id is variable-length → length-prefix it. The rest are
        // fixed-width (epoch 8, pubkeys 32, nonces 32) and unambiguous raw.
        mac.update(&(self.subnet_id.len() as u32).to_be_bytes());
        mac.update(self.subnet_id.as_bytes());
        mac.update(&self.seed_epoch.to_be_bytes());
        mac.update(&self.dialer_pub.to_bytes());
        mac.update(&self.acceptor_pub.to_bytes());
        mac.update(&self.dialer_nonce);
        mac.update(&self.acceptor_nonce);
        mac.finalize().into_bytes().into()
    }

    /// Constant-time verify a peer's `claimed` tag for role `prover`. `true` only
    /// on an exact match — no early-exit timing oracle (`subtle::ct_eq`).
    // [impl->REQ-MESH-1]
    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool {
        let want = self.tag(mk, prover);
        want.ct_eq(claimed).into()
    }
}

/// The wire frames of the mutual exchange.
///
/// Mesh-D2 drives the exchange with [`Hello`](SeedProofFrame::Hello) (a party's
/// nonce **plus** the subnet names it offers — the acceptor replies with the
/// intersection only) followed by one [`Proof`](SeedProofFrame::Proof) per shared
/// subnet. [`Challenge`](SeedProofFrame::Challenge) is the D1 nonce-only opener,
/// retained for the codec's stability tests; `Hello` subsumes it on the wire.
///
/// Encoding: a 1-byte tag followed by the payload. `Challenge` (`0`) and `Proof`
/// (`1`) are fixed-width; `Hello` (`2`) is `nonce ‖ u32 count ‖ {u32 len ‖ utf8}*`
/// — every variable field length-prefixed (the `pairing::wire` discipline).
/// Decoding is total: any malformed buffer (unknown tag, wrong length, bad
/// count, non-UTF-8 name, trailing/short bytes) decodes to `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedProofFrame {
    /// A party's per-connection nonce, sent first. (D1 opener; `Hello` subsumes it.)
    Challenge { nonce: [u8; NONCE_LEN] },
    /// A party's seed-proof tag over the agreed transcript.
    Proof { tag: [u8; PROOF_TAG_LEN] },
    /// A party's per-connection nonce together with the subnet names it offers.
    /// The dialer sends its full local list; the acceptor replies with only the
    /// intersection (minimizing name disclosure). The proven set is established
    /// by the per-subnet [`Proof`](SeedProofFrame::Proof)s that follow.
    Hello {
        /// This party's fresh per-connection nonce.
        nonce: [u8; NONCE_LEN],
        /// Offered subnet names (dialer: local set; acceptor: intersection).
        subnets: Vec<String>,
    },
}

const FRAME_CHALLENGE: u8 = 0;
const FRAME_PROOF: u8 = 1;
const FRAME_HELLO: u8 = 2;

impl SeedProofFrame {
    /// Serialize to the wire form.
    pub fn encode(&self) -> Vec<u8> {
        match self {
            SeedProofFrame::Challenge { nonce } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN);
                out.push(FRAME_CHALLENGE);
                out.extend_from_slice(nonce);
                out
            }
            SeedProofFrame::Proof { tag } => {
                let mut out = Vec::with_capacity(1 + PROOF_TAG_LEN);
                out.push(FRAME_PROOF);
                out.extend_from_slice(tag);
                out
            }
            SeedProofFrame::Hello { nonce, subnets } => {
                let mut out = Vec::with_capacity(1 + NONCE_LEN + 4 + subnets.len() * 16);
                out.push(FRAME_HELLO);
                out.extend_from_slice(nonce);
                out.extend_from_slice(&(subnets.len() as u32).to_be_bytes());
                for name in subnets {
                    out.extend_from_slice(&(name.len() as u32).to_be_bytes());
                    out.extend_from_slice(name.as_bytes());
                }
                out
            }
        }
    }

    /// Parse from the wire form. `None` on an unknown tag byte, a wrong-length
    /// payload, a name-count/length that overruns the buffer, or a non-UTF-8
    /// name — never panics, never partial-reads.
    pub fn decode(bytes: &[u8]) -> Option<SeedProofFrame> {
        let (&tag, rest) = bytes.split_first()?;
        match tag {
            FRAME_CHALLENGE if rest.len() == NONCE_LEN => {
                let mut nonce = [0u8; NONCE_LEN];
                nonce.copy_from_slice(rest);
                Some(SeedProofFrame::Challenge { nonce })
            }
            FRAME_PROOF if rest.len() == PROOF_TAG_LEN => {
                let mut t = [0u8; PROOF_TAG_LEN];
                t.copy_from_slice(rest);
                Some(SeedProofFrame::Proof { tag: t })
            }
            FRAME_HELLO => Self::decode_hello(rest),
            _ => None,
        }
    }

    /// Decode the `Hello` payload (everything after the tag byte). Total: any
    /// truncation or non-UTF-8 name yields `None`.
    fn decode_hello(rest: &[u8]) -> Option<SeedProofFrame> {
        if rest.len() < NONCE_LEN + 4 {
            return None;
        }
        let mut nonce = [0u8; NONCE_LEN];
        nonce.copy_from_slice(&rest[..NONCE_LEN]);
        let mut pos = NONCE_LEN;
        let count = u32::from_be_bytes(rest[pos..pos + 4].try_into().ok()?) as usize;
        pos += 4;
        let mut subnets = Vec::with_capacity(count.min(64));
        for _ in 0..count {
            if pos + 4 > rest.len() {
                return None;
            }
            let len = u32::from_be_bytes(rest[pos..pos + 4].try_into().ok()?) as usize;
            pos += 4;
            if pos + len > rest.len() {
                return None;
            }
            let name = std::str::from_utf8(&rest[pos..pos + len]).ok()?;
            subnets.push(name.to_string());
            pos += len;
        }
        // No trailing bytes beyond the declared names.
        if pos != rest.len() {
            return None;
        }
        Some(SeedProofFrame::Hello { nonce, subnets })
    }
}

// A pubkey-length cross-check so a future PublicKey size change trips the test
// suite rather than silently reshaping the transcript.
const _: () = assert!(PUBLIC_KEY_LEN == 32);

#[cfg(test)]
mod tests {
    use super::*;
    use spt_proto::identity::Identity;

    const SEED: &[u8] = b"a-32-byte-ish-subnet-seed-value!";

    fn pubs() -> (PublicKey, PublicKey) {
        (
            Identity::from_seed(&[1u8; 32]).public_key(),
            Identity::from_seed(&[2u8; 32]).public_key(),
        )
    }

    fn transcript() -> SeedProofTranscript {
        let (d, a) = pubs();
        SeedProofTranscript {
            subnet_id: "home".into(),
            seed_epoch: 1,
            dialer_pub: d,
            acceptor_pub: a,
            dialer_nonce: [7u8; NONCE_LEN],
            acceptor_nonce: [9u8; NONCE_LEN],
        }
    }

    // [unit->REQ-MESH-1] MK is deterministic for equal inputs and diverges for
    // a different seed, subnet, OR epoch — and the length-prefix closes the
    // ("a",1) vs ("a1",0) field-boundary collision.
    #[test]
    fn mk_derivation_is_deterministic_and_input_sensitive() {
        let base = MembershipKey::derive(SEED, "home", 1);
        assert_eq!(
            base.0,
            MembershipKey::derive(SEED, "home", 1).0,
            "deterministic"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(b"other-seed-other-seed-other-seed", "home", 1).0,
            "seed-sensitive"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(SEED, "work", 1).0,
            "subnet-sensitive"
        );
        assert_ne!(
            base.0,
            MembershipKey::derive(SEED, "home", 2).0,
            "epoch-sensitive"
        );
        // Field-boundary: ("a", epoch 1) must not equal ("a1", epoch 0)…0x01.
        // (to_be_bytes of 1 ends in 0x01; without the length prefix the two
        // info strings would be identical bytewise.)
        assert_ne!(
            MembershipKey::derive(SEED, "a", 1).0,
            MembershipKey::derive(SEED, "a1", 0).0,
            "length-prefix prevents subnet/epoch boundary collision"
        );
    }

    // [unit->REQ-MESH-1] a matching MK + transcript verifies, and the two role
    // tags differ so the dialer's proof can't be replayed as the acceptor's.
    #[test]
    fn valid_proof_verifies_and_roles_differ() {
        let mk = MembershipKey::derive(SEED, "home", 1);
        let t = transcript();
        let d = t.tag(&mk, ProofRole::Dialer);
        let a = t.tag(&mk, ProofRole::Acceptor);
        assert_ne!(d, a, "role byte distinguishes the two tags");
        assert!(t.verify(&mk, ProofRole::Dialer, &d));
        assert!(t.verify(&mk, ProofRole::Acceptor, &a));
        // No reflection: an acceptor tag must not verify as a dialer tag.
        assert!(!t.verify(&mk, ProofRole::Dialer, &a));
    }

    // [unit->REQ-MESH-1] a forged proof — a non-member without the seed, modeled
    // as a wrong MK — fails verification.
    #[test]
    fn forged_proof_fails() {
        let real = MembershipKey::derive(SEED, "home", 1);
        let t = transcript();
        let tag = t.tag(&real, ProofRole::Dialer);
        let outsider = MembershipKey::derive(b"the-outsider-has-the-wrong-seed!", "home", 1);
        assert!(!t.verify(&outsider, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] wrong-subnet: a tag made under subnet "home"'s MK does
    // not verify under subnet "work"'s MK (same seed, different MK).
    #[test]
    fn wrong_subnet_fails() {
        let home = MembershipKey::derive(SEED, "home", 1);
        let work = MembershipKey::derive(SEED, "work", 1);
        let t = transcript();
        let tag = t.tag(&home, ProofRole::Dialer);
        assert!(!t.verify(&work, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] wrong-epoch: exact-epoch match — a tag for epoch 1 does
    // not verify under epoch 2's MK (the N-1 grace is D7, not this codec).
    #[test]
    fn wrong_epoch_fails() {
        let e1 = MembershipKey::derive(SEED, "home", 1);
        let e2 = MembershipKey::derive(SEED, "home", 2);
        let t = transcript();
        let tag = t.tag(&e1, ProofRole::Dialer);
        assert!(!t.verify(&e2, ProofRole::Dialer, &tag));
    }

    // [unit->REQ-MESH-1] the channel-binding center: a tag valid on connection A
    // (its pubkeys + nonces) fails on connection B that differs in ANY of the
    // four channel fields — no cross-connection replay.
    #[test]
    fn cross_connection_replay_fails() {
        let mk = MembershipKey::derive(SEED, "home", 1);
        let base = transcript();
        let tag = base.tag(&mk, ProofRole::Dialer);

        let other_pub = Identity::from_seed(&[42u8; 32]).public_key();
        let variants = [
            SeedProofTranscript {
                dialer_pub: other_pub,
                ..base.clone()
            },
            SeedProofTranscript {
                acceptor_pub: other_pub,
                ..base.clone()
            },
            SeedProofTranscript {
                dialer_nonce: [0xABu8; NONCE_LEN],
                ..base.clone()
            },
            SeedProofTranscript {
                acceptor_nonce: [0xCDu8; NONCE_LEN],
                ..base.clone()
            },
        ];
        for (i, v) in variants.iter().enumerate() {
            assert!(
                !v.verify(&mk, ProofRole::Dialer, &tag),
                "channel field {i} must bind the tag"
            );
        }
    }

    // [unit->REQ-MESH-1] mutual: the pair is admitted only when BOTH sides
    // verify; if either side proves under the wrong MK, the mutual check fails.
    #[test]
    fn mutual_requires_both_sides() {
        let t = transcript();
        let good = MembershipKey::derive(SEED, "home", 1);
        let bad = MembershipKey::derive(b"impostor-impostor-impostor-impo!", "home", 1);

        // Each side sends its own-role tag; the peer verifies it.
        let dialer_tag = t.tag(&good, ProofRole::Dialer);
        let acceptor_tag_good = t.tag(&good, ProofRole::Acceptor);
        let acceptor_tag_bad = t.tag(&bad, ProofRole::Acceptor);

        let mutual = |dt: &[u8], at: &[u8]| {
            t.verify(&good, ProofRole::Dialer, dt) && t.verify(&good, ProofRole::Acceptor, at)
        };
        assert!(mutual(&dialer_tag, &acceptor_tag_good), "both prove → admit");
        assert!(
            !mutual(&dialer_tag, &acceptor_tag_bad),
            "one impostor → reject"
        );
    }

    // [unit->REQ-MESH-1] frame codec round-trips both variants, and a malformed
    // buffer (unknown tag / wrong length / empty) decodes to None.
    #[test]
    fn frame_codec_round_trips_and_rejects_garbage() {
        let ch = SeedProofFrame::Challenge {
            nonce: [3u8; NONCE_LEN],
        };
        let pf = SeedProofFrame::Proof {
            tag: [5u8; PROOF_TAG_LEN],
        };
        assert_eq!(SeedProofFrame::decode(&ch.encode()), Some(ch));
        assert_eq!(SeedProofFrame::decode(&pf.encode()), Some(pf));

        assert_eq!(SeedProofFrame::decode(&[]), None, "empty");
        assert_eq!(SeedProofFrame::decode(&[9u8; 33]), None, "unknown tag");
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_CHALLENGE, 1, 2, 3]),
            None,
            "short challenge"
        );
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_PROOF; 8]),
            None,
            "short proof"
        );
    }

    // [unit->REQ-MESH-1] the Mesh-D2 Hello frame (nonce + offered subnet names)
    // round-trips for the empty list, a multi-name list, and a unicode name; and
    // every malformed shape (truncated count, short name, name overrun, non-UTF-8,
    // trailing bytes) decodes to None — the connect-time negotiation can't be
    // tricked into a partial read.
    #[test]
    fn hello_frame_round_trips_and_rejects_malformed() {
        let nonce = [4u8; NONCE_LEN];
        for subnets in [
            vec![],
            vec!["home".to_string()],
            vec!["home".to_string(), "work".to_string(), "".to_string()],
            vec!["réseau-maison".to_string()],
        ] {
            let f = SeedProofFrame::Hello {
                nonce,
                subnets: subnets.clone(),
            };
            assert_eq!(
                SeedProofFrame::decode(&f.encode()),
                Some(f),
                "round-trip {subnets:?}"
            );
        }

        // Truncated: tag + short-of-(nonce+count).
        assert_eq!(
            SeedProofFrame::decode(&[FRAME_HELLO, 0, 0, 0]),
            None,
            "short hello header"
        );
        // Count claims 1 name but no length follows.
        let mut overrun = vec![FRAME_HELLO];
        overrun.extend_from_slice(&nonce);
        overrun.extend_from_slice(&1u32.to_be_bytes());
        assert_eq!(SeedProofFrame::decode(&overrun), None, "missing name len");
        // Name length overruns the buffer.
        let mut nameover = overrun.clone();
        nameover.extend_from_slice(&99u32.to_be_bytes());
        nameover.extend_from_slice(b"hi");
        assert_eq!(SeedProofFrame::decode(&nameover), None, "name overrun");
        // Non-UTF-8 name body.
        let mut badutf = vec![FRAME_HELLO];
        badutf.extend_from_slice(&nonce);
        badutf.extend_from_slice(&1u32.to_be_bytes());
        badutf.extend_from_slice(&2u32.to_be_bytes());
        badutf.extend_from_slice(&[0xff, 0xfe]);
        assert_eq!(SeedProofFrame::decode(&badutf), None, "non-utf8 name");
        // Trailing bytes past the declared (empty) name list.
        let mut trailing = vec![FRAME_HELLO];
        trailing.extend_from_slice(&nonce);
        trailing.extend_from_slice(&0u32.to_be_bytes());
        trailing.push(0x00);
        assert_eq!(SeedProofFrame::decode(&trailing), None, "trailing bytes");
    }
}


Codec layer = exact-epoch (MK from seed+epoch). 3-valued grading belongs at orchestrator (spt-daemon seedproofx, which knows local epoch). Read orchestrator + stores.