1	//! Connect-time seed-proof exchange (Mesh-D2, REQ-MESH-1) — the daemon-side
2	//! orchestration that drives the `spt-net` [`seedproof`] codec onto a live QUIC
3	//! connection.
4	//!
5	//! [`seedproof`](spt_net::net::mesh::seedproof) is the pure crypto (membership
6	//! key + mutual channel-bound transcript). This module runs the **mutual
7	//! exchange** over a dedicated control stream at every dial + accept, *before*
8	//! the connection is registered as usable: each side proves current-epoch
9	//! knowledge of the seed for **every subnet the two nodes share**, and the
10	//! verified set is cached on the broker `ConnEntry`. A failed or empty proof
11	//! drops the connection.
12	//!
13	//! ## Why a set, not a single subnet
14	//!
15	//! Connections are not subnet-scoped (`dial` takes only an address; iroh dedups
16	//! to one connection per peer-pair on the shared ALPN), and the inbound gates
17	//! the mesh swaps at D5 are per-`(subnet, origin)`, evaluated per item. So one
18	//! connection carries traffic for every shared subnet, and the cached verdict
19	//! must answer "did this peer prove subnet S?" for any S — a [`HashSet`], not a
20	//! bool.
21	//!
22	//! ## The exchange (one bidi control stream)
23	//!
24	//! Dialer opens it (`open_bi`); acceptor accepts it (`accept_bi`) *before*
25	//! `register_conn` wires the general app-stream acceptor, so it is never
26	//! confused with an app stream. Then a four-leg ping-pong:
27	//!
28	//! 1. dialer → `Hello { nonce_d, subnets = its local names }`
29	//! 2. acceptor → `Hello { nonce_a, subnets = intersection }` (reveals only shared names)
30	//! 3. dialer → one `Proof` per shared subnet; acceptor reads + verifies each
31	//! 4. acceptor → one `Proof` per shared subnet; dialer reads + verifies each
32	//!
33	//! Each side derives `MK_S` from **its own** local seed + epoch for S; an epoch
34	//! skew or a wrong seed makes the tags diverge → that subnet fails → the whole
35	//! connection drops (exact-epoch; the N-1 re-seed grace is Mesh-D7). Nonces are
36	//! per-connection-fresh and reused across the per-subnet proofs (the transcript
37	//! already binds `subnet_id` + `seed_epoch`).
38	// [impl->REQ-MESH-1]
39
40	use std::collections::{HashMap, HashSet};
41	use std::sync::Arc;
42
43	use spt_net::net::endpoint::{Connection, RecvStream, SendStream};
44	use spt_net::net::mesh::seedproof::{
45	    MembershipKey, ProofRole, SeedProofFrame, SeedProofTranscript, NONCE_LEN,
46	};
47	use spt_proto::identity::{Identity, PublicKey};
48	use spt_store::peeraddrs::PeerAddrStore;
49	use spt_store::roster::{RosterEntry, RosterStore, Tombstone};
50	use spt_store::subnet::SubnetStore;
51
52	/// One subnet's membership credential as the proof needs it: the human name
53	/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
54	/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
55	#[derive(Clone)]
56	pub struct SubnetCred {
57	    /// Subnet name — the only field that crosses the wire (a label, not a secret).
58	    pub name: String,
59	    /// The subnet seed bytes (HKDF input material). Never transmitted.
60	    pub seed: Vec<u8>,
61	    /// The local current `seed_epoch` for this subnet. Never transmitted.
62	    pub epoch: u64,
63	}
64
65	/// A live source of this node's subnet credentials, re-read per connection so a
66	/// rotation (Mesh-D7) is picked up without a restart. Production wires this to
67	/// `SubnetStore::load()`; `None` on [`NetConfig`](crate::nethost::NetConfig)
68	/// disables seed-proof entirely (both peers must agree — a mixed pair would
69	/// deadlock on the missing control stream).
70	pub type MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred> + Send + Sync>;
71
72	/// The production membership source: load the node's [`SubnetStore`] fresh on
73	/// every connect and project each subnet to its [`SubnetCred`]. Fresh-load (not
74	/// a snapshot) so a future seed rotation is honored without a daemon restart;
75	/// connects are rare enough that the file read is negligible. A subnet whose
76	/// stored seed hex is malformed is skipped (it can't prove anyway).
77	// [impl->REQ-MESH-1]
78	pub fn production_membership() -> MembershipSource {
79	    Arc::new(|| {
80	        SubnetStore::load()
81	            .subnets
82	            .iter()
83	            .filter_map(|r| {
84	                r.seed_bytes().map(|seed| SubnetCred {
85	                    name: r.name.clone(),
86	                    seed: seed.to_vec(),
87	                    epoch: r.epoch,
88	                })
89	            })
90	            .collect()
91	    })
92	}
93
94	/// Cap on the seed-proof control-stream framing — a single small handshake; a
95	/// length claiming more is a malformed/hostile peer, dropped.
96	const MAX_FRAME: usize = 64 * 1024;
97
98	/// Generate a fresh 32-byte per-connection nonce from the OS CSPRNG. Reuses
99	/// `spt-proto`'s Ed25519 seed generator (OsRng) as the randomness source — the
100	/// same no-extra-`rand`-dependency trick the stores use.
101	fn fresh_nonce() -> [u8; NONCE_LEN] {
102	    Identity::generate().seed()
103	}
104
105	/// Run the mutual seed-proof on `conn` and return the set of subnets BOTH sides
106	/// proved, or `None` if the connection must be dropped (no shared subnet, a
107	/// failed proof, or a transport/protocol error). `local_id` is this node's
108	/// pubkey; `role` is which side of the connection we are.
109	///
110	/// When `roster` is `Some` (Mesh-D4, REQ-MESH-2), a roster-exchange leg rides
111	/// the **same** control stream **after** both proofs verify — so the roster is
112	/// "exchanged only over seed-proof'd member connections" by construction, not by
113	/// convention. `self_addr` is this node's live dialable address (opaque JSON,
114	/// `Null` if none) which the provider stamps onto its own advertised entry. The
115	/// leg is **best-effort**: the connection is already a proven member conn, so a
116	/// missing/short/garbled roster frame is swallowed (it never drops the conn) —
117	/// which also lets a D4 peer interoperate with a not-yet-upgraded one that sends
118	/// no roster.
119	// [impl->REQ-MESH-1]
120	// [impl->REQ-MESH-2]
121	pub async fn prove_membership(
122	    conn: &Connection,
123	    local_id: PublicKey,
124	    role: ProofRole,
125	    creds: &[SubnetCred],
126	    roster: Option<&RosterExchange>,
127	    self_addr: &serde_json::Value,
128	) -> Option<HashSet<String>> {
129	    let remote = PublicKey::from_bytes(conn.remote_id().as_bytes()).ok()?;
130	    // Channel binding: the transcript pins both handshake-proven pubkeys in a
131	    // role-fixed order (dialer first), so a tag is valid on exactly this conn.
132	    let (dialer_pub, acceptor_pub) = match role {
133	        ProofRole::Dialer => (local_id, remote),
134	        ProofRole::Acceptor => (remote, local_id),
135	    };
136	    let by_name: HashMap<&str, &SubnetCred> =
137	        creds.iter().map(|c| (c.name.as_str(), c)).collect();
138
139	    match role {
140	        ProofRole::Dialer => {
141	            let (mut send, mut recv) = conn.open_bi().await.ok()?;
142	            prove_as_dialer(
143	                &mut send,
144	                &mut recv,
145	                creds,
146	                &by_name,
147	                dialer_pub,
148	                acceptor_pub,
149	                roster,
150	                self_addr,
151	            )
152	            .await
153	        }
154	        ProofRole::Acceptor => {
155	            let (mut send, mut recv) = conn.accept_bi().await.ok()?;
156	            prove_as_acceptor(
157	                &mut send,
158	                &mut recv,
159	                &by_name,
160	                dialer_pub,
161	                acceptor_pub,
162	                roster,
163	                self_addr,
164	            )
165	            .await
166	        }
167	    }
168	}
169
170	#[allow(clippy::too_many_arguments)]
171	async fn prove_as_dialer(
172	    send: &mut SendStream,
173	    recv: &mut RecvStream,
174	    creds: &[SubnetCred],
175	    by_name: &HashMap<&str, &SubnetCred>,
176	    dialer_pub: PublicKey,
177	    acceptor_pub: PublicKey,
178	    roster: Option<&RosterExchange>,
179	    self_addr: &serde_json::Value,
180	) -> Option<HashSet<String>> {
181	    let nonce_d = fresh_nonce();
182	    let my_names: Vec<String> = creds.iter().map(|c| c.name.clone()).collect();
183	    write_frame(send, &SeedProofFrame::Hello { nonce: nonce_d, subnets: my_names }.encode())
184	        .await?;
185
186	    // Acceptor replies with the intersection (in its order) + its nonce.
187	    let (nonce_a, shared) = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
188	        Some(SeedProofFrame::Hello { nonce, subnets }) => (nonce, subnets),
189	        _ => return None,
190	    };
191	    // Defensive: only honor subnets we actually offered.
192	    if shared.is_empty() || shared.iter().any(|s| !by_name.contains_key(s.as_str())) {
193	        let _ = send.finish();
194	        return None;
195	    }
196
197	    let transcripts = build_transcripts(&shared, by_name, dialer_pub, acceptor_pub, nonce_d, nonce_a)?;
198
199	    // Send all our (dialer-role) proofs first; they buffer while the acceptor
200	    // reads them, so this ping-pong never deadlocks on the single stream.
201	    for (_name, t, mk) in &transcripts {
202	        let tag = t.tag(mk, ProofRole::Dialer);
203	        write_frame(send, &SeedProofFrame::Proof { tag }.encode()).await?;
204	    }
205	    // Then read + verify the acceptor's proofs.
206	    let mut proven = HashSet::new();
207	    for (name, t, mk) in &transcripts {
208	        let tag = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
209	            Some(SeedProofFrame::Proof { tag }) => tag,
210	            _ => return None,
211	        };
212	        if !t.verify(mk, ProofRole::Acceptor, &tag) {
213	            return None; // any agreed subnet failing ⇒ drop the whole connection
214	        }
215	        proven.insert(name.clone());
216	    }
217	    // Mesh-D4 roster leg (REQ-MESH-2): dialer sends its slice first (it buffers
218	    // while the acceptor reads), then reads the acceptor's. Best-effort — never
219	    // fails the already-proven connection.
220	    exchange_roster_dialer(send, recv, roster, self_addr, &proven).await;
221	    let _ = send.finish();
222	    Some(proven)
223	}
224
225	#[allow(clippy::too_many_arguments)]
226	async fn prove_as_acceptor(
227	    send: &mut SendStream,
228	    recv: &mut RecvStream,
229	    by_name: &HashMap<&str, &SubnetCred>,
230	    dialer_pub: PublicKey,
231	    acceptor_pub: PublicKey,
232	    roster: Option<&RosterExchange>,
233	    self_addr: &serde_json::Value,
234	) -> Option<HashSet<String>> {
235	    let (nonce_d, dialer_names) = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
236	        Some(SeedProofFrame::Hello { nonce, subnets }) => (nonce, subnets),
237	        _ => return None,
238	    };
239	    // Intersection in a deterministic (sorted) order both sides walk identically.
240	    let mut shared: Vec<String> = dialer_names
241	        .into_iter()
242	        .filter(|s| by_name.contains_key(s.as_str()))
243	        .collect();
244	    shared.sort();
245	    shared.dedup();
246
247	    let nonce_a = fresh_nonce();
248	    write_frame(
249	        send,
250	        &SeedProofFrame::Hello { nonce: nonce_a, subnets: shared.clone() }.encode(),
251	    )
252	    .await?;
253	    if shared.is_empty() {
254	        let _ = send.finish();
255	        return None; // no shared subnet — nothing to mesh, drop
256	    }
257
258	    let transcripts = build_transcripts(&shared, by_name, dialer_pub, acceptor_pub, nonce_d, nonce_a)?;
259
260	    // Read + verify the dialer's proofs first, then send ours (mirror of the
261	    // dialer's send-then-read, so the ping-pong stays deadlock-free).
262	    let mut proven = HashSet::new();
263	    for (name, t, mk) in &transcripts {
264	        let tag = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
265	            Some(SeedProofFrame::Proof { tag }) => tag,
266	            _ => return None,
267	        };
268	        if !t.verify(mk, ProofRole::Dialer, &tag) {
269	            return None;
270	        }
271	        proven.insert(name.clone());
272	    }
273	    for (_name, t, mk) in &transcripts {
274	        let tag = t.tag(mk, ProofRole::Acceptor);
275	        write_frame(send, &SeedProofFrame::Proof { tag }.encode()).await?;
276	    }
277	    // Mesh-D4 roster leg (REQ-MESH-2): mirror of the dialer — read its slice
278	    // first, then send ours. Best-effort (see `prove_membership`).
279	    exchange_roster_acceptor(send, recv, roster, self_addr, &proven).await;
280	    let _ = send.finish();
281	    Some(proven)
282	}
283
284	/// Build `(name, transcript, MK)` for each shared subnet, deriving each `MK`
285	/// from the local credential's seed + epoch. `None` if a shared name has no
286	/// local credential (cannot happen after the membership filter, but kept total).
287	fn build_transcripts(
288	    shared: &[String],
289	    by_name: &HashMap<&str, &SubnetCred>,
290	    dialer_pub: PublicKey,
291	    acceptor_pub: PublicKey,
292	    nonce_d: [u8; NONCE_LEN],
293	    nonce_a: [u8; NONCE_LEN],
294	) -> Option<Vec<(String, SeedProofTranscript, MembershipKey)>> {
295	    let mut out = Vec::with_capacity(shared.len());
296	    for name in shared {
297	        let cred = by_name.get(name.as_str())?;
298	        let mk = MembershipKey::derive(&cred.seed, name, cred.epoch);
299	        let t = SeedProofTranscript {
300	            subnet_id: name.clone(),
301	            seed_epoch: cred.epoch,
302	            dialer_pub,
303	            acceptor_pub,
304	            dialer_nonce: nonce_d,
305	            acceptor_nonce: nonce_a,
306	        };
307	        out.push((name.clone(), t, mk));
308	    }
309	    Some(out)
310	}
311
312	/// Write a length-delimited frame (`u32` BE length prefix + body).
313	async fn write_frame(send: &mut SendStream, body: &[u8]) -> Option<()> {
314	    send.write_all(&(body.len() as u32).to_be_bytes()).await.ok()?;
315	    send.write_all(body).await.ok()?;
316	    Some(())
317	}
318
319	/// Read one length-delimited frame, rejecting a length of 0 or > `max`.
320	async fn read_frame(recv: &mut RecvStream, max: usize) -> Option<Vec<u8>> {
321	    let mut lenb = [0u8; 4];
322	    recv.read_exact(&mut lenb).await.ok()?;
323	    let n = u32::from_be_bytes(lenb) as usize;
324	    if n == 0 || n > max {
325	        return None;
326	    }
327	    let mut buf = vec![0u8; n];
328	    recv.read_exact(&mut buf).await.ok()?;
329	    Some(buf)
330	}
331
332	// ── Mesh-D4: on-connect roster propagation (REQ-MESH-2) ──────────────────────
333	//
334	// After both sides prove membership, each sends the other its roster slice for
335	// the *proven* subnets only (a peer that proved X never receives subnet Y's
336	// roster — the same cross-subnet-leak defense D3 baked into `Frame::Seed`). The
337	// receiver merges the slice into its durable roster (so it learns members it
338	// never paired with — the transitive `A learns C via B`) and gap-fills its dial
339	// cache from any advertised addresses (so it can later *reach* them — the D6
340	// precondition). The store wiring is injected as closures so this crypto-adjacent
341	// module stays decoupled from the spt-store directory (the `MembershipSource`
342	// posture).
343
344	/// Builds this node's roster slice for the proven subnets, stamping its own
345	/// live address (`self_addr`) onto its advertised self-entry so peers learn how
346	/// to reach it. `proven` is the set both sides verified; `self_addr` is the
347	/// opaque dialable-address JSON (`Null` ⇒ advertise no address).
348	pub type RosterProvider =
349	    Arc<dyn Fn(&HashSet<String>, &serde_json::Value) -> (Vec<RosterEntry>, Vec<Tombstone>) + Send + Sync>;
350
351	/// Merges a received roster slice into the durable store and reconciles the dial
352	/// cache (gap-fill). Receives the peer's advertised entries + tombstones.
353	pub type RosterSink = Arc<dyn Fn(Vec<RosterEntry>, Vec<Tombstone>) + Send + Sync>;
354
355	/// The injected roster-exchange seam wired onto a live member connection
356	/// (Mesh-D4, REQ-MESH-2). `None` (the default) disables the leg entirely — the
357	/// proof still runs, no roster is exchanged. Cheap to clone (Arc closures).
358	#[derive(Clone)]
359	pub struct RosterExchange {
360	    /// Produces our outbound slice (+ self-address stamp) for the proven subnets.
361	    pub provider: RosterProvider,
362	    /// Consumes the peer's inbound slice (merge + dial-cache gap-fill).
363	    pub sink: RosterSink,
364	}
365
366	/// The dialer's roster leg: send ours, then read + sink theirs. Best-effort —
367	/// any framing/codec failure is swallowed (the conn is already proven).
368	async fn exchange_roster_dialer(
369	    send: &mut SendStream,
370	    recv: &mut RecvStream,
371	    roster: Option<&RosterExchange>,
372	    self_addr: &serde_json::Value,
373	    proven: &HashSet<String>,
374	) {
375	    let Some(rx) = roster else { return };
376	    if proven.is_empty() {
377	        return;
378	    }
379	    let (entries, tombstones) = (rx.provider)(proven, self_addr);
380	    if write_frame(send, &enc_roster(&entries, &tombstones)).await.is_none() {
381	        return;
382	    }
383	    if let Some(body) = read_frame(recv, MAX_FRAME).await {
384	        if let Some((rent, rtomb)) = dec_roster(&body) {
385	            (rx.sink)(rent, rtomb);
386	        }
387	    }
388	}
389
390	/// The acceptor's roster leg: read + sink theirs, then send ours (mirror of the
391	/// dialer so the single bidi stream never deadlocks). Best-effort.
392	async fn exchange_roster_acceptor(
393	    send: &mut SendStream,
394	    recv: &mut RecvStream,
395	    roster: Option<&RosterExchange>,
396	    self_addr: &serde_json::Value,
397	    proven: &HashSet<String>,
398	) {
399	    let Some(rx) = roster else { return };
400	    if proven.is_empty() {
401	        return;
402	    }
403	    if let Some(body) = read_frame(recv, MAX_FRAME).await {
404	        if let Some((rent, rtomb)) = dec_roster(&body) {
405	            (rx.sink)(rent, rtomb);
406	        }
407	    }
408	    let (entries, tombstones) = (rx.provider)(proven, self_addr);
409	    let _ = write_frame(send, &enc_roster(&entries, &tombstones)).await;
410	}
411
412	/// Append a length-prefixed (`u32` len + bytes) field.
413	fn push_field(out: &mut Vec<u8>, bytes: &[u8]) {
414	    out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
415	    out.extend_from_slice(bytes);
416	}
417
418	/// Encode a roster slice for the member-connection leg. Distinct channel from
419	/// the pairing `Frame::Seed`, so the `subnet` field is **explicit** here (a
420	/// member conn spans every proven subnet at once — there is no single implicit
421	/// ceremony subnet). Self-contained positional codec; the field set mirrors
422	/// `RosterEntry` and is kept in sync by `roster_frame_round_trips`. Counts are
423	/// never pre-allocated on decode — a hostile count exhausts the buffer on the
424	/// first short field, not a huge allocation.
425	// [impl->REQ-MESH-2]
426	fn enc_roster(entries: &[RosterEntry], tombstones: &[Tombstone]) -> Vec<u8> {
427	    let mut b = Vec::new();
428	    b.extend_from_slice(&(entries.len() as u32).to_be_bytes());
429	    for e in entries {
430	        push_field(&mut b, e.pubkey_hex.as_bytes());
431	        push_field(&mut b, e.subnet.as_bytes());
432	        push_field(&mut b, e.label.as_bytes());
433	        push_field(&mut b, e.machine_id.as_bytes());
434	        let addr = e
435	            .address
436	            .as_ref()
437	            .and_then(|v| serde_json::to_vec(v).ok())
438	            .unwrap_or_default();
439	        push_field(&mut b, &addr); // empty field = None
440	        push_field(&mut b, e.last_seen.as_bytes());
441	        b.extend_from_slice(&e.lease_epoch.to_be_bytes());
442	    }
443	    b.extend_from_slice(&(tombstones.len() as u32).to_be_bytes());
444	    for t in tombstones {
445	        push_field(&mut b, t.pubkey_hex.as_bytes());
446	        push_field(&mut b, t.subnet.as_bytes());
447	        push_field(&mut b, t.stamp.as_bytes());
448	    }
449	    b
450	}
451
452	/// A bounds-checked positional reader over a roster frame body. Every accessor
453	/// returns `None` rather than panicking — the input is attacker-reachable.
454	struct Rdr<'a> {
455	    b: &'a [u8],
456	    pos: usize,
457	}
458
459	impl<'a> Rdr<'a> {
460	    fn take(&mut self, n: usize) -> Option<&'a [u8]> {
461	        let end = self.pos.checked_add(n)?;
462	        if end > self.b.len() {
463	            return None;
464	        }
465	        let s = &self.b[self.pos..end];
466	        self.pos = end;
467	        Some(s)
468	    }
469	    fn u32(&mut self) -> Option<u32> {
470	        Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?))
471	    }
472	    fn u64(&mut self) -> Option<u64> {
473	        Some(u64::from_be_bytes(self.take(8)?.try_into().ok()?))
474	    }
475	    fn field(&mut self) -> Option<&'a [u8]> {
476	        let n = self.u32()? as usize;
477	        self.take(n)
478	    }
479	    fn utf8(&mut self) -> Option<String> {
480	        Some(std::str::from_utf8(self.field()?).ok()?.to_string())
481	    }
482	}
483
484	/// Decode a roster frame body. Total: any truncation, non-UTF-8 field, non-JSON
485	/// address, or trailing garbage decodes to `None` (the leg then no-ops).
486	// [impl->REQ-MESH-2]
487	fn dec_roster(buf: &[u8]) -> Option<(Vec<RosterEntry>, Vec<Tombstone>)> {
488	    let mut r = Rdr { b: buf, pos: 0 };
489	    let n = r.u32()? as usize;
490	    let mut entries = Vec::new();
491	    for _ in 0..n {
492	        let pubkey_hex = r.utf8()?;
493	        let subnet = r.utf8()?;
494	        let label = r.utf8()?;
495	        let machine_id = r.utf8()?;
496	        let addr_bytes = r.field()?;
497	        let address = if addr_bytes.is_empty() {
498	            None
499	        } else {
500	            Some(serde_json::from_slice(addr_bytes).ok()?)
501	        };
502	        let last_seen = r.utf8()?;
503	        let lease_epoch = r.u64()?;
504	        entries.push(RosterEntry {
505	            pubkey_hex,
506	            subnet,
507	            label,
508	            machine_id,
509	            address,
510	            last_seen,
511	            lease_epoch,
512	        });
513	    }
514	    let tn = r.u32()? as usize;
515	    let mut tombstones = Vec::new();
516	    for _ in 0..tn {
517	        let pubkey_hex = r.utf8()?;
518	        let subnet = r.utf8()?;
519	        let stamp = r.utf8()?;
520	        tombstones.push(Tombstone {
521	            pubkey_hex,
522	            subnet,
523	            stamp,
524	        });
525	    }
526	    if r.pos != buf.len() {
527	        return None; // no trailing garbage
528	    }
529	    Some((entries, tombstones))
530	}
531
532	/// Gap-fill a dial cache from learned roster entries (the seam decision): a
533	/// learned member's advertised address seeds `peer-addrs.json` **only when
534	/// absent** — a locally-observed address (written by the pump after a real dial)
535	/// is fresher and more reachable, so it is never clobbered. Self entries are
536	/// skipped. Returns whether anything changed (callers skip the disk write on a
537	/// no-op). This is what lets a node that learned C's entry from B's roster later
538	/// dial C directly — the Mesh-D6 reach precondition.
539	// [impl->REQ-MESH-2]
540	pub fn gapfill_peeraddrs(pa: &mut PeerAddrStore, self_hex: &str, entries: &[RosterEntry]) -> bool {
541	    let mut changed = false;
542	    for e in entries {
543	        if e.pubkey_hex == self_hex {
544	            continue;
545	        }
546	        if let Some(addr) = &e.address {
547	            if pa.get(&e.pubkey_hex).is_none() {
548	                changed |= pa.put(&e.pubkey_hex, addr.clone());
549	            }
550	        }
551	    }
552	    changed
553	}
554
555	/// The production roster exchange: file-backed `RosterStore` + `PeerAddrStore`,
556	/// re-read per connect (rare; honors a concurrent pairing/revoke without a
557	/// restart). Wired onto the live host by the daemon after `NetHost::start`. The
558	/// provider stamps the node's live address onto its self-entry so peers learn
559	/// how to reach it; the sink merges the peer's slice and gap-fills the dial
560	/// cache. Used by the real daemon only — tests inject their own exchange.
561	// [impl->REQ-MESH-2]
562	pub fn production_roster_exchange() -> RosterExchange {
563	    let self_hex = spt_store::nodeid::load_or_create()
564	        .map(|id| id.public_key().to_hex())
565	        .unwrap_or_default();
566	    let provider_hex = self_hex.clone();
567	    RosterExchange {
568	        provider: Arc::new(move |proven: &HashSet<String>, self_addr: &serde_json::Value| {
569	            let mut store = RosterStore::load();
570	            let label = spt_store::hostlabel::os_hostname().unwrap_or_default();
571	            let machine_id = crate::machineid::machine_id_hash().unwrap_or_default();
572	            // Peek, never consume: roster propagation must not disturb the
573	            // registry lease counter (the D3 rule).
574	            let lease = spt_store::epoch::EpochSource::load().current();
575	            let now = now_secs().to_string();
576	            let addr = if self_addr.is_null() {
577	                None
578	            } else {
579	                Some(self_addr.clone())
580	            };
581	            for s in proven {
582	                store.upsert_self(s, &provider_hex, &label, &machine_id, addr.clone(), &now, lease);
583	            }
584	            let _ = store.save();
585	            let mut entries = Vec::new();
586	            let mut tombstones = Vec::new();
587	            for s in proven {
588	                let (m, t) = store.roster_for(s);
589	                entries.extend(m);
590	                tombstones.extend(t);
591	            }
592	            (entries, tombstones)
593	        }),
594	        sink: Arc::new(move |entries: Vec<RosterEntry>, tombstones: Vec<Tombstone>| {
595	            let mut store = RosterStore::load();
596	            for e in &entries {
597	                store.merge_entry(e.clone());
598	            }
599	            for t in &tombstones {
600	                store.tombstone(&t.subnet, &t.pubkey_hex, &t.stamp);
601	            }
602	            let _ = store.save();
603	            let path = spt_store::peeraddrs::peer_addrs_file();
604	            let mut pa = PeerAddrStore::load_from(&path);
605	            if gapfill_peeraddrs(&mut pa, &self_hex, &entries) {
606	                let _ = pa.save_to(&path);
607	            }
608	        }),
609	    }
610	}
611
612	/// Unix seconds on the ceremony clock (NTP-corrected per REQ-PAIR-8; plain
613	/// system clock when NTP is disabled in hermetic scope) — the same stamp source
614	/// the pairing roster writers use, so `last_seen` values are comparable.
615	fn now_secs() -> u64 {
616	    spt_net::net::pairing::ntp::ceremony_now_secs()
617	}
618
619	#[cfg(test)]
620	mod roster_tests {
621	    use super::*;
622
623	    fn entry(pk: &str, subnet: &str, addr: Option<serde_json::Value>, lease: u64) -> RosterEntry {
624	        RosterEntry {
625	            pubkey_hex: pk.to_string(),
626	            subnet: subnet.to_string(),
627	            label: format!("{pk}-host"),
628	            machine_id: format!("{pk}-mid"),
629	            address: addr,
630	            last_seen: "1700000000".to_string(),
631	            lease_epoch: lease,
632	        }
633	    }
634
635	    fn tomb(pk: &str, subnet: &str) -> Tombstone {
636	        Tombstone {
637	            pubkey_hex: pk.to_string(),
638	            subnet: subnet.to_string(),
639	            stamp: "1700000001".to_string(),
640	        }
641	    }
642
643	    // [unit->REQ-MESH-2] the member-conn roster frame round-trips: an entry with
644	    // an address JSON, one without, across two subnets, plus tombstones — and the
645	    // explicit subnet field survives (unlike the implicit-subnet pairing Seed).
646	    #[test]
647	    fn roster_frame_round_trips() {
648	        let entries = vec![
649	            entry("aa", "home", Some(serde_json::json!({"id": "aa", "addrs": ["10.0.0.1:7"]})), 3),
650	            entry("bb", "home", None, 1),
651	            entry("cc", "work", Some(serde_json::json!({"id": "cc"})), 9),
652	        ];
653	        let tombstones = vec![tomb("dd", "home")];
654	        let buf = enc_roster(&entries, &tombstones);
655	        let (de, dt) = dec_roster(&buf).expect("round-trip");
656	        assert_eq!(de, entries, "entries (incl. explicit subnet + address) survive");
657	        assert_eq!(dt, tombstones, "tombstones survive");
658	    }
659
660	    // [unit->REQ-MESH-2] the empty slice round-trips (a node with nothing to
661	    // advertise still sends a well-formed, decodable frame).
662	    #[test]
663	    fn empty_roster_frame_round_trips() {
664	        let buf = enc_roster(&[], &[]);
665	        assert_eq!(dec_roster(&buf), Some((vec![], vec![])));
666	    }
667
668	    // [unit->REQ-MESH-2] every malformed frame decodes to None — a garbled roster
669	    // leg no-ops, never panics and never partial-applies a torn slice.
670	    #[test]
671	    fn malformed_roster_frame_rejected() {
672	        assert_eq!(dec_roster(&[]), None, "empty buffer (no count)");
673	        assert_eq!(dec_roster(&[0, 0, 0, 1]), None, "count claims 1, no entry");
674	        // A valid frame with one trailing byte is rejected (no silent slack).
675	        let mut buf = enc_roster(&[entry("aa", "home", None, 1)], &[]);
676	        buf.push(0xff);
677	        assert_eq!(dec_roster(&buf), None, "trailing garbage");
678	        // A non-JSON address field is rejected.
679	        let mut b = Vec::new();
680	        b.extend_from_slice(&1u32.to_be_bytes());
681	        push_field(&mut b, b"aa");
682	        push_field(&mut b, b"home");
683	        push_field(&mut b, b"lbl");
684	        push_field(&mut b, b"mid");
685	        push_field(&mut b, b"not json"); // address field, non-empty + non-JSON
686	        push_field(&mut b, b"1700000000");
687	        b.extend_from_slice(&1u64.to_be_bytes());
688	        b.extend_from_slice(&0u32.to_be_bytes());
689	        assert_eq!(dec_roster(&b), None, "non-json address");
690	    }
691
692	    // [unit->REQ-MESH-2] gap-fill seeds the dial cache from a learned member's
693	    // advertised address ONLY when absent: a locally-observed entry is never
694	    // clobbered, and the node's own entry is skipped.
695	    #[test]
696	    fn gapfill_fills_absent_only_and_skips_self() {
697	        let addr_c = serde_json::json!({"id": "cc", "addrs": ["10.0.0.3:7"]});
698	        let observed_b = serde_json::json!({"id": "bb", "addrs": ["192.168.0.9:7"]});
699	        let advertised_b = serde_json::json!({"id": "bb", "addrs": ["10.0.0.2:7"]});
700
701	        let mut pa = PeerAddrStore::default();
702	        pa.put("bb", observed_b.clone()); // a fresher, locally-observed addr
703
704	        let entries = vec![
705	            entry("self", "home", Some(serde_json::json!({"id": "self"})), 1), // skipped
706	            entry("cc", "home", Some(addr_c.clone()), 1),                      // absent ⇒ fill
707	            entry("bb", "home", Some(advertised_b), 1),                        // present ⇒ keep observed
708	        ];
709	        assert!(gapfill_peeraddrs(&mut pa, "self", &entries), "C was filled");
710	        assert_eq!(pa.get("cc"), Some(&addr_c), "absent member filled from roster");
711	        assert_eq!(pa.get("bb"), Some(&observed_b), "observed addr not clobbered");
712	        assert_eq!(pa.get("self"), None, "own entry skipped");
713
714	        // Idempotent: a second pass with the same input changes nothing.
715	        assert!(!gapfill_peeraddrs(&mut pa, "self", &entries), "no further change");
716	    }
717	}
718

