1	//! `trust/peers.json` — the local trust store of authorized peer node pubkeys
2	//! (ADR-0005, R-PAIR-2, D2e).
3	//!
4	//! After a [pairing ceremony](../../../spt_net/net/pairing) confirms a peer's Ed25519
5	//! node pubkey, that pubkey is **pinned** here as trusted *within the subnet it paired
6	//! into*. This is the **TOFU** (trust-on-first-use) record the runtime consults to
7	//! decide whether an inbound peer is one of the user's own already-paired nodes
8	//! (`CONTEXT.md` "trust store"). Node-local, durable, **never git-tracked and never
9	//! synced over the context repo** — it is subnet security material, alongside
10	//! [`subnet.json`](crate::subnet) and `node.key` under [`identity/`](crate::perch::identity_dir).
11	//!
12	//! ## TOFU + warn-on-change (R-PAIR-2)
13	//!
14	//! - **Accept on first use.** The first ceremony with a given `(subnet, pubkey)` pins
15	//!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
16	//!   anchor. A re-pair with the *same* key is idempotent ([`AlreadyTrusted`]).
17	//! - **Warn on key change.** If a peer is pinned under an optional human **label** and
18	//!   a later record for that `(subnet, label)` carries a *different* pubkey, [`record`]
19	//!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
20	//!   the warning (a legitimate key rotation vs a MITM look the same at this layer) and,
21	//!   only if accepted, applies it with [`repin`].
22	//!
23	//!   The change anchor is the label: an iroh NodeId *is* the Ed25519 pubkey, so a
24	//!   different key is cryptographically a different node, not a "changed" one. Until
25	//!   labels are assigned (deferred to the GUI/D9 pairing UX), warn-on-change is dormant
26	//!   — every distinct key pins as its own peer — which is the honest v1 behavior. The
27	//!   mechanism is implemented and tested now so the capability lands with the schema.
28	//!
29	//! ## Revocation
30	//!
31	//! Removing a paired node is a trust-store delete ([`revoke`]) — TOTP gates *joining*,
32	//! not continued membership (`CONTEXT.md`, ADR-0005). De-trusting a node should be
33	//! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
34	//! a trust-delete alone leaves the still-replicated seed able to re-pair.
35	//!
36	//! ## `(subnet, user)`-ready shape
37	//!
38	//! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
39	//! the cross-user seam (`CONTEXT.md`). v1 has one implicit user (`None`); the trust key
40	//! generalizes to `(subnet, user, pubkey)` with no re-architecture.
41	//
42	// [impl->REQ-PAIR-2]
43
44	use serde::{Deserialize, Serialize};
45	use std::path::Path;
46
47	use crate::atomic::atomic_write_string;
48	use crate::perch::trust_file;
49
50	/// Failures operating on the trust store.
51	#[derive(Debug, PartialEq, Eq)]
52	pub enum TrustError {
53	    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
54	    NotFound(String),
55	    /// Reading/writing the backing file failed (message preserved).
56	    Io(String),
57	}
58
59	impl std::fmt::Display for TrustError {
60	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61	        match self {
62	            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
63	            TrustError::Io(e) => write!(f, "trust store io: {e}"),
64	        }
65	    }
66	}
67
68	impl std::error::Error for TrustError {}
69
70	/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
71	#[derive(Debug, PartialEq, Eq)]
72	pub enum TrustOutcome {
73	    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
74	    Pinned,
75	    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
76	    AlreadyTrusted,
77	    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
78	    /// NOT applied: the caller must warn the user and, only if the change is accepted,
79	    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
80	    /// key under a known node's label.
81	    KeyChanged {
82	        /// The hex pubkey currently pinned for that label (the one being challenged).
83	        existing_pubkey_hex: String,
84	    },
85	}
86
87	/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
88	/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
89	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90	pub struct TrustedPeer {
91	    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92	    pub pubkey_hex: String,
93	    /// The subnet this peer paired into / is trusted within.
94	    pub subnet: String,
95	    /// Optional human node label. When present it is the stable handle warn-on-change
96	    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97	    #[serde(skip_serializing_if = "Option::is_none")]
98	    pub label: Option<String>,
99	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100	    #[serde(skip_serializing_if = "Option::is_none")]
101	    pub user: Option<String>,
102	    /// First-trust stamp (epoch-seconds string; opaque).
103	    pub paired: String,
104	}
105
106	/// The whole `peers.json` document: every peer this node trusts, across all subnets.
107	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108	pub struct TrustStore {
109	    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
110	    #[serde(default)]
111	    pub peers: Vec<TrustedPeer>,
112	}
113
114	impl TrustStore {
115	    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
116	    /// unpaired node) — never an error.
117	    pub fn load() -> TrustStore {
118	        Self::load_from(&trust_file())
119	    }
120
121	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
122	    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
123	    /// must not brick the node (re-pair to rebuild it), the same stance as
124	    /// [`SubnetStore`](crate::subnet::SubnetStore).
125	    pub fn load_from(path: &Path) -> TrustStore {
126	        match std::fs::read_to_string(path) {
127	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
128	            Err(_) => TrustStore::default(),
129	        }
130	    }
131
132	    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
133	    pub fn save(&self) -> Result<(), TrustError> {
134	        self.save_to(&trust_file())
135	    }
136
137	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
138	    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
139	    /// invariant).
140	    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
141	        if let Some(parent) = path.parent() {
142	            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
143	        }
144	        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
145	        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
146	    }
147
148	    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
149	    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
150	    // [impl->REQ-PAIR-2]
151	    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
152	        self.peers
153	            .iter()
154	            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
155	    }
156
157	    /// All peers trusted in `subnet`.
158	    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
159	        self.peers.iter().filter(move |p| p.subnet == subnet)
160	    }
161
162	    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
163	    ///
164	    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
165	    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
166	    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
167	    /// label)` already holds a different key — in which case **nothing is written** and
168	    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
169	    /// scope is honored (a label is only an anchor within its own user scope).
170	    // [impl->REQ-PAIR-2]
171	    pub fn record(
172	        &mut self,
173	        subnet: &str,
174	        pubkey_hex: &str,
175	        label: Option<&str>,
176	        user: Option<&str>,
177	    ) -> TrustOutcome {
178	        // Exact identity already trusted → idempotent (a key isn't "changed" against
179	        // itself, even when a label is present).
180	        if self.is_trusted(subnet, pubkey_hex) {
181	            return TrustOutcome::AlreadyTrusted;
182	        }
183	        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
184	        if let Some(lbl) = label {
185	            if let Some(existing) = self.peers.iter().find(|p| {
186	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
187	            }) {
188	                return TrustOutcome::KeyChanged {
189	                    existing_pubkey_hex: existing.pubkey_hex.clone(),
190	                };
191	            }
192	        }
193	        self.peers.push(TrustedPeer {
194	            pubkey_hex: pubkey_hex.to_string(),
195	            subnet: subnet.to_string(),
196	            label: label.map(str::to_string),
197	            user: user.map(str::to_string),
198	            paired: now_stamp(),
199	        });
200	        TrustOutcome::Pinned
201	    }
202
203	    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
204	    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
205	    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
206	    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
207	    /// peer holds that label in scope.
208	    // [impl->REQ-PAIR-2]
209	    pub fn repin(
210	        &mut self,
211	        subnet: &str,
212	        label: &str,
213	        new_pubkey_hex: &str,
214	        user: Option<&str>,
215	    ) -> Result<(), TrustError> {
216	        let peer = self
217	            .peers
218	            .iter_mut()
219	            .find(|p| {
220	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
221	            })
222	            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
223	        peer.pubkey_hex = new_pubkey_hex.to_string();
224	        peer.paired = now_stamp();
225	        Ok(())
226	    }
227
228	    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
229	    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
230	    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
231	    // [impl->REQ-PAIR-2]
232	    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
233	        let before = self.peers.len();
234	        self.peers
235	            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
236	        self.peers.len() != before
237	    }
238	}
239
240	/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
241	fn now_stamp() -> String {
242	    std::time::SystemTime::now()
243	        .duration_since(std::time::UNIX_EPOCH)
244	        .map(|d| d.as_secs().to_string())
245	        .unwrap_or_else(|_| "0".to_string())
246	}
247
248	#[cfg(test)]
249	mod tests {
250	    use super::*;
251
252	    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
253	    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
254	    // duplicate — the post-ceremony accept path.
255	    #[test]
256	    fn first_use_pins_then_is_trusted_idempotently() {
257	        let mut store = TrustStore::default();
258	        assert_eq!(
259	            store.record("home", "aa11", None, None),
260	            TrustOutcome::Pinned
261	        );
262	        assert!(store.is_trusted("home", "aa11"));
263	        assert_eq!(
264	            store.record("home", "aa11", None, None),
265	            TrustOutcome::AlreadyTrusted,
266	            "re-pair with same key is idempotent"
267	        );
268	        assert_eq!(store.peers.len(), 1, "no duplicate row");
269	    }
270
271	    // [unit->REQ-PAIR-2] trust is per-subnet: the same node pubkey trusted in `home`
272	    // is NOT trusted in `work` until separately paired there (each subnet its own trust
273	    // context). An unknown key is simply untrusted.
274	    #[test]
275	    fn trust_is_scoped_per_subnet() {
276	        let mut store = TrustStore::default();
277	        store.record("home", "aa11", None, None);
278	        assert!(store.is_trusted("home", "aa11"));
279	        assert!(
280	            !store.is_trusted("work", "aa11"),
281	            "not trusted in other subnet"
282	        );
283	        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
284	    }
285
286	    // [unit->REQ-PAIR-2] WARN-ON-CHANGE: a labelled peer that later presents a
287	    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
288	    // the store — the caller must decide. The MITM-vs-rotation guard.
289	    #[test]
290	    fn labelled_key_change_warns_without_mutating() {
291	        let mut store = TrustStore::default();
292	        store.record("home", "aa11", Some("laptop"), None);
293	        let outcome = store.record("home", "bb22", Some("laptop"), None);
294	        assert_eq!(
295	            outcome,
296	            TrustOutcome::KeyChanged {
297	                existing_pubkey_hex: "aa11".into()
298	            }
299	        );
300	        assert!(store.is_trusted("home", "aa11"), "old key untouched");
301	        assert!(
302	            !store.is_trusted("home", "bb22"),
303	            "new key NOT auto-trusted"
304	        );
305	        assert_eq!(store.peers.len(), 1, "no row added on a warned change");
306	    }
307
308	    // [unit->REQ-PAIR-2] an ACCEPTED key change is applied by repin: the label now maps
309	    // to the new key and the old one is no longer trusted. Repinning an unknown label is
310	    // an error, not a silent insert.
311	    #[test]
312	    fn repin_applies_accepted_key_change() {
313	        let mut store = TrustStore::default();
314	        store.record("home", "aa11", Some("laptop"), None);
315	        store.repin("home", "laptop", "bb22", None).expect("repin");
316	        assert!(store.is_trusted("home", "bb22"), "new key trusted");
317	        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
318	        assert_eq!(
319	            store.repin("home", "ghost", "cc33", None).unwrap_err(),
320	            TrustError::NotFound("home/ghost".into())
321	        );
322	    }
323
324	    // [unit->REQ-PAIR-2] without a label there is no change anchor (v1 default): a
325	    // second distinct key pins as its own peer rather than warning. Documented honest
326	    // behavior — warn-on-change activates once labels exist.
327	    #[test]
328	    fn unlabelled_distinct_keys_each_pin() {
329	        let mut store = TrustStore::default();
330	        assert_eq!(
331	            store.record("home", "aa11", None, None),
332	            TrustOutcome::Pinned
333	        );
334	        assert_eq!(
335	            store.record("home", "bb22", None, None),
336	            TrustOutcome::Pinned
337	        );
338	        assert_eq!(store.peers.len(), 2);
339	        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
340	    }
341
342	    // [unit->REQ-PAIR-2] revocation is a trust-delete: a revoked peer is no longer
343	    // trusted; a second revoke is a no-op false. (Pairs with seed rotation for real
344	    // revocation, #10.)
345	    #[test]
346	    fn revoke_removes_trust() {
347	        let mut store = TrustStore::default();
348	        store.record("home", "aa11", None, None);
349	        assert!(store.revoke("home", "aa11"));
350	        assert!(!store.is_trusted("home", "aa11"));
351	        assert!(
352	            !store.revoke("home", "aa11"),
353	            "second revoke is no-op false"
354	        );
355	    }
356
357	    // [unit->REQ-PAIR-2] the same key can label-collide across DIFFERENT subnets without
358	    // a false warn — the change anchor is scoped to (subnet, user, label).
359	    #[test]
360	    fn label_anchor_is_subnet_scoped() {
361	        let mut store = TrustStore::default();
362	        store.record("home", "aa11", Some("laptop"), None);
363	        // Same label, different subnet, different key → a clean first pin, not a warn.
364	        assert_eq!(
365	            store.record("work", "bb22", Some("laptop"), None),
366	            TrustOutcome::Pinned
367	        );
368	    }
369
370	    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
371	    // store.
372	    #[test]
373	    fn peers_in_filters_by_subnet() {
374	        let mut store = TrustStore::default();
375	        store.record("home", "aa11", None, None);
376	        store.record("home", "bb22", None, None);
377	        store.record("work", "cc33", None, None);
378	        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
379	        assert_eq!(home.len(), 2);
380	        assert!(home.contains(&&"aa11".to_string()));
381	        assert_eq!(store.peers_in("work").count(), 1);
382	    }
383
384	    // [unit->REQ-PAIR-2] the store round-trips through the file atomically (multi-subnet
385	    // + label + a repin all preserved), and an absent/corrupt file degrades to empty —
386	    // a damaged trust file must not brick the node.
387	    #[test]
388	    fn store_persists_and_degrades_gracefully() {
389	        let dir = tempfile::tempdir().unwrap();
390	        let path = dir.path().join("identity").join("trust").join("peers.json");
391
392	        let mut store = TrustStore::default();
393	        store.record("home", "aa11", Some("laptop"), None);
394	        store.record("work", "cc33", None, None);
395	        store.repin("home", "laptop", "dd44", None).unwrap();
396	        store.save_to(&path).unwrap();
397
398	        let reloaded = TrustStore::load_from(&path);
399	        assert_eq!(reloaded.peers.len(), 2, "multi-subnet preserved");
400	        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
401	        assert!(reloaded.is_trusted("work", "cc33"));
402
403	        // Absent + corrupt → empty, never a panic.
404	        assert!(TrustStore::load_from(&dir.path().join("nope.json"))
405	            .peers
406	            .is_empty());
407	        let corrupt = dir.path().join("corrupt.json");
408	        std::fs::write(&corrupt, b"{ not json").unwrap();
409	        assert!(TrustStore::load_from(&corrupt).peers.is_empty());
410	    }
411	}
412

