1	//! The Iroh endpoint — the node's WAN/LAN presence (D1).
2	//!
3	//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
4	//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
5	//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
6	//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
7	//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
8	//!
9	//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
10	//! n0's public relays by default for zero-config onboarding, a self-host escape
11	//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
12	//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
13	//!
14	//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
15	//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
16	//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
17	//! escape hatch the daemon uses to take ownership.
18
19	use iroh::address_lookup::MdnsAddressLookup;
20	use iroh::endpoint::{presets, Incoming, PortmapperConfig};
21	use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
22	use spt_proto::identity::{Identity, PublicKey};
23
24	// Re-exported for the daemon (D4): the broker holds live QUIC connections +
25	// streams in its tables and dialable addresses in its IPC frames without growing
26	// its own iroh dependency edge — the exact version pin (0.98.2) stays in this
27	// one crate.
28	pub use iroh::endpoint::{Connection, RecvStream, SendStream};
29	pub use iroh::EndpointAddr;
30
31	/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
32	/// future wire break is a new protocol id, not a silent incompatibility.
33	pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";
34
35	/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
36	/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
37	/// path; the dependency is operational + metadata-only (traffic is end-to-end
38	/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
39	// [impl->REQ-NET-2]
40	pub const RELAY_DISCLOSURE: &str = "\
41	By default spt connects through n0's free public relay servers to find your other \
42	machines and to assist NAT hole-punching. Relays only help peers locate and connect \
43	to each other — your traffic between machines is end-to-end encrypted and the relay \
44	cannot read it. You can run your own relay instead (self-host) or disable relays \
45	entirely for a LAN-only/air-gapped setup.";
46
47	/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
48	/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
49	// [impl->REQ-NET-2]
50	#[derive(Debug, Clone)]
51	pub enum RelayPolicy {
52	    /// n0's public production relays — the zero-config "just works" default.
53	    N0Default,
54	    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
55	    SelfHosted(RelayMap),
56	    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
57	    /// hermetic loopback tests (no network reached at all).
58	    Disabled,
59	}
60
61	/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
62	/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
63	/// alone on the local network, so two machines on the same LAN find each other with
64	/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
65	// [impl->REQ-NET-1]
66	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67	pub enum LocalDiscovery {
68	    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
69	    Mdns,
70	    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
71	    Off,
72	}
73
74	/// Which interfaces the endpoint's UDP sockets bind — a mechanical knob, not
75	/// policy. [`BindScope::Loopback`] exists for hermetic tests: a loopback-only
76	/// listener never trips the Windows Defender Firewall "allow access?" prompt
77	/// (the prompt fires per freshly-built test executable that listens on
78	/// `0.0.0.0`), and it makes "no network reached" literally true at the socket.
79	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80	pub enum BindScope {
81	    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
82	    /// The production scope; required for any off-host connectivity.
83	    All,
84	    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
85	    /// nothing can reach (or prompt about) a real interface.
86	    Loopback,
87	    /// Bind all interfaces on an **explicit UDP port** (`0.0.0.0:p` / `[::]:p`)
88	    /// instead of an ephemeral one. A node whose port is stable can be named by
89	    /// a static `ip:port` ticket (firewall allow-rules, the two-host rig's
90	    /// env-derived peer addresses) with no relay/discovery infrastructure.
91	    Port(u16),
92	}
93
94	/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
95	/// so iroh's churning error types don't leak into this crate's public surface
96	/// (matches the workspace's hand-rolled, dependency-free error convention).
97	#[derive(Debug)]
98	pub enum NetError {
99	    /// The endpoint could not bind its UDP socket / initialise the transport.
100	    Bind(String),
101	    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
102	    Connect(String),
103	}
104
105	impl std::fmt::Display for NetError {
106	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107	        match self {
108	            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
109	            NetError::Connect(e) => write!(f, "connect failed: {e}"),
110	        }
111	    }
112	}
113
114	impl std::error::Error for NetError {}
115
116	/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
117	pub struct NetEndpoint {
118	    inner: Endpoint,
119	}
120
121	impl NetEndpoint {
122	    /// Bind an endpoint to this node's identity under the given relay policy.
123	    ///
124	    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
125	    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
126	    /// the name are the same value.
127	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
128	    pub async fn bind(
129	        identity: &Identity,
130	        relay: RelayPolicy,
131	        discovery: LocalDiscovery,
132	    ) -> Result<NetEndpoint, NetError> {
133	        Self::bind_with_alpns(identity, relay, discovery, vec![SPT_NET_ALPN.to_vec()]).await
134	    }
135
136	    /// Bind on an explicit ALPN set instead of the default trusted [`SPT_NET_ALPN`].
137	    ///
138	    /// The pairing driver uses this to stand up a **dedicated pre-trust endpoint** bound
139	    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
140	    /// accept loop must not share the trusted message surface (ADR-0005; `pairing.rs`
141	    /// "Why a dedicated pre-trust ALPN"). All relay/discovery handling is identical to
142	    /// [`bind`](Self::bind).
143	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
144	    pub async fn bind_with_alpns(
145	        identity: &Identity,
146	        relay: RelayPolicy,
147	        discovery: LocalDiscovery,
148	        alpns: Vec<Vec<u8>>,
149	    ) -> Result<NetEndpoint, NetError> {
150	        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
151	    }
152
153	    /// The full bind form: [`bind_with_alpns`](Self::bind_with_alpns) plus an
154	    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
155	    /// endpoint never listens on a real interface (and never triggers a Windows
156	    /// firewall prompt per test binary); everything else delegates here with
157	    /// [`BindScope::All`].
158	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
159	    pub async fn bind_scoped(
160	        identity: &Identity,
161	        relay: RelayPolicy,
162	        discovery: LocalDiscovery,
163	        alpns: Vec<Vec<u8>>,
164	        scope: BindScope,
165	    ) -> Result<NetEndpoint, NetError> {
166	        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
167	        // seed, so this preserves the node's stable public-key name on the wire.
168	        let secret = SecretKey::from_bytes(&identity.seed());
169	        let builder = Endpoint::builder(presets::N0)
170	            .secret_key(secret)
171	            .alpns(alpns);
172	        // Loopback scope replaces the builder's pre-configured unspecified binds
173	        // (0.0.0.0 / [::]) with loopback-only sockets, AND disables iroh's
174	        // portmapper (UPnP/PCP/NAT-PMP — on by default), which would otherwise
175	        // bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless
176	        // on loopback, and the other source of per-test-binary Windows firewall
177	        // prompts.
178	        let builder = match scope {
179	            BindScope::All => builder,
180	            BindScope::Loopback => builder
181	                .clear_ip_transports()
182	                .bind_addr("127.0.0.1:0")
183	                .map_err(|e| NetError::Bind(e.to_string()))?
184	                .bind_addr("[::1]:0")
185	                .map_err(|e| NetError::Bind(e.to_string()))?
186	                .portmapper_config(PortmapperConfig::Disabled),
187	            // All-interfaces like the production scope, but on a caller-fixed
188	            // port so the node is dialable by a static ip:port ticket.
189	            BindScope::Port(p) => builder
190	                .clear_ip_transports()
191	                .bind_addr(std::net::SocketAddr::from((
192	                    std::net::Ipv4Addr::UNSPECIFIED,
193	                    p,
194	                )))
195	                .map_err(|e| NetError::Bind(e.to_string()))?
196	                .bind_addr(std::net::SocketAddr::from((
197	                    std::net::Ipv6Addr::UNSPECIFIED,
198	                    p,
199	                )))
200	                .map_err(|e| NetError::Bind(e.to_string()))?,
201	        };
202	        // The N0 preset already wires the n0 relay default; only override it when the
203	        // policy asks for self-host or disabled.
204	        let builder = match relay {
205	            RelayPolicy::N0Default => builder,
206	            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
207	            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
208	        };
209	        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
210	        // lookup, giving LAN-local advertise + resolve-by-id.
211	        let builder = match discovery {
212	            LocalDiscovery::Mdns => {
213	                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
214	            }
215	            LocalDiscovery::Off => builder,
216	        };
217	        let inner = builder
218	            .bind()
219	            .await
220	            .map_err(|e| NetError::Bind(e.to_string()))?;
221	        Ok(NetEndpoint { inner })
222	    }
223
224	    /// A cheap handle clone (iroh endpoints are internally reference-counted):
225	    /// the SAME bound endpoint — same sockets, same id — usable from
226	    /// concurrent tasks (the meet-probe race spawns one per window token).
227	    pub fn clone_handle(&self) -> NetEndpoint {
228	        NetEndpoint {
229	            inner: self.inner.clone(),
230	        }
231	    }
232
233	    /// This node's public key — its stable subnet name, derived from the live
234	    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
235	    // [impl->REQ-NET-1]
236	    pub fn node_id(&self) -> PublicKey {
237	        PublicKey::from_bytes(self.inner.id().as_bytes())
238	            .expect("an iroh EndpointId is always a valid ed25519 public key")
239	    }
240
241	    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
242	    pub fn node_id_hex(&self) -> String {
243	        self.node_id().to_hex()
244	    }
245
246	    /// This endpoint's dialable address (id + currently-known direct/relay paths).
247	    /// Serialise it (serde) to hand a peer a connect ticket.
248	    pub fn addr(&self) -> EndpointAddr {
249	        self.inner.addr()
250	    }
251
252	    /// Wait until the endpoint has registered with its relays — needed before the
253	    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
254	    /// direct/loopback dial where the peer already has a direct address.
255	    pub async fn online(&self) {
256	        self.inner.online().await;
257	    }
258
259	    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
260	    /// `None` once the endpoint is closed.
261	    pub async fn accept(&self) -> Option<Incoming> {
262	        self.inner.accept().await
263	    }
264
265	    /// Gracefully close the endpoint: every held connection sends its QUIC
266	    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
267	    /// tear the runtime down before the close ever flushes, leaving peers to
268	    /// an idle timeout). The D4c presence seam relies on this for prompt
269	    /// disconnected events.
270	    // [impl->REQ-NET-1]
271	    pub async fn close(&self) {
272	        self.inner.close().await;
273	    }
274
275	    /// Dial a peer by address over [`SPT_NET_ALPN`].
276	    // [impl->REQ-NET-1]
277	    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
278	        self.connect_with_alpn(addr, SPT_NET_ALPN).await
279	    }
280
281	    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
282	    // [impl->REQ-NET-1]
283	    pub async fn connect_with_alpn(
284	        &self,
285	        addr: EndpointAddr,
286	        alpn: &[u8],
287	    ) -> Result<Connection, NetError> {
288	        self.inner
289	            .connect(addr, alpn)
290	            .await
291	            .map_err(|e| NetError::Connect(e.to_string()))
292	    }
293
294	    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
295	    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
296	    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
297	    /// knowing only each other's stable name.
298	    // [impl->REQ-NET-1]
299	    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
300	        let id = EndpointId::from_bytes(&peer.to_bytes())
301	            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
302	        self.connect(EndpointAddr::new(id)).await
303	    }
304
305	    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
306	    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
307	    pub fn iroh(&self) -> &Endpoint {
308	        &self.inner
309	    }
310	}
311
312	/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
313	/// pubkey (the trust store's identity column), letting discovery (mDNS on the
314	/// LAN, n0 DNS on the WAN) resolve the transport address. The peer pump's
315	/// production resolver (D9-1): dial trusted peers knowing only their stable
316	/// name. `None` on a malformed hex — a corrupt trust row must not panic the
317	/// pump.
318	// [impl->REQ-NET-1]
319	pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
320	    let pk = spt_proto::identity::PublicKey::from_hex(hex).ok()?;
321	    let id = EndpointId::from_bytes(&pk.to_bytes()).ok()?;
322	    Some(EndpointAddr::new(id))
323	}
324
325	/// The peer's dialable address as OBSERVED from a live connection: the
326	/// handshake-proven remote id + the transport addresses of the connection's
327	/// current (non-closed) network paths. This is what the address-seeding
328	/// writers persist (M8 decisions 14/20, REQ-CONV-1): an address that just
329	/// carried a real connection is the best possible cold-start seed — unlike
330	/// the id-only `addr_for_node_hex`, it dials without a discovery round-trip.
331	/// May be path-less (id-only) when no path is readable; callers skip
332	/// persisting those (they would add nothing over id-only discovery).
333	// [impl->REQ-CONV-1]
334	pub fn conn_remote_addr(conn: &Connection) -> EndpointAddr {
335	    let addrs: Vec<_> = conn
336	        .paths()
337	        .into_iter()
338	        .filter(|p| !p.is_closed())
339	        .map(|p| p.remote_addr().clone())
340	        .collect();
341	    EndpointAddr::from_parts(conn.remote_id(), addrs)
342	}
343
344	#[cfg(test)]
345	mod tests {
346	    use super::*;
347
348	    /// Bind a fully hermetic endpoint: relays disabled, no discovery, loopback
349	    /// sockets only (no real interface touched, no Windows firewall prompt).
350	    async fn bind_hermetic(id: &Identity) -> NetEndpoint {
351	        NetEndpoint::bind_scoped(
352	            id,
353	            RelayPolicy::Disabled,
354	            LocalDiscovery::Off,
355	            vec![SPT_NET_ALPN.to_vec()],
356	            BindScope::Loopback,
357	        )
358	        .await
359	        .expect("hermetic bind")
360	    }
361
362	    /// Accept exactly one connection on `server` and echo a single bidi request as
363	    /// `echo:<req>`. Shared by the loopback + mDNS tests.
364	    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
365	        tokio::spawn(async move {
366	            let incoming = server.accept().await.expect("incoming");
367	            let conn = incoming
368	                .accept()
369	                .expect("accept")
370	                .await
371	                .expect("connecting");
372	            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
373	            let req = recv.read_to_end(1024).await.expect("read req");
374	            let mut echo = b"echo:".to_vec();
375	            echo.extend_from_slice(&req);
376	            send.write_all(&echo).await.expect("write echo");
377	            send.finish().expect("finish");
378	            conn.closed().await;
379	        })
380	    }
381
382	    /// Drive one ping→`echo:ping` round-trip over an established connection.
383	    async fn ping_echo(conn: &Connection) {
384	        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
385	        send.write_all(b"ping").await.expect("write ping");
386	        send.finish().expect("finish");
387	        let resp = recv.read_to_end(1024).await.expect("read resp");
388	        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
389	    }
390
391	    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
392	    // iroh EndpointId equals the node's public key (same seed → same keypair).
393	    #[tokio::test]
394	    async fn endpoint_id_binds_to_identity() {
395	        let id = Identity::generate();
396	        let ep = bind_hermetic(&id).await;
397	        assert_eq!(
398	            ep.node_id(),
399	            id.public_key(),
400	            "endpoint id must equal node pubkey"
401	        );
402	        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
403	    }
404
405	    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
406	    // QUIC with relays DISABLED, no discovery, and loopback-only sockets — fully
407	    // hermetic (no network), proving the ALPN + connect/accept path by direct
408	    // address. (The cross-node WAN `int` stage is the two-host E2E at D9; this is
409	    // the component-level evidence.)
410	    #[tokio::test]
411	    async fn two_endpoints_loopback_echo() {
412	        let server = bind_hermetic(&Identity::generate()).await;
413	        let server_addr = server.addr();
414	        let srv = spawn_echo_once(server);
415
416	        let client = bind_hermetic(&Identity::generate()).await;
417	        let conn = client.connect(server_addr).await.expect("connect");
418	        ping_echo(&conn).await;
419	        conn.close(0u32.into(), b"done");
420
421	        srv.await.expect("server task joins");
422	    }
423
424	    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
425	    // knowing only the peer's node id (no address ticket) — the address is resolved
426	    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
427	    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
428	    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
429	    #[tokio::test]
430	    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
431	    async fn mdns_discovery_connects_by_id() {
432	        let server = NetEndpoint::bind(
433	            &Identity::generate(),
434	            RelayPolicy::Disabled,
435	            LocalDiscovery::Mdns,
436	        )
437	        .await
438	        .expect("server bind");
439	        let server_pk = server.node_id();
440	        let srv = spawn_echo_once(server);
441
442	        let client = NetEndpoint::bind(
443	            &Identity::generate(),
444	            RelayPolicy::Disabled,
445	            LocalDiscovery::Mdns,
446	        )
447	        .await
448	        .expect("client bind");
449	        // Give mDNS a moment to advertise + resolve before dialing by id alone.
450	        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
451	        let conn = client
452	            .connect_by_id(server_pk)
453	            .await
454	            .expect("connect by id resolves over mDNS");
455	        ping_echo(&conn).await;
456	        conn.close(0u32.into(), b"done");
457
458	        srv.await.expect("server task joins");
459	    }
460	}
461

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, Incoming, PortmapperConfig};
use iroh::{Endpoint, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

// Re-exported for the daemon (D4): the broker holds live QUIC connections +
// streams in its tables and dialable addresses in its IPC frames without growing
// its own iroh dependency edge — the exact version pin (0.98.2) stays in this
// one crate.
pub use iroh::endpoint::{Connection, RecvStream, SendStream};
pub use iroh::EndpointAddr;

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
/// alone on the local network, so two machines on the same LAN find each other with
/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
// [impl->REQ-NET-1]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalDiscovery {
    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
    Mdns,
    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
    Off,
}