//! Connect-time seed-proof exchange (Mesh-D2, REQ-MESH-1) — the daemon-side
//! orchestration that drives the `spt-net` [`seedproof`] codec onto a live QUIC
//! connection.
//!
//! [`seedproof`](spt_net::net::mesh::seedproof) is the pure crypto (membership
//! key + mutual channel-bound transcript). This module runs the **mutual
//! exchange** over a dedicated control stream at every dial + accept, *before*
//! the connection is registered as usable: each side proves current-epoch
//! knowledge of the seed for **every subnet the two nodes share**, and the
//! verified set is cached on the broker `ConnEntry`. A failed or empty proof
//! drops the connection.
//!
//! ## Why a set, not a single subnet
//!
//! Connections are not subnet-scoped (`dial` takes only an address; iroh dedups
//! to one connection per peer-pair on the shared ALPN), and the inbound gates
//! the mesh swaps at D5 are per-`(subnet, origin)`, evaluated per item. So one
//! connection carries traffic for every shared subnet, and the cached verdict
//! must answer "did this peer prove subnet S?" for any S — a [`HashSet`], not a
//! bool.
//!
//! ## The exchange (one bidi control stream)
//!
//! Dialer opens it (`open_bi`); acceptor accepts it (`accept_bi`) *before*
//! `register_conn` wires the general app-stream acceptor, so it is never
//! confused with an app stream. Then a four-leg ping-pong:
//!
//! 1. dialer → `Hello { nonce_d, subnets = its local names }`
//! 2. acceptor → `Hello { nonce_a, subnets = intersection }` (reveals only shared names)
//! 3. dialer → one `Proof` per shared subnet; acceptor reads + verifies each
//! 4. acceptor → one `Proof` per shared subnet; dialer reads + verifies each
//!
//! Each side derives `MK_S` from **its own** local seed + epoch for S; an epoch
//! skew or a wrong seed makes the tags diverge → that subnet fails → the whole
//! connection drops (exact-epoch; the N-1 re-seed grace is Mesh-D7). Nonces are
//! per-connection-fresh and reused across the per-subnet proofs (the transcript
//! already binds `subnet_id` + `seed_epoch`).
// [impl->REQ-MESH-1]

