<system-reminder>[Truncated: PARTIAL view — showing lines 1-1311 of 1635 total (26500 tokens, cap 25000). Call Read with offset=1312 limit=1311 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	//! D2-wire: the pairing ALPN ceremony driver (ADR-0005, REQ-PAIR-1).
2	//!
3	//! This is the transport glue that runs the TOTP-SPAKE2 ceremony (the crypto core of
4	//! [`spake`](super::spake) + [`transcript`](super::transcript)) over a real iroh bidi
5	//! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
6	//! subnet-global [rate limiter](super::ratelimit), and writes the confirmed peer into
7	//! each side's [`TrustStore`]. It turns "two machines pair via one TOTP code" from a
8	//! same-process unit test into an over-the-wire exchange.
9	//!
10	//! ## Why a responder `Announce{epoch}` precedes the SPAKE messages
11	//!
12	//! [`Initiator::start`](super::spake::Initiator::start) bakes the **full transcript**
13	//! into `msg_a`, and the transcript binds the seed-rotation [`epoch`] — which is
14	//! *responder-authoritative* (only seed-holders track it; the joiner has just the
15	//! 6-digit code + subnet name, per [`transcript`](super::transcript) docs). So the
16	//! joiner cannot construct `msg_a` until the responder tells it the current epoch. The
17	//! wire therefore opens with the joiner's [`Hello`](Frame::Hello) (subnet name + its
18	//! clock-derived TOTP step), the responder's [`Announce`](Frame::Announce) (the epoch,
19	//! *after* it has admitted the ceremony and accepted the ±1 step), and only then the
20	//! four SPAKE messages. The TOTP **step** is dialer-announced and clock-derived
21	//! (`totp.rs`), so it needs no announce — only the seed epoch does.
22	//!
23	//! ## Authenticated identities
24	//!
25	//! Both node pubkeys bound into the transcript come from the **iroh-authenticated**
26	//! [`Connection::remote_id`] (the QUIC handshake already proves the peer holds the
27	//! Ed25519 key that *is* its node id) plus the local node's own pubkey — never the
28	//! bytes a peer claims in a frame. A MITM substituting its key is rejected by the
29	//! transcript binding (`spake.rs` `substituted_pubkey_fails`).
30	//!
31	//! ## Scope (component-level; WAN is D9)
32	//!
33	//! Like [`endpoint`](super::super::endpoint) tests, this dials by `EndpointAddr` over
34	//! loopback with relays disabled. The relay **rendezvous** (`H(subnet-name ‖ step)`,
35	//! `CONTEXT.md`) that lets two nodes that don't know each other's id *meet* is deferred
36	//! to D9; here the joiner already holds the responder's dialable address. Seed transfer
37	//! to the joiner (so it becomes a future seed-holder) is deferred to D2f — this driver
38	//! delivers the ceremony + mutual trust write only.
39	//
40	// [impl->REQ-PAIR-1]
41
42	use iroh::endpoint::{Connection, RecvStream, SendStream};
43	use spt_proto::identity::PublicKey;
44	use spt_store::trust::{TrustOutcome, TrustStore};
45
46	use spt_store::roster::{RosterEntry, RosterStore, Tombstone};
47	use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};
48
49	use super::ratelimit::{PairingRateLimiter, RateLimited};
50	use super::spake::{Initiator, PairError, Responder};
51	use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
52	use super::transcript::{PairingTranscript, TAG_LEN};
53
54	/// The pre-trust pairing ALPN. Distinct from the trusted
55	/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
56	/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
57	/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
58	/// future wire break.
59	pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";
60
61	/// Hard cap on a single wire frame. Pre-trust frames (Hello/Spake/…) are tens of bytes;
62	/// the largest payload is the **post-confirm `Seed` frame**, which now carries the
63	/// seed-holder's full member roster (REQ-MESH-2) — a sequence of small per-member fields.
64	/// 64 KiB bounds a hostile length prefix (one ceremony at a time, rate-limited, so this is
65	/// not a DoS lever) while leaving generous room for a real subnet's roster; each *field*
66	/// stays individually bounded by this same cap, and counts are never pre-allocated from the
67	/// wire (a huge count hits "frame truncated" immediately, not a huge allocation).
68	const MAX_FRAME: usize = 65536;
69
70	// Frame tag bytes.
71	const T_ABORT: u8 = 0;
72	const T_HELLO: u8 = 1;
73	const T_ANNOUNCE: u8 = 2;
74	const T_SPAKE1: u8 = 3;
75	const T_SPAKE2: u8 = 4;
76	const T_CONFIRM: u8 = 5;
77	const T_DONE: u8 = 6;
78	const T_SEED: u8 = 7;
79
80	// Abort reason bytes.
81	const A_BUSY: u8 = 1;
82	const A_BACKOFF: u8 = 2;
83	const A_BAD_STEP: u8 = 3;
84	const A_UNKNOWN_SUBNET: u8 = 4;
85	const A_BAD_MSG: u8 = 5;
86	const A_CONFIRM_FAIL: u8 = 6;
87
88	/// A failure of the pairing wire ceremony. Carries no secret material.
89	#[derive(Debug)]
90	pub enum PairWireError {
91	    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
92	    Io(String),
93	    /// A frame was the wrong type or malformed for the current ceremony step.
94	    Protocol(String),
95	    /// The responder refused to admit the ceremony (rate-limit): another is in flight
96	    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
97	    /// ([`Backoff`](RateLimited::Backoff)).
98	    Refused(RateLimited),
99	    /// The dialer's announced TOTP step was outside the responder's ±1 window.
100	    StepRejected,
101	    /// The responder holds no subnet by that name.
102	    UnknownSubnet(String),
103	    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
104	    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
105	    Pairing(PairError),
106	}
107
108	impl std::fmt::Display for PairWireError {
109	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110	        match self {
111	            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
112	            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
113	            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
114	            PairWireError::StepRejected => {
115	                write!(f, "pairing rejected: TOTP step outside the ±1 window")
116	            }
117	            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
118	            PairWireError::Pairing(e) => write!(f, "{e}"),
119	        }
120	    }
121	}
122
123	impl std::error::Error for PairWireError {}
124
125	/// The joiner's self-introduction riding `Hello` (M8 decision 13,
126	/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
127	/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
128	/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
129	/// the code, and Hello rode that same iroh-authenticated connection.
130	/// Optional + additive on the wire: an older joiner sends none, an older
131	/// responder ignores the trailing Hello bytes (the decoder reads
132	/// positionally and never rejects a longer frame).
133	// [impl->REQ-SUBNET-7]
134	#[derive(Debug, Clone, PartialEq, Eq)]
135	pub struct NodeIntro {
136	    /// The joiner's advertised node label (its OS hostname).
137	    pub label: String,
138	    /// The joiner's hashed stable machine id (domain-separated SHA-256 —
139	    /// never the raw id; `spt-daemon::machineid` is the producer).
140	    pub machine_id: String,
141	}
142
143	/// What a completed responder ceremony yields (M8 decision 13): the TOFU
144	/// verdict plus the identity facts the re-pair overwrite consumes — the
145	/// caller (the daemon's pairing host) decides whether a superseded identity
146	/// must be evicted; this layer only reports what the ceremony proved.
147	#[derive(Debug)]
148	pub struct ResponderOutcome {
149	    /// The TOFU verdict for the joiner.
150	    pub trust: TrustOutcome,
151	    /// The joiner's iroh-authenticated node pubkey, lowercase hex.
152	    pub joiner_hex: String,
153	    /// The subnet paired into.
154	    pub subnet: String,
155	    /// The joiner's self-intro, if it sent one.
156	    pub intro: Option<NodeIntro>,
157	}
158
159	/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.
160	///
161	/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the
162	/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
163	/// receives the peer on success. `now` (unix seconds) is injected for deterministic
164	/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
165	///
166	/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
167	/// limiter slot is released as a success. Every error path releases the slot too — as a
168	/// failure when a ceremony was admitted (charging backoff), or not at all when admission
169	/// itself was refused.
170	// [impl->REQ-PAIR-1]
171	#[allow(clippy::too_many_arguments)]
172	pub async fn run_responder(
173	    conn: &Connection,
174	    local_pub: PublicKey,
175	    subnets: &SubnetStore,
176	    roster: &RosterStore,
177	    rate: &mut PairingRateLimiter,
178	    trust: &mut TrustStore,
179	    now: u64,
180	) -> Result<ResponderOutcome, PairWireError> {
181	    let (mut send, mut recv) = conn
182	        .accept_bi()
183	        .await
184	        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
185	    let initiator_pub = remote_pub(conn)?;
186
187	    // F1: Hello — the joiner's subnet name + clock-derived TOTP step (+ its
188	    // optional self-intro, M8 decision 13).
189	    let (subnet_name, peer_step, intro) = match read_frame(&mut recv).await? {
190	        Frame::Hello {
191	            subnet_name,
192	            step,
193	            intro,
194	        } => (subnet_name, step, intro),
195	        other => return Err(unexpected("Hello", other)),
196	    };
197
198	    // Subnet lookup BEFORE admission so an unknown name never consumes a ceremony slot.
199	    let record = match subnets.find(&subnet_name) {
200	        Some(r) => r,
201	        None => {
202	            abort(&mut send, A_UNKNOWN_SUBNET, 0).await?;
203	            return Err(PairWireError::UnknownSubnet(subnet_name));
204	        }
205	    };
206
207	    // Admission: one ceremony per subnet + exponential backoff (#11). On refusal the
208	    // slot is NOT taken, so there is nothing to release.
209	    if let Err(rl) = rate.begin(&subnet_name, now) {
210	        let (reason, retry) = match &rl {
211	            RateLimited::Busy => (A_BUSY, 0),
212	            RateLimited::Backoff { retry_after_secs } => (A_BACKOFF, *retry_after_secs),
213	        };
214	        abort(&mut send, reason, retry).await?;
215	        return Err(PairWireError::Refused(rl));
216	    }
217	    // From here the slot is HELD. Release it exactly once below, charging a failure on
218	    // ANY error path — a wrong code, a peer that vanished, a malformed frame, or an
219	    // abandoned ceremony all count against the subnet's backoff (the #11 guarantee only
220	    // holds if every admitted-but-unsuccessful ceremony is charged, not just clean
221	    // confirmation mismatches). Decoding seed + epoch here drops the `subnets` borrow
222	    // before the (mutating) release.
223	    let (roster_members, roster_tombstones) = roster.roster_for(&subnet_name);
224	    let params = Admitted {
225	        subnet_name: &subnet_name,
226	        peer_step,
227	        epoch: record.epoch,
228	        seed: record.seed_bytes(),
229	        initiator_pub,
230	        local_pub,
231	        now,
232	        roster_members: &roster_members,
233	        roster_tombstones: &roster_tombstones,
234	    };
235	    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
236	    match &result {
237	        Ok(_) => rate.record_success(&subnet_name),
238	        Err(_) => rate.record_failure(&subnet_name, now),
239	    }
240	    result.map(|trust| ResponderOutcome {
241	        trust,
242	        joiner_hex: initiator_pub.to_hex(),
243	        subnet: subnet_name,
244	        intro,
245	    })
246	}
247
248	/// The responder ceremony *after* admission — the announce → SPAKE → confirm flow. Does
249	/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
250	/// on this result, so every early return here (including a propagated I/O error) is
251	/// charged uniformly. Best-effort `Abort`/`Done` frames are sent so the joiner fails fast
252	/// instead of waiting out the ceremony timeout.
253	/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
254	/// once a subnet seed is found and the rate-limit slot is held. Bundled so the inner
255	/// driver stays a tidy four-argument call (the streams, this, and the trust store).
256	struct Admitted<'a> {
257	    subnet_name: &'a str,
258	    /// The dialer-announced TOTP step (already known to exist; ±1-checked inside).
259	    peer_step: u64,
260	    /// The responder-authoritative seed epoch bound into the transcript.
261	    epoch: u64,
262	    /// The subnet seed bytes — `None` if the stored hex was malformed (an internal fault).
263	    seed: Option<[u8; 20]>,
264	    /// The joiner's iroh-authenticated node pubkey (the trust write target).
265	    initiator_pub: PublicKey,
266	    /// This seed-holder's own node pubkey (the transcript's responder side).
267	    local_pub: PublicKey,
268	    /// This node's clock, for the ±1 step window. Injected for deterministic tests.
269	    now: u64,
270	    /// The seed-holder's current roster members for this subnet — handed to the
271	    /// joiner in the `Seed` frame on confirm-success (REQ-MESH-2).
272	    roster_members: &'a [RosterEntry],
273	    /// The subnet's revocation tombstones, transferred alongside the members.
274	    roster_tombstones: &'a [Tombstone],
275	}
276
277	async fn responder_admitted(
278	    send: &mut SendStream,
279	    recv: &mut RecvStream,
280	    p: &Admitted<'_>,
281	    trust: &mut TrustStore,
282	) -> Result<TrustOutcome, PairWireError> {
283	    // ±1 clock-skew window on the dialer-announced step.
284	    if !accepts_step(time_step(p.now), p.peer_step) {
285	        abort(send, A_BAD_STEP, 0).await?;
286	        return Err(PairWireError::StepRejected);
287	    }
288
289	    // The PAKE password: the code at the dialer's exact step (both sides bind that step).
290	    let code = match p.seed {
291	        Some(bytes) => code_at_step(&TotpSeed::from_bytes(bytes), p.peer_step),
292	        None => {
293	            abort(send, A_BAD_MSG, 0).await?;
294	            return Err(PairWireError::Protocol("stored seed is malformed".into()));
295	        }
296	    };
297
298	    // F2: Announce the epoch — the joiner needs it to build the transcript for msg_a.
299	    write_frame(send, &enc_announce(p.epoch)).await?;
300
301	    // F3: Spake1 — the joiner's msg_a (built against the announced epoch).
302	    let msg_a = match read_frame(recv).await? {
303	        Frame::Spake1 { msg_a } => msg_a,
304	        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
305	        other => return Err(unexpected("Spake1", other)),
306	    };
307
308	    let transcript = PairingTranscript {
309	        subnet_name: p.subnet_name.to_string(),
310	        epoch: p.epoch,
311	        step: p.peer_step,
312	        initiator_pub: p.initiator_pub,
313	        responder_pub: p.local_pub,
314	    };
315
316	    let responder = match Responder::respond(transcript, &code, &msg_a) {
317	        Ok((r, msg_b, responder_tag)) => {
318	            // F4: Spake2 — msg_b + the responder's confirmation tag.
319	            write_frame(send, &enc_spake2(&msg_b, &responder_tag)).await?;
320	            r
321	        }
322	        Err(e) => {
323	            abort(send, A_BAD_MSG, 0).await?;
324	            return Err(PairWireError::Pairing(e));
325	        }
326	    };
327
328	    // F5: Confirm — the joiner's tag (or an Abort if its finish() already failed).
329	    let initiator_tag = match read_frame(recv).await? {
330	        Frame::Confirm { tag } => tag,
331	        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
332	        other => return Err(unexpected("Confirm", other)),
333	    };
334
335	    match responder.confirm(&initiator_tag) {
336	        Ok(_paired) => {
337	            // Confirmed live: pin the peer (TOFU). The peer id is the iroh-authenticated
338	            // remote id; label/user None (v1).
339	            let outcome = trust.record(p.subnet_name, &p.initiator_pub.to_hex(), None, None);
340	            write_frame(send, &enc_done(true)).await?;
341	            // F7: transfer the subnet seed so the new node becomes a full member /
342	            // future seed-holder ("every trusted node holds the seed", CONTEXT.md). Sent
343	            // ONLY on confirm-success, over the iroh-authenticated TLS stream to the
344	            // now-trust-pinned peer. `seed` is Some here (a None seed aborted upstream).
345	            let seed = p.seed.expect("seed present on the confirm-success path");
346	            write_frame(
347	                send,
348	                &enc_seed(&seed, p.epoch, p.roster_members, p.roster_tombstones),
349	            )
350	            .await?;
351	            let _ = send.finish();
352	            Ok(outcome)
353	        }
354	        Err(e) => {
355	            write_frame(send, &enc_done(false)).await?;
356	            let _ = send.finish();
357	            Err(PairWireError::Pairing(e))
358	        }
359	    }
360	}
361
362	/// Run the **initiator** (joining node) side of the ceremony over a dialed connection.
363	///
364	/// `local_pub` is this node's own pubkey; `subnet_name` + `code` are the two user-entered
365	/// inputs (the subnet label and the current 6-digit TOTP code); `now` is the local clock.
366	/// On success the responder is pinned in `trust` AND the transferred subnet seed is written
367	/// into `subnets` (the joiner becomes a full member / future seed-holder — D2f, REQ-PAIR-5);
368	/// the returned [`TrustOutcome`] is the TOFU decision.
369	///
370	/// `step_back` shifts the ANNOUNCED time-step below the local clock's (0 =
371	/// the normal attempt). The joiner cannot validate the typed code locally
372	/// (it holds no seed yet), so a code read just before a rotation and entered
373	/// just after binds to the wrong step and fails as "wrong code" — the ±1
374	/// responder window ([`accepts_step`](super::totp)) tolerates an announced
375	/// `step-1`, letting the caller retry the SAME typed code against the
376	/// previous step before re-prompting the user (the late-code retry, field
377	/// report 2026-06-06).
378	// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
379	#[allow(clippy::too_many_arguments)]
380	pub async fn run_initiator(
381	    conn: &Connection,
382	    local_pub: PublicKey,
383	    subnet_name: &str,
384	    code: &str,
385	    trust: &mut TrustStore,
386	    subnets: &mut SubnetStore,
387	    roster: &mut RosterStore,
388	    now: u64,
389	    step_back: u64,
390	    intro: Option<&NodeIntro>,
391	) -> Result<TrustOutcome, PairWireError> {
392	    let (mut send, mut recv) = conn
393	        .open_bi()
394	        .await
395	        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
396	    let responder_pub = remote_pub(conn)?;
397	    let step = time_step(now).saturating_sub(step_back);
398
399	    // F1: Hello — opens the stream (the dialer must write first for accept_bi
400	    // to fire). Carries the optional self-intro (M8 decision 13).
401	    write_frame(&mut send, &enc_hello(subnet_name, step, intro)).await?;
402
403	    // F2: Announce(epoch) — or an Abort if the responder refused admission / step / name.
404	    let epoch = match read_frame(&mut recv).await? {
405	        Frame::Announce { epoch } => epoch,
406	        Frame::Abort {
407	            reason,
408	            retry_after,
409	        } => return Err(map_abort(reason, retry_after, subnet_name)),
410	        other => return Err(unexpected("Announce", other)),
411	    };
412
413	    // Now the joiner can bind the full transcript and run SPAKE2 side A.
414	    let transcript = PairingTranscript {
415	        subnet_name: subnet_name.to_string(),
416	        epoch,
417	        step,
418	        initiator_pub: local_pub,
419	        responder_pub,
420	    };
421	    let (initiator, msg_a) = Initiator::start(transcript, code);
422
423	    // F3: Spake1 — msg_a.
424	    write_frame(&mut send, &enc_spake1(&msg_a)).await?;
425
426	    // F4: Spake2 — msg_b + the responder's confirmation tag.
427	    let (msg_b, responder_tag) = match read_frame(&mut recv).await? {
428	        Frame::Spake2 { msg_b, tag } => (msg_b, tag),
429	        Frame::Abort {
430	            reason,
431	            retry_after,
432	        } => return Err(map_abort(reason, retry_after, subnet_name)),
433	        other => return Err(unexpected("Spake2", other)),
434	    };
435
436	    // Finish the PAKE and verify the responder's tag. On failure, signal the responder
437	    // with an Abort so it doesn't wait out the ceremony timeout, then surface the error.
438	    let (paired, initiator_tag) = match initiator.finish(&msg_b, &responder_tag) {
439	        Ok(v) => v,
440	        Err(e) => {
441	            abort(&mut send, A_CONFIRM_FAIL, 0).await?;
442	            let _ = send.finish();
443	            return Err(PairWireError::Pairing(e));
444	        }
445	    };
446
447	    // F5: Confirm — the joiner's own tag (proves to the responder it holds the same key).
448	    write_frame(&mut send, &enc_confirm(&initiator_tag)).await?;
449	    let _ = send.finish();
450
451	    // F6: Done — the responder's verdict on our tag.
452	    match read_frame(&mut recv).await? {
453	        Frame::Done { ok: true } => {
454	            // Pin the responder (its pubkey was proven live by the confirmation round).
455	            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
456	            // F7: receive the subnet seed and become a full member / future seed-holder.
457	            // Read it on the same confirmed stream; a missing/short Seed frame is a
458	            // protocol error (the responder always sends it on success).
459	            match read_frame(&mut recv).await? {
460	                Frame::Seed {
461	                    seed,
462	                    epoch,
463	                    roster: members,
464	                    tombstones,
465	                } => {
466	                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
467	                    // hold this subnet) — that is success, not failure: keep our record.
468	                    match subnets.add_joined(subnet_name, seed, epoch) {
469	                        Ok(_) | Err(SubnetError::Exists(_)) => {}
470	                        Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
471	                    }
472	                    // Adopt the seed-holder's roster (REQ-MESH-2): stamp the
473	                    // implicit subnet onto each wire entry (defends against a
474	                    // cross-subnet inject — every adopted row is forced into the
475	                    // subnet we are joining) and union-merge it in.
476	                    adopt_roster(roster, subnet_name, members, tombstones);
477	                }
478	                other => return Err(unexpected("Seed", other)),
479	            }
480	            Ok(outcome)
481	        }
482	        Frame::Done { ok: false } => Err(PairWireError::Pairing(PairError::Confirm)),
483	        Frame::Abort {
484	            reason,
485	            retry_after,
486	        } => Err(map_abort(reason, retry_after, subnet_name)),
487	        other => Err(unexpected("Done", other)),
488	    }
489	}
490
491	/// The pubkey of the connection's remote peer, from the iroh-authenticated endpoint id.
492	fn remote_pub(conn: &Connection) -> Result<PublicKey, PairWireError> {
493	    PublicKey::from_bytes(conn.remote_id().as_bytes())
494	        .map_err(|e| PairWireError::Protocol(format!("invalid remote id: {e}")))
495	}
496
497	/// The TOTP code string at an exact step (zero-padded to [`DIGITS`]) — the PAKE password.
498	fn code_at_step(seed: &TotpSeed, step: u64) -> String {
499	    let n = seed.code_at_step(step, DIGITS);
500	    format!("{n:0width$}", width = DIGITS as usize)
501	}
502
503	/// Map a responder Abort reason to the initiator-side error.
504	fn map_abort(reason: u8, retry_after: u64, subnet_name: &str) -> PairWireError {
505	    match reason {
506	        A_BUSY => PairWireError::Refused(RateLimited::Busy),
507	        A_BACKOFF => PairWireError::Refused(RateLimited::Backoff {
508	            retry_after_secs: retry_after,
509	        }),
510	        A_BAD_STEP => PairWireError::StepRejected,
511	        A_UNKNOWN_SUBNET => PairWireError::UnknownSubnet(subnet_name.to_string()),
512	        A_BAD_MSG | A_CONFIRM_FAIL => PairWireError::Pairing(PairError::Confirm),
513	        r => PairWireError::Protocol(format!("unknown abort reason {r}")),
514	    }
515	}
516
517	fn unexpected(want: &str, got: Frame) -> PairWireError {
518	    PairWireError::Protocol(format!("expected {want}, got {}", got.kind()))
519	}
520
521	// ── Frame model + codec ──────────────────────────────────────────────────────────
522
523	/// A decoded ceremony frame. The wire form is a u32-BE total length followed by a tag
524	/// byte and the tag's fields; variable fields are themselves u32-BE length-prefixed
525	/// (the same convention as [`transcript`](super::transcript)).
526	enum Frame {
527	    Hello {
528	        subnet_name: String,
529	        step: u64,
530	        /// The joiner's optional self-intro (M8 decision 13) — trailing,
531	        /// additive fields an older peer never sends and an older decoder
532	        /// never reads.
533	        intro: Option<NodeIntro>,
534	    },
535	    Announce {
536	        epoch: u64,
537	    },
538	    Spake1 {
539	        msg_a: Vec<u8>,
540	    },
541	    Spake2 {
542	        msg_b: Vec<u8>,
543	        tag: [u8; TAG_LEN],
544	    },
545	    Confirm {
546	        tag: [u8; TAG_LEN],
547	    },
548	    Done {
549	        ok: bool,
550	    },
551	    Seed {
552	        seed: [u8; TOTP_SEED_LEN],
553	        epoch: u64,
554	        /// The seed-holder's full current roster for this subnet (REQ-MESH-2),
555	        /// trailing + additive — a legacy/short Seed body (seed+epoch only)
556	        /// decodes to an empty roster. Entries carry an **empty** `subnet`
557	        /// field on the wire (it is implicit = the ceremony's subnet); the
558	        /// joiner stamps the real name on adopt.
559	        roster: Vec<RosterEntry>,
560	        /// The revocation tombstones for this subnet (also trailing/additive).
561	        tombstones: Vec<Tombstone>,
562	    },
563	    Abort {
564	        reason: u8,
565	        retry_after: u64,
566	    },
567	}
568
569	impl Frame {
570	    fn kind(&self) -> &'static str {
571	        match self {
572	            Frame::Hello { .. } => "Hello",
573	            Frame::Announce { .. } => "Announce",
574	            Frame::Spake1 { .. } => "Spake1",
575	            Frame::Spake2 { .. } => "Spake2",
576	            Frame::Confirm { .. } => "Confirm",
577	            Frame::Done { .. } => "Done",
578	            Frame::Seed { .. } => "Seed",
579	            Frame::Abort { .. } => "Abort",
580	        }
581	    }
582	}
583
584	fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {
585	    let mut b = vec![T_HELLO];
586	    push_field(&mut b, subnet_name.as_bytes());
587	    b.extend_from_slice(&step.to_be_bytes());
588	    // Trailing additive intro fields (M8 decision 13): both or neither —
589	    // an older responder's decoder reads positionally and ignores them.
590	    if let Some(i) = intro {
591	        push_field(&mut b, i.label.as_bytes());
592	        push_field(&mut b, i.machine_id.as_bytes());
593	    }
594	    b
595	}
596
597	fn enc_announce(epoch: u64) -> Vec<u8> {
598	    let mut b = vec![T_ANNOUNCE];
599	    b.extend_from_slice(&epoch.to_be_bytes());
600	    b
601	}
602
603	fn enc_spake1(msg_a: &[u8]) -> Vec<u8> {
604	    let mut b = vec![T_SPAKE1];
605	    push_field(&mut b, msg_a);
606	    b
607	}
608
609	fn enc_spake2(msg_b: &[u8], tag: &[u8; TAG_LEN]) -> Vec<u8> {
610	    let mut b = vec![T_SPAKE2];
611	    push_field(&mut b, msg_b);
612	    b.extend_from_slice(tag);
613	    b
614	}
615
616	fn enc_confirm(tag: &[u8; TAG_LEN]) -> Vec<u8> {
617	    let mut b = vec![T_CONFIRM];
618	    b.extend_from_slice(tag);
619	    b
620	}
621
622	fn enc_done(ok: bool) -> Vec<u8> {
623	    vec![T_DONE, ok as u8]
624	}
625
626	fn enc_seed(
627	    seed: &[u8; TOTP_SEED_LEN],
628	    epoch: u64,
629	    roster: &[RosterEntry],
630	    tombstones: &[Tombstone],
631	) -> Vec<u8> {
632	    let mut b = vec![T_SEED];
633	    b.extend_from_slice(seed);
634	    b.extend_from_slice(&epoch.to_be_bytes());
635	    // Trailing + additive roster (REQ-MESH-2): a pre-mesh decoder stops after
636	    // epoch; a mesh decoder reads these counts. `subnet` is implicit (the
637	    // ceremony's) so it is NOT written — the joiner stamps it on adopt.
638	    b.extend_from_slice(&(roster.len() as u32).to_be_bytes());
639	    for m in roster {
640	        push_field(&mut b, m.pubkey_hex.as_bytes());
641	        push_field(&mut b, m.label.as_bytes());
642	        push_field(&mut b, m.machine_id.as_bytes());
643	        // Address: opaque JSON bytes, empty field = None.
644	        let addr_bytes = m
645	            .address
646	            .as_ref()
647	            .and_then(|v| serde_json::to_vec(v).ok())
648	            .unwrap_or_default();
649	        push_field(&mut b, &addr_bytes);
650	        push_field(&mut b, m.last_seen.as_bytes());
651	        b.extend_from_slice(&m.lease_epoch.to_be_bytes());
652	    }
653	    b.extend_from_slice(&(tombstones.len() as u32).to_be_bytes());
654	    for t in tombstones {
655	        push_field(&mut b, t.pubkey_hex.as_bytes());
656	        push_field(&mut b, t.stamp.as_bytes());
657	    }
658	    b
659	}
660
661	/// Decode the trailing roster block of a `Seed` frame (REQ-MESH-2): a u32 member
662	/// count + entries, then a u32 tombstone count + entries. Entries carry an empty
663	/// `subnet` (implicit; the joiner stamps it). Counts are never pre-allocated — a
664	/// hostile count exhausts the buffer on the first short field and errors
665	/// "truncated", not a huge allocation. Every field is bounds-checked by [`Cursor`].
666	fn dec_roster(c: &mut Cursor) -> Result<(Vec<RosterEntry>, Vec<Tombstone>), PairWireError> {
667	    let member_count = u32::from_be_bytes(c.take(4)?.try_into().expect("took 4"));
668	    let mut roster = Vec::new();
669	    for _ in 0..member_count {
670	        let pubkey_hex = dec_utf8(c.field()?, "roster pubkey")?;
671	        let label = dec_utf8(c.field()?, "roster label")?;
672	        let machine_id = dec_utf8(c.field()?, "roster machine_id")?;
673	        let addr_bytes = c.field()?;
674	        let address = if addr_bytes.is_empty() {
675	            None
676	        } else {
677	            Some(
678	                serde_json::from_slice(addr_bytes)
679	                    .map_err(|_| PairWireError::Protocol("roster address not json".into()))?,
680	            )
681	        };
682	        let last_seen = dec_utf8(c.field()?, "roster last_seen")?;
683	        let lease_epoch = c.u64()?;
684	        roster.push(RosterEntry {
685	            pubkey_hex,
686	            subnet: String::new(), // implicit — stamped by the joiner on adopt
687	            label,
688	            machine_id,
689	            address,
690	            last_seen,
691	            lease_epoch,
692	        });
693	    }
694	    let tomb_count = u32::from_be_bytes(c.take(4)?.try_into().expect("took 4"));
695	    let mut tombstones = Vec::new();
696	    for _ in 0..tomb_count {
697	        let pubkey_hex = dec_utf8(c.field()?, "tombstone pubkey")?;
698	        let stamp = dec_utf8(c.field()?, "tombstone stamp")?;
699	        tombstones.push(Tombstone {
700	            pubkey_hex,
701	            subnet: String::new(),
702	            stamp,
703	        });
704	    }
705	    Ok((roster, tombstones))
706	}
707
708	/// Decode a length-prefixed field as UTF-8, mapping a bad encoding to a labelled
709	/// protocol error (never a panic — the input is attacker-reachable).
710	fn dec_utf8(bytes: &[u8], what: &str) -> Result<String, PairWireError> {
711	    String::from_utf8(bytes.to_vec())
712	        .map_err(|_| PairWireError::Protocol(format!("{what} not utf-8")))
713	}
714
715	/// Adopt a received roster into the joiner's store (REQ-MESH-2): force each wire
716	/// entry/tombstone into `subnet` (they arrive with an empty implicit subnet —
717	/// stamping it here is what defends against a cross-subnet inject) and
718	/// union-merge strictly-greater-wins. Pure store mutation — the caller saves.
719	fn adopt_roster(
720	    roster: &mut RosterStore,
721	    subnet: &str,
722	    members: Vec<RosterEntry>,
723	    tombstones: Vec<Tombstone>,
724	) {
725	    for mut e in members {
726	        e.subnet = subnet.to_string();
727	        roster.merge_entry(e);
728	    }
729	    for t in tombstones {
730	        roster.tombstone(subnet, &t.pubkey_hex, &t.stamp);
731	    }
732	}
733
734	fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {
735	    let mut b = vec![T_ABORT, reason];
736	    b.extend_from_slice(&retry_after.to_be_bytes());
737	    b
738	}
739
740	/// Append a u32-BE length-prefixed field.
741	fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
742	    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
743	    buf.extend_from_slice(field);
744	}
745
746	/// Write one length-framed message: u32-BE total length, then the body.
747	async fn write_frame(send: &mut SendStream, body: &[u8]) -> Result<(), PairWireError> {
748	    let len = body.len() as u32;
749	    send.write_all(&len.to_be_bytes())
750	        .await
751	        .map_err(|e| PairWireError::Io(format!("write len: {e}")))?;
752	    send.write_all(body)
753	        .await
754	        .map_err(|e| PairWireError::Io(format!("write body: {e}")))
755	}
756
757	/// Send an Abort frame (best-effort signal to the peer before returning an error).
758	async fn abort(send: &mut SendStream, reason: u8, retry_after: u64) -> Result<(), PairWireError> {
759	    write_frame(send, &enc_abort(reason, retry_after)).await?;
760	    let _ = send.finish();
761	    Ok(())
762	}
763
764	/// Read and decode one length-framed message, bounding the length at [`MAX_FRAME`].
765	async fn read_frame(recv: &mut RecvStream) -> Result<Frame, PairWireError> {
766	    let mut len_buf = [0u8; 4];
767	    recv.read_exact(&mut len_buf)
768	        .await
769	        .map_err(|e| PairWireError::Io(format!("read len: {e}")))?;
770	    let len = u32::from_be_bytes(len_buf) as usize;
771	    if len == 0 || len > MAX_FRAME {
772	        return Err(PairWireError::Protocol(format!(
773	            "frame length {len} out of range"
774	        )));
775	    }
776	    let mut body = vec![0u8; len];
777	    recv.read_exact(&mut body)
778	        .await
779	        .map_err(|e| PairWireError::Io(format!("read body: {e}")))?;
780	    decode_frame(&body)
781	}
782
783	fn decode_frame(body: &[u8]) -> Result<Frame, PairWireError> {
784	    let mut c = Cursor::new(body);
785	    let tag = c.u8()?;
786	    let frame = match tag {
787	        T_HELLO => {
788	            let subnet_name = String::from_utf8(c.field()?.to_vec())
789	                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
790	            let step = c.u64()?;
791	            // Optional trailing intro (M8 decision 13): absent on a pre-M8
792	            // joiner; when present, BOTH fields must decode (a half-written
793	            // intro is a protocol error, never a silent half-claim).
794	            let intro = if c.remaining() > 0 {
795	                let label = String::from_utf8(c.field()?.to_vec())
796	                    .map_err(|_| PairWireError::Protocol("intro label not utf-8".into()))?;
797	                let machine_id = String::from_utf8(c.field()?.to_vec())
798	                    .map_err(|_| PairWireError::Protocol("intro machine id not utf-8".into()))?;
799	                Some(NodeIntro { label, machine_id })
800	            } else {
801	                None
802	            };
803	            Frame::Hello {
804	                subnet_name,
805	                step,
806	                intro,
807	            }
808	        }
809	        T_ANNOUNCE => Frame::Announce { epoch: c.u64()? },
810	        T_SPAKE1 => Frame::Spake1 {
811	            msg_a: c.field()?.to_vec(),
812	        },
813	        T_SPAKE2 => {
814	            let msg_b = c.field()?.to_vec();
815	            let tag = c.tag()?;
816	            Frame::Spake2 { msg_b, tag }
817	        }
818	        T_CONFIRM => Frame::Confirm { tag: c.tag()? },
819	        T_DONE => Frame::Done { ok: c.u8()? != 0 },
820	        T_SEED => {
821	            let seed: [u8; TOTP_SEED_LEN] = c
822	                .take(TOTP_SEED_LEN)?
823	                .try_into()
824	                .expect("took TOTP_SEED_LEN");
825	            let epoch = c.u64()?;
826	            // Trailing + additive roster (REQ-MESH-2): a legacy short Seed body
827	            // (seed+epoch only) leaves nothing here ⇒ empty roster.
828	            let (roster, tombstones) = if c.remaining() > 0 {
829	                dec_roster(&mut c)?
830	            } else {
831	                (Vec::new(), Vec::new())
832	            };
833	            Frame::Seed {
834	                seed,
835	                epoch,
836	                roster,
837	                tombstones,
838	            }
839	        }
840	        T_ABORT => {
841	            let reason = c.u8()?;
842	            let retry_after = c.u64()?;
843	            Frame::Abort {
844	                reason,
845	                retry_after,
846	            }
847	        }
848	        other => {
849	            return Err(PairWireError::Protocol(format!(
850	                "unknown frame tag {other}"
851	            )))
852	        }
853	    };
854	    Ok(frame)
855	}
856
857	/// A minimal big-endian field reader over a frame body — every accessor bounds-checks so
858	/// a truncated or oversized field is a [`Protocol`](PairWireError::Protocol) error, never
859	/// a panic (the input is pre-trust attacker-reachable).
860	struct Cursor<'a> {
861	    buf: &'a [u8],
862	    pos: usize,
863	}
864
865	impl<'a> Cursor<'a> {
866	    fn new(buf: &'a [u8]) -> Cursor<'a> {
867	        Cursor { buf, pos: 0 }
868	    }
869
870	    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {
871	        let end = self
872	            .pos
873	            .checked_add(n)
874	            .filter(|e| *e <= self.buf.len())
875	            .ok_or_else(|| PairWireError::Protocol("frame truncated".into()))?;
876	        let slice = &self.buf[self.pos..end];
877	        self.pos = end;
878	        Ok(slice)
879	    }
880
881	    fn u8(&mut self) -> Result<u8, PairWireError> {
882	        Ok(self.take(1)?[0])
883	    }
884
885	    fn u64(&mut self) -> Result<u64, PairWireError> {
886	        let b = self.take(8)?;
887	        Ok(u64::from_be_bytes(b.try_into().expect("took 8")))
888	    }
889
890	    /// A u32-BE length-prefixed field, bounded by [`MAX_FRAME`].
891	    fn field(&mut self) -> Result<&'a [u8], PairWireError> {
892	        let len = u32::from_be_bytes(self.take(4)?.try_into().expect("took 4")) as usize;
893	        if len > MAX_FRAME {
894	            return Err(PairWireError::Protocol("field length out of range".into()));
895	        }
896	        self.take(len)
897	    }
898
899	    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
900	        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
901	    }
902
903	    /// Unread bytes left in the frame (the optional-trailing-fields probe).
904	    fn remaining(&self) -> usize {
905	        self.buf.len().saturating_sub(self.pos)
906	    }
907	}
908
909	#[cfg(test)]
910	mod tests {
911	    use super::*;
912	    use crate::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
913	    use spt_proto::identity::Identity;
914	    use spt_store::roster::RosterStore;
915	    use spt_store::subnet::{SubnetRecord, SubnetStore};
916	    use spt_store::trust::TrustStore;
917
918	    /// Reference time: a fixed unix second so both sides share a TOTP step deterministically.
919	    const NOW: u64 = 1_711_111_110;
920
921	    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
922	    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
923	        SubnetStore {
924	            subnets: vec![SubnetRecord {
925	                name: name.to_string(),
926	                seed_hex: hex(&seed),
927	                epoch,
928	                user: None,
929	                created: "0".into(),
930	                hide_new_endpoints: false,
931	            }],
932	        }
933	    }
934
935	    fn hex(bytes: &[u8]) -> String {
936	        bytes.iter().map(|b| format!("{b:02x}")).collect()
937	    }
938
939	    /// The current code for a seed at `NOW` — what the user "reads off their phone".
940	    fn code_for(seed: [u8; 20]) -> String {
941	        code_at_step(&TotpSeed::from_bytes(seed), time_step(NOW))
942	    }
943
944	    /// Bind a responder + an initiator endpoint on the pairing ALPN over loopback
945	    /// (loopback-scoped sockets: hermetic, and no Windows firewall prompt per
946	    /// freshly-built test binary).
947	    async fn pair_endpoints() -> (NetEndpoint, NetEndpoint, Identity, Identity) {
948	        let resp_id = Identity::generate();
949	        let init_id = Identity::generate();
950	        let responder = NetEndpoint::bind_scoped(
951	            &resp_id,
952	            RelayPolicy::Disabled,
953	            LocalDiscovery::Off,
954	            vec![SPT_PAIR_ALPN.to_vec()],
955	            BindScope::Loopback,
956	        )
957	        .await
958	        .expect("responder bind");
959	        let initiator = NetEndpoint::bind_scoped(
960	            &init_id,
961	            RelayPolicy::Disabled,
962	            LocalDiscovery::Off,
963	            vec![SPT_PAIR_ALPN.to_vec()],
964	            BindScope::Loopback,
965	        )
966	        .await
967	        .expect("initiator bind");
968	        (responder, initiator, resp_id, init_id)
969	    }
970
971	    // [unit->REQ-PAIR-1] HAPPY PATH over the wire: two endpoints, one TOTP code, complete
972	    // the ceremony on the pairing ALPN and each pins the other's pubkey in its trust
973	    // store — "two machines pair via one TOTP code" at the component level (loopback,
974	    // relays disabled). The two-host WAN form is the int stage at D9.
975	    #[tokio::test]
976	    async fn loopback_pairs_and_pins_both_sides() {
977	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
978	        let resp_pub = resp_id.public_key();
979	        let init_pub = init_id.public_key();
980	        let seed = [7u8; 20];
981	        let code = code_for(seed);
982	        let resp_addr = responder.addr();
983
984	        // Responder task: accept, run the ceremony, return its trust store + outcome.
985	        let resp_task = tokio::spawn(async move {
986	            let incoming = responder.accept().await.expect("incoming");
987	            let conn = incoming
988	                .accept()
989	                .expect("accept")
990	                .await
991	                .expect("connecting");
992	            let subnets = subnet_store("home", seed, 3);
993	            let mut rate = PairingRateLimiter::new();
994	            let mut trust = TrustStore::default();
995	            // A non-empty roster to transfer: one live member + one tombstoned
996	            // (REQ-MESH-2 — the joiner must adopt both).
997	            let mut resp_roster = RosterStore::default();
998	            resp_roster.upsert_self("home", "alpha", "host-a", "mid-a", None, "10", 4);
999	            resp_roster.tombstone("home", "ghost", "20");
1000	            let outcome = run_responder(&conn, resp_pub, &subnets, &resp_roster, &mut rate, &mut trust, NOW)
1001	                .await
1002	                .expect("responder pairs");
1003	            conn.closed().await;
1004	            (trust, outcome)
1005	        });
1006
1007	        let conn = initiator
1008	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1009	            .await
1010	            .expect("connect");
1011	        let mut init_trust = TrustStore::default();
1012	        let mut init_subnets = SubnetStore::default();
1013	        let mut init_roster = RosterStore::default();
1014	        let init_outcome = run_initiator(
1015	            &conn,
1016	            init_pub,
1017	            "home",
1018	            &code,
1019	            &mut init_trust,
1020	            &mut init_subnets,
1021	            &mut init_roster,
1022	            NOW,
1023	            0,    // step_back: the normal attempt
1024	            None, // intro: pre-M8 joiner shape
1025	        )
1026	        .await
1027	        .expect("initiator pairs");
1028	        conn.close(0u32.into(), b"done");
1029
1030	        let (resp_trust, resp_outcome) = resp_task.await.expect("responder task");
1031
1032	        assert_eq!(
1033	            init_outcome,
1034	            TrustOutcome::Pinned,
1035	            "initiator pins responder"
1036	        );
1037	        assert_eq!(
1038	            resp_outcome.trust,
1039	            TrustOutcome::Pinned,
1040	            "responder pins initiator"
1041	        );
1042	        // The outcome reports the ceremony facts the M8 re-pair overwrite
1043	        // consumes: the joiner's authenticated identity + the subnet. No
1044	        // intro was sent on this pre-M8-shaped call.
1045	        assert_eq!(resp_outcome.joiner_hex, init_pub.to_hex());
1046	        assert_eq!(resp_outcome.subnet, "home");
1047	        assert!(resp_outcome.intro.is_none(), "no intro sent");
1048	        // The joiner ADOPTED the seed-holder's roster (REQ-MESH-2): the live
1049	        // member is now its member, the tombstone propagated, and both were
1050	        // stamped into the subnet it joined (not the empty wire subnet).
1051	        assert!(init_roster.is_member("home", "alpha"), "adopted live member");
1052	        assert!(init_roster.is_tombstoned("home", "ghost"), "adopted tombstone");
1053	        assert!(!init_roster.is_member("home", "ghost"), "tombstone dominates");
1054	        assert_eq!(
1055	            init_roster.find("home", "alpha").unwrap().label,
1056	            "host-a",
1057	            "entry fields transferred"
1058	        );
1059	        // Each side trusts the OTHER's real pubkey within the subnet.
1060	        assert!(init_trust.is_trusted("home", &resp_pub.to_hex()));
1061	        assert!(resp_trust.is_trusted("home", &init_pub.to_hex()));
1062	        assert!(
1063	            !init_trust.is_trusted("home", &init_pub.to_hex()),
1064	            "self not pinned"
1065	        );
1066	        // [unit->REQ-PAIR-5] the joiner received the subnet seed (it is now a full member /
1067	        // future seed-holder): same name, seed, and the responder's epoch (3).
1068	        let joined = init_subnets.find("home").expect("joiner gained the subnet");
1069	        assert_eq!(joined.seed_bytes(), Some(seed), "seed transferred intact");
1070	        assert_eq!(joined.epoch, 3, "epoch transferred");
1071	    }
1072
1073	    // [unit->REQ-PAIR-1] WRONG CODE: the joiner types a code that doesn't match the
1074	    // seed-derived one → key confirmation fails on the wire. Neither side writes trust,
1075	    // and the responder charges the failure (so an immediate retry is backed off) — the
1076	    // negative path that proves the code actually authenticates the channel.
1077	    #[tokio::test]
1078	    async fn wrong_code_fails_and_writes_no_trust() {
1079	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
1080	        let resp_pub = resp_id.public_key();
1081	        let init_pub = init_id.public_key();
1082	        let seed = [9u8; 20];
1083	        let resp_addr = responder.addr();
1084
1085	        let resp_task = tokio::spawn(async move {
1086	            let incoming = responder.accept().await.expect("incoming");
1087	            let conn = incoming
1088	                .accept()
1089	                .expect("accept")
1090	                .await
1091	                .expect("connecting");
1092	            let subnets = subnet_store("home", seed, 1);
1093	            let mut rate = PairingRateLimiter::new();
1094	            let mut trust = TrustStore::default();
1095	            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
1096	            conn.closed().await;
1097	            // After a failed ceremony the slot is backed off (charged a failure).
1098	            let retry = rate.begin("home", NOW);
1099	            (trust, result.is_err(), retry)
1100	        });
1101
1102	        let conn = initiator
1103	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1104	            .await
1105	            .expect("connect");
1106	        let mut init_trust = TrustStore::default();
1107	        let mut init_subnets = SubnetStore::default();
1108	        let mut init_roster = RosterStore::default();
1109	        let init_result = run_initiator(
1110	            &conn,
1111	            init_pub,
1112	            "home",
1113	            "000000",
1114	            &mut init_trust,
1115	            &mut init_subnets,
1116	            &mut init_roster,
1117	            NOW,
1118	            0,    // step_back: the normal attempt
1119	            None, // intro: pre-M8 joiner shape
1120	        )
1121	        .await;
1122	        conn.close(0u32.into(), b"done");
1123
1124	        let (resp_trust, resp_errored, retry) = resp_task.await.expect("responder task");
1125
1126	        assert!(
1127	            matches!(init_result, Err(PairWireError::Pairing(_))),
1128	            "initiator rejects"
1129	        );
1130	        assert!(resp_errored, "responder rejects");
1131	        assert!(init_trust.peers.is_empty(), "no trust on initiator");
1132	        assert!(resp_trust.peers.is_empty(), "no trust on responder");
1133	        // [unit->REQ-PAIR-5] a failed ceremony transfers NO seed (the joiner is not a member).
1134	        assert!(
1135	            init_subnets.subnets.is_empty(),
1136	            "no seed on a failed pairing"
1137	        );
1138	        assert!(
1139	            matches!(retry, Err(RateLimited::Backoff { .. })),
1140	            "a wrong-code ceremony is charged a failure"
1141	        );
1142	    }
1143
1144	    // [unit->REQ-PAIR-1] BUSY: a ceremony is refused while another holds the subnet's
1145	    // one-at-a-time slot — the wire surfaces the rate limiter's serialization (#11) to
1146	    // the joiner as a refusal, and no trust is written.
1147	    #[tokio::test]
1148	    async fn busy_subnet_refuses_ceremony() {
1149	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
1150	        let resp_pub = resp_id.public_key();
1151	        let init_pub = init_id.public_key();
1152	        let seed = [3u8; 20];
1153	        let code = code_for(seed);
1154	        let resp_addr = responder.addr();
1155
1156	        let resp_task = tokio::spawn(async move {
1157	            let incoming = responder.accept().await.expect("incoming");
1158	            let conn = incoming
1159	                .accept()
1160	                .expect("accept")
1161	                .await
1162	                .expect("connecting");
1163	            let subnets = subnet_store("home", seed, 1);
1164	            let mut rate = PairingRateLimiter::new();
1165	            // A different ceremony already holds the slot.
1166	            rate.begin("home", NOW).expect("pre-occupy slot");
1167	            let mut trust = TrustStore::default();
1168	            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
1169	            conn.closed().await;
1170	            (trust, result)
1171	        });
1172
1173	        let conn = initiator
1174	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1175	            .await
1176	            .expect("connect");
1177	        let mut init_trust = TrustStore::default();
1178	        let mut init_subnets = SubnetStore::default();
1179	        let mut init_roster = RosterStore::default();
1180	        let init_result = run_initiator(
1181	            &conn,
1182	            init_pub,
1183	            "home",
1184	            &code,
1185	            &mut init_trust,
1186	            &mut init_subnets,
1187	            &mut init_roster,
1188	            NOW,
1189	            0,    // step_back: the normal attempt
1190	            None, // intro: pre-M8 joiner shape
1191	        )
1192	        .await;
1193	        conn.close(0u32.into(), b"done");
1194
1195	        let (resp_trust, resp_result) = resp_task.await.expect("responder task");
1196
1197	        assert!(
1198	            matches!(init_result, Err(PairWireError::Refused(RateLimited::Busy))),
1199	            "joiner sees Busy, got {init_result:?}"
1200	        );
1201	        assert!(matches!(
1202	            resp_result,
1203	            Err(PairWireError::Refused(RateLimited::Busy))
1204	        ));
1205	        assert!(init_trust.peers.is_empty());
1206	        assert!(resp_trust.peers.is_empty());
1207	    }
1208
1209	    // [unit->REQ-PAIR-1] STALE STEP: a dialer whose clock is >1 TOTP window off is
1210	    // rejected before the PAKE — the ±1 skew tolerance enforced on the wire.
1211	    #[tokio::test]
1212	    async fn stale_step_is_rejected() {
1213	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
1214	        let resp_pub = resp_id.public_key();
1215	        let init_pub = init_id.public_key();
1216	        let seed = [5u8; 20];
1217	        let resp_addr = responder.addr();
1218	        // The initiator's clock is 5 TOTP periods ahead of the responder's.
1219	        let init_now = NOW + 5 * 30;
1220	        let code = code_for(seed); // computed at the responder's window — irrelevant, rejected first
1221
1222	        let resp_task = tokio::spawn(async move {
1223	            let incoming = responder.accept().await.expect("incoming");
1224	            let conn = incoming
1225	                .accept()
1226	                .expect("accept")
1227	                .await
1228	                .expect("connecting");
1229	            let subnets = subnet_store("home", seed, 1);
1230	            let mut rate = PairingRateLimiter::new();
1231	            let mut trust = TrustStore::default();
1232	            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
1233	            conn.closed().await;
1234	            (trust, result)
1235	        });
1236
1237	        let conn = initiator
1238	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1239	            .await
1240	            .expect("connect");
1241	        let mut init_trust = TrustStore::default();
1242	        let mut init_subnets = SubnetStore::default();
1243	        let mut init_roster = RosterStore::default();
1244	        let init_result = run_initiator(
1245	            &conn,
1246	            init_pub,
1247	            "home",
1248	            &code,
1249	            &mut init_trust,
1250	            &mut init_subnets,
1251	            &mut init_roster,
1252	            init_now,
1253	            0,    // step_back: the normal attempt
1254	            None, // intro: pre-M8 joiner shape
1255	        )
1256	        .await;
1257	        conn.close(0u32.into(), b"done");
1258
1259	        let (resp_trust, resp_result) = resp_task.await.expect("responder task");
1260
1261	        assert!(
1262	            matches!(init_result, Err(PairWireError::StepRejected)),
1263	            "joiner sees a step rejection, got {init_result:?}"
1264	        );
1265	        assert!(matches!(resp_result, Err(PairWireError::StepRejected)));
1266	        assert!(resp_trust.peers.is_empty());
1267	    }
1268
1269	    // [unit->REQ-PAIR-1] UNKNOWN SUBNET: the responder holds no subnet by the joiner's
1270	    // name → refused before any slot is taken; the joiner gets a clear unknown-subnet
1271	    // error, no trust written.
1272	    #[tokio::test]
1273	    async fn unknown_subnet_is_rejected() {
1274	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
1275	        let resp_pub = resp_id.public_key();
1276	        let init_pub = init_id.public_key();
1277	        let seed = [1u8; 20];
1278	        let code = code_for(seed);
1279	        let resp_addr = responder.addr();
1280
1281	        let resp_task = tokio::spawn(async move {
1282	            let incoming = responder.accept().await.expect("incoming");
1283	            let conn = incoming
1284	                .accept()
1285	                .expect("accept")
1286	                .await
1287	                .expect("connecting");
1288	            let subnets = subnet_store("home", seed, 1); // only "home" exists
1289	            let mut rate = PairingRateLimiter::new();
1290	            let mut trust = TrustStore::default();
1291	            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
1292	            conn.closed().await;
1293	            (trust, result)
1294	        });
1295
1296	        let conn = initiator
1297	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1298	            .await
1299	            .expect("connect");
1300	        let mut init_trust = TrustStore::default();
1301	        let mut init_subnets = SubnetStore::default();
1302	        let mut init_roster = RosterStore::default();
1303	        // The joiner asks for "work", which the responder doesn't have.
1304	        let init_result = run_initiator(
1305	            &conn,
1306	            init_pub,
1307	            "work",
1308	            &code,
1309	            &mut init_trust,
1310	            &mut init_subnets,
1311	            &mut init_roster,

//! D2-wire: the pairing ALPN ceremony driver (ADR-0005, REQ-PAIR-1).
//!
//! This is the transport glue that runs the TOTP-SPAKE2 ceremony (the crypto core of
//! [`spake`](super::spake) + [`transcript`](super::transcript)) over a real iroh bidi
//! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
//! subnet-global [rate limiter](super::ratelimit), and writes the confirmed peer into
//! each side's [`TrustStore`]. It turns "two machines pair via one TOTP code" from a
//! same-process unit test into an over-the-wire exchange.
//!
//! ## Why a responder `Announce{epoch}` precedes the SPAKE messages
//!
//! [`Initiator::start`](super::spake::Initiator::start) bakes the **full transcript**
//! into `msg_a`, and the transcript binds the seed-rotation [`epoch`] — which is
//! *responder-authoritative* (only seed-holders track it; the joiner has just the
//! 6-digit code + subnet name, per [`transcript`](super::transcript) docs). So the
//! joiner cannot construct `msg_a` until the responder tells it the current epoch. The
//! wire therefore opens with the joiner's [`Hello`](Frame::Hello) (subnet name + its
//! clock-derived TOTP step), the responder's [`Announce`](Frame::Announce) (the epoch,
//! *after* it has admitted the ceremony and accepted the ±1 step), and only then the
//! four SPAKE messages. The TOTP **step** is dialer-announced and clock-derived
//! (`totp.rs`), so it needs no announce — only the seed epoch does.
//!
//! ## Authenticated identities
//!
//! Both node pubkeys bound into the transcript come from the **iroh-authenticated**
//! [`Connection::remote_id`] (the QUIC handshake already proves the peer holds the
//! Ed25519 key that *is* its node id) plus the local node's own pubkey — never the
//! bytes a peer claims in a frame. A MITM substituting its key is rejected by the
//! transcript binding (`spake.rs` `substituted_pubkey_fails`).
//!
//! ## Scope (component-level; WAN is D9)
//!
//! Like [`endpoint`](super::super::endpoint) tests, this dials by `EndpointAddr` over
//! loopback with relays disabled. The relay **rendezvous** (`H(subnet-name ‖ step)`,
//! `CONTEXT.md`) that lets two nodes that don't know each other's id *meet* is deferred
//! to D9; here the joiner already holds the responder's dialable address. Seed transfer
//! to the joiner (so it becomes a future seed-holder) is deferred to D2f — this driver
//! delivers the ceremony + mutual trust write only.
//
// [impl->REQ-PAIR-1]

use iroh::endpoint::{Connection, RecvStream, SendStream};
use spt_proto::identity::PublicKey;
use spt_store::trust::{TrustOutcome, TrustStore};

use spt_store::roster::{RosterEntry, RosterStore, Tombstone};
use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};

use super::ratelimit::{PairingRateLimiter, RateLimited};
use super::spake::{Initiator, PairError, Responder};
use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
use super::transcript::{PairingTranscript, TAG_LEN};

/// The pre-trust pairing ALPN. Distinct from the trusted
/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
/// future wire break.
pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";

/// Hard cap on a single wire frame. Pre-trust frames (Hello/Spake/…) are tens of bytes;
/// the largest payload is the **post-confirm `Seed` frame**, which now carries the
/// seed-holder's full member roster (REQ-MESH-2) — a sequence of small per-member fields.
/// 64 KiB bounds a hostile length prefix (one ceremony at a time, rate-limited, so this is
/// not a DoS lever) while leaving generous room for a real subnet's roster; each *field*
/// stays individually bounded by this same cap, and counts are never pre-allocated from the
/// wire (a huge count hits "frame truncated" immediately, not a huge allocation).
const MAX_FRAME: usize = 65536;

// Frame tag bytes.
const T_ABORT: u8 = 0;
const T_HELLO: u8 = 1;
const T_ANNOUNCE: u8 = 2;
const T_SPAKE1: u8 = 3;
const T_SPAKE2: u8 = 4;
const T_CONFIRM: u8 = 5;
const T_DONE: u8 = 6;
const T_SEED: u8 = 7;

// Abort reason bytes.
const A_BUSY: u8 = 1;
const A_BACKOFF: u8 = 2;
const A_BAD_STEP: u8 = 3;
const A_UNKNOWN_SUBNET: u8 = 4;
const A_BAD_MSG: u8 = 5;
const A_CONFIRM_FAIL: u8 = 6;

/// A failure of the pairing wire ceremony. Carries no secret material.
#[derive(Debug)]
pub enum PairWireError {
    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
    Io(String),
    /// A frame was the wrong type or malformed for the current ceremony step.
    Protocol(String),
    /// The responder refused to admit the ceremony (rate-limit): another is in flight
    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
    /// ([`Backoff`](RateLimited::Backoff)).
    Refused(RateLimited),
    /// The dialer's announced TOTP step was outside the responder's ±1 window.
    StepRejected,
    /// The responder holds no subnet by that name.
    UnknownSubnet(String),
    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
    Pairing(PairError),
}

impl std::fmt::Display for PairWireError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
            PairWireError::StepRejected => {
                write!(f, "pairing rejected: TOTP step outside the ±1 window")
            }
            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
            PairWireError::Pairing(e) => write!(f, "{e}"),
        }
    }
}

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