//! `trust/peers.json` — the local trust store of authorized peer node pubkeys
//! (ADR-0005, R-PAIR-2, D2e).
//!
//! After a [pairing ceremony](../../../spt_net/net/pairing) confirms a peer's Ed25519
//! node pubkey, that pubkey is **pinned** here as trusted *within the subnet it paired
//! into*. This is the **TOFU** (trust-on-first-use) record the runtime consults to
//! decide whether an inbound peer is one of the user's own already-paired nodes
//! (`CONTEXT.md` "trust store"). Node-local, durable, **never git-tracked and never
//! synced over the context repo** — it is subnet security material, alongside
//! [`subnet.json`](crate::subnet) and `node.key` under [`identity/`](crate::perch::identity_dir).
//!
//! ## TOFU + warn-on-change (R-PAIR-2)
//!
//! - **Accept on first use.** The first ceremony with a given `(subnet, pubkey)` pins
//!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
//!   anchor. A re-pair with the *same* key is idempotent ([`AlreadyTrusted`]).
//! - **Warn on key change.** If a peer is pinned under an optional human **label** and
//!   a later record for that `(subnet, label)` carries a *different* pubkey, [`record`]
//!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
//!   the warning (a legitimate key rotation vs a MITM look the same at this layer) and,
//!   only if accepted, applies it with [`repin`].
//!
//!   The change anchor is the label: an iroh NodeId *is* the Ed25519 pubkey, so a
//!   different key is cryptographically a different node, not a "changed" one. Until
//!   labels are assigned (deferred to the GUI/D9 pairing UX), warn-on-change is dormant
//!   — every distinct key pins as its own peer — which is the honest v1 behavior. The
//!   mechanism is implemented and tested now so the capability lands with the schema.
//!
//! ## Revocation
//!
//! Removing a paired node is a trust-store delete ([`revoke`]) — TOTP gates *joining*,
//! not continued membership (`CONTEXT.md`, ADR-0005). De-trusting a node should be
//! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
//! a trust-delete alone leaves the still-replicated seed able to re-pair.
//!
//! ## `(subnet, user)`-ready shape
//!
//! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
//! the cross-user seam (`CONTEXT.md`). v1 has one implicit user (`None`); the trust key
//! generalizes to `(subnet, user, pubkey)` with no re-architecture.
//
// [impl->REQ-PAIR-2]

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;
use crate::perch::trust_file;