use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use spt_net::net::endpoint::{Connection, RecvStream, SendStream};
use spt_net::net::mesh::seedproof::{
    MembershipKey, ProofRole, SeedProofFrame, SeedProofTranscript, NONCE_LEN,
};
use spt_proto::identity::{Identity, PublicKey};
use spt_store::peeraddrs::PeerAddrStore;
use spt_store::roster::{RosterEntry, RosterStore, Tombstone};
use spt_store::subnet::SubnetStore;

/// One subnet's membership credential as the proof needs it: the human name
/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
/// never on the wire). Built from `spt_store::subnet::SubnetStore`.
#[derive(Clone)]
pub struct SubnetCred {
    /// Subnet name — the only field that crosses the wire (a label, not a secret).
    pub name: String,
    /// The subnet seed bytes (HKDF input material). Never transmitted.
    pub seed: Vec<u8>,
    /// The local current `seed_epoch` for this subnet. Never transmitted.
    pub epoch: u64,
}

/// A live source of this node's subnet credentials, re-read per connection so a
/// rotation (Mesh-D7) is picked up without a restart. Production wires this to
/// `SubnetStore::load()`; `None` on [`NetConfig`](crate::nethost::NetConfig)
/// disables seed-proof entirely (both peers must agree — a mixed pair would
/// deadlock on the missing control stream).
pub type MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred> + Send + Sync>;