/// The joiner's self-introduction riding `Hello` (M8 decision 13,
/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
/// the code, and Hello rode that same iroh-authenticated connection.
/// Optional + additive on the wire: an older joiner sends none, an older
/// responder ignores the trailing Hello bytes (the decoder reads
/// positionally and never rejects a longer frame).
// [impl->REQ-SUBNET-7]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeIntro {
    /// The joiner's advertised node label (its OS hostname).
    pub label: String,
    /// The joiner's hashed stable machine id (domain-separated SHA-256 —
    /// never the raw id; `spt-daemon::machineid` is the producer).
    pub machine_id: String,
}

/// What a completed responder ceremony yields (M8 decision 13): the TOFU
/// verdict plus the identity facts the re-pair overwrite consumes — the
/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
#[derive(Debug)]
pub struct ResponderOutcome {
    /// The TOFU verdict for the joiner.
    pub trust: TrustOutcome,
    /// The joiner's iroh-authenticated node pubkey, lowercase hex.
    pub joiner_hex: String,
    /// The subnet paired into.
    pub subnet: String,
    /// The joiner's self-intro, if it sent one.
    pub intro: Option<NodeIntro>,
}

/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.
///
/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the
/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
/// receives the peer on success. `now` (unix seconds) is injected for deterministic
/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
///
/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
/// limiter slot is released as a success. Every error path releases the slot too — as a
/// failure when a ceremony was admitted (charging backoff), or not at all when admission
/// itself was refused.
// [impl->REQ-PAIR-1]
#[allow(clippy::too_many_arguments)]
pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    roster: &RosterStore,
    rate: &mut PairingRateLimiter,
    trust: &mut TrustStore,
    now: u64,
) -> Result<ResponderOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .accept_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
    let initiator_pub = remote_pub(conn)?;

    // F1: Hello — the joiner's subnet name + clock-derived TOTP step (+ its
    // optional self-intro, M8 decision 13).
    let (subnet_name, peer_step, intro) = match read_frame(&mut recv).await? {
        Frame::Hello {
            subnet_name,
            step,
            intro,
        } => (subnet_name, step, intro),
        other => return Err(unexpected("Hello", other)),
    };

    // Subnet lookup BEFORE admission so an unknown name never consumes a ceremony slot.
    let record = match subnets.find(&subnet_name) {
        Some(r) => r,
        None => {
            abort(&mut send, A_UNKNOWN_SUBNET, 0).await?;
            return Err(PairWireError::UnknownSubnet(subnet_name));
        }
    };

    // Admission: one ceremony per subnet + exponential backoff (#11). On refusal the
    // slot is NOT taken, so there is nothing to release.
    if let Err(rl) = rate.begin(&subnet_name, now) {
        let (reason, retry) = match &rl {
            RateLimited::Busy => (A_BUSY, 0),
            RateLimited::Backoff { retry_after_secs } => (A_BACKOFF, *retry_after_secs),
        };
        abort(&mut send, reason, retry).await?;
        return Err(PairWireError::Refused(rl));
    }
    // From here the slot is HELD. Release it exactly once below, charging a failure on
    // ANY error path — a wrong code, a peer that vanished, a malformed frame, or an
    // abandoned ceremony all count against the subnet's backoff (the #11 guarantee only
    // holds if every admitted-but-unsuccessful ceremony is charged, not just clean
    // confirmation mismatches). Decoding seed + epoch here drops the `subnets` borrow
    // before the (mutating) release.
    let (roster_members, roster_tombstones) = roster.roster_for(&subnet_name);
    let params = Admitted {
        subnet_name: &subnet_name,
        peer_step,
        epoch: record.epoch,
        seed: record.seed_bytes(),
        initiator_pub,
        local_pub,
        now,
        roster_members: &roster_members,
        roster_tombstones: &roster_tombstones,
    };
    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
    match &result {
        Ok(_) => rate.record_success(&subnet_name),
        Err(_) => rate.record_failure(&subnet_name, now),
    }
    result.map(|trust| ResponderOutcome {
        trust,
        joiner_hex: initiator_pub.to_hex(),
        subnet: subnet_name,
        intro,
    })
}