/// Which interfaces the endpoint's UDP sockets bind — a mechanical knob, not
/// policy. [`BindScope::Loopback`] exists for hermetic tests: a loopback-only
/// listener never trips the Windows Defender Firewall "allow access?" prompt
/// (the prompt fires per freshly-built test executable that listens on
/// `0.0.0.0`), and it makes "no network reached" literally true at the socket.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindScope {
    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
    /// The production scope; required for any off-host connectivity.
    All,
    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
    /// nothing can reach (or prompt about) a real interface.
    Loopback,
    /// Bind all interfaces on an **explicit UDP port** (`0.0.0.0:p` / `[::]:p`)
    /// instead of an ephemeral one. A node whose port is stable can be named by
    /// a static `ip:port` ticket (firewall allow-rules, the two-host rig's
    /// env-derived peer addresses) with no relay/discovery infrastructure.
    Port(u16),
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

impl std::error::Error for NetError {}

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
    ) -> Result<NetEndpoint, NetError> {
        Self::bind_with_alpns(identity, relay, discovery, vec![SPT_NET_ALPN.to_vec()]).await
    }

    /// Bind on an explicit ALPN set instead of the default trusted [`SPT_NET_ALPN`].
    ///
    /// The pairing driver uses this to stand up a **dedicated pre-trust endpoint** bound
    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
    /// accept loop must not share the trusted message surface (ADR-0005; `pairing.rs`
    /// "Why a dedicated pre-trust ALPN"). All relay/discovery handling is identical to
    /// [`bind`](Self::bind).
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind_with_alpns(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
        alpns: Vec<Vec<u8>>,
    ) -> Result<NetEndpoint, NetError> {
        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
    }

    /// The full bind form: [`bind_with_alpns`](Self::bind_with_alpns) plus an
    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
    /// endpoint never listens on a real interface (and never triggers a Windows
    /// firewall prompt per test binary); everything else delegates here with
    /// [`BindScope::All`].
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind_scoped(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
        alpns: Vec<Vec<u8>>,
        scope: BindScope,
    ) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(alpns);
        // Loopback scope replaces the builder's pre-configured unspecified binds
        // (0.0.0.0 / [::]) with loopback-only sockets, AND disables iroh's
        // portmapper (UPnP/PCP/NAT-PMP — on by default), which would otherwise
        // bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless
        // on loopback, and the other source of per-test-binary Windows firewall
        // prompts.
        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(std::net::SocketAddr::from((
                    std::net::Ipv4Addr::UNSPECIFIED,
                    p,
                )))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(std::net::SocketAddr::from((
                    std::net::Ipv6Addr::UNSPECIFIED,
                    p,
                )))
                .map_err(|e| NetError::Bind(e.to_string()))?,
        };
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
        // lookup, giving LAN-local advertise + resolve-by-id.
        let builder = match discovery {
            LocalDiscovery::Mdns => {
                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
            }
            LocalDiscovery::Off => builder,
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// A cheap handle clone (iroh endpoints are internally reference-counted):
    /// the SAME bound endpoint — same sockets, same id — usable from
    /// concurrent tasks (the meet-probe race spawns one per window token).
    pub fn clone_handle(&self) -> NetEndpoint {
        NetEndpoint {
            inner: self.inner.clone(),
        }
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Gracefully close the endpoint: every held connection sends its QUIC
    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
    /// tear the runtime down before the close ever flushes, leaving peers to
    /// an idle timeout). The D4c presence seam relies on this for prompt
    /// disconnected events.
    // [impl->REQ-NET-1]
    pub async fn close(&self) {
        self.inner.close().await;
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.connect_with_alpn(addr, SPT_NET_ALPN).await
    }

    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
    // [impl->REQ-NET-1]
    pub async fn connect_with_alpn(
        &self,
        addr: EndpointAddr,
        alpn: &[u8],
    ) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, alpn)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
    /// knowing only each other's stable name.
    // [impl->REQ-NET-1]
    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
        let id = EndpointId::from_bytes(&peer.to_bytes())
            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
        self.connect(EndpointAddr::new(id)).await
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

/// An id-only dialable [`EndpointAddr`] for a node named by its lowercase-hex
/// pubkey (the trust store's identity column), letting discovery (mDNS on the
/// LAN, n0 DNS on the WAN) resolve the transport address. The peer pump's
/// production resolver (D9-1): dial trusted peers knowing only their stable
/// name. `None` on a malformed hex — a corrupt trust row must not panic the
/// pump.
// [impl->REQ-NET-1]
pub fn addr_for_node_hex(hex: &str) -> Option<EndpointAddr> {
    let pk = spt_proto::identity::PublicKey::from_hex(hex).ok()?;
    let id = EndpointId::from_bytes(&pk.to_bytes()).ok()?;
    Some(EndpointAddr::new(id))
}

/// The peer's dialable address as OBSERVED from a live connection: the
/// handshake-proven remote id + the transport addresses of the connection's
/// current (non-closed) network paths. This is what the address-seeding
/// writers persist (M8 decisions 14/20, REQ-CONV-1): an address that just
/// carried a real connection is the best possible cold-start seed — unlike
/// the id-only `addr_for_node_hex`, it dials without a discovery round-trip.
/// May be path-less (id-only) when no path is readable; callers skip
/// persisting those (they would add nothing over id-only discovery).
// [impl->REQ-CONV-1]
pub fn conn_remote_addr(conn: &Connection) -> EndpointAddr {
    let addrs: Vec<_> = conn
        .paths()
        .into_iter()
        .filter(|p| !p.is_closed())
        .map(|p| p.remote_addr().clone())
        .collect();
    EndpointAddr::from_parts(conn.remote_id(), addrs)
}

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

    /// Bind a fully hermetic endpoint: relays disabled, no discovery, loopback
    /// sockets only (no real interface touched, no Windows firewall prompt).
    async fn bind_hermetic(id: &Identity) -> NetEndpoint {
        NetEndpoint::bind_scoped(
            id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_NET_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("hermetic bind")
    }

    /// Accept exactly one connection on `server` and echo a single bidi request as
    /// `echo:<req>`. Shared by the loopback + mDNS tests.
    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
        tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        })
    }

    /// Drive one ping→`echo:ping` round-trip over an established connection.
    async fn ping_echo(conn: &Connection) {
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
    }

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = bind_hermetic(&id).await;
        assert_eq!(
            ep.node_id(),
            id.public_key(),
            "endpoint id must equal node pubkey"
        );
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED, no discovery, and loopback-only sockets — fully
    // hermetic (no network), proving the ALPN + connect/accept path by direct
    // address. (The cross-node WAN `int` stage is the two-host E2E at D9; this is
    // the component-level evidence.)
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = bind_hermetic(&Identity::generate()).await;
        let server_addr = server.addr();
        let srv = spawn_echo_once(server);

        let client = bind_hermetic(&Identity::generate()).await;
        let conn = client.connect(server_addr).await.expect("connect");
        ping_echo(&conn).await;
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }

    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
    // knowing only the peer's node id (no address ticket) — the address is resolved
    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
    #[tokio::test]
    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
    async fn mdns_discovery_connects_by_id() {
        let server = NetEndpoint::bind(
            &Identity::generate(),
            RelayPolicy::Disabled,
            LocalDiscovery::Mdns,
        )
        .await
        .expect("server bind");
        let server_pk = server.node_id();
        let srv = spawn_echo_once(server);

        let client = NetEndpoint::bind(
            &Identity::generate(),
            RelayPolicy::Disabled,
            LocalDiscovery::Mdns,
        )
        .await
        .expect("client bind");
        // Give mDNS a moment to advertise + resolve before dialing by id alone.
        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        let conn = client
            .connect_by_id(server_pk)
            .await
            .expect("connect by id resolves over mDNS");
        ping_echo(&conn).await;
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}