/// The production membership source: load the node's [`SubnetStore`] fresh on
/// every connect and project each subnet to its [`SubnetCred`]. Fresh-load (not
/// a snapshot) so a future seed rotation is honored without a daemon restart;
/// connects are rare enough that the file read is negligible. A subnet whose
/// stored seed hex is malformed is skipped (it can't prove anyway).
// [impl->REQ-MESH-1]
pub fn production_membership() -> MembershipSource {
    Arc::new(|| {
        SubnetStore::load()
            .subnets
            .iter()
            .filter_map(|r| {
                r.seed_bytes().map(|seed| SubnetCred {
                    name: r.name.clone(),
                    seed: seed.to_vec(),
                    epoch: r.epoch,
                })
            })
            .collect()
    })
}

/// Cap on the seed-proof control-stream framing — a single small handshake; a
/// length claiming more is a malformed/hostile peer, dropped.
const MAX_FRAME: usize = 64 * 1024;

/// Generate a fresh 32-byte per-connection nonce from the OS CSPRNG. Reuses
/// `spt-proto`'s Ed25519 seed generator (OsRng) as the randomness source — the
/// same no-extra-`rand`-dependency trick the stores use.
fn fresh_nonce() -> [u8; NONCE_LEN] {
    Identity::generate().seed()
}