/// The responder ceremony *after* admission — the announce → SPAKE → confirm flow. Does
/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
/// on this result, so every early return here (including a propagated I/O error) is
/// charged uniformly. Best-effort `Abort`/`Done` frames are sent so the joiner fails fast
/// instead of waiting out the ceremony timeout.
/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
/// once a subnet seed is found and the rate-limit slot is held. Bundled so the inner
/// driver stays a tidy four-argument call (the streams, this, and the trust store).
struct Admitted<'a> {
    subnet_name: &'a str,
    /// The dialer-announced TOTP step (already known to exist; ±1-checked inside).
    peer_step: u64,
    /// The responder-authoritative seed epoch bound into the transcript.
    epoch: u64,
    /// The subnet seed bytes — `None` if the stored hex was malformed (an internal fault).
    seed: Option<[u8; 20]>,
    /// The joiner's iroh-authenticated node pubkey (the trust write target).
    initiator_pub: PublicKey,
    /// This seed-holder's own node pubkey (the transcript's responder side).
    local_pub: PublicKey,
    /// This node's clock, for the ±1 step window. Injected for deterministic tests.
    now: u64,
    /// The seed-holder's current roster members for this subnet — handed to the
    /// joiner in the `Seed` frame on confirm-success (REQ-MESH-2).
    roster_members: &'a [RosterEntry],
    /// The subnet's revocation tombstones, transferred alongside the members.
    roster_tombstones: &'a [Tombstone],
}