/// Failures operating on the trust store.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustError {
    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for TrustError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
            TrustError::Io(e) => write!(f, "trust store io: {e}"),
        }
    }
}

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

/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustOutcome {
    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
    Pinned,
    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
    AlreadyTrusted,
    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
    /// NOT applied: the caller must warn the user and, only if the change is accepted,
    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
    /// key under a known node's label.
    KeyChanged {
        /// The hex pubkey currently pinned for that label (the one being challenged).
        existing_pubkey_hex: String,
    },
}

/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
    pub pubkey_hex: String,
    /// The subnet this peer paired into / is trusted within.
    pub subnet: String,
    /// Optional human node label. When present it is the stable handle warn-on-change
    /// keys against; absent (v1 default) means the pubkey is the sole identity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// First-trust stamp (epoch-seconds string; opaque).
    pub paired: String,
}

/// The whole `peers.json` document: every peer this node trusts, across all subnets.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
    #[serde(default)]
    pub peers: Vec<TrustedPeer>,
}

impl TrustStore {
    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
    /// unpaired node) — never an error.
    pub fn load() -> TrustStore {
        Self::load_from(&trust_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
    /// must not brick the node (re-pair to rebuild it), the same stance as
    /// [`SubnetStore`](crate::subnet::SubnetStore).
    pub fn load_from(path: &Path) -> TrustStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => TrustStore::default(),
        }
    }

    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
    pub fn save(&self) -> Result<(), TrustError> {
        self.save_to(&trust_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
        }
        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
    }

    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
    // [impl->REQ-PAIR-2]
    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.peers
            .iter()
            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
    }

    /// All peers trusted in `subnet`.
    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
        self.peers.iter().filter(move |p| p.subnet == subnet)
    }

    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
    ///
    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
    /// label)` already holds a different key — in which case **nothing is written** and
    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
    /// scope is honored (a label is only an anchor within its own user scope).
    // [impl->REQ-PAIR-2]
    pub fn record(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: Option<&str>,
        user: Option<&str>,
    ) -> TrustOutcome {
        // Exact identity already trusted → idempotent (a key isn't "changed" against
        // itself, even when a label is present).
        if self.is_trusted(subnet, pubkey_hex) {
            return TrustOutcome::AlreadyTrusted;
        }
        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
        if let Some(lbl) = label {
            if let Some(existing) = self.peers.iter().find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
            }) {
                return TrustOutcome::KeyChanged {
                    existing_pubkey_hex: existing.pubkey_hex.clone(),
                };
            }
        }
        self.peers.push(TrustedPeer {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.map(str::to_string),
            user: user.map(str::to_string),
            paired: now_stamp(),
        });
        TrustOutcome::Pinned
    }

    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
    /// peer holds that label in scope.
    // [impl->REQ-PAIR-2]
    pub fn repin(
        &mut self,
        subnet: &str,
        label: &str,
        new_pubkey_hex: &str,
        user: Option<&str>,
    ) -> Result<(), TrustError> {
        let peer = self
            .peers
            .iter_mut()
            .find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
            })
            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
        peer.pubkey_hex = new_pubkey_hex.to_string();
        peer.paired = now_stamp();
        Ok(())
    }

    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
    // [impl->REQ-PAIR-2]
    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
        let before = self.peers.len();
        self.peers
            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
        self.peers.len() != before
    }
}

/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

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

    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
    // duplicate — the post-ceremony accept path.
    #[test]
    fn first_use_pins_then_is_trusted_idempotently() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::Pinned
        );
        assert!(store.is_trusted("home", "aa11"));
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::AlreadyTrusted,
            "re-pair with same key is idempotent"
        );
        assert_eq!(store.peers.len(), 1, "no duplicate row");
    }

    // [unit->REQ-PAIR-2] trust is per-subnet: the same node pubkey trusted in `home`
    // is NOT trusted in `work` until separately paired there (each subnet its own trust
    // context). An unknown key is simply untrusted.
    #[test]
    fn trust_is_scoped_per_subnet() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        assert!(store.is_trusted("home", "aa11"));
        assert!(
            !store.is_trusted("work", "aa11"),
            "not trusted in other subnet"
        );
        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
    }

    // [unit->REQ-PAIR-2] WARN-ON-CHANGE: a labelled peer that later presents a
    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
    // the store — the caller must decide. The MITM-vs-rotation guard.
    #[test]
    fn labelled_key_change_warns_without_mutating() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        let outcome = store.record("home", "bb22", Some("laptop"), None);
        assert_eq!(
            outcome,
            TrustOutcome::KeyChanged {
                existing_pubkey_hex: "aa11".into()
            }
        );
        assert!(store.is_trusted("home", "aa11"), "old key untouched");
        assert!(
            !store.is_trusted("home", "bb22"),
            "new key NOT auto-trusted"
        );
        assert_eq!(store.peers.len(), 1, "no row added on a warned change");
    }

    // [unit->REQ-PAIR-2] an ACCEPTED key change is applied by repin: the label now maps
    // to the new key and the old one is no longer trusted. Repinning an unknown label is
    // an error, not a silent insert.
    #[test]
    fn repin_applies_accepted_key_change() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        store.repin("home", "laptop", "bb22", None).expect("repin");
        assert!(store.is_trusted("home", "bb22"), "new key trusted");
        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
        assert_eq!(
            store.repin("home", "ghost", "cc33", None).unwrap_err(),
            TrustError::NotFound("home/ghost".into())
        );
    }

    // [unit->REQ-PAIR-2] without a label there is no change anchor (v1 default): a
    // second distinct key pins as its own peer rather than warning. Documented honest
    // behavior — warn-on-change activates once labels exist.
    #[test]
    fn unlabelled_distinct_keys_each_pin() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::Pinned
        );
        assert_eq!(
            store.record("home", "bb22", None, None),
            TrustOutcome::Pinned
        );
        assert_eq!(store.peers.len(), 2);
        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
    }

    // [unit->REQ-PAIR-2] revocation is a trust-delete: a revoked peer is no longer
    // trusted; a second revoke is a no-op false. (Pairs with seed rotation for real
    // revocation, #10.)
    #[test]
    fn revoke_removes_trust() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        assert!(store.revoke("home", "aa11"));
        assert!(!store.is_trusted("home", "aa11"));
        assert!(
            !store.revoke("home", "aa11"),
            "second revoke is no-op false"
        );
    }

    // [unit->REQ-PAIR-2] the same key can label-collide across DIFFERENT subnets without
    // a false warn — the change anchor is scoped to (subnet, user, label).
    #[test]
    fn label_anchor_is_subnet_scoped() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        // Same label, different subnet, different key → a clean first pin, not a warn.
        assert_eq!(
            store.record("work", "bb22", Some("laptop"), None),
            TrustOutcome::Pinned
        );
    }

    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
    // store.
    #[test]
    fn peers_in_filters_by_subnet() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        store.record("home", "bb22", None, None);
        store.record("work", "cc33", None, None);
        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
        assert_eq!(home.len(), 2);
        assert!(home.contains(&&"aa11".to_string()));
        assert_eq!(store.peers_in("work").count(), 1);
    }

    // [unit->REQ-PAIR-2] the store round-trips through the file atomically (multi-subnet
    // + label + a repin all preserved), and an absent/corrupt file degrades to empty —
    // a damaged trust file must not brick the node.
    #[test]
    fn store_persists_and_degrades_gracefully() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("peers.json");

        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        store.record("work", "cc33", None, None);
        store.repin("home", "laptop", "dd44", None).unwrap();
        store.save_to(&path).unwrap();

        let reloaded = TrustStore::load_from(&path);
        assert_eq!(reloaded.peers.len(), 2, "multi-subnet preserved");
        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
        assert!(reloaded.is_trusted("work", "cc33"));

        // Absent + corrupt → empty, never a panic.
        assert!(TrustStore::load_from(&dir.path().join("nope.json"))
            .peers
            .is_empty());
        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(TrustStore::load_from(&corrupt).peers.is_empty());
    }
}