/// Run the mutual seed-proof on `conn` and return the set of subnets BOTH sides
/// proved, or `None` if the connection must be dropped (no shared subnet, a
/// failed proof, or a transport/protocol error). `local_id` is this node's
/// pubkey; `role` is which side of the connection we are.
///
/// When `roster` is `Some` (Mesh-D4, REQ-MESH-2), a roster-exchange leg rides
/// the **same** control stream **after** both proofs verify — so the roster is
/// "exchanged only over seed-proof'd member connections" by construction, not by
/// convention. `self_addr` is this node's live dialable address (opaque JSON,
/// `Null` if none) which the provider stamps onto its own advertised entry. The
/// leg is **best-effort**: the connection is already a proven member conn, so a
/// missing/short/garbled roster frame is swallowed (it never drops the conn) —
/// which also lets a D4 peer interoperate with a not-yet-upgraded one that sends
/// no roster.
// [impl->REQ-MESH-1]
// [impl->REQ-MESH-2]
pub async fn prove_membership(
    conn: &Connection,
    local_id: PublicKey,
    role: ProofRole,
    creds: &[SubnetCred],
    roster: Option<&RosterExchange>,
    self_addr: &serde_json::Value,
) -> Option<HashSet<String>> {
    let remote = PublicKey::from_bytes(conn.remote_id().as_bytes()).ok()?;
    // Channel binding: the transcript pins both handshake-proven pubkeys in a
    // role-fixed order (dialer first), so a tag is valid on exactly this conn.
    let (dialer_pub, acceptor_pub) = match role {
        ProofRole::Dialer => (local_id, remote),
        ProofRole::Acceptor => (remote, local_id),
    };
    let by_name: HashMap<&str, &SubnetCred> =
        creds.iter().map(|c| (c.name.as_str(), c)).collect();

    match role {
        ProofRole::Dialer => {
            let (mut send, mut recv) = conn.open_bi().await.ok()?;
            prove_as_dialer(
                &mut send,
                &mut recv,
                creds,
                &by_name,
                dialer_pub,
                acceptor_pub,
                roster,
                self_addr,
            )
            .await
        }
        ProofRole::Acceptor => {
            let (mut send, mut recv) = conn.accept_bi().await.ok()?;
            prove_as_acceptor(
                &mut send,
                &mut recv,
                &by_name,
                dialer_pub,
                acceptor_pub,
                roster,
                self_addr,
            )
            .await
        }
    }
}