async fn responder_admitted(
    send: &mut SendStream,
    recv: &mut RecvStream,
    p: &Admitted<'_>,
    trust: &mut TrustStore,
) -> Result<TrustOutcome, PairWireError> {
    // ±1 clock-skew window on the dialer-announced step.
    if !accepts_step(time_step(p.now), p.peer_step) {
        abort(send, A_BAD_STEP, 0).await?;
        return Err(PairWireError::StepRejected);
    }

    // The PAKE password: the code at the dialer's exact step (both sides bind that step).
    let code = match p.seed {
        Some(bytes) => code_at_step(&TotpSeed::from_bytes(bytes), p.peer_step),
        None => {
            abort(send, A_BAD_MSG, 0).await?;
            return Err(PairWireError::Protocol("stored seed is malformed".into()));
        }
    };

    // F2: Announce the epoch — the joiner needs it to build the transcript for msg_a.
    write_frame(send, &enc_announce(p.epoch)).await?;

    // F3: Spake1 — the joiner's msg_a (built against the announced epoch).
    let msg_a = match read_frame(recv).await? {
        Frame::Spake1 { msg_a } => msg_a,
        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
        other => return Err(unexpected("Spake1", other)),
    };

    let transcript = PairingTranscript {
        subnet_name: p.subnet_name.to_string(),
        epoch: p.epoch,
        step: p.peer_step,
        initiator_pub: p.initiator_pub,
        responder_pub: p.local_pub,
    };

    let responder = match Responder::respond(transcript, &code, &msg_a) {
        Ok((r, msg_b, responder_tag)) => {
            // F4: Spake2 — msg_b + the responder's confirmation tag.
            write_frame(send, &enc_spake2(&msg_b, &responder_tag)).await?;
            r
        }
        Err(e) => {
            abort(send, A_BAD_MSG, 0).await?;
            return Err(PairWireError::Pairing(e));
        }
    };

    // F5: Confirm — the joiner's tag (or an Abort if its finish() already failed).
    let initiator_tag = match read_frame(recv).await? {
        Frame::Confirm { tag } => tag,
        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
        other => return Err(unexpected("Confirm", other)),
    };

    match responder.confirm(&initiator_tag) {
        Ok(_paired) => {
            // Confirmed live: pin the peer (TOFU). The peer id is the iroh-authenticated
            // remote id; label/user None (v1).
            let outcome = trust.record(p.subnet_name, &p.initiator_pub.to_hex(), None, None);
            write_frame(send, &enc_done(true)).await?;
            // F7: transfer the subnet seed so the new node becomes a full member /
            // future seed-holder ("every trusted node holds the seed", CONTEXT.md). Sent
            // ONLY on confirm-success, over the iroh-authenticated TLS stream to the
            // now-trust-pinned peer. `seed` is Some here (a None seed aborted upstream).
            let seed = p.seed.expect("seed present on the confirm-success path");
            write_frame(
                send,
                &enc_seed(&seed, p.epoch, p.roster_members, p.roster_tombstones),
            )
            .await?;
            let _ = send.finish();
            Ok(outcome)
        }
        Err(e) => {
            write_frame(send, &enc_done(false)).await?;
            let _ = send.finish();
            Err(PairWireError::Pairing(e))
        }
    }
}