Store pattern clear. Read perch path helpers + spt-store lib + find Frame::Seed.


81:/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
84:pub fn identity_dir() -> PathBuf {
93:pub fn node_key_file() -> PathBuf {
94:    identity_dir().join("node.key")
102:pub fn subnet_file() -> PathBuf {
103:    identity_dir().join("subnet.json")
110:pub fn epoch_file() -> PathBuf {
111:    identity_dir().join("epoch")
119:pub fn visibility_file() -> PathBuf {
120:    identity_dir().join("visibility.json")
128:pub fn notifs_file() -> PathBuf {
129:    identity_dir().join("notifs.db")
136:    identity_dir().join("trust")
142:pub fn trust_file() -> PathBuf {
150:pub fn access_file() -> PathBuf {
159:pub fn recent_outbound_file() -> PathBuf {
168:pub fn grants_file() -> PathBuf {
327:pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
337:pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
354:pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
361:pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
366:pub fn info_file_at(perch_path: &Path) -> PathBuf {


1	//! `spt-store` — the persistence commodity layer for spt-core.
2	//!
3	//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
4	//! types to disk (spool, registry, info.json) through a single atomic-write
5	//! primitive. Stable on-disk formats are copied verbatim from the sister
6	//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
7	//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
8	//!
9	// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod access;
15	pub mod atomic;
16	pub mod attachment;
17	pub mod branchstore;
18	pub mod contextmark;
19	pub mod contextstore;
20	mod db;
21	pub mod epoch;
22	pub mod gitrun;
23	pub mod grants;
24	pub mod history;
25	pub mod home;
26	pub mod hostlabel;
27	pub mod info;
28	pub mod liveness;
29	pub mod nodeid;
30	pub mod notif;
31	pub mod peeraddrs;
32	pub mod perch;
33	pub mod proc;
34	pub mod project;
35	pub mod registry;
36	pub mod rename;
37	pub mod seed;
38	pub mod shellinfo;
39	pub mod spool;
40	pub mod subnet;
41	pub mod syncmerge;
42	pub mod trust;
43	pub mod visibility;
44	pub mod xfer;
45

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod attachment;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod grants;
pub mod history;
pub mod home;
pub mod hostlabel;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod peeraddrs;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;


Found 11 files
traceable-reqs.toml
MESH-D2-PLAN.md
SUBNET-MESH-PLAN.md
docs\adr\0017-subnet-mesh-membership-seed-proof.md
crates\spt-net\src\net\pairing\wire.rs
crates\spt-daemon\src\seedmap.rs
crates\spt\src\api\startup.rs
crates\spt\src\api\mod.rs
M4-D2f-PLAN.md
crates\spt-net\src\net\pairing\totp.rs
crates\spt-store\src\seed.rs


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-//
--
47-
48-use super::ratelimit::{PairingRateLimiter, RateLimited};
49-use super::spake::{Initiator, PairError, Responder};
50:use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
51-use super::transcript::{PairingTranscript, TAG_LEN};
52-
53-/// The pre-trust pairing ALPN. Distinct from the trusted
--
273-
274-    \ The PAKE password: the code at the dialer's exact step (both sides bind that step).
275-    let code = match p.seed {
276:        Some(bytes) => code_at_step(&TotpSeed::from_bytes(bytes), p.peer_step),
277-        None => {
278-            abort(send, A_BAD_MSG, 0).await?;
279-            return Err(PairWireError::Protocol("stored seed is malformed".into()));
--
434-            // Pin the responder (its pubkey was proven live by the confirmation round).
435-            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
436-            \ F7: receive the subnet seed and become a full member / future seed-holder.
437:            // Read it on the same confirmed stream; a missing/short Seed frame is a
438-            // protocol error (the responder always sends it on success).
439-            match read_frame(&mut recv).await? {
440:                Frame::Seed { seed, epoch } => {
441-                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
442-                    \ hold this subnet) — that is success, not failure: keep our record.
443-                    match subnets.add_joined(subnet_name, seed, epoch) {
--
445-                        Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
446-                    }
447-                }
448:                other => return Err(unexpected("Seed", other)),
449-            }
450-            Ok(outcome)
451-        }
--
465-}
466-
467-/// The TOTP code string at an exact step (zero-padded to [`DIGITS`]) — the PAKE password.
468:fn code_at_step(seed: &TotpSeed, step: u64) -> String {
469-    let n = seed.code_at_step(step, DIGITS);
470-    format!("{n:0width$}", width = DIGITS as usize)
471-}
--
518-    Done {
519-        ok: bool,
520-    },
521:    Seed {
522-        seed: [u8; TOTP_SEED_LEN],
523-        epoch: u64,
524-    },
--
537-            Frame::Spake2 { .. } => "Spake2",
538-            Frame::Confirm { .. } => "Confirm",
539-            Frame::Done { .. } => "Done",
540:            Frame::Seed { .. } => "Seed",
541-            Frame::Abort { .. } => "Abort",
542-        }
543-    }
--
684-                .try_into()
685-                .expect("took TOTP_SEED_LEN");
686-            let epoch = c.u64()?;
687:            Frame::Seed { seed, epoch }
688-        }
689-        T_ABORT => {
690-            let reason = c.u8()?;
--
786-
787-    /// The current code for a seed at `NOW` — what the user "reads off their phone".
788-    fn code_for(seed: [u8; 20]) -> String {
789:        code_at_step(&TotpSeed::from_bytes(seed), time_step(NOW))
790-    }
791-
792-    /// Bind a responder + an initiator endpoint on the pairing ALPN over loopback


500	        /// The joiner's optional self-intro (M8 decision 13) — trailing,
501	        /// additive fields an older peer never sends and an older decoder
502	        /// never reads.
503	        intro: Option<NodeIntro>,
504	    },
505	    Announce {
506	        epoch: u64,
507	    },
508	    Spake1 {
509	        msg_a: Vec<u8>,
510	    },
511	    Spake2 {
512	        msg_b: Vec<u8>,
513	        tag: [u8; TAG_LEN],
514	    },
515	    Confirm {
516	        tag: [u8; TAG_LEN],
517	    },
518	    Done {
519	        ok: bool,
520	    },
521	    Seed {
522	        seed: [u8; TOTP_SEED_LEN],
523	        epoch: u64,
524	    },
525	    Abort {
526	        reason: u8,
527	        retry_after: u64,
528	    },
529	}
530
531	impl Frame {
532	    fn kind(&self) -> &'static str {
533	        match self {
534	            Frame::Hello { .. } => "Hello",
535	            Frame::Announce { .. } => "Announce",
536	            Frame::Spake1 { .. } => "Spake1",
537	            Frame::Spake2 { .. } => "Spake2",
538	            Frame::Confirm { .. } => "Confirm",
539	            Frame::Done { .. } => "Done",
540	            Frame::Seed { .. } => "Seed",
541	            Frame::Abort { .. } => "Abort",
542	        }
543	    }
544	}
545
546	fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {
547	    let mut b = vec![T_HELLO];
548	    push_field(&mut b, subnet_name.as_bytes());
549	    b.extend_from_slice(&step.to_be_bytes());
550	    // Trailing additive intro fields (M8 decision 13): both or neither —
551	    // an older responder's decoder reads positionally and ignores them.
552	    if let Some(i) = intro {
553	        push_field(&mut b, i.label.as_bytes());
554	        push_field(&mut b, i.machine_id.as_bytes());
555	    }
556	    b
557	}
558
559	fn enc_announce(epoch: u64) -> Vec<u8> {
560	    let mut b = vec![T_ANNOUNCE];
561	    b.extend_from_slice(&epoch.to_be_bytes());
562	    b
563	}
564
565	fn enc_spake1(msg_a: &[u8]) -> Vec<u8> {
566	    let mut b = vec![T_SPAKE1];
567	    push_field(&mut b, msg_a);
568	    b
569	}
570
571	fn enc_spake2(msg_b: &[u8], tag: &[u8; TAG_LEN]) -> Vec<u8> {
572	    let mut b = vec![T_SPAKE2];
573	    push_field(&mut b, msg_b);
574	    b.extend_from_slice(tag);
575	    b
576	}
577
578	fn enc_confirm(tag: &[u8; TAG_LEN]) -> Vec<u8> {
579	    let mut b = vec![T_CONFIRM];
580	    b.extend_from_slice(tag);
581	    b
582	}
583
584	fn enc_done(ok: bool) -> Vec<u8> {
585	    vec![T_DONE, ok as u8]
586	}
587
588	fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
589	    let mut b = vec![T_SEED];
590	    b.extend_from_slice(seed);
591	    b.extend_from_slice(&epoch.to_be_bytes());
592	    b
593	}
594
595	fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {
596	    let mut b = vec![T_ABORT, reason];
597	    b.extend_from_slice(&retry_after.to_be_bytes());
598	    b
599	}
600
601	/// Append a u32-BE length-prefixed field.
602	fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
603	    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
604	    buf.extend_from_slice(field);
605	}
606
607	/// Write one length-framed message: u32-BE total length, then the body.
608	async fn write_frame(send: &mut SendStream, body: &[u8]) -> Result<(), PairWireError> {
609	    let len = body.len() as u32;
610	    send.write_all(&len.to_be_bytes())
611	        .await
612	        .map_err(|e| PairWireError::Io(format!("write len: {e}")))?;
613	    send.write_all(body)
614	        .await
615	        .map_err(|e| PairWireError::Io(format!("write body: {e}")))
616	}
617
618	/// Send an Abort frame (best-effort signal to the peer before returning an error).
619	async fn abort(send: &mut SendStream, reason: u8, retry_after: u64) -> Result<(), PairWireError> {
620	    write_frame(send, &enc_abort(reason, retry_after)).await?;
621	    let _ = send.finish();
622	    Ok(())
623	}
624
625	/// Read and decode one length-framed message, bounding the length at [`MAX_FRAME`].
626	async fn read_frame(recv: &mut RecvStream) -> Result<Frame, PairWireError> {
627	    let mut len_buf = [0u8; 4];
628	    recv.read_exact(&mut len_buf)
629	        .await
630	        .map_err(|e| PairWireError::Io(format!("read len: {e}")))?;
631	    let len = u32::from_be_bytes(len_buf) as usize;
632	    if len == 0 || len > MAX_FRAME {
633	        return Err(PairWireError::Protocol(format!(
634	            "frame length {len} out of range"
635	        )));
636	    }
637	    let mut body = vec![0u8; len];
638	    recv.read_exact(&mut body)
639	        .await
640	        .map_err(|e| PairWireError::Io(format!("read body: {e}")))?;
641	    decode_frame(&body)
642	}
643
644	fn decode_frame(body: &[u8]) -> Result<Frame, PairWireError> {
645	    let mut c = Cursor::new(body);
646	    let tag = c.u8()?;
647	    let frame = match tag {
648	        T_HELLO => {
649	            let subnet_name = String::from_utf8(c.field()?.to_vec())
650	                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
651	            let step = c.u64()?;
652	            // Optional trailing intro (M8 decision 13): absent on a pre-M8
653	            // joiner; when present, BOTH fields must decode (a half-written
654	            // intro is a protocol error, never a silent half-claim).
655	            let intro = if c.remaining() > 0 {
656	                let label = String::from_utf8(c.field()?.to_vec())
657	                    .map_err(|_| PairWireError::Protocol("intro label not utf-8".into()))?;
658	                let machine_id = String::from_utf8(c.field()?.to_vec())
659	                    .map_err(|_| PairWireError::Protocol("intro machine id not utf-8".into()))?;
660	                Some(NodeIntro { label, machine_id })
661	            } else {
662	                None
663	            };
664	            Frame::Hello {
665	                subnet_name,
666	                step,
667	                intro,
668	            }
669	        }
670	        T_ANNOUNCE => Frame::Announce { epoch: c.u64()? },
671	        T_SPAKE1 => Frame::Spake1 {
672	            msg_a: c.field()?.to_vec(),
673	        },
674	        T_SPAKE2 => {
675	            let msg_b = c.field()?.to_vec();
676	            let tag = c.tag()?;
677	            Frame::Spake2 { msg_b, tag }
678	        }
679	        T_CONFIRM => Frame::Confirm { tag: c.tag()? },
680	        T_DONE => Frame::Done { ok: c.u8()? != 0 },
681	        T_SEED => {
682	            let seed: [u8; TOTP_SEED_LEN] = c
683	                .take(TOTP_SEED_LEN)?
684	                .try_into()
685	                .expect("took TOTP_SEED_LEN");
686	            let epoch = c.u64()?;
687	            Frame::Seed { seed, epoch }
688	        }
689	        T_ABORT => {
690	            let reason = c.u8()?;
691	            let retry_after = c.u64()?;
692	            Frame::Abort {
693	                reason,
694	                retry_after,
695	            }
696	        }
697	        other => {
698	            return Err(PairWireError::Protocol(format!(
699	                "unknown frame tag {other}"
700	            )))
701	        }
702	    };
703	    Ok(frame)
704	}
705
706	/// A minimal big-endian field reader over a frame body — every accessor bounds-checks so
707	/// a truncated or oversized field is a [`Protocol`](PairWireError::Protocol) error, never
708	/// a panic (the input is pre-trust attacker-reachable).
709	struct Cursor<'a> {
710	    buf: &'a [u8],
711	    pos: usize,
712	}
713
714	impl<'a> Cursor<'a> {
715	    fn new(buf: &'a [u8]) -> Cursor<'a> {
716	        Cursor { buf, pos: 0 }
717	    }
718
719	    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {

        /// 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,
    },
    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) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    b
}

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()?;
            Frame::Seed { seed, epoch }
        }
        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> {


130:pub struct NodeIntro {
135:    pub machine_id: String,
151:    pub intro: Option<NodeIntro>,
166:pub async fn run_responder(
331:            write_frame(send, &enc_seed(&seed, p.epoch)).await?;
361:pub async fn run_initiator(
370:    intro: Option<&NodeIntro>,
435:            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
503:        intro: Option<NodeIntro>,
546:fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {
554:        push_field(&mut b, i.machine_id.as_bytes());
588:fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
658:                let machine_id = String::from_utf8(c.field()?.to_vec())
660:                Some(NodeIntro { label, machine_id })
1298:        let sent = NodeIntro {
1300:            machine_id: "cd".repeat(32),
1335:                Some(&NodeIntro {
1337:                    machine_id: "ab".repeat(32),
1346:            enc_seed(&[5u8; TOTP_SEED_LEN], 9),


120	/// The joiner's self-introduction riding `Hello` (M8 decision 13,
121	/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
122	/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
123	/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
124	/// the code, and Hello rode that same iroh-authenticated connection.
125	/// Optional + additive on the wire: an older joiner sends none, an older
126	/// responder ignores the trailing Hello bytes (the decoder reads
127	/// positionally and never rejects a longer frame).
128	// [impl->REQ-SUBNET-7]
129	#[derive(Debug, Clone, PartialEq, Eq)]
130	pub struct NodeIntro {
131	    /// The joiner's advertised node label (its OS hostname).
132	    pub label: String,
133	    /// The joiner's hashed stable machine id (domain-separated SHA-256 —
134	    /// never the raw id; `spt-daemon::machineid` is the producer).
135	    pub machine_id: String,
136	}
137
138	/// What a completed responder ceremony yields (M8 decision 13): the TOFU
139	/// verdict plus the identity facts the re-pair overwrite consumes — the
140	/// caller (the daemon's pairing host) decides whether a superseded identity
141	/// must be evicted; this layer only reports what the ceremony proved.
142	#[derive(Debug)]
143	pub struct ResponderOutcome {
144	    /// The TOFU verdict for the joiner.
145	    pub trust: TrustOutcome,
146	    /// The joiner's iroh-authenticated node pubkey, lowercase hex.
147	    pub joiner_hex: String,
148	    /// The subnet paired into.
149	    pub subnet: String,
150	    /// The joiner's self-intro, if it sent one.
151	    pub intro: Option<NodeIntro>,
152	}
153
154	/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.
155	///
156	/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the
157	/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
158	/// receives the peer on success. `now` (unix seconds) is injected for deterministic
159	/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
160	///
161	/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
162	/// limiter slot is released as a success. Every error path releases the slot too — as a
163	/// failure when a ceremony was admitted (charging backoff), or not at all when admission
164	/// itself was refused.
165	// [impl->REQ-PAIR-1]
166	pub async fn run_responder(
167	    conn: &Connection,
168	    local_pub: PublicKey,
169	    subnets: &SubnetStore,
170	    rate: &mut PairingRateLimiter,
171	    trust: &mut TrustStore,
172	    now: u64,
173	) -> Result<ResponderOutcome, PairWireError> {
174	    let (mut send, mut recv) = conn
175	        .accept_bi()
176	        .await
177	        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
178	    let initiator_pub = remote_pub(conn)?;
179
180	    // F1: Hello — the joiner's subnet name + clock-derived TOTP step (+ its
181	    // optional self-intro, M8 decision 13).
182	    let (subnet_name, peer_step, intro) = match read_frame(&mut recv).await? {
183	        Frame::Hello {
184	            subnet_name,
185	            step,
186	            intro,
187	        } => (subnet_name, step, intro),
188	        other => return Err(unexpected("Hello", other)),
189	    };
190
191	    // Subnet lookup BEFORE admission so an unknown name never consumes a ceremony slot.
192	    let record = match subnets.find(&subnet_name) {
193	        Some(r) => r,
194	        None => {
195	            abort(&mut send, A_UNKNOWN_SUBNET, 0).await?;
196	            return Err(PairWireError::UnknownSubnet(subnet_name));
197	        }
198	    };
199
200	    // Admission: one ceremony per subnet + exponential backoff (#11). On refusal the
201	    // slot is NOT taken, so there is nothing to release.
202	    if let Err(rl) = rate.begin(&subnet_name, now) {
203	        let (reason, retry) = match &rl {
204	            RateLimited::Busy => (A_BUSY, 0),
205	            RateLimited::Backoff { retry_after_secs } => (A_BACKOFF, *retry_after_secs),
206	        };
207	        abort(&mut send, reason, retry).await?;
208	        return Err(PairWireError::Refused(rl));
209	    }
210	    // From here the slot is HELD. Release it exactly once below, charging a failure on
211	    // ANY error path — a wrong code, a peer that vanished, a malformed frame, or an
212	    // abandoned ceremony all count against the subnet's backoff (the #11 guarantee only
213	    // holds if every admitted-but-unsuccessful ceremony is charged, not just clean
214	    // confirmation mismatches). Decoding seed + epoch here drops the `subnets` borrow
215	    // before the (mutating) release.
216	    let params = Admitted {
217	        subnet_name: &subnet_name,
218	        peer_step,
219	        epoch: record.epoch,
220	        seed: record.seed_bytes(),
221	        initiator_pub,
222	        local_pub,
223	        now,
224	    };
225	    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
226	    match &result {
227	        Ok(_) => rate.record_success(&subnet_name),
228	        Err(_) => rate.record_failure(&subnet_name, now),
229	    }
230	    result.map(|trust| ResponderOutcome {
231	        trust,
232	        joiner_hex: initiator_pub.to_hex(),
233	        subnet: subnet_name,
234	        intro,
235	    })
236	}
237
238	/// The responder ceremony *after* admission — the announce → SPAKE → confirm flow. Does
239	/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
240	/// on this result, so every early return here (including a propagated I/O error) is
241	/// charged uniformly. Best-effort `Abort`/`Done` frames are sent so the joiner fails fast
242	/// instead of waiting out the ceremony timeout.
243	/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
244	/// once a subnet seed is found and the rate-limit slot is held. Bundled so the inner
245	/// driver stays a tidy four-argument call (the streams, this, and the trust store).
246	struct Admitted<'a> {
247	    subnet_name: &'a str,
248	    /// The dialer-announced TOTP step (already known to exist; ±1-checked inside).
249	    peer_step: u64,
250	    /// The responder-authoritative seed epoch bound into the transcript.
251	    epoch: u64,
252	    /// The subnet seed bytes — `None` if the stored hex was malformed (an internal fault).
253	    seed: Option<[u8; 20]>,
254	    /// The joiner's iroh-authenticated node pubkey (the trust write target).
255	    initiator_pub: PublicKey,
256	    /// This seed-holder's own node pubkey (the transcript's responder side).
257	    local_pub: PublicKey,
258	    /// This node's clock, for the ±1 step window. Injected for deterministic tests.
259	    now: u64,
260	}
261
262	async fn responder_admitted(
263	    send: &mut SendStream,
264	    recv: &mut RecvStream,
265	    p: &Admitted<'_>,
266	    trust: &mut TrustStore,
267	) -> Result<TrustOutcome, PairWireError> {
268	    // ±1 clock-skew window on the dialer-announced step.
269	    if !accepts_step(time_step(p.now), p.peer_step) {
270	        abort(send, A_BAD_STEP, 0).await?;
271	        return Err(PairWireError::StepRejected);
272	    }
273
274	    // The PAKE password: the code at the dialer's exact step (both sides bind that step).
275	    let code = match p.seed {
276	        Some(bytes) => code_at_step(&TotpSeed::from_bytes(bytes), p.peer_step),
277	        None => {
278	            abort(send, A_BAD_MSG, 0).await?;
279	            return Err(PairWireError::Protocol("stored seed is malformed".into()));
280	        }
281	    };
282
283	    // F2: Announce the epoch — the joiner needs it to build the transcript for msg_a.
284	    write_frame(send, &enc_announce(p.epoch)).await?;
285
286	    // F3: Spake1 — the joiner's msg_a (built against the announced epoch).
287	    let msg_a = match read_frame(recv).await? {
288	        Frame::Spake1 { msg_a } => msg_a,
289	        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
290	        other => return Err(unexpected("Spake1", other)),
291	    };
292
293	    let transcript = PairingTranscript {
294	        subnet_name: p.subnet_name.to_string(),
295	        epoch: p.epoch,
296	        step: p.peer_step,
297	        initiator_pub: p.initiator_pub,
298	        responder_pub: p.local_pub,
299	    };

/// 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]
pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    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 params = Admitted {
        subnet_name: &subnet_name,
        peer_step,
        epoch: record.epoch,
        seed: record.seed_bytes(),
        initiator_pub,
        local_pub,
        now,
    };
    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,
}

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


300
301	    let responder = match Responder::respond(transcript, &code, &msg_a) {
302	        Ok((r, msg_b, responder_tag)) => {
303	            // F4: Spake2 — msg_b + the responder's confirmation tag.
304	            write_frame(send, &enc_spake2(&msg_b, &responder_tag)).await?;
305	            r
306	        }
307	        Err(e) => {
308	            abort(send, A_BAD_MSG, 0).await?;
309	            return Err(PairWireError::Pairing(e));
310	        }
311	    };
312
313	    // F5: Confirm — the joiner's tag (or an Abort if its finish() already failed).
314	    let initiator_tag = match read_frame(recv).await? {
315	        Frame::Confirm { tag } => tag,
316	        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
317	        other => return Err(unexpected("Confirm", other)),
318	    };
319
320	    match responder.confirm(&initiator_tag) {
321	        Ok(_paired) => {
322	            // Confirmed live: pin the peer (TOFU). The peer id is the iroh-authenticated
323	            // remote id; label/user None (v1).
324	            let outcome = trust.record(p.subnet_name, &p.initiator_pub.to_hex(), None, None);
325	            write_frame(send, &enc_done(true)).await?;
326	            // F7: transfer the subnet seed so the new node becomes a full member /
327	            // future seed-holder ("every trusted node holds the seed", CONTEXT.md). Sent
328	            // ONLY on confirm-success, over the iroh-authenticated TLS stream to the
329	            // now-trust-pinned peer. `seed` is Some here (a None seed aborted upstream).
330	            let seed = p.seed.expect("seed present on the confirm-success path");
331	            write_frame(send, &enc_seed(&seed, p.epoch)).await?;
332	            let _ = send.finish();
333	            Ok(outcome)
334	        }
335	        Err(e) => {
336	            write_frame(send, &enc_done(false)).await?;
337	            let _ = send.finish();
338	            Err(PairWireError::Pairing(e))
339	        }
340	    }
341	}
342
343	/// Run the **initiator** (joining node) side of the ceremony over a dialed connection.
344	///
345	/// `local_pub` is this node's own pubkey; `subnet_name` + `code` are the two user-entered
346	/// inputs (the subnet label and the current 6-digit TOTP code); `now` is the local clock.
347	/// On success the responder is pinned in `trust` AND the transferred subnet seed is written
348	/// into `subnets` (the joiner becomes a full member / future seed-holder — D2f, REQ-PAIR-5);
349	/// the returned [`TrustOutcome`] is the TOFU decision.
350	///
351	/// `step_back` shifts the ANNOUNCED time-step below the local clock's (0 =
352	/// the normal attempt). The joiner cannot validate the typed code locally
353	/// (it holds no seed yet), so a code read just before a rotation and entered
354	/// just after binds to the wrong step and fails as "wrong code" — the ±1
355	/// responder window ([`accepts_step`](super::totp)) tolerates an announced
356	/// `step-1`, letting the caller retry the SAME typed code against the
357	/// previous step before re-prompting the user (the late-code retry, field
358	/// report 2026-06-06).
359	// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
360	#[allow(clippy::too_many_arguments)]
361	pub async fn run_initiator(

    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)).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(


Read peeraddrs (D4 reconcile target) + find ceremony callers + machineid + confirm no roster.rs yet.


1	//! Durable last-known peer addresses (M8 decisions 14/20, REQ-CONV-1).
2	//!
3	//! `identity/peer-addrs.json` maps a trusted peer's stable name
4	//! (lowercase-hex pubkey) to its **last-known dialable address** — an opaque,
5	//! serde-serialized iroh `EndpointAddr` (this crate never grows an iroh
6	//! dependency edge; the value's shape belongs to the net layer, same posture
7	//! as the broker IPC's `net-dial` payload).
8	//!
9	//! Why it exists: production peer resolution is id-only discovery (mDNS on
10	//! the LAN, n0 DNS on the WAN) — both cold starts pay it in full (~1 min
11	//! observed). Two writers keep this file warm: the pairing ceremony (both
12	//! sides, from the live ceremony connection) and the peer pump on every
13	//! successful connect. The pump's resolver consults it FIRST and falls back
14	//! to id-only discovery on a miss **or a dial failure** — a stale address
15	//! can cost one failed dial, never strand a peer.
16	//!
17	//! Absent/corrupt degrades to empty (seeding is an optimization; the id-only
18	//! path is always behind it).
19	// [impl->REQ-CONV-1]
20
21	use std::collections::BTreeMap;
22	use std::path::{Path, PathBuf};
23
24	use serde::{Deserialize, Serialize};
25
26	use crate::atomic::atomic_write_string;
27
28	/// The canonical path: `<spt_home>/identity/peer-addrs.json` (sibling of
29	/// `pump-ops.json` — M8 decision 20).
30	pub fn peer_addrs_file() -> PathBuf {
31	    crate::perch::spt_home()
32	        .join("identity")
33	        .join("peer-addrs.json")
34	}
35
36	/// The on-disk + in-memory shape: peer pubkey-hex → last-known address.
37	#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38	pub struct PeerAddrStore {
39	    /// Last-known dialable address per peer (opaque `EndpointAddr` JSON).
40	    #[serde(default)]
41	    pub addrs: BTreeMap<String, serde_json::Value>,
42	}
43
44	impl PeerAddrStore {
45	    /// Load from the canonical [`peer_addrs_file`]. Absent ⇒ empty.
46	    pub fn load() -> PeerAddrStore {
47	        Self::load_from(&peer_addrs_file())
48	    }
49
50	    /// Load from an explicit path. Absent/corrupt degrades to empty — a
51	    /// broken seed file must never break peer resolution (the id-only
52	    /// discovery path is always behind it).
53	    pub fn load_from(path: &Path) -> PeerAddrStore {
54	        match std::fs::read_to_string(path) {
55	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
56	            Err(_) => PeerAddrStore::default(),
57	        }
58	    }
59
60	    /// Persist to the canonical path.
61	    pub fn save(&self) -> std::io::Result<()> {
62	        self.save_to(&peer_addrs_file())
63	    }
64
65	    /// Persist to an explicit path (atomic; creates the parent dir).
66	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
67	        if let Some(parent) = path.parent() {
68	            std::fs::create_dir_all(parent)?;
69	        }
70	        let json =
71	            serde_json::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
72	        atomic_write_string(path, &json)
73	    }
74
75	    /// The last-known address for a peer, if one was ever recorded.
76	    pub fn get(&self, peer_hex: &str) -> Option<&serde_json::Value> {
77	        self.addrs.get(peer_hex)
78	    }
79
80	    /// Record a peer's freshly-observed address. Returns `true` when the
81	    /// stored value changed (callers skip the disk write on a no-op).
82	    pub fn put(&mut self, peer_hex: &str, addr: serde_json::Value) -> bool {
83	        match self.addrs.get(peer_hex) {
84	            Some(prev) if *prev == addr => false,
85	            _ => {
86	                self.addrs.insert(peer_hex.to_string(), addr);
87	                true
88	            }
89	        }
90	    }

//! Durable last-known peer addresses (M8 decisions 14/20, REQ-CONV-1).
//!
//! `identity/peer-addrs.json` maps a trusted peer's stable name
//! (lowercase-hex pubkey) to its **last-known dialable address** — an opaque,
//! serde-serialized iroh `EndpointAddr` (this crate never grows an iroh
//! dependency edge; the value's shape belongs to the net layer, same posture
//! as the broker IPC's `net-dial` payload).
//!
//! Why it exists: production peer resolution is id-only discovery (mDNS on
//! the LAN, n0 DNS on the WAN) — both cold starts pay it in full (~1 min
//! observed). Two writers keep this file warm: the pairing ceremony (both
//! sides, from the live ceremony connection) and the peer pump on every
//! successful connect. The pump's resolver consults it FIRST and falls back
//! to id-only discovery on a miss **or a dial failure** — a stale address
//! can cost one failed dial, never strand a peer.
//!
//! Absent/corrupt degrades to empty (seeding is an optimization; the id-only
//! path is always behind it).
// [impl->REQ-CONV-1]

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;

/// The canonical path: `<spt_home>/identity/peer-addrs.json` (sibling of
/// `pump-ops.json` — M8 decision 20).
pub fn peer_addrs_file() -> PathBuf {
    crate::perch::spt_home()
        .join("identity")
        .join("peer-addrs.json")
}

/// The on-disk + in-memory shape: peer pubkey-hex → last-known address.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerAddrStore {
    /// Last-known dialable address per peer (opaque `EndpointAddr` JSON).
    #[serde(default)]
    pub addrs: BTreeMap<String, serde_json::Value>,
}

impl PeerAddrStore {
    /// Load from the canonical [`peer_addrs_file`]. Absent ⇒ empty.
    pub fn load() -> PeerAddrStore {
        Self::load_from(&peer_addrs_file())
    }

    /// Load from an explicit path. Absent/corrupt degrades to empty — a
    /// broken seed file must never break peer resolution (the id-only
    /// discovery path is always behind it).
    pub fn load_from(path: &Path) -> PeerAddrStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => PeerAddrStore::default(),
        }
    }

    /// Persist to the canonical path.
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&peer_addrs_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json =
            serde_json::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
        atomic_write_string(path, &json)
    }

    /// The last-known address for a peer, if one was ever recorded.
    pub fn get(&self, peer_hex: &str) -> Option<&serde_json::Value> {
        self.addrs.get(peer_hex)
    }

    /// Record a peer's freshly-observed address. Returns `true` when the
    /// stored value changed (callers skip the disk write on a no-op).
    pub fn put(&mut self, peer_hex: &str, addr: serde_json::Value) -> bool {
        match self.addrs.get(peer_hex) {
            Some(prev) if *prev == addr => false,
            _ => {
                self.addrs.insert(peer_hex.to_string(), addr);
                true
            }
        }
    }


Found 7 files
crates\spt-daemon\tests\twohost.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-daemon\src\pairhost.rs
docs\TWO-HOST-RUNBOOK.md
crates\spt-net\src\net\pairing\meet.rs
M7-PLAN.md
M4-D2f-PLAN.md


There's already `crates/spt/src/roster.rs`. Check it + pairhost caller + machineid.


1	//! Perch enumeration + self-identification helpers for the `list`, `whoami`,
2	//! and `stop` subcommands. M1 enumerates **flat** (Self_) perches only —
3	//! nested Psyche/Worker perches arrive with the M2 lifecycle.
4	//!
5	//! These are CLI-layer conveniences: they read the on-disk owlery layout and
6	//! the registry through the spt-store API. Nothing here is on the hot
7	//! delivery path.
8
9	use spt_store::info;
10	use spt_store::liveness;
11	use spt_store::perch::{self, ParentHint};
12	use spt_store::registry;
13
14	/// A snapshot of one perch for `spt list`.
15	pub struct PerchEntry {
16	    pub id: String,
17	    pub state: String,
18	    pub address: Option<String>,
19	    pub ready: bool,
20	    pub alive: bool,
21	}
22
23	/// Enumerate flat perches under the owlery: any top-level dir holding an
24	/// `info.json`. Returns them sorted by id.
25	pub fn enumerate() -> Vec<PerchEntry> {
26	    let owlery = perch::owlery_dir();
27	    let mut out = Vec::new();
28	    let Ok(entries) = std::fs::read_dir(&owlery) else {
29	        return out;
30	    };
31	    for entry in entries.flatten() {
32	        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
33	            continue;
34	        }
35	        let perch_path = entry.path();
36	        let Some(rec) = info::read_info(&perch_path) else {
37	            continue; // not a perch (no info.json)
38	        };
39	        let id = rec.id.clone();
40	        let ready = perch::resolve_ready_file(&id, ParentHint::Infer).exists();
41	        // Single liveness resolver (2.5): daemon-authoritative `status` for
42	        // daemon-hosted perches, per-pid probe for external/interim ones.
43	        let alive = liveness::is_perch_alive(&perch_path);
44	        let address = registry::lookup_address(&id, &owlery);
45	        out.push(PerchEntry {
46	            id,
47	            state: rec.state,
48	            address,
49	            ready,
50	            alive,
51	        });
52	    }
53	    out.sort_by(|a, b| a.id.cmp(&b.id));
54	    out
55	}
56
57	/// Identify the perch owned by *this* session: the one whose `info.json`
58	/// `session_id` matches `$OWL_SESSION_ID`. Falls back to `$SPT_AGENT_ID` if
59	/// that env is set. `None` if neither resolves.
60	pub fn detect_self_id() -> Option<String> {
61	    if let Ok(session) = std::env::var("OWL_SESSION_ID") {
62	        if !session.is_empty() {
63	            let owlery = perch::owlery_dir();
64	            if let Ok(entries) = std::fs::read_dir(&owlery) {
65	                for entry in entries.flatten() {
66	                    if let Some(rec) = info::read_info(&entry.path()) {
67	                        if rec.session_id == session {
68	                            return Some(rec.id);
69	                        }
70	                    }
71	                }
72	            }
73	        }
74	    }
75	    std::env::var("SPT_AGENT_ID").ok().filter(|s| !s.is_empty())
76	}
77

//! Perch enumeration + self-identification helpers for the `list`, `whoami`,
//! and `stop` subcommands. M1 enumerates **flat** (Self_) perches only —
//! nested Psyche/Worker perches arrive with the M2 lifecycle.
//!
//! These are CLI-layer conveniences: they read the on-disk owlery layout and
//! the registry through the spt-store API. Nothing here is on the hot
//! delivery path.

use spt_store::info;
use spt_store::liveness;
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

/// A snapshot of one perch for `spt list`.
pub struct PerchEntry {
    pub id: String,
    pub state: String,
    pub address: Option<String>,
    pub ready: bool,
    pub alive: bool,
}

/// Enumerate flat perches under the owlery: any top-level dir holding an
/// `info.json`. Returns them sorted by id.
pub fn enumerate() -> Vec<PerchEntry> {
    let owlery = perch::owlery_dir();
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(&owlery) else {
        return out;
    };
    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let perch_path = entry.path();
        let Some(rec) = info::read_info(&perch_path) else {
            continue; // not a perch (no info.json)
        };
        let id = rec.id.clone();
        let ready = perch::resolve_ready_file(&id, ParentHint::Infer).exists();
        // Single liveness resolver (2.5): daemon-authoritative `status` for
        // daemon-hosted perches, per-pid probe for external/interim ones.
        let alive = liveness::is_perch_alive(&perch_path);
        let address = registry::lookup_address(&id, &owlery);
        out.push(PerchEntry {
            id,
            state: rec.state,
            address,
            ready,
            alive,
        });
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

/// Identify the perch owned by *this* session: the one whose `info.json`
/// `session_id` matches `$OWL_SESSION_ID`. Falls back to `$SPT_AGENT_ID` if
/// that env is set. `None` if neither resolves.
pub fn detect_self_id() -> Option<String> {
    if let Ok(session) = std::env::var("OWL_SESSION_ID") {
        if !session.is_empty() {
            let owlery = perch::owlery_dir();
            if let Ok(entries) = std::fs::read_dir(&owlery) {
                for entry in entries.flatten() {
                    if let Some(rec) = info::read_info(&entry.path()) {
                        if rec.session_id == session {
                            return Some(rec.id);
                        }
                    }
                }
            }
        }
    }
    std::env::var("SPT_AGENT_ID").ok().filter(|s| !s.is_empty())
}


`spt/src/roster.rs` = unrelated perch-listing (name collision only). Mine goes in `spt-store/src/roster.rs`. Read pairhost caller + machineid.