#[allow(clippy::too_many_arguments)]
async fn prove_as_dialer(
    send: &mut SendStream,
    recv: &mut RecvStream,
    creds: &[SubnetCred],
    by_name: &HashMap<&str, &SubnetCred>,
    dialer_pub: PublicKey,
    acceptor_pub: PublicKey,
    roster: Option<&RosterExchange>,
    self_addr: &serde_json::Value,
) -> Option<HashSet<String>> {
    let nonce_d = fresh_nonce();
    let my_names: Vec<String> = creds.iter().map(|c| c.name.clone()).collect();
    write_frame(send, &SeedProofFrame::Hello { nonce: nonce_d, subnets: my_names }.encode())
        .await?;

    // Acceptor replies with the intersection (in its order) + its nonce.
    let (nonce_a, shared) = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
        Some(SeedProofFrame::Hello { nonce, subnets }) => (nonce, subnets),
        _ => return None,
    };
    // Defensive: only honor subnets we actually offered.
    if shared.is_empty() || shared.iter().any(|s| !by_name.contains_key(s.as_str())) {
        let _ = send.finish();
        return None;
    }

    let transcripts = build_transcripts(&shared, by_name, dialer_pub, acceptor_pub, nonce_d, nonce_a)?;

    // Send all our (dialer-role) proofs first; they buffer while the acceptor
    // reads them, so this ping-pong never deadlocks on the single stream.
    for (_name, t, mk) in &transcripts {
        let tag = t.tag(mk, ProofRole::Dialer);
        write_frame(send, &SeedProofFrame::Proof { tag }.encode()).await?;
    }
    // Then read + verify the acceptor's proofs.
    let mut proven = HashSet::new();
    for (name, t, mk) in &transcripts {
        let tag = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
            Some(SeedProofFrame::Proof { tag }) => tag,
            _ => return None,
        };
        if !t.verify(mk, ProofRole::Acceptor, &tag) {
            return None; // any agreed subnet failing ⇒ drop the whole connection
        }
        proven.insert(name.clone());
    }
    // Mesh-D4 roster leg (REQ-MESH-2): dialer sends its slice first (it buffers
    // while the acceptor reads), then reads the acceptor's. Best-effort — never
    // fails the already-proven connection.
    exchange_roster_dialer(send, recv, roster, self_addr, &proven).await;
    let _ = send.finish();
    Some(proven)
}

#[allow(clippy::too_many_arguments)]
async fn prove_as_acceptor(
    send: &mut SendStream,
    recv: &mut RecvStream,
    by_name: &HashMap<&str, &SubnetCred>,
    dialer_pub: PublicKey,
    acceptor_pub: PublicKey,
    roster: Option<&RosterExchange>,
    self_addr: &serde_json::Value,
) -> Option<HashSet<String>> {
    let (nonce_d, dialer_names) = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
        Some(SeedProofFrame::Hello { nonce, subnets }) => (nonce, subnets),
        _ => return None,
    };
    // Intersection in a deterministic (sorted) order both sides walk identically.
    let mut shared: Vec<String> = dialer_names
        .into_iter()
        .filter(|s| by_name.contains_key(s.as_str()))
        .collect();
    shared.sort();
    shared.dedup();

    let nonce_a = fresh_nonce();
    write_frame(
        send,
        &SeedProofFrame::Hello { nonce: nonce_a, subnets: shared.clone() }.encode(),
    )
    .await?;
    if shared.is_empty() {
        let _ = send.finish();
        return None; // no shared subnet — nothing to mesh, drop
    }

    let transcripts = build_transcripts(&shared, by_name, dialer_pub, acceptor_pub, nonce_d, nonce_a)?;

    // Read + verify the dialer's proofs first, then send ours (mirror of the
    // dialer's send-then-read, so the ping-pong stays deadlock-free).
    let mut proven = HashSet::new();
    for (name, t, mk) in &transcripts {
        let tag = match read_frame(recv, MAX_FRAME).await.and_then(|b| SeedProofFrame::decode(&b)) {
            Some(SeedProofFrame::Proof { tag }) => tag,
            _ => return None,
        };
        if !t.verify(mk, ProofRole::Dialer, &tag) {
            return None;
        }
        proven.insert(name.clone());
    }
    for (_name, t, mk) in &transcripts {
        let tag = t.tag(mk, ProofRole::Acceptor);
        write_frame(send, &SeedProofFrame::Proof { tag }.encode()).await?;
    }
    // Mesh-D4 roster leg (REQ-MESH-2): mirror of the dialer — read its slice
    // first, then send ours. Best-effort (see `prove_membership`).
    exchange_roster_acceptor(send, recv, roster, self_addr, &proven).await;
    let _ = send.finish();
    Some(proven)
}

/// Build `(name, transcript, MK)` for each shared subnet, deriving each `MK`
/// from the local credential's seed + epoch. `None` if a shared name has no
/// local credential (cannot happen after the membership filter, but kept total).
fn build_transcripts(
    shared: &[String],
    by_name: &HashMap<&str, &SubnetCred>,
    dialer_pub: PublicKey,
    acceptor_pub: PublicKey,
    nonce_d: [u8; NONCE_LEN],
    nonce_a: [u8; NONCE_LEN],
) -> Option<Vec<(String, SeedProofTranscript, MembershipKey)>> {
    let mut out = Vec::with_capacity(shared.len());
    for name in shared {
        let cred = by_name.get(name.as_str())?;
        let mk = MembershipKey::derive(&cred.seed, name, cred.epoch);
        let t = SeedProofTranscript {
            subnet_id: name.clone(),
            seed_epoch: cred.epoch,
            dialer_pub,
            acceptor_pub,
            dialer_nonce: nonce_d,
            acceptor_nonce: nonce_a,
        };
        out.push((name.clone(), t, mk));
    }
    Some(out)
}

/// Write a length-delimited frame (`u32` BE length prefix + body).
async fn write_frame(send: &mut SendStream, body: &[u8]) -> Option<()> {
    send.write_all(&(body.len() as u32).to_be_bytes()).await.ok()?;
    send.write_all(body).await.ok()?;
    Some(())
}

/// Read one length-delimited frame, rejecting a length of 0 or > `max`.
async fn read_frame(recv: &mut RecvStream, max: usize) -> Option<Vec<u8>> {
    let mut lenb = [0u8; 4];
    recv.read_exact(&mut lenb).await.ok()?;
    let n = u32::from_be_bytes(lenb) as usize;
    if n == 0 || n > max {
        return None;
    }
    let mut buf = vec![0u8; n];
    recv.read_exact(&mut buf).await.ok()?;
    Some(buf)
}

// ── Mesh-D4: on-connect roster propagation (REQ-MESH-2) ──────────────────────
//
// After both sides prove membership, each sends the other its roster slice for
// the *proven* subnets only (a peer that proved X never receives subnet Y's
// roster — the same cross-subnet-leak defense D3 baked into `Frame::Seed`). The
// receiver merges the slice into its durable roster (so it learns members it
// never paired with — the transitive `A learns C via B`) and gap-fills its dial
// cache from any advertised addresses (so it can later *reach* them — the D6
// precondition). The store wiring is injected as closures so this crypto-adjacent
// module stays decoupled from the spt-store directory (the `MembershipSource`
// posture).