/// Run the **initiator** (joining node) side of the ceremony over a dialed connection.
///
/// `local_pub` is this node's own pubkey; `subnet_name` + `code` are the two user-entered
/// inputs (the subnet label and the current 6-digit TOTP code); `now` is the local clock.
/// On success the responder is pinned in `trust` AND the transferred subnet seed is written
/// into `subnets` (the joiner becomes a full member / future seed-holder — D2f, REQ-PAIR-5);
/// the returned [`TrustOutcome`] is the TOFU decision.
///
/// `step_back` shifts the ANNOUNCED time-step below the local clock's (0 =
/// the normal attempt). The joiner cannot validate the typed code locally
/// (it holds no seed yet), so a code read just before a rotation and entered
/// just after binds to the wrong step and fails as "wrong code" — the ±1
/// responder window ([`accepts_step`](super::totp)) tolerates an announced
/// `step-1`, letting the caller retry the SAME typed code against the
/// previous step before re-prompting the user (the late-code retry, field
/// report 2026-06-06).
// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
#[allow(clippy::too_many_arguments)]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    roster: &mut RosterStore,
    now: u64,
    step_back: u64,
    intro: Option<&NodeIntro>,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
    let responder_pub = remote_pub(conn)?;
    let step = time_step(now).saturating_sub(step_back);

    // F1: Hello — opens the stream (the dialer must write first for accept_bi
    // to fire). Carries the optional self-intro (M8 decision 13).
    write_frame(&mut send, &enc_hello(subnet_name, step, intro)).await?;

    // F2: Announce(epoch) — or an Abort if the responder refused admission / step / name.
    let epoch = match read_frame(&mut recv).await? {
        Frame::Announce { epoch } => epoch,
        Frame::Abort {
            reason,
            retry_after,
        } => return Err(map_abort(reason, retry_after, subnet_name)),
        other => return Err(unexpected("Announce", other)),
    };

    // Now the joiner can bind the full transcript and run SPAKE2 side A.
    let transcript = PairingTranscript {
        subnet_name: subnet_name.to_string(),
        epoch,
        step,
        initiator_pub: local_pub,
        responder_pub,
    };
    let (initiator, msg_a) = Initiator::start(transcript, code);

    // F3: Spake1 — msg_a.
    write_frame(&mut send, &enc_spake1(&msg_a)).await?;

    // F4: Spake2 — msg_b + the responder's confirmation tag.
    let (msg_b, responder_tag) = match read_frame(&mut recv).await? {
        Frame::Spake2 { msg_b, tag } => (msg_b, tag),
        Frame::Abort {
            reason,
            retry_after,
        } => return Err(map_abort(reason, retry_after, subnet_name)),
        other => return Err(unexpected("Spake2", other)),
    };

    // Finish the PAKE and verify the responder's tag. On failure, signal the responder
    // with an Abort so it doesn't wait out the ceremony timeout, then surface the error.
    let (paired, initiator_tag) = match initiator.finish(&msg_b, &responder_tag) {
        Ok(v) => v,
        Err(e) => {
            abort(&mut send, A_CONFIRM_FAIL, 0).await?;
            let _ = send.finish();
            return Err(PairWireError::Pairing(e));
        }
    };

    // F5: Confirm — the joiner's own tag (proves to the responder it holds the same key).
    write_frame(&mut send, &enc_confirm(&initiator_tag)).await?;
    let _ = send.finish();

    // F6: Done — the responder's verdict on our tag.
    match read_frame(&mut recv).await? {
        Frame::Done { ok: true } => {
            // Pin the responder (its pubkey was proven live by the confirmation round).
            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
            // F7: receive the subnet seed and become a full member / future seed-holder.
            // Read it on the same confirmed stream; a missing/short Seed frame is a
            // protocol error (the responder always sends it on success).
            match read_frame(&mut recv).await? {
                Frame::Seed {
                    seed,
                    epoch,
                    roster: members,
                    tombstones,
                } => {
                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
                    // hold this subnet) — that is success, not failure: keep our record.
                    match subnets.add_joined(subnet_name, seed, epoch) {
                        Ok(_) | Err(SubnetError::Exists(_)) => {}
                        Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
                    }
                    // Adopt the seed-holder's roster (REQ-MESH-2): stamp the
                    // implicit subnet onto each wire entry (defends against a
                    // cross-subnet inject — every adopted row is forced into the
                    // subnet we are joining) and union-merge it in.
                    adopt_roster(roster, subnet_name, members, tombstones);
                }
                other => return Err(unexpected("Seed", other)),
            }
            Ok(outcome)
        }
        Frame::Done { ok: false } => Err(PairWireError::Pairing(PairError::Confirm)),
        Frame::Abort {
            reason,
            retry_after,
        } => Err(map_abort(reason, retry_after, subnet_name)),
        other => Err(unexpected("Done", other)),
    }
}