Need to find: seed/subnet/epoch storage (for MK derivation at connect), and `is_trusted`/TrustStore. Let me search.


Grep

fn is_trusted|struct TrustStore|seed_epoch|fn seed\b|current_seed|subnet_id


Found 8 files
crates\spt-net\src\net\mesh\seedproof.rs
crates\spt-net\Cargo.toml
traceable-reqs.toml
MESH-D1-PLAN.md
SUBNET-MESH-PLAN.md
docs\adr\0017-subnet-mesh-membership-seed-proof.md
crates\spt-store\src\trust.rs
crates\spt-proto\src\identity.rs


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 two wire frames of the mutual exchange. Each side opens with its
170	/// [`Challenge`](SeedProofFrame::Challenge) (its nonce), then — once both nonces
171	/// and the two handshake pubkeys fix the transcript — sends its
172	/// [`Proof`](SeedProofFrame::Proof). Defined here (Mesh-D1); *driven* onto a
173	/// connection at Mesh-D2.
174	///
175	/// Encoding: a 1-byte tag (`0` = Challenge, `1` = Proof) followed by the
176	/// fixed-width payload. Decoding is total and rejects any malformed buffer with
177	/// `None` (additive/robust, like the pairing wire frames).
178	#[derive(Debug, Clone, PartialEq, Eq)]
179	pub enum SeedProofFrame {
180	    /// A party's per-connection nonce, sent first.
181	    Challenge { nonce: [u8; NONCE_LEN] },
182	    /// A party's seed-proof tag over the agreed transcript.
183	    Proof { tag: [u8; PROOF_TAG_LEN] },
184	}
185
186	const FRAME_CHALLENGE: u8 = 0;
187	const FRAME_PROOF: u8 = 1;
188
189	impl SeedProofFrame {
190	    /// Serialize to the wire form.
191	    pub fn encode(&self) -> Vec<u8> {
192	        match self {
193	            SeedProofFrame::Challenge { nonce } => {
194	                let mut out = Vec::with_capacity(1 + NONCE_LEN);
195	                out.push(FRAME_CHALLENGE);
196	                out.extend_from_slice(nonce);
197	                out
198	            }
199	            SeedProofFrame::Proof { tag } => {
200	                let mut out = Vec::with_capacity(1 + PROOF_TAG_LEN);
201	                out.push(FRAME_PROOF);
202	                out.extend_from_slice(tag);
203	                out
204	            }
205	        }
206	    }
207
208	    /// Parse from the wire form. `None` on an unknown tag byte or a
209	    /// wrong-length payload — never panics, never partial-reads.
210	    pub fn decode(bytes: &[u8]) -> Option<SeedProofFrame> {
211	        let (&tag, rest) = bytes.split_first()?;
212	        match tag {
213	            FRAME_CHALLENGE if rest.len() == NONCE_LEN => {
214	                let mut nonce = [0u8; NONCE_LEN];
215	                nonce.copy_from_slice(rest);
216	                Some(SeedProofFrame::Challenge { nonce })
217	            }
218	            FRAME_PROOF if rest.len() == PROOF_TAG_LEN => {
219	                let mut t = [0u8; PROOF_TAG_LEN];
220	                t.copy_from_slice(rest);
221	                Some(SeedProofFrame::Proof { tag: t })
222	            }
223	            _ => None,
224	        }
225	    }
226	}
227
228	// A pubkey-length cross-check so a future PublicKey size change trips the test
229	// suite rather than silently reshaping the transcript.
230	const _: () = assert!(PUBLIC_KEY_LEN == 32);
231
232	#[cfg(test)]
233	mod tests {
234	    use super::*;
235	    use spt_proto::identity::Identity;
236
237	    const SEED: &[u8] = b"a-32-byte-ish-subnet-seed-value!";
238
239	    fn pubs() -> (PublicKey, PublicKey) {
240	        (
241	            Identity::from_seed(&[1u8; 32]).public_key(),
242	            Identity::from_seed(&[2u8; 32]).public_key(),
243	        )
244	    }
245
246	    fn transcript() -> SeedProofTranscript {
247	        let (d, a) = pubs();
248	        SeedProofTranscript {
249	            subnet_id: "home".into(),
250	            seed_epoch: 1,
251	            dialer_pub: d,
252	            acceptor_pub: a,
253	            dialer_nonce: [7u8; NONCE_LEN],
254	            acceptor_nonce: [9u8; NONCE_LEN],
255	        }
256	    }
257
258	    // [unit->REQ-MESH-1] MK is deterministic for equal inputs and diverges for
259	    // a different seed, subnet, OR epoch — and the length-prefix closes the
260	    // ("a",1) vs ("a1",0) field-boundary collision.
261	    #[test]
262	    fn mk_derivation_is_deterministic_and_input_sensitive() {
263	        let base = MembershipKey::derive(SEED, "home", 1);
264	        assert_eq!(
265	            base.0,
266	            MembershipKey::derive(SEED, "home", 1).0,
267	            "deterministic"
268	        );
269	        assert_ne!(
270	            base.0,
271	            MembershipKey::derive(b"other-seed-other-seed-other-seed", "home", 1).0,
272	            "seed-sensitive"
273	        );
274	        assert_ne!(
275	            base.0,
276	            MembershipKey::derive(SEED, "work", 1).0,
277	            "subnet-sensitive"
278	        );
279	        assert_ne!(
280	            base.0,
281	            MembershipKey::derive(SEED, "home", 2).0,
282	            "epoch-sensitive"
283	        );
284	        // Field-boundary: ("a", epoch 1) must not equal ("a1", epoch 0)…0x01.
285	        // (to_be_bytes of 1 ends in 0x01; without the length prefix the two
286	        // info strings would be identical bytewise.)
287	        assert_ne!(
288	            MembershipKey::derive(SEED, "a", 1).0,
289	            MembershipKey::derive(SEED, "a1", 0).0,
290	            "length-prefix prevents subnet/epoch boundary collision"
291	        );
292	    }
293
294	    // [unit->REQ-MESH-1] a matching MK + transcript verifies, and the two role
295	    // tags differ so the dialer's proof can't be replayed as the acceptor's.
296	    #[test]
297	    fn valid_proof_verifies_and_roles_differ() {
298	        let mk = MembershipKey::derive(SEED, "home", 1);
299	        let t = transcript();
300	        let d = t.tag(&mk, ProofRole::Dialer);
301	        let a = t.tag(&mk, ProofRole::Acceptor);
302	        assert_ne!(d, a, "role byte distinguishes the two tags");
303	        assert!(t.verify(&mk, ProofRole::Dialer, &d));
304	        assert!(t.verify(&mk, ProofRole::Acceptor, &a));
305	        // No reflection: an acceptor tag must not verify as a dialer tag.
306	        assert!(!t.verify(&mk, ProofRole::Dialer, &a));
307	    }
308
309	    // [unit->REQ-MESH-1] a forged proof — a non-member without the seed, modeled
310	    // as a wrong MK — fails verification.
311	    #[test]
312	    fn forged_proof_fails() {
313	        let real = MembershipKey::derive(SEED, "home", 1);
314	        let t = transcript();
315	        let tag = t.tag(&real, ProofRole::Dialer);
316	        let outsider = MembershipKey::derive(b"the-outsider-has-the-wrong-seed!", "home", 1);
317	        assert!(!t.verify(&outsider, ProofRole::Dialer, &tag));
318	    }
319
320	    // [unit->REQ-MESH-1] wrong-subnet: a tag made under subnet "home"'s MK does
321	    // not verify under subnet "work"'s MK (same seed, different MK).
322	    #[test]
323	    fn wrong_subnet_fails() {
324	        let home = MembershipKey::derive(SEED, "home", 1);
325	        let work = MembershipKey::derive(SEED, "work", 1);
326	        let t = transcript();
327	        let tag = t.tag(&home, ProofRole::Dialer);
328	        assert!(!t.verify(&work, ProofRole::Dialer, &tag));
329	    }
330
331	    // [unit->REQ-MESH-1] wrong-epoch: exact-epoch match — a tag for epoch 1 does
332	    // not verify under epoch 2's MK (the N-1 grace is D7, not this codec).
333	    #[test]
334	    fn wrong_epoch_fails() {
335	        let e1 = MembershipKey::derive(SEED, "home", 1);
336	        let e2 = MembershipKey::derive(SEED, "home", 2);
337	        let t = transcript();
338	        let tag = t.tag(&e1, ProofRole::Dialer);
339	        assert!(!t.verify(&e2, ProofRole::Dialer, &tag));
340	    }
341
342	    // [unit->REQ-MESH-1] the channel-binding center: a tag valid on connection A
343	    // (its pubkeys + nonces) fails on connection B that differs in ANY of the
344	    // four channel fields — no cross-connection replay.
345	    #[test]
346	    fn cross_connection_replay_fails() {
347	        let mk = MembershipKey::derive(SEED, "home", 1);
348	        let base = transcript();
349	        let tag = base.tag(&mk, ProofRole::Dialer);
350
351	        let other_pub = Identity::from_seed(&[42u8; 32]).public_key();
352	        let variants = [
353	            SeedProofTranscript {
354	                dialer_pub: other_pub,
355	                ..base.clone()
356	            },
357	            SeedProofTranscript {
358	                acceptor_pub: other_pub,
359	                ..base.clone()
360	            },
361	            SeedProofTranscript {
362	                dialer_nonce: [0xABu8; NONCE_LEN],
363	                ..base.clone()
364	            },
365	            SeedProofTranscript {
366	                acceptor_nonce: [0xCDu8; NONCE_LEN],
367	                ..base.clone()
368	            },
369	        ];
370	        for (i, v) in variants.iter().enumerate() {
371	            assert!(
372	                !v.verify(&mk, ProofRole::Dialer, &tag),
373	                "channel field {i} must bind the tag"
374	            );
375	        }
376	    }
377
378	    // [unit->REQ-MESH-1] mutual: the pair is admitted only when BOTH sides
379	    // verify; if either side proves under the wrong MK, the mutual check fails.
380	    #[test]
381	    fn mutual_requires_both_sides() {
382	        let t = transcript();
383	        let good = MembershipKey::derive(SEED, "home", 1);
384	        let bad = MembershipKey::derive(b"impostor-impostor-impostor-impo!", "home", 1);
385
386	        // Each side sends its own-role tag; the peer verifies it.
387	        let dialer_tag = t.tag(&good, ProofRole::Dialer);
388	        let acceptor_tag_good = t.tag(&good, ProofRole::Acceptor);
389	        let acceptor_tag_bad = t.tag(&bad, ProofRole::Acceptor);
390
391	        let mutual = |dt: &[u8], at: &[u8]| {
392	            t.verify(&good, ProofRole::Dialer, dt) && t.verify(&good, ProofRole::Acceptor, at)
393	        };
394	        assert!(mutual(&dialer_tag, &acceptor_tag_good), "both prove → admit");
395	        assert!(
396	            !mutual(&dialer_tag, &acceptor_tag_bad),
397	            "one impostor → reject"
398	        );
399	    }
400
401	    // [unit->REQ-MESH-1] frame codec round-trips both variants, and a malformed
402	    // buffer (unknown tag / wrong length / empty) decodes to None.
403	    #[test]
404	    fn frame_codec_round_trips_and_rejects_garbage() {
405	        let ch = SeedProofFrame::Challenge {
406	            nonce: [3u8; NONCE_LEN],
407	        };
408	        let pf = SeedProofFrame::Proof {
409	            tag: [5u8; PROOF_TAG_LEN],
410	        };
411	        assert_eq!(SeedProofFrame::decode(&ch.encode()), Some(ch));
412	        assert_eq!(SeedProofFrame::decode(&pf.encode()), Some(pf));
413
414	        assert_eq!(SeedProofFrame::decode(&[]), None, "empty");
415	        assert_eq!(SeedProofFrame::decode(&[9u8; 33]), None, "unknown tag");
416	        assert_eq!(
417	            SeedProofFrame::decode(&[FRAME_CHALLENGE, 1, 2, 3]),
418	            None,
419	            "short challenge"
420	        );
421	        assert_eq!(
422	            SeedProofFrame::decode(&[FRAME_PROOF; 8]),
423	            None,
424	            "short proof"
425	        );
426	    }
427	}
428

//! 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 two wire frames of the mutual exchange. Each side opens with its
/// [`Challenge`](SeedProofFrame::Challenge) (its nonce), then — once both nonces
/// and the two handshake pubkeys fix the transcript — sends its
/// [`Proof`](SeedProofFrame::Proof). Defined here (Mesh-D1); *driven* onto a
/// connection at Mesh-D2.
///
/// Encoding: a 1-byte tag (`0` = Challenge, `1` = Proof) followed by the
/// fixed-width payload. Decoding is total and rejects any malformed buffer with
/// `None` (additive/robust, like the pairing wire frames).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedProofFrame {
    /// A party's per-connection nonce, sent first.
    Challenge { nonce: [u8; NONCE_LEN] },
    /// A party's seed-proof tag over the agreed transcript.
    Proof { tag: [u8; PROOF_TAG_LEN] },
}

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

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
            }
        }
    }

    /// Parse from the wire form. `None` on an unknown tag byte or a
    /// wrong-length payload — 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 })
            }
            _ => None,
        }
    }
}

// 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"
        );
    }
}


D1 codec is solid. Now the trust store — where seed/subnet/epoch live, and `is_trusted`.