/// Builds this node's roster slice for the proven subnets, stamping its own
/// live address (`self_addr`) onto its advertised self-entry so peers learn how
/// to reach it. `proven` is the set both sides verified; `self_addr` is the
/// opaque dialable-address JSON (`Null` ⇒ advertise no address).
pub type RosterProvider =
    Arc<dyn Fn(&HashSet<String>, &serde_json::Value) -> (Vec<RosterEntry>, Vec<Tombstone>) + Send + Sync>;

/// Merges a received roster slice into the durable store and reconciles the dial
/// cache (gap-fill). Receives the peer's advertised entries + tombstones.
pub type RosterSink = Arc<dyn Fn(Vec<RosterEntry>, Vec<Tombstone>) + Send + Sync>;

/// The injected roster-exchange seam wired onto a live member connection
/// (Mesh-D4, REQ-MESH-2). `None` (the default) disables the leg entirely — the
/// proof still runs, no roster is exchanged. Cheap to clone (Arc closures).
#[derive(Clone)]
pub struct RosterExchange {
    /// Produces our outbound slice (+ self-address stamp) for the proven subnets.
    pub provider: RosterProvider,
    /// Consumes the peer's inbound slice (merge + dial-cache gap-fill).
    pub sink: RosterSink,
}

/// The dialer's roster leg: send ours, then read + sink theirs. Best-effort —
/// any framing/codec failure is swallowed (the conn is already proven).
async fn exchange_roster_dialer(
    send: &mut SendStream,
    recv: &mut RecvStream,
    roster: Option<&RosterExchange>,
    self_addr: &serde_json::Value,
    proven: &HashSet<String>,
) {
    let Some(rx) = roster else { return };
    if proven.is_empty() {
        return;
    }
    let (entries, tombstones) = (rx.provider)(proven, self_addr);
    if write_frame(send, &enc_roster(&entries, &tombstones)).await.is_none() {
        return;
    }
    if let Some(body) = read_frame(recv, MAX_FRAME).await {
        if let Some((rent, rtomb)) = dec_roster(&body) {
            (rx.sink)(rent, rtomb);
        }
    }
}

/// The acceptor's roster leg: read + sink theirs, then send ours (mirror of the
/// dialer so the single bidi stream never deadlocks). Best-effort.
async fn exchange_roster_acceptor(
    send: &mut SendStream,
    recv: &mut RecvStream,
    roster: Option<&RosterExchange>,
    self_addr: &serde_json::Value,
    proven: &HashSet<String>,
) {
    let Some(rx) = roster else { return };
    if proven.is_empty() {
        return;
    }
    if let Some(body) = read_frame(recv, MAX_FRAME).await {
        if let Some((rent, rtomb)) = dec_roster(&body) {
            (rx.sink)(rent, rtomb);
        }
    }
    let (entries, tombstones) = (rx.provider)(proven, self_addr);
    let _ = write_frame(send, &enc_roster(&entries, &tombstones)).await;
}

/// Append a length-prefixed (`u32` len + bytes) field.
fn push_field(out: &mut Vec<u8>, bytes: &[u8]) {
    out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
    out.extend_from_slice(bytes);
}

/// Encode a roster slice for the member-connection leg. Distinct channel from
/// the pairing `Frame::Seed`, so the `subnet` field is **explicit** here (a
/// member conn spans every proven subnet at once — there is no single implicit
/// ceremony subnet). Self-contained positional codec; the field set mirrors
/// `RosterEntry` and is kept in sync by `roster_frame_round_trips`. Counts are
/// never pre-allocated on decode — a hostile count exhausts the buffer on the
/// first short field, not a huge allocation.
// [impl->REQ-MESH-2]
fn enc_roster(entries: &[RosterEntry], tombstones: &[Tombstone]) -> Vec<u8> {
    let mut b = Vec::new();
    b.extend_from_slice(&(entries.len() as u32).to_be_bytes());
    for e in entries {
        push_field(&mut b, e.pubkey_hex.as_bytes());
        push_field(&mut b, e.subnet.as_bytes());
        push_field(&mut b, e.label.as_bytes());
        push_field(&mut b, e.machine_id.as_bytes());
        let addr = e
            .address
            .as_ref()
            .and_then(|v| serde_json::to_vec(v).ok())
            .unwrap_or_default();
        push_field(&mut b, &addr); // empty field = None
        push_field(&mut b, e.last_seen.as_bytes());
        b.extend_from_slice(&e.lease_epoch.to_be_bytes());
    }
    b.extend_from_slice(&(tombstones.len() as u32).to_be_bytes());
    for t in tombstones {
        push_field(&mut b, t.pubkey_hex.as_bytes());
        push_field(&mut b, t.subnet.as_bytes());
        push_field(&mut b, t.stamp.as_bytes());
    }
    b
}

/// A bounds-checked positional reader over a roster frame body. Every accessor
/// returns `None` rather than panicking — the input is attacker-reachable.
struct Rdr<'a> {
    b: &'a [u8],
    pos: usize,
}

impl<'a> Rdr<'a> {
    fn take(&mut self, n: usize) -> Option<&'a [u8]> {
        let end = self.pos.checked_add(n)?;
        if end > self.b.len() {
            return None;
        }
        let s = &self.b[self.pos..end];
        self.pos = end;
        Some(s)
    }
    fn u32(&mut self) -> Option<u32> {
        Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?))
    }
    fn u64(&mut self) -> Option<u64> {
        Some(u64::from_be_bytes(self.take(8)?.try_into().ok()?))
    }
    fn field(&mut self) -> Option<&'a [u8]> {
        let n = self.u32()? as usize;
        self.take(n)
    }
    fn utf8(&mut self) -> Option<String> {
        Some(std::str::from_utf8(self.field()?).ok()?.to_string())
    }
}

/// Decode a roster frame body. Total: any truncation, non-UTF-8 field, non-JSON
/// address, or trailing garbage decodes to `None` (the leg then no-ops).
// [impl->REQ-MESH-2]
fn dec_roster(buf: &[u8]) -> Option<(Vec<RosterEntry>, Vec<Tombstone>)> {
    let mut r = Rdr { b: buf, pos: 0 };
    let n = r.u32()? as usize;
    let mut entries = Vec::new();
    for _ in 0..n {
        let pubkey_hex = r.utf8()?;
        let subnet = r.utf8()?;
        let label = r.utf8()?;
        let machine_id = r.utf8()?;
        let addr_bytes = r.field()?;
        let address = if addr_bytes.is_empty() {
            None
        } else {
            Some(serde_json::from_slice(addr_bytes).ok()?)
        };
        let last_seen = r.utf8()?;
        let lease_epoch = r.u64()?;
        entries.push(RosterEntry {
            pubkey_hex,
            subnet,
            label,
            machine_id,
            address,
            last_seen,
            lease_epoch,
        });
    }
    let tn = r.u32()? as usize;
    let mut tombstones = Vec::new();
    for _ in 0..tn {
        let pubkey_hex = r.utf8()?;
        let subnet = r.utf8()?;
        let stamp = r.utf8()?;
        tombstones.push(Tombstone {
            pubkey_hex,
            subnet,
            stamp,
        });
    }
    if r.pos != buf.len() {
        return None; // no trailing garbage
    }
    Some((entries, tombstones))
}

/// Gap-fill a dial cache from learned roster entries (the seam decision): a
/// learned member's advertised address seeds `peer-addrs.json` **only when
/// absent** — a locally-observed address (written by the pump after a real dial)
/// is fresher and more reachable, so it is never clobbered. Self entries are
/// skipped. Returns whether anything changed (callers skip the disk write on a
/// no-op). This is what lets a node that learned C's entry from B's roster later
/// dial C directly — the Mesh-D6 reach precondition.
// [impl->REQ-MESH-2]
pub fn gapfill_peeraddrs(pa: &mut PeerAddrStore, self_hex: &str, entries: &[RosterEntry]) -> bool {
    let mut changed = false;
    for e in entries {
        if e.pubkey_hex == self_hex {
            continue;
        }
        if let Some(addr) = &e.address {
            if pa.get(&e.pubkey_hex).is_none() {
                changed |= pa.put(&e.pubkey_hex, addr.clone());
            }
        }
    }
    changed
}