/// The pubkey of the connection's remote peer, from the iroh-authenticated endpoint id.
fn remote_pub(conn: &Connection) -> Result<PublicKey, PairWireError> {
    PublicKey::from_bytes(conn.remote_id().as_bytes())
        .map_err(|e| PairWireError::Protocol(format!("invalid remote id: {e}")))
}

/// The TOTP code string at an exact step (zero-padded to [`DIGITS`]) — the PAKE password.
fn code_at_step(seed: &TotpSeed, step: u64) -> String {
    let n = seed.code_at_step(step, DIGITS);
    format!("{n:0width$}", width = DIGITS as usize)
}

/// Map a responder Abort reason to the initiator-side error.
fn map_abort(reason: u8, retry_after: u64, subnet_name: &str) -> PairWireError {
    match reason {
        A_BUSY => PairWireError::Refused(RateLimited::Busy),
        A_BACKOFF => PairWireError::Refused(RateLimited::Backoff {
            retry_after_secs: retry_after,
        }),
        A_BAD_STEP => PairWireError::StepRejected,
        A_UNKNOWN_SUBNET => PairWireError::UnknownSubnet(subnet_name.to_string()),
        A_BAD_MSG | A_CONFIRM_FAIL => PairWireError::Pairing(PairError::Confirm),
        r => PairWireError::Protocol(format!("unknown abort reason {r}")),
    }
}

fn unexpected(want: &str, got: Frame) -> PairWireError {
    PairWireError::Protocol(format!("expected {want}, got {}", got.kind()))
}

// ── Frame model + codec ──────────────────────────────────────────────────────────

/// A decoded ceremony frame. The wire form is a u32-BE total length followed by a tag
/// byte and the tag's fields; variable fields are themselves u32-BE length-prefixed
/// (the same convention as [`transcript`](super::transcript)).
enum Frame {
    Hello {
        subnet_name: String,
        step: u64,
        /// The joiner's optional self-intro (M8 decision 13) — trailing,
        /// additive fields an older peer never sends and an older decoder
        /// never reads.
        intro: Option<NodeIntro>,
    },
    Announce {
        epoch: u64,
    },
    Spake1 {
        msg_a: Vec<u8>,
    },
    Spake2 {
        msg_b: Vec<u8>,
        tag: [u8; TAG_LEN],
    },
    Confirm {
        tag: [u8; TAG_LEN],
    },
    Done {
        ok: bool,
    },
    Seed {
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
        /// The seed-holder's full current roster for this subnet (REQ-MESH-2),
        /// trailing + additive — a legacy/short Seed body (seed+epoch only)
        /// decodes to an empty roster. Entries carry an **empty** `subnet`
        /// field on the wire (it is implicit = the ceremony's subnet); the
        /// joiner stamps the real name on adopt.
        roster: Vec<RosterEntry>,
        /// The revocation tombstones for this subnet (also trailing/additive).
        tombstones: Vec<Tombstone>,
    },
    Abort {
        reason: u8,
        retry_after: u64,
    },
}

impl Frame {
    fn kind(&self) -> &'static str {
        match self {
            Frame::Hello { .. } => "Hello",
            Frame::Announce { .. } => "Announce",
            Frame::Spake1 { .. } => "Spake1",
            Frame::Spake2 { .. } => "Spake2",
            Frame::Confirm { .. } => "Confirm",
            Frame::Done { .. } => "Done",
            Frame::Seed { .. } => "Seed",
            Frame::Abort { .. } => "Abort",
        }
    }
}

fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {
    let mut b = vec![T_HELLO];
    push_field(&mut b, subnet_name.as_bytes());
    b.extend_from_slice(&step.to_be_bytes());
    // Trailing additive intro fields (M8 decision 13): both or neither —
    // an older responder's decoder reads positionally and ignores them.
    if let Some(i) = intro {
        push_field(&mut b, i.label.as_bytes());
        push_field(&mut b, i.machine_id.as_bytes());
    }
    b
}

fn enc_announce(epoch: u64) -> Vec<u8> {
    let mut b = vec![T_ANNOUNCE];
    b.extend_from_slice(&epoch.to_be_bytes());
    b
}

fn enc_spake1(msg_a: &[u8]) -> Vec<u8> {
    let mut b = vec![T_SPAKE1];
    push_field(&mut b, msg_a);
    b
}

fn enc_spake2(msg_b: &[u8], tag: &[u8; TAG_LEN]) -> Vec<u8> {
    let mut b = vec![T_SPAKE2];
    push_field(&mut b, msg_b);
    b.extend_from_slice(tag);
    b
}

fn enc_confirm(tag: &[u8; TAG_LEN]) -> Vec<u8> {
    let mut b = vec![T_CONFIRM];
    b.extend_from_slice(tag);
    b
}

fn enc_done(ok: bool) -> Vec<u8> {
    vec![T_DONE, ok as u8]
}

fn enc_seed(
    seed: &[u8; TOTP_SEED_LEN],
    epoch: u64,
    roster: &[RosterEntry],
    tombstones: &[Tombstone],
) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    // Trailing + additive roster (REQ-MESH-2): a pre-mesh decoder stops after
    // epoch; a mesh decoder reads these counts. `subnet` is implicit (the
    // ceremony's) so it is NOT written — the joiner stamps it on adopt.
    b.extend_from_slice(&(roster.len() as u32).to_be_bytes());
    for m in roster {
        push_field(&mut b, m.pubkey_hex.as_bytes());
        push_field(&mut b, m.label.as_bytes());
        push_field(&mut b, m.machine_id.as_bytes());
        // Address: opaque JSON bytes, empty field = None.
        let addr_bytes = m
            .address
            .as_ref()
            .and_then(|v| serde_json::to_vec(v).ok())
            .unwrap_or_default();
        push_field(&mut b, &addr_bytes);
        push_field(&mut b, m.last_seen.as_bytes());
        b.extend_from_slice(&m.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.stamp.as_bytes());
    }
    b
}

/// Decode the trailing roster block of a `Seed` frame (REQ-MESH-2): a u32 member
/// count + entries, then a u32 tombstone count + entries. Entries carry an empty
/// `subnet` (implicit; the joiner stamps it). Counts are never pre-allocated — a
/// hostile count exhausts the buffer on the first short field and errors
/// "truncated", not a huge allocation. Every field is bounds-checked by [`Cursor`].
fn dec_roster(c: &mut Cursor) -> Result<(Vec<RosterEntry>, Vec<Tombstone>), PairWireError> {
    let member_count = u32::from_be_bytes(c.take(4)?.try_into().expect("took 4"));
    let mut roster = Vec::new();
    for _ in 0..member_count {
        let pubkey_hex = dec_utf8(c.field()?, "roster pubkey")?;
        let label = dec_utf8(c.field()?, "roster label")?;
        let machine_id = dec_utf8(c.field()?, "roster machine_id")?;
        let addr_bytes = c.field()?;
        let address = if addr_bytes.is_empty() {
            None
        } else {
            Some(
                serde_json::from_slice(addr_bytes)
                    .map_err(|_| PairWireError::Protocol("roster address not json".into()))?,
            )
        };
        let last_seen = dec_utf8(c.field()?, "roster last_seen")?;
        let lease_epoch = c.u64()?;
        roster.push(RosterEntry {
            pubkey_hex,
            subnet: String::new(), // implicit — stamped by the joiner on adopt
            label,
            machine_id,
            address,
            last_seen,
            lease_epoch,
        });
    }
    let tomb_count = u32::from_be_bytes(c.take(4)?.try_into().expect("took 4"));
    let mut tombstones = Vec::new();
    for _ in 0..tomb_count {
        let pubkey_hex = dec_utf8(c.field()?, "tombstone pubkey")?;
        let stamp = dec_utf8(c.field()?, "tombstone stamp")?;
        tombstones.push(Tombstone {
            pubkey_hex,
            subnet: String::new(),
            stamp,
        });
    }
    Ok((roster, tombstones))
}

/// Decode a length-prefixed field as UTF-8, mapping a bad encoding to a labelled
/// protocol error (never a panic — the input is attacker-reachable).
fn dec_utf8(bytes: &[u8], what: &str) -> Result<String, PairWireError> {
    String::from_utf8(bytes.to_vec())
        .map_err(|_| PairWireError::Protocol(format!("{what} not utf-8")))
}

/// Adopt a received roster into the joiner's store (REQ-MESH-2): force each wire
/// entry/tombstone into `subnet` (they arrive with an empty implicit subnet —
/// stamping it here is what defends against a cross-subnet inject) and
/// union-merge strictly-greater-wins. Pure store mutation — the caller saves.
fn adopt_roster(
    roster: &mut RosterStore,
    subnet: &str,
    members: Vec<RosterEntry>,
    tombstones: Vec<Tombstone>,
) {
    for mut e in members {
        e.subnet = subnet.to_string();
        roster.merge_entry(e);
    }
    for t in tombstones {
        roster.tombstone(subnet, &t.pubkey_hex, &t.stamp);
    }
}

fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {
    let mut b = vec![T_ABORT, reason];
    b.extend_from_slice(&retry_after.to_be_bytes());
    b
}

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

/// Write one length-framed message: u32-BE total length, then the body.
async fn write_frame(send: &mut SendStream, body: &[u8]) -> Result<(), PairWireError> {
    let len = body.len() as u32;
    send.write_all(&len.to_be_bytes())
        .await
        .map_err(|e| PairWireError::Io(format!("write len: {e}")))?;
    send.write_all(body)
        .await
        .map_err(|e| PairWireError::Io(format!("write body: {e}")))
}

/// Send an Abort frame (best-effort signal to the peer before returning an error).
async fn abort(send: &mut SendStream, reason: u8, retry_after: u64) -> Result<(), PairWireError> {
    write_frame(send, &enc_abort(reason, retry_after)).await?;
    let _ = send.finish();
    Ok(())
}

/// Read and decode one length-framed message, bounding the length at [`MAX_FRAME`].
async fn read_frame(recv: &mut RecvStream) -> Result<Frame, PairWireError> {
    let mut len_buf = [0u8; 4];
    recv.read_exact(&mut len_buf)
        .await
        .map_err(|e| PairWireError::Io(format!("read len: {e}")))?;
    let len = u32::from_be_bytes(len_buf) as usize;
    if len == 0 || len > MAX_FRAME {
        return Err(PairWireError::Protocol(format!(
            "frame length {len} out of range"
        )));
    }
    let mut body = vec![0u8; len];
    recv.read_exact(&mut body)
        .await
        .map_err(|e| PairWireError::Io(format!("read body: {e}")))?;
    decode_frame(&body)
}

fn decode_frame(body: &[u8]) -> Result<Frame, PairWireError> {
    let mut c = Cursor::new(body);
    let tag = c.u8()?;
    let frame = match tag {
        T_HELLO => {
            let subnet_name = String::from_utf8(c.field()?.to_vec())
                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
            let step = c.u64()?;
            // Optional trailing intro (M8 decision 13): absent on a pre-M8
            // joiner; when present, BOTH fields must decode (a half-written
            // intro is a protocol error, never a silent half-claim).
            let intro = if c.remaining() > 0 {
                let label = String::from_utf8(c.field()?.to_vec())
                    .map_err(|_| PairWireError::Protocol("intro label not utf-8".into()))?;
                let machine_id = String::from_utf8(c.field()?.to_vec())
                    .map_err(|_| PairWireError::Protocol("intro machine id not utf-8".into()))?;
                Some(NodeIntro { label, machine_id })
            } else {
                None
            };
            Frame::Hello {
                subnet_name,
                step,
                intro,
            }
        }
        T_ANNOUNCE => Frame::Announce { epoch: c.u64()? },
        T_SPAKE1 => Frame::Spake1 {
            msg_a: c.field()?.to_vec(),
        },
        T_SPAKE2 => {
            let msg_b = c.field()?.to_vec();
            let tag = c.tag()?;
            Frame::Spake2 { msg_b, tag }
        }
        T_CONFIRM => Frame::Confirm { tag: c.tag()? },
        T_DONE => Frame::Done { ok: c.u8()? != 0 },
        T_SEED => {
            let seed: [u8; TOTP_SEED_LEN] = c
                .take(TOTP_SEED_LEN)?
                .try_into()
                .expect("took TOTP_SEED_LEN");
            let epoch = c.u64()?;
            // Trailing + additive roster (REQ-MESH-2): a legacy short Seed body
            // (seed+epoch only) leaves nothing here ⇒ empty roster.
            let (roster, tombstones) = if c.remaining() > 0 {
                dec_roster(&mut c)?
            } else {
                (Vec::new(), Vec::new())
            };
            Frame::Seed {
                seed,
                epoch,
                roster,
                tombstones,
            }
        }
        T_ABORT => {
            let reason = c.u8()?;
            let retry_after = c.u64()?;
            Frame::Abort {
                reason,
                retry_after,
            }
        }
        other => {
            return Err(PairWireError::Protocol(format!(
                "unknown frame tag {other}"
            )))
        }
    };
    Ok(frame)
}