/// The production roster exchange: file-backed `RosterStore` + `PeerAddrStore`,
/// re-read per connect (rare; honors a concurrent pairing/revoke without a
/// restart). Wired onto the live host by the daemon after `NetHost::start`. The
/// provider stamps the node's live address onto its self-entry so peers learn
/// how to reach it; the sink merges the peer's slice and gap-fills the dial
/// cache. Used by the real daemon only — tests inject their own exchange.
// [impl->REQ-MESH-2]
pub fn production_roster_exchange() -> RosterExchange {
    let self_hex = spt_store::nodeid::load_or_create()
        .map(|id| id.public_key().to_hex())
        .unwrap_or_default();
    let provider_hex = self_hex.clone();
    RosterExchange {
        provider: Arc::new(move |proven: &HashSet<String>, self_addr: &serde_json::Value| {
            let mut store = RosterStore::load();
            let label = spt_store::hostlabel::os_hostname().unwrap_or_default();
            let machine_id = crate::machineid::machine_id_hash().unwrap_or_default();
            // Peek, never consume: roster propagation must not disturb the
            // registry lease counter (the D3 rule).
            let lease = spt_store::epoch::EpochSource::load().current();
            let now = now_secs().to_string();
            let addr = if self_addr.is_null() {
                None
            } else {
                Some(self_addr.clone())
            };
            for s in proven {
                store.upsert_self(s, &provider_hex, &label, &machine_id, addr.clone(), &now, lease);
            }
            let _ = store.save();
            let mut entries = Vec::new();
            let mut tombstones = Vec::new();
            for s in proven {
                let (m, t) = store.roster_for(s);
                entries.extend(m);
                tombstones.extend(t);
            }
            (entries, tombstones)
        }),
        sink: Arc::new(move |entries: Vec<RosterEntry>, tombstones: Vec<Tombstone>| {
            let mut store = RosterStore::load();
            for e in &entries {
                store.merge_entry(e.clone());
            }
            for t in &tombstones {
                store.tombstone(&t.subnet, &t.pubkey_hex, &t.stamp);
            }
            let _ = store.save();
            let path = spt_store::peeraddrs::peer_addrs_file();
            let mut pa = PeerAddrStore::load_from(&path);
            if gapfill_peeraddrs(&mut pa, &self_hex, &entries) {
                let _ = pa.save_to(&path);
            }
        }),
    }
}

/// Unix seconds on the ceremony clock (NTP-corrected per REQ-PAIR-8; plain
/// system clock when NTP is disabled in hermetic scope) — the same stamp source
/// the pairing roster writers use, so `last_seen` values are comparable.
fn now_secs() -> u64 {
    spt_net::net::pairing::ntp::ceremony_now_secs()
}

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

    fn entry(pk: &str, subnet: &str, addr: Option<serde_json::Value>, lease: u64) -> RosterEntry {
        RosterEntry {
            pubkey_hex: pk.to_string(),
            subnet: subnet.to_string(),
            label: format!("{pk}-host"),
            machine_id: format!("{pk}-mid"),
            address: addr,
            last_seen: "1700000000".to_string(),
            lease_epoch: lease,
        }
    }

    fn tomb(pk: &str, subnet: &str) -> Tombstone {
        Tombstone {
            pubkey_hex: pk.to_string(),
            subnet: subnet.to_string(),
            stamp: "1700000001".to_string(),
        }
    }

    // [unit->REQ-MESH-2] the member-conn roster frame round-trips: an entry with
    // an address JSON, one without, across two subnets, plus tombstones — and the
    // explicit subnet field survives (unlike the implicit-subnet pairing Seed).
    #[test]
    fn roster_frame_round_trips() {
        let entries = vec![
            entry("aa", "home", Some(serde_json::json!({"id": "aa", "addrs": ["10.0.0.1:7"]})), 3),
            entry("bb", "home", None, 1),
            entry("cc", "work", Some(serde_json::json!({"id": "cc"})), 9),
        ];
        let tombstones = vec![tomb("dd", "home")];
        let buf = enc_roster(&entries, &tombstones);
        let (de, dt) = dec_roster(&buf).expect("round-trip");
        assert_eq!(de, entries, "entries (incl. explicit subnet + address) survive");
        assert_eq!(dt, tombstones, "tombstones survive");
    }

    // [unit->REQ-MESH-2] the empty slice round-trips (a node with nothing to
    // advertise still sends a well-formed, decodable frame).
    #[test]
    fn empty_roster_frame_round_trips() {
        let buf = enc_roster(&[], &[]);
        assert_eq!(dec_roster(&buf), Some((vec![], vec![])));
    }

    // [unit->REQ-MESH-2] every malformed frame decodes to None — a garbled roster
    // leg no-ops, never panics and never partial-applies a torn slice.
    #[test]
    fn malformed_roster_frame_rejected() {
        assert_eq!(dec_roster(&[]), None, "empty buffer (no count)");
        assert_eq!(dec_roster(&[0, 0, 0, 1]), None, "count claims 1, no entry");
        // A valid frame with one trailing byte is rejected (no silent slack).
        let mut buf = enc_roster(&[entry("aa", "home", None, 1)], &[]);
        buf.push(0xff);
        assert_eq!(dec_roster(&buf), None, "trailing garbage");
        // A non-JSON address field is rejected.
        let mut b = Vec::new();
        b.extend_from_slice(&1u32.to_be_bytes());
        push_field(&mut b, b"aa");
        push_field(&mut b, b"home");
        push_field(&mut b, b"lbl");
        push_field(&mut b, b"mid");
        push_field(&mut b, b"not json"); // address field, non-empty + non-JSON
        push_field(&mut b, b"1700000000");
        b.extend_from_slice(&1u64.to_be_bytes());
        b.extend_from_slice(&0u32.to_be_bytes());
        assert_eq!(dec_roster(&b), None, "non-json address");
    }

    // [unit->REQ-MESH-2] gap-fill seeds the dial cache from a learned member's
    // advertised address ONLY when absent: a locally-observed entry is never
    // clobbered, and the node's own entry is skipped.
    #[test]
    fn gapfill_fills_absent_only_and_skips_self() {
        let addr_c = serde_json::json!({"id": "cc", "addrs": ["10.0.0.3:7"]});
        let observed_b = serde_json::json!({"id": "bb", "addrs": ["192.168.0.9:7"]});
        let advertised_b = serde_json::json!({"id": "bb", "addrs": ["10.0.0.2:7"]});

        let mut pa = PeerAddrStore::default();
        pa.put("bb", observed_b.clone()); // a fresher, locally-observed addr

        let entries = vec![
            entry("self", "home", Some(serde_json::json!({"id": "self"})), 1), // skipped
            entry("cc", "home", Some(addr_c.clone()), 1),                      // absent ⇒ fill
            entry("bb", "home", Some(advertised_b), 1),                        // present ⇒ keep observed
        ];
        assert!(gapfill_peeraddrs(&mut pa, "self", &entries), "C was filled");
        assert_eq!(pa.get("cc"), Some(&addr_c), "absent member filled from roster");
        assert_eq!(pa.get("bb"), Some(&observed_b), "observed addr not clobbered");
        assert_eq!(pa.get("self"), None, "own entry skipped");

        // Idempotent: a second pass with the same input changes nothing.
        assert!(!gapfill_peeraddrs(&mut pa, "self", &entries), "no further change");
    }
}


Orchestration clear. Key crypto insight: re-seed grace needs the verifier to retain the **previous-generation seed** (MK derives from seed+epoch, both change on rotation). So `rotate_seed` must keep prev seed one-deep, and the proof exchange must carry both current+prev proofs so a stale peer can verify a fresh one. Read the stores + consumption sites.