/// A minimal big-endian field reader over a frame body — every accessor bounds-checks so
/// a truncated or oversized field is a [`Protocol`](PairWireError::Protocol) error, never
/// a panic (the input is pre-trust attacker-reachable).
struct Cursor<'a> {
    buf: &'a [u8],
    pos: usize,
}

impl<'a> Cursor<'a> {
    fn new(buf: &'a [u8]) -> Cursor<'a> {
        Cursor { buf, pos: 0 }
    }

    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {
        let end = self
            .pos
            .checked_add(n)
            .filter(|e| *e <= self.buf.len())
            .ok_or_else(|| PairWireError::Protocol("frame truncated".into()))?;
        let slice = &self.buf[self.pos..end];
        self.pos = end;
        Ok(slice)
    }

    fn u8(&mut self) -> Result<u8, PairWireError> {
        Ok(self.take(1)?[0])
    }

    fn u64(&mut self) -> Result<u64, PairWireError> {
        let b = self.take(8)?;
        Ok(u64::from_be_bytes(b.try_into().expect("took 8")))
    }

    /// A u32-BE length-prefixed field, bounded by [`MAX_FRAME`].
    fn field(&mut self) -> Result<&'a [u8], PairWireError> {
        let len = u32::from_be_bytes(self.take(4)?.try_into().expect("took 4")) as usize;
        if len > MAX_FRAME {
            return Err(PairWireError::Protocol("field length out of range".into()));
        }
        self.take(len)
    }

    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
    }

    /// Unread bytes left in the frame (the optional-trailing-fields probe).
    fn remaining(&self) -> usize {
        self.buf.len().saturating_sub(self.pos)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
    use spt_proto::identity::Identity;
    use spt_store::roster::RosterStore;
    use spt_store::subnet::{SubnetRecord, SubnetStore};
    use spt_store::trust::TrustStore;

    /// Reference time: a fixed unix second so both sides share a TOTP step deterministically.
    const NOW: u64 = 1_711_111_110;

    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
        SubnetStore {
            subnets: vec![SubnetRecord {
                name: name.to_string(),
                seed_hex: hex(&seed),
                epoch,
                user: None,
                created: "0".into(),
                hide_new_endpoints: false,
            }],
        }
    }

    fn hex(bytes: &[u8]) -> String {
        bytes.iter().map(|b| format!("{b:02x}")).collect()
    }

    /// The current code for a seed at `NOW` — what the user "reads off their phone".
    fn code_for(seed: [u8; 20]) -> String {
        code_at_step(&TotpSeed::from_bytes(seed), time_step(NOW))
    }

    /// Bind a responder + an initiator endpoint on the pairing ALPN over loopback
    /// (loopback-scoped sockets: hermetic, and no Windows firewall prompt per
    /// freshly-built test binary).
    async fn pair_endpoints() -> (NetEndpoint, NetEndpoint, Identity, Identity) {
        let resp_id = Identity::generate();
        let init_id = Identity::generate();
        let responder = NetEndpoint::bind_scoped(
            &resp_id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("responder bind");
        let initiator = NetEndpoint::bind_scoped(
            &init_id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("initiator bind");
        (responder, initiator, resp_id, init_id)
    }

    // [unit->REQ-PAIR-1] HAPPY PATH over the wire: two endpoints, one TOTP code, complete
    // the ceremony on the pairing ALPN and each pins the other's pubkey in its trust
    // store — "two machines pair via one TOTP code" at the component level (loopback,
    // relays disabled). The two-host WAN form is the int stage at D9.
    #[tokio::test]
    async fn loopback_pairs_and_pins_both_sides() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [7u8; 20];
        let code = code_for(seed);
        let resp_addr = responder.addr();

        // Responder task: accept, run the ceremony, return its trust store + outcome.
        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 3);
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            // A non-empty roster to transfer: one live member + one tombstoned
            // (REQ-MESH-2 — the joiner must adopt both).
            let mut resp_roster = RosterStore::default();
            resp_roster.upsert_self("home", "alpha", "host-a", "mid-a", None, "10", 4);
            resp_roster.tombstone("home", "ghost", "20");
            let outcome = run_responder(&conn, resp_pub, &subnets, &resp_roster, &mut rate, &mut trust, NOW)
                .await
                .expect("responder pairs");
            conn.closed().await;
            (trust, outcome)
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let mut init_roster = RosterStore::default();
        let init_outcome = run_initiator(
            &conn,
            init_pub,
            "home",
            &code,
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,
            NOW,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await
        .expect("initiator pairs");
        conn.close(0u32.into(), b"done");

        let (resp_trust, resp_outcome) = resp_task.await.expect("responder task");

        assert_eq!(
            init_outcome,
            TrustOutcome::Pinned,
            "initiator pins responder"
        );
        assert_eq!(
            resp_outcome.trust,
            TrustOutcome::Pinned,
            "responder pins initiator"
        );
        // The outcome reports the ceremony facts the M8 re-pair overwrite
        // consumes: the joiner's authenticated identity + the subnet. No
        // intro was sent on this pre-M8-shaped call.
        assert_eq!(resp_outcome.joiner_hex, init_pub.to_hex());
        assert_eq!(resp_outcome.subnet, "home");
        assert!(resp_outcome.intro.is_none(), "no intro sent");
        // The joiner ADOPTED the seed-holder's roster (REQ-MESH-2): the live
        // member is now its member, the tombstone propagated, and both were
        // stamped into the subnet it joined (not the empty wire subnet).
        assert!(init_roster.is_member("home", "alpha"), "adopted live member");
        assert!(init_roster.is_tombstoned("home", "ghost"), "adopted tombstone");
        assert!(!init_roster.is_member("home", "ghost"), "tombstone dominates");
        assert_eq!(
            init_roster.find("home", "alpha").unwrap().label,
            "host-a",
            "entry fields transferred"
        );
        // Each side trusts the OTHER's real pubkey within the subnet.
        assert!(init_trust.is_trusted("home", &resp_pub.to_hex()));
        assert!(resp_trust.is_trusted("home", &init_pub.to_hex()));
        assert!(
            !init_trust.is_trusted("home", &init_pub.to_hex()),
            "self not pinned"
        );
        // [unit->REQ-PAIR-5] the joiner received the subnet seed (it is now a full member /
        // future seed-holder): same name, seed, and the responder's epoch (3).
        let joined = init_subnets.find("home").expect("joiner gained the subnet");
        assert_eq!(joined.seed_bytes(), Some(seed), "seed transferred intact");
        assert_eq!(joined.epoch, 3, "epoch transferred");
    }

    // [unit->REQ-PAIR-1] WRONG CODE: the joiner types a code that doesn't match the
    // seed-derived one → key confirmation fails on the wire. Neither side writes trust,
    // and the responder charges the failure (so an immediate retry is backed off) — the
    // negative path that proves the code actually authenticates the channel.
    #[tokio::test]
    async fn wrong_code_fails_and_writes_no_trust() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [9u8; 20];
        let resp_addr = responder.addr();

        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 1);
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
            conn.closed().await;
            // After a failed ceremony the slot is backed off (charged a failure).
            let retry = rate.begin("home", NOW);
            (trust, result.is_err(), retry)
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let mut init_roster = RosterStore::default();
        let init_result = run_initiator(
            &conn,
            init_pub,
            "home",
            "000000",
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,
            NOW,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await;
        conn.close(0u32.into(), b"done");

        let (resp_trust, resp_errored, retry) = resp_task.await.expect("responder task");

        assert!(
            matches!(init_result, Err(PairWireError::Pairing(_))),
            "initiator rejects"
        );
        assert!(resp_errored, "responder rejects");
        assert!(init_trust.peers.is_empty(), "no trust on initiator");
        assert!(resp_trust.peers.is_empty(), "no trust on responder");
        // [unit->REQ-PAIR-5] a failed ceremony transfers NO seed (the joiner is not a member).
        assert!(
            init_subnets.subnets.is_empty(),
            "no seed on a failed pairing"
        );
        assert!(
            matches!(retry, Err(RateLimited::Backoff { .. })),
            "a wrong-code ceremony is charged a failure"
        );
    }

    // [unit->REQ-PAIR-1] BUSY: a ceremony is refused while another holds the subnet's
    // one-at-a-time slot — the wire surfaces the rate limiter's serialization (#11) to
    // the joiner as a refusal, and no trust is written.
    #[tokio::test]
    async fn busy_subnet_refuses_ceremony() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [3u8; 20];
        let code = code_for(seed);
        let resp_addr = responder.addr();

        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 1);
            let mut rate = PairingRateLimiter::new();
            // A different ceremony already holds the slot.
            rate.begin("home", NOW).expect("pre-occupy slot");
            let mut trust = TrustStore::default();
            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
            conn.closed().await;
            (trust, result)
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let mut init_roster = RosterStore::default();
        let init_result = run_initiator(
            &conn,
            init_pub,
            "home",
            &code,
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,
            NOW,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await;
        conn.close(0u32.into(), b"done");

        let (resp_trust, resp_result) = resp_task.await.expect("responder task");

        assert!(
            matches!(init_result, Err(PairWireError::Refused(RateLimited::Busy))),
            "joiner sees Busy, got {init_result:?}"
        );
        assert!(matches!(
            resp_result,
            Err(PairWireError::Refused(RateLimited::Busy))
        ));
        assert!(init_trust.peers.is_empty());
        assert!(resp_trust.peers.is_empty());
    }

    // [unit->REQ-PAIR-1] STALE STEP: a dialer whose clock is >1 TOTP window off is
    // rejected before the PAKE — the ±1 skew tolerance enforced on the wire.
    #[tokio::test]
    async fn stale_step_is_rejected() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [5u8; 20];
        let resp_addr = responder.addr();
        // The initiator's clock is 5 TOTP periods ahead of the responder's.
        let init_now = NOW + 5 * 30;
        let code = code_for(seed); // computed at the responder's window — irrelevant, rejected first

        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 1);
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
            conn.closed().await;
            (trust, result)
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let mut init_roster = RosterStore::default();
        let init_result = run_initiator(
            &conn,
            init_pub,
            "home",
            &code,
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,
            init_now,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await;
        conn.close(0u32.into(), b"done");

        let (resp_trust, resp_result) = resp_task.await.expect("responder task");

        assert!(
            matches!(init_result, Err(PairWireError::StepRejected)),
            "joiner sees a step rejection, got {init_result:?}"
        );
        assert!(matches!(resp_result, Err(PairWireError::StepRejected)));
        assert!(resp_trust.peers.is_empty());
    }

    // [unit->REQ-PAIR-1] UNKNOWN SUBNET: the responder holds no subnet by the joiner's
    // name → refused before any slot is taken; the joiner gets a clear unknown-subnet
    // error, no trust written.
    #[tokio::test]
    async fn unknown_subnet_is_rejected() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [1u8; 20];
        let code = code_for(seed);
        let resp_addr = responder.addr();

        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 1); // only "home" exists
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            let result = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW).await;
            conn.closed().await;
            (trust, result)
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let mut init_roster = RosterStore::default();
        // The joiner asks for "work", which the responder doesn't have.
        let init_result = run_initiator(
            &conn,
            init_pub,
            "work",
            &code,
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,