File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\pairing\spake.rs (file state is current in your context — no need to Read it back)

//! The SPAKE2 pairing handshake state machine (ADR-0005, D2a/b).
//!
//! Wraps the `spake2` crate (RustCrypto, Ed25519 group) into the two role-typed flows
//! of the ceremony, layering the [transcript confirmation](super::transcript) the PAKE
//! itself omits. The TOTP [code](super::totp) is the PAKE password.
//!
//! ## The flow (initiator = joiner, responder = seed-holder)
//!
//! ```text
//!   Initiator::start(transcript, code)          -> (Initiator, msg_a)         ──msg_a──▶
//!                                       Responder::respond(transcript, code, msg_a)
//!                                          -> (Responder, msg_b, responder_tag)
//!                                       ◀──msg_b, responder_tag──
//!   Initiator::finish(msg_b, responder_tag)     -> (shared, initiator_tag)    ──init_tag──▶
//!                                       Responder::confirm(initiator_tag)     -> shared
//! ```
//!
//! ## The load-bearing crypto fact
//!
//! SPAKE2's `finish()` **does not fail on a wrong password** — it returns a *different*
//! shared secret on each side. So a wrong code, a MITM, a wrong subnet, or a stale step
//! all surface the same way: the [confirmation tags](super::transcript) don't match. The
//! confirmation round is therefore not optional polish — it is the *only* thing that
//! rejects a bad pairing. Roles are pinned to `start_a`/`start_b`, so a reflected
//! message (both sides side-A) is additionally rejected by the PAKE as `BadSide`.
//
// [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]

use spake2::{Ed25519Group, Identity as PakeIdentity, Password, Spake2};
use spt_proto::identity::PublicKey;
use zeroize::Zeroizing;

use super::transcript::{PairingTranscript, Role, TAG_LEN};

/// A pairing handshake failure. Carries no secret material.
#[derive(Debug, PartialEq, Eq)]
pub enum PairError {
    /// The SPAKE2 message was malformed / wrong-length / reflected (`spake2::Error`).
    /// A *wrong code* does NOT land here — it surfaces as [`Confirm`](Self::Confirm).
    Spake(String),
    /// The peer's confirmation tag did not match: wrong code, MITM, wrong subnet,
    /// stale step, or a tampered transcript. The ceremony aborts.
    Confirm,
}

impl std::fmt::Display for PairError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PairError::Spake(e) => write!(f, "SPAKE2 handshake failed: {e}"),
            PairError::Confirm => write!(f, "key confirmation failed — pairing aborted"),
        }
    }
}

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

/// The confirmed outcome of a successful ceremony: the shared session key (zeroized on
/// drop) plus the peer's now-trusted node public key (to write into the trust store).
pub struct Paired {
    /// The PAKE-derived, mutually-confirmed shared secret. Wiped from memory on drop.
    pub session_key: Zeroizing<Vec<u8>>,
    /// The peer's Ed25519 public key, proven live by the confirmation round.
    pub peer_pub: PublicKey,
}

/// Initiator (joining node) state, awaiting the responder's reply.
pub struct Initiator {
    state: Spake2<Ed25519Group>,
    transcript: PairingTranscript,
    msg_a: Vec<u8>,
}

impl Initiator {
    /// Begin the ceremony as the joining node. Returns the state and `msg_a` to send
    /// to the seed-holder. `code` is the user-typed current TOTP code (the PAKE
    /// password); `transcript` carries the agreed context (both pubkeys, subnet, epoch,
    /// step) — note the joiner does NOT hold the seed, only the code.
    // [impl->REQ-PAIR-1]
    pub fn start(transcript: PairingTranscript, code: &str) -> (Initiator, Vec<u8>) {
        let (state, msg_a) = Spake2::<Ed25519Group>::start_a(
            &Password::new(code.as_bytes()),
            &PakeIdentity::new(&transcript.pake_identity(Role::Initiator)),
            &PakeIdentity::new(&transcript.pake_identity(Role::Responder)),
        );
        let msg = msg_a.clone();
        (Initiator { state, transcript, msg_a }, msg)
    }

    /// Finish the PAKE with the responder's `msg_b`, verify the responder's
    /// confirmation tag, and produce the initiator's own tag to send back. On success
    /// the shared key is confirmed live on the responder side; the returned
    /// `initiator_tag` lets the responder confirm in turn.
    // [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
    pub fn finish(
        self,
        msg_b: &[u8],
        responder_tag: &[u8],
    ) -> Result<(Paired, [u8; TAG_LEN]), PairError> {
        let shared = Zeroizing::new(
            self.state.finish(msg_b).map_err(|e| PairError::Spake(e.to_string()))?,
        );
        // The responder finished first and tagged with role=Responder; verify it.
        if !self.transcript.verify_tag(&shared, Role::Responder, &self.msg_a, msg_b, responder_tag) {
            return Err(PairError::Confirm);
        }
        let initiator_tag = self.transcript.confirm_tag(&shared, Role::Initiator, &self.msg_a, msg_b);
        let paired = Paired { session_key: shared, peer_pub: self.transcript.responder_pub };
        Ok((paired, initiator_tag))
    }
}

/// Responder (seed-holder) state, awaiting the initiator's confirmation tag.
pub struct Responder {
    transcript: PairingTranscript,
    shared: Zeroizing<Vec<u8>>,
    msg_a: Vec<u8>,
    msg_b: Vec<u8>,
}

impl Responder {
    /// Respond to the initiator's `msg_a` as the seed-holder. Runs SPAKE2 side B,
    /// derives the shared secret, and returns `msg_b` plus the responder's confirmation
    /// tag to send back. `code` is computed by the caller from the seed at the
    /// transcript's step (the caller enforces the ±1 window via
    /// [`accepts_step`](super::totp::accepts_step) before constructing the transcript).
    // [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
    pub fn respond(
        transcript: PairingTranscript,
        code: &str,
        msg_a: &[u8],
    ) -> Result<(Responder, Vec<u8>, [u8; TAG_LEN]), PairError> {
        let (state, msg_b) = Spake2::<Ed25519Group>::start_b(
            &Password::new(code.as_bytes()),
            &PakeIdentity::new(&transcript.pake_identity(Role::Initiator)),
            &PakeIdentity::new(&transcript.pake_identity(Role::Responder)),
        );
        let shared = Zeroizing::new(
            state.finish(msg_a).map_err(|e| PairError::Spake(e.to_string()))?,
        );
        let responder_tag = transcript.confirm_tag(&shared, Role::Responder, msg_a, &msg_b);
        let r = Responder { transcript, shared, msg_a: msg_a.to_vec(), msg_b: msg_b.clone() };
        Ok((r, msg_b, responder_tag))
    }

    /// Verify the initiator's confirmation tag and complete the ceremony. Only now is
    /// the initiator proven to hold the same key (a wrong code makes this fail), so the
    /// trust write happens after this returns `Ok`.
    // [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
    pub fn confirm(self, initiator_tag: &[u8]) -> Result<Paired, PairError> {
        if !self.transcript.verify_tag(&self.shared, Role::Initiator, &self.msg_a, &self.msg_b, initiator_tag)
        {
            return Err(PairError::Confirm);
        }
        Ok(Paired { session_key: self.shared, peer_pub: self.transcript.initiator_pub })
    }
}

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

    /// Build a matching pair of transcripts for two node identities. Both sides see
    /// the same field values (the agreement); only the role differs at use sites.
    fn transcript(a: &Identity, b: &Identity, subnet: &str, epoch: u64, step: u64) -> PairingTranscript {
        PairingTranscript {
            subnet_name: subnet.into(),
            epoch,
            step,
            initiator_pub: a.public_key(),
            responder_pub: b.public_key(),
        }
    }

    /// Drive the full four-message ceremony to completion; return both outcomes.
    fn run_ceremony(t: &PairingTranscript, init_code: &str, resp_code: &str) -> Result<(Paired, Paired), PairError> {
        let (initiator, msg_a) = Initiator::start(t.clone(), init_code);
        let (responder, msg_b, resp_tag) = Responder::respond(t.clone(), resp_code, &msg_a)?;
        let (init_paired, init_tag) = initiator.finish(&msg_b, &resp_tag)?;
        let resp_paired = responder.confirm(&init_tag)?;
        Ok((init_paired, resp_paired))
    }

    // [unit->REQ-PAIR-1] the happy path: same code on both sides → both derive the
    // SAME session key and each learns the other's true pubkey. This is "two machines
    // pair via one TOTP code" at the component level.
    #[test]
    fn matching_code_pairs_and_agrees_on_key() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t = transcript(&a, &b, "home", 1, 57_037_037);
        let (ip, rp) = run_ceremony(&t, "314159", "314159").expect("pairs");
        assert_eq!(ip.session_key.to_vec(), rp.session_key.to_vec(), "shared key agrees");
        assert_eq!(ip.peer_pub, b.public_key(), "initiator learns responder pubkey");
        assert_eq!(rp.peer_pub, a.public_key(), "responder learns initiator pubkey");
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] WRONG CODE: SPAKE2 finish() still
    // succeeds (no error) but the keys diverge, so confirmation fails — the negative
    // test that proves the code is actually authenticating the channel.
    #[test]
    fn wrong_code_fails_at_confirmation_not_spake() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t = transcript(&a, &b, "home", 1, 57_037_037);
        let err = run_ceremony(&t, "111111", "222222").unwrap_err();
        assert_eq!(err, PairError::Confirm, "wrong code => confirmation mismatch, not a SPAKE error");
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] WRONG SUBNET: same code, but the two
    // sides bind different subnet names → keys diverge (subnet is inside the PAKE id)
    // and confirmation fails. A code collision across unrelated pairings cannot join.
    #[test]
    fn wrong_subnet_fails() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t_init = transcript(&a, &b, "home", 1, 57_037_037);
        let t_resp = transcript(&a, &b, "work", 1, 57_037_037);
        let (initiator, msg_a) = Initiator::start(t_init, "314159");
        let (_responder, msg_b, resp_tag) = Responder::respond(t_resp, "314159", &msg_a).expect("respond");
        assert_eq!(initiator.finish(&msg_b, &resp_tag).unwrap_err(), PairError::Confirm);
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] STALE STEP: a transcript step mismatch
    // (replay at a later window) diverges the binding and fails confirmation, even with
    // the same code value.
    #[test]
    fn stale_step_fails() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t_init = transcript(&a, &b, "home", 1, 57_037_037);
        let t_resp = transcript(&a, &b, "home", 1, 57_037_040);
        let (initiator, msg_a) = Initiator::start(t_init, "314159");
        let (_responder, msg_b, resp_tag) = Responder::respond(t_resp, "314159", &msg_a).expect("respond");
        assert_eq!(initiator.finish(&msg_b, &resp_tag).unwrap_err(), PairError::Confirm);
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] STALE EPOCH (#10 precursor): a rotated
    // seed bumps the epoch; an old-epoch transcript can't confirm even with a valid
    // code-shaped value. D2c drives the rotation that changes the epoch.
    #[test]
    fn stale_epoch_fails() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t_init = transcript(&a, &b, "home", 1, 57_037_037);
        let t_resp = transcript(&a, &b, "home", 2, 57_037_037);
        let (initiator, msg_a) = Initiator::start(t_init, "314159");
        let (_responder, msg_b, resp_tag) = Responder::respond(t_resp, "314159", &msg_a).expect("respond");
        assert_eq!(initiator.finish(&msg_b, &resp_tag).unwrap_err(), PairError::Confirm);
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] UNKNOWN-KEY-SHARE: a MITM that relays
    // the PAKE but substitutes its own pubkey into its transcript cannot produce a tag
    // the honest side accepts, because both pubkeys are bound.
    #[test]
    fn substituted_pubkey_fails() {
        let (a, b, mallory) = (Identity::generate(), Identity::generate(), Identity::generate());
        let t_honest = transcript(&a, &b, "home", 1, 57_037_037);
        // Responder binds Mallory's key as the initiator (a key-substitution attempt).
        let t_attack = transcript(&mallory, &b, "home", 1, 57_037_037);
        let (initiator, msg_a) = Initiator::start(t_honest, "314159");
        let (_responder, msg_b, resp_tag) = Responder::respond(t_attack, "314159", &msg_a).expect("respond");
        assert_eq!(initiator.finish(&msg_b, &resp_tag).unwrap_err(), PairError::Confirm);
    }

    // [unit->REQ-PAIR-1] REFLECTION: SPAKE2 role-pinning rejects a reflected message —
    // if the "responder" mistakenly runs side A (start_a) like the initiator, the PAKE
    // itself errors (BadSide), before confirmation. Proven by reusing start_a on both.
    #[test]
    fn reflected_role_is_rejected_by_pake() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t = transcript(&a, &b, "home", 1, 57_037_037);
        let (_initiator, msg_a) = Initiator::start(t.clone(), "314159");
        // Reflect: a second side-A instead of side-B.
        let (reflected, _msg_a2) = Initiator::start(t, "314159");
        let err = reflected.finish(&msg_a, &[0u8; TAG_LEN]).unwrap_err();
        assert!(matches!(err, PairError::Spake(_)), "A-vs-A is a SPAKE BadSide, got {err:?}");
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] TAMPERED TAG: flipping a bit in the
    // responder's confirmation tag fails verification (constant-time compare).
    #[test]
    fn tampered_tag_fails() {
        let (a, b) = (Identity::generate(), Identity::generate());
        let t = transcript(&a, &b, "home", 1, 57_037_037);
        let (initiator, msg_a) = Initiator::start(t.clone(), "314159");
        let (_responder, msg_b, mut resp_tag) = Responder::respond(t, "314159", &msg_a).expect("respond");
        resp_tag[0] ^= 0x01;
        assert_eq!(initiator.finish(&msg_b, &resp_tag).unwrap_err(), PairError::Confirm);
    }
}


Edit

pub mod spake;
pub mod totp;
pub mod transcript;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\pairing.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod spake;
pub mod totp;
pub mod transcript;

//! TOTP-seeded SPAKE2 node pairing (ADR-0005) — the first-contact ceremony that
//! authorizes a new node into a user's subnet with no account and no central
//! operator.
//!
//! The construction (ADR-0005, `CONTEXT.md` "Pairing & trust"): a durable **TOTP
//! seed** is the subnet secret, replicated to every already-trusted node and stored
//! in the user's authenticator app. To pair a new node the rotating 6-digit code is
//! used as the **password for a SPAKE2 (PAKE) handshake** — *not* a bearer token —
//! so the low-entropy code becomes MITM-resistant and an attacker gets one online
//! guess per attempt (no offline brute force).
//!
//! ## Module tree (M4-D2 sub-tasks)
//!
//!   [`totp`]      RFC 6238 seed + code + ±1 window + step counter + otpauth URI   ← D2a
//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
//!   (ratelimit)   subnet-global one-ceremony + shared counter + backoff (#11)       D2d
//!
//! ## Why a dedicated pre-trust ALPN
//!
//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
//! new node) without exposing the trusted message surface.

pub mod totp;


Bash

cargo test -p spt-net --lib pairing 2>&1 | tail -40

Build + test transcript + spake


help: consider annotating `spake::Paired` with `#[derive(Debug)]`
     |
  59 + #[derive(Debug)]
  60 | pub struct Paired {
     |

error[E0277]: `spake::Paired` doesn't implement `Debug`
    --> crates\spt-net\src\net\pairing\spake.rs:284:56
     |
 284 |         assert_eq!(initiator.finish(&msg_b, &resp_tag).unwrap_err(), PairError::Confirm);
     |                                                        ^^^^^^^^^^ the trait `Debug` is not implemented for `spake::Paired`
     |
     = note: add `#[derive(Debug)]` to `spake::Paired` or manually `impl Debug for spake::Paired`
     = help: the following other types implement trait `Debug`:
               ()
               (A, Z, Y, X, W, V, U, T)
               (B, A, Z, Y, X, W, V, U, T)
               (C, B, A, Z, Y, X, W, V, U, T)
               (D, C, B, A, Z, Y, X, W, V, U, T)
               (E, D, C, B, A, Z, Y, X, W, V, U, T)
               (T,)
               (U, T)
             and 5 others
     = note: required for `(spake::Paired, [u8; 32])` to implement `Debug`
note: required by a bound in `Result::<T, E>::unwrap_err`
    --> C:\Users\decid\.rustup\toolchains\stable-x86_64-pc-windows-msvc\lib/rustlib/src/rust\library\core\src\result.rs:1326:12
     |
1324 |     pub fn unwrap_err(self) -> E
     |            ---------- required by a bound in this associated function
1325 |     where
1326 |         T: fmt::Debug,
     |            ^^^^^^^^^^ required by this bound in `Result::<T, E>::unwrap_err`
help: consider annotating `spake::Paired` with `#[derive(Debug)]`
     |
  59 + #[derive(Debug)]
  60 | pub struct Paired {
     |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `spt-net` (lib test) due to 7 previous errors


`unwrap_err` needs `Paired: Debug` — but it holds the secret key. Add a redacting manual Debug.


Edit

    /// The peer's Ed25519 public key, proven live by the confirmation round.
    pub peer_pub: PublicKey,
}

    /// The peer's Ed25519 public key, proven live by the confirmation round.
    pub peer_pub: PublicKey,
}

/// Redact the session key in debug output — it is live key material.
impl std::fmt::Debug for Paired {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Paired")
            .field("session_key", &"<redacted>")
            .field("peer_pub", &self.peer_pub)
            .finish()
    }
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\pairing\spake.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    /// The peer's Ed25519 public key, proven live by the confirmation round.
    pub peer_pub: PublicKey,
}

/// Redact the session key in debug output — it is live key material.
impl std::fmt::Debug for Paired {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Paired")
            .field("session_key", &"<redacted>")
            .field("peer_pub", &self.peer_pub)
            .finish()
    }
}

+/// Redact the session key in debug output — it is live key material.

+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

+            .field("session_key", &"<redacted>")

+            .field("peer_pub", &self.peer_pub)

/// Initiator (joining node) state, awaiting the responder's reply.


NOTE FROM THE USER:proceed with next steps.


Bash

cargo test -p spt-net --lib pairing 2>&1 | tail -30

Re-test pairing


   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 4.18s
     Running unittests src\lib.rs (target\debug\deps\spt_net-b8fb0a38adb7f2c3.exe)

running 22 tests
test net::pairing::totp::tests::time_step_is_floor_unix_over_period ... ok
test net::pairing::totp::tests::code_at_is_six_zero_padded_digits ... ok
test net::pairing::totp::tests::debug_redacts_seed ... ok
test net::pairing::totp::tests::window_accepts_plus_minus_one_only ... ok
test net::pairing::totp::tests::matches_rfc6238_appendix_b_vectors ... ok
test net::pairing::totp::tests::otpauth_uri_is_well_formed_and_encoded ... ok
test net::pairing::totp::tests::seed_round_trips_through_base32 ... ok
test net::pairing::transcript::tests::diverged_shared_secret_fails_verification ... ok
test net::pairing::totp::tests::generated_seeds_are_distinct ... ok
test net::pairing::transcript::tests::length_prefixing_prevents_boundary_collision ... ok
test net::pairing::transcript::tests::matching_tag_verifies_and_roles_differ ... ok
test net::pairing::transcript::tests::message_order_is_bound ... ok
test net::pairing::transcript::tests::pake_identity_binds_role_and_context ... ok
test net::pairing::transcript::tests::each_bound_field_changes_the_tag ... ok
test net::pairing::spake::tests::reflected_role_is_rejected_by_pake ... ok
test net::pairing::spake::tests::wrong_code_fails_at_confirmation_not_spake ... ok
test net::pairing::spake::tests::matching_code_pairs_and_agrees_on_key ... ok
test net::pairing::spake::tests::tampered_tag_fails ... ok
test net::pairing::spake::tests::wrong_subnet_fails ... ok
test net::pairing::spake::tests::stale_step_fails ... ok
test net::pairing::spake::tests::stale_epoch_fails ... ok
test net::pairing::spake::tests::substituted_pubkey_fails ... ok

test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.07s


All 22 pass — every negative test green (wrong code, wrong subnet, stale step, stale epoch, unknown-key-share, reflection, tampered tag). Now clippy, then activate REQ-HAZARD-PAIR-TRANSCRIPT-BIND (fully delivered+tested). Keep REQ-PAIR-1 inactive until the pairing-ALPN wire lands — "activated = delivered" stays honest.


cargo clippy -p spt-net --all-features 2>&1 | grep -iE "warning|error" | head; echo "exit:done"; cargo build -p spt-net --no-default-features 2>&1 | tail -2

Clippy all-features + no-default-features build


exit:done
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.20s


D2a: TOTP module (seed gen, code, ±1 window, otpauth URI)

spt-net pairing::totp — random 20-byte seed (reuse Identity::generate RNG), RFC 6238 code at step T, adjacent ±1 steps, expose raw time-step counter (needed for transcript bind), otpauth:// URI + QR. Test against RFC 6238 vectors. REQ-PAIR-1 (impl).

D2a/b: SPAKE2 handshake + pairing-transcript binding

SPAKE2 PAKE with current TOTP code as password over dedicated pre-trust pairing ALPN. Transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, confirmation MACs (REQ-HAZARD-PAIR-TRANSCRIPT-BIND). Negative tests: MITM, replay, reflection, wrong-subnet, stale time-step. Blocked on SPAKE2 crate decision (research running). REQ-PAIR-1, activate REQ-HAZARD-PAIR-TRANSCRIPT-BIND.

D2c: Seed epoch + rotation

Subnet seed carries an epoch; removing a node bumps epoch so old node/seed can't rejoin. Transcript binds epoch. Trust-store delete alone is NOT revocation. REQ-HAZARD-PAIR-SEED-ROTATION. Test: old-epoch rejoin rejected.


Clippy clean, no-default-features clean. Activate REQ-HAZARD-PAIR-TRANSCRIPT-BIND.


Edit

title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = []  # rule 5: activate at M4-D2b when the transcript spec + negative tests (MITM, replay, reflection, wrong-subnet, stale time-step) land

title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = []  # rule 5: activate at M4-D2b when the transcript spec + negative tests (MITM, replay, reflection, wrong-subnet, stale time-step) land

title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9

-required_stages = []  # rule 5: activate at M4-D2b when the transcript spec + negative tests (MITM, replay, reflection, wrong-subnet, stale time-step) land

+required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9

title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"


Bash

traceable-reqs check 2>&1 | grep -iE "FAIL|error|TRANSCRIPT-BIND" | head; echo "EXIT:${PIPESTATUS[0]}"

Traceability check after activation


  [OK] REQ-HAZARD-PAIR-TRANSCRIPT-BIND  required: [impl, unit]  stages: -doc +impl +unit -int
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
EXIT:0


TRANSCRIPT-BIND satisfied [impl,unit], check exit 0. Verify fmt + full spt-net suite, then commit D2b.


cargo fmt -p spt-net -- --check 2>&1 | tail -5; echo "fmt-exit:$?"; cargo test -p spt-net --lib 2>&1 | tail -3

fmt check + full spt-net lib tests


+            other.pake_identity(Role::Initiator)
+        );
     }
 }

fmt-exit:0

test result: ok. 24 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.10s


fmt --check shows diffs. Apply formatting.


cargo fmt -p spt-net 2>&1; cargo fmt -p spt-net -- --check 2>&1 | tail -2; echo "clean-exit:$?"

Apply rustfmt


clean-exit:0
[This command modified 4 files you've previously read: crates\spt-net\src\net\pairing\spake.rs, crates\spt-net\src\net\pairing\transcript.rs, crates\spt-net\src\net\pairing\totp.rs, crates\spt-net\src\net\endpoint.rs. Call Read before editing.]


1	//! The SPAKE2 pairing handshake state machine (ADR-0005, D2a/b).
2	//!
3	//! Wraps the `spake2` crate (RustCrypto, Ed25519 group) into the two role-typed flows
4	//! of the ceremony, layering the [transcript confirmation](super::transcript) the PAKE
5	//! itself omits. The TOTP [code](super::totp) is the PAKE password.
6	//!
7	//! ## The flow (initiator = joiner, responder = seed-holder)
8	//!
9	//! ```text
10	//!   Initiator::start(transcript, code)          -> (Initiator, msg_a)         ──msg_a──▶
11	//!                                       Responder::respond(transcript, code, msg_a)
12	//!                                          -> (Responder, msg_b, responder_tag)
13	//!                                       ◀──msg_b, responder_tag──
14	//!   Initiator::finish(msg_b, responder_tag)     -> (shared, initiator_tag)    ──init_tag──▶
15	//!                                       Responder::confirm(initiator_tag)     -> shared
16	//! ```
17	//!
18	//! ## The load-bearing crypto fact
19	//!
20	//! SPAKE2's `finish()` **does not fail on a wrong password** — it returns a *different*
21	//! shared secret on each side. So a wrong code, a MITM, a wrong subnet, or a stale step
22	//! all surface the same way: the [confirmation tags](super::transcript) don't match. The
23	//! confirmation round is therefore not optional polish — it is the *only* thing that
24	//! rejects a bad pairing. Roles are pinned to `start_a`/`start_b`, so a reflected
25	//! message (both sides side-A) is additionally rejected by the PAKE as `BadSide`.
26	//
27	// [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
28
29	use spake2::{Ed25519Group, Identity as PakeIdentity, Password, Spake2};
30	use spt_proto::identity::PublicKey;
31	use zeroize::Zeroizing;
32
33	use super::transcript::{PairingTranscript, Role, TAG_LEN};
34
35	/// A pairing handshake failure. Carries no secret material.
36	#[derive(Debug, PartialEq, Eq)]
37	pub enum PairError {
38	    /// The SPAKE2 message was malformed / wrong-length / reflected (`spake2::Error`).
39	    /// A *wrong code* does NOT land here — it surfaces as [`Confirm`](Self::Confirm).
40	    Spake(String),
41	    /// The peer's confirmation tag did not match: wrong code, MITM, wrong subnet,
42	    /// stale step, or a tampered transcript. The ceremony aborts.
43	    Confirm,
44	}
45
46	impl std::fmt::Display for PairError {
47	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48	        match self {
49	            PairError::Spake(e) => write!(f, "SPAKE2 handshake failed: {e}"),
50	            PairError::Confirm => write!(f, "key confirmation failed — pairing aborted"),
51	        }
52	    }
53	}
54
55	impl std::error::Error for PairError {}
56
57	/// The confirmed outcome of a successful ceremony: the shared session key (zeroized on
58	/// drop) plus the peer's now-trusted node public key (to write into the trust store).
59	pub struct Paired {
60	    /// The PAKE-derived, mutually-confirmed shared secret. Wiped from memory on drop.
61	    pub session_key: Zeroizing<Vec<u8>>,
62	    /// The peer's Ed25519 public key, proven live by the confirmation round.
63	    pub peer_pub: PublicKey,
64	}
65
66	/// Redact the session key in debug output — it is live key material.
67	impl std::fmt::Debug for Paired {
68	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69	        f.debug_struct("Paired")
70	            .field("session_key", &"<redacted>")
71	            .field("peer_pub", &self.peer_pub)
72	            .finish()
73	    }
74	}
75
76	/// Initiator (joining node) state, awaiting the responder's reply.
77	pub struct Initiator {
78	    state: Spake2<Ed25519Group>,
79	    transcript: PairingTranscript,
80	    msg_a: Vec<u8>,
81	}
82
83	impl Initiator {
84	    /// Begin the ceremony as the joining node. Returns the state and `msg_a` to send
85	    /// to the seed-holder. `code` is the user-typed current TOTP code (the PAKE
86	    /// password); `transcript` carries the agreed context (both pubkeys, subnet, epoch,
87	    /// step) — note the joiner does NOT hold the seed, only the code.
88	    // [impl->REQ-PAIR-1]
89	    pub fn start(transcript: PairingTranscript, code: &str) -> (Initiator, Vec<u8>) {
90	        let (state, msg_a) = Spake2::<Ed25519Group>::start_a(
91	            &Password::new(code.as_bytes()),
92	            &PakeIdentity::new(&transcript.pake_identity(Role::Initiator)),
93	            &PakeIdentity::new(&transcript.pake_identity(Role::Responder)),
94	        );
95	        let msg = msg_a.clone();
96	        (
97	            Initiator {
98	                state,
99	                transcript,
100	                msg_a,
101	            },
102	            msg,
103	        )
104	    }
105
106	    /// Finish the PAKE with the responder's `msg_b`, verify the responder's
107	    /// confirmation tag, and produce the initiator's own tag to send back. On success
108	    /// the shared key is confirmed live on the responder side; the returned
109	    /// `initiator_tag` lets the responder confirm in turn.
110	    // [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
111	    pub fn finish(
112	        self,
113	        msg_b: &[u8],
114	        responder_tag: &[u8],
115	    ) -> Result<(Paired, [u8; TAG_LEN]), PairError> {
116	        let shared = Zeroizing::new(
117	            self.state
118	                .finish(msg_b)
119	                .map_err(|e| PairError::Spake(e.to_string()))?,
120	        );
121	        // The responder finished first and tagged with role=Responder; verify it.
122	        if !self
123	            .transcript
124	            .verify_tag(&shared, Role::Responder, &self.msg_a, msg_b, responder_tag)
125	        {
126	            return Err(PairError::Confirm);
127	        }
128	        let initiator_tag =
129	            self.transcript
130	                .confirm_tag(&shared, Role::Initiator, &self.msg_a, msg_b);
131	        let paired = Paired {
132	            session_key: shared,
133	            peer_pub: self.transcript.responder_pub,
134	        };
135	        Ok((paired, initiator_tag))
136	    }
137	}
138
139	/// Responder (seed-holder) state, awaiting the initiator's confirmation tag.
140	pub struct Responder {
141	    transcript: PairingTranscript,
142	    shared: Zeroizing<Vec<u8>>,
143	    msg_a: Vec<u8>,
144	    msg_b: Vec<u8>,
145	}
146
147	impl Responder {
148	    /// Respond to the initiator's `msg_a` as the seed-holder. Runs SPAKE2 side B,
149	    /// derives the shared secret, and returns `msg_b` plus the responder's confirmation
150	    /// tag to send back. `code` is computed by the caller from the seed at the
151	    /// transcript's step (the caller enforces the ±1 window via
152	    /// [`accepts_step`](super::totp::accepts_step) before constructing the transcript).
153	    // [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
154	    pub fn respond(
155	        transcript: PairingTranscript,
156	        code: &str,
157	        msg_a: &[u8],
158	    ) -> Result<(Responder, Vec<u8>, [u8; TAG_LEN]), PairError> {
159	        let (state, msg_b) = Spake2::<Ed25519Group>::start_b(
160	            &Password::new(code.as_bytes()),
161	            &PakeIdentity::new(&transcript.pake_identity(Role::Initiator)),
162	            &PakeIdentity::new(&transcript.pake_identity(Role::Responder)),
163	        );
164	        let shared = Zeroizing::new(
165	            state
166	                .finish(msg_a)
167	                .map_err(|e| PairError::Spake(e.to_string()))?,
168	        );
169	        let responder_tag = transcript.confirm_tag(&shared, Role::Responder, msg_a, &msg_b);
170	        let r = Responder {
171	            transcript,
172	            shared,
173	            msg_a: msg_a.to_vec(),
174	            msg_b: msg_b.clone(),
175	        };
176	        Ok((r, msg_b, responder_tag))
177	    }
178
179	    /// Verify the initiator's confirmation tag and complete the ceremony. Only now is
180	    /// the initiator proven to hold the same key (a wrong code makes this fail), so the
181	    /// trust write happens after this returns `Ok`.
182	    // [impl->REQ-PAIR-1] [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]

... [199 lines truncated] ...


1	//! Pairing transcript + key-confirmation MAC (ADR-0005 #12, D2b).
2	//!
3	//! SPAKE2 derives a shared key that already folds in the password and both parties'
4	//! identities/messages — but it performs **no key confirmation** and knows nothing of
5	//! *our* context (subnet, seed epoch, TOTP step). Stage-A red-team #12 is explicit:
6	//! "PAKE binds pubkeys *only if the transcript does*." Without an explicit
7	//! confirmation that binds the full context, unknown-key-share, reflection, and
8	//! wrong-subnet/stale-step pairings remain possible.
9	//!
10	//! This module is that binding: after the PAKE, each side computes an HMAC-SHA-256
11	//! [confirmation tag](PairingTranscript::confirm_tag) keyed by the shared secret over
12	//! the canonical transcript —
13	//!
14	//!   domain ‖ confirmer-role ‖ initiator_msg ‖ responder_msg ‖
15	//!   subnet-name ‖ seed-epoch ‖ TOTP-step ‖ initiator_pubkey ‖ responder_pubkey
16	//!
17	//! — and verifies the peer's tag in constant time. A mismatch (wrong code, MITM,
18	//! wrong subnet, stale step, swapped role) aborts pairing. Defense in depth: the same
19	//! context is *also* fed into the PAKE [`Identity`](super::spake) strings, so it sits
20	//! inside the KDF as well as the outer MAC.
21	//!
22	//! ## Why the joiner binds the subnet *name*, not the seed
23	//!
24	//! The joining node holds only the user-typed 6-digit code and the subnet name — it
25	//! does **not** have the seed (only trusted seed-holders do). So the transcript's
26	//! subnet identity is the human **name** both sides possess; knowledge of the seed is
27	//! bound implicitly through the PAKE password (the code is derived from the seed). The
28	//! [`epoch`](PairingTranscript::epoch) (responder-authoritative) binds *which* seed
29	//! generation, closing the #10 rotation gap once D2c rotates it.
30	//
31	// [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] [impl->REQ-PAIR-1]
32
33	use hmac::{Hmac, Mac};
34	use sha2::Sha256;
35	use spt_proto::identity::PublicKey;
36	use subtle::ConstantTimeEq;
37
38	type HmacSha256 = Hmac<Sha256>;
39
40	/// Domain separation tag — versioned so a future transcript-format change is a clean
41	/// break, never a silent cross-version confusion.
42	const CONFIRM_DOMAIN: &[u8] = b"spt-core/pairing/confirm/v1";
43
44	/// Length of a confirmation tag (HMAC-SHA-256 output).
45	pub const TAG_LEN: usize = 32;
46
47	/// Which side of the ceremony a party plays. The initiator is the **joining** node
48	/// (it has only the code); the responder is the online **seed-holder**. Roles are
49	/// pinned (SPAKE2 `start_a`/`start_b`, not symmetric) so a reflected message is
50	/// rejected by the PAKE itself, and the role byte in the confirmation tag stops one
51	/// side's tag from being replayed as the other's.
52	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53	pub enum Role {
54	    /// The joining node — types the code, lacks the seed. SPAKE2 side A.
55	    Initiator,
56	    /// The online trusted seed-holder — computes the code from the seed. SPAKE2 side B.
57	    Responder,
58	}
59
60	impl Role {
61	    /// Stable byte label bound into the PAKE identity + confirmation tag.
62	    fn label(self) -> &'static [u8] {
63	        match self {
64	            Role::Initiator => b"initiator",
65	            Role::Responder => b"responder",
66	        }
67	    }
68	}
69
70	/// The full context bound by both the PAKE identities and the confirmation MAC. Both
71	/// sides MUST construct an identical transcript (same field values, same pubkey order)
72	/// or the tags will not match — that equality *is* the agreement check.
73	#[derive(Debug, Clone, PartialEq, Eq)]
74	pub struct PairingTranscript {
75	    /// The human subnet label (R-PAIR-4) — the identity both sides share. The joiner
76	    /// types it; it namespaces the rendezvous and binds the ceremony to one subnet.
77	    pub subnet_name: String,
78	    /// Seed epoch (responder-authoritative): which generation of the subnet seed the
79	    /// code was derived from. Binds #10 once rotation lands (D2c).
80	    pub epoch: u64,
81	    /// TOTP time-step the code was computed at (ADR-0005 #12) — binds the ceremony to
82	    /// a 30s window so a captured transcript can't be replayed at a later step.
83	    pub step: u64,
84	    /// The joining node's Ed25519 public key (SPAKE2 side A).
85	    pub initiator_pub: PublicKey,
86	    /// The seed-holder's Ed25519 public key (SPAKE2 side B). Binding both pubkeys
87	    /// closes the unknown-key-share seam (#12).
88	    pub responder_pub: PublicKey,
89	}
90
91	impl PairingTranscript {
92	    /// The PAKE `Identity` bytes for a given role — the context fed *inside* the
93	    /// SPAKE2 KDF (defense in depth alongside the outer confirmation MAC). Both sides
94	    /// agree the (initiator-id, responder-id) pair; SPAKE2 binds both messages itself.
95	    pub fn pake_identity(&self, role: Role) -> Vec<u8> {
96	        // Length-prefixed fields so no two distinct transcripts collide via field-
97	        // boundary ambiguity (e.g. subnet "a" + epoch 1 vs subnet "a1").
98	        let mut id = Vec::new();
99	        id.extend_from_slice(b"spt-core/pairing/id/v1");
100	        push_field(&mut id, role.label());
101	        push_field(&mut id, self.subnet_name.as_bytes());
102	        push_field(&mut id, &self.epoch.to_be_bytes());
103	        push_field(&mut id, &self.step.to_be_bytes());
104	        push_field(&mut id, &self.initiator_pub.to_bytes());
105	        push_field(&mut id, &self.responder_pub.to_bytes());
106	        id
107	    }
108
109	    /// The confirmation tag a party of role `confirmer` sends, keyed by the PAKE
110	    /// `shared` secret, over the canonical transcript. Messages are bound in a fixed
111	    /// order (initiator first) regardless of confirmer, so both sides hash the same
112	    /// two-message transcript and only the role byte differs between the two tags.
113	    // [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
114	    pub fn confirm_tag(
115	        &self,
116	        shared: &[u8],
117	        confirmer: Role,
118	        initiator_msg: &[u8],
119	        responder_msg: &[u8],
120	    ) -> [u8; TAG_LEN] {
121	        let mut mac = HmacSha256::new_from_slice(shared).expect("HMAC accepts any key length");
122	        mac.update(CONFIRM_DOMAIN);
123	        mac.update(confirmer.label());
124	        // Length-prefix every variable field to prevent transcript-concatenation
125	        // ambiguity (a sliding field boundary forging an equal MAC).
126	        update_field(&mut mac, initiator_msg);
127	        update_field(&mut mac, responder_msg);
128	        update_field(&mut mac, self.subnet_name.as_bytes());
129	        mac.update(&self.epoch.to_be_bytes());
130	        mac.update(&self.step.to_be_bytes());
131	        mac.update(&self.initiator_pub.to_bytes());
132	        mac.update(&self.responder_pub.to_bytes());
133	        mac.finalize().into_bytes().into()
134	    }
135
136	    /// Constant-time verify a peer's confirmation tag. Returns `true` only on an exact
137	    /// match — no early-exit timing oracle on the comparison (`subtle::ct_eq`).
138	    // [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
139	    pub fn verify_tag(
140	        &self,
141	        shared: &[u8],
142	        confirmer: Role,
143	        initiator_msg: &[u8],
144	        responder_msg: &[u8],
145	        claimed: &[u8],
146	    ) -> bool {
147	        let want = self.confirm_tag(shared, confirmer, initiator_msg, responder_msg);
148	        want.ct_eq(claimed).into()
149	    }
150	}
151
152	/// Append a length-prefixed (u32-BE) field to a byte buffer.
153	fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
154	    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
155	    buf.extend_from_slice(field);
156	}
157
158	/// Feed a length-prefixed (u32-BE) field into an HMAC.
159	fn update_field(mac: &mut HmacSha256, field: &[u8]) {
160	    mac.update(&(field.len() as u32).to_be_bytes());
161	    mac.update(field);
162	}
163
164	#[cfg(test)]
165	mod tests {
166	    use super::*;
167	    use spt_proto::identity::Identity;
168
169	    fn transcript() -> PairingTranscript {
170	        PairingTranscript {

... [131 lines truncated] ...


fmt applied clean. Commit D2b (quick re-test to be safe).


cargo test -p spt-net --lib pairing 2>&1 | tail -2 && cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(net): M4-D2b SPAKE2 handshake + pairing-transcript binding (#12)

The cryptographic heart of pairing (ADR-0005). Two role-typed flows over the
`spake2` crate (RustCrypto, Ed25519 group) with the TOTP code as the PAKE
password, plus the explicit key-confirmation SPAKE2 omits — the transcript
binding red-team #12 demands.

pairing::transcript — HMAC-SHA-256 confirmation MAC over the canonical
transcript: domain · confirmer-role · both SPAKE2 messages · subnet-name ·
seed-epoch · TOTP-step · both node pubkeys, every variable field length-prefixed
(no field-boundary forgery), verified with subtle::ct_eq (no timing oracle). The
same context is also folded into the SPAKE2 Identity strings — bound inside the
KDF and in the outer MAC. The joiner binds the subnet *name* (it holds only the
code, not the seed); seed-knowledge is bound implicitly via the password, seed
*generation* via the epoch (closes #10 once D2c rotates).

pairing::spake — Initiator (joiner, side A) / Responder (seed-holder, side B)
state machines. The load-bearing fact: SPAKE2 finish() does NOT error on a wrong
password — it yields a divergent key — so the confirmation round is the ONLY
thing that rejects a bad pairing. Roles pinned to start_a/start_b so reflection
is a PAKE BadSide. Shared secret wrapped in zeroize::Zeroizing; Paired redacts
its key under Debug.

Negative tests (all green): wrong-code, wrong-subnet, stale-step, stale-epoch,
unknown-key-share (substituted pubkey), reflection, tampered-tag — plus the
happy path proving both sides agree on the key and learn each other's pubkey.
TOTP verified against RFC 6238 Appendix-B vectors (D2a).

Activates REQ-HAZARD-PAIR-TRANSCRIPT-BIND [impl,unit] (int = on-wire MITM/replay
E2E at D9). REQ-PAIR-1 stays inactive until the pairing-ALPN connection driver
lands — the crypto core is done; the transport glue is the next sub-task.
All net-gated (--no-default-features clean); deps spake2/sha2/subtle/zeroize
optional. clippy clean; rustfmt clean; traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log -1 --format='%h %s'

Re-test + commit D2b


test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.07s

warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/endpoint.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/pairing.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/pairing/totp.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/pairing/spake.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/pairing/transcript.rs', LF will be replaced by CRLF the next time Git touches it
597786e feat(net): M4-D2b SPAKE2 handshake + pairing-transcript binding (#12)


D2b committed (597786e).

Layering insight: TotpSeed lives in spt-net but persistence belongs in spt-store (CONTEXT 451) — spt-store can't depend on spt-net (cycle). Resolution: spt-store stores raw subnet material (seed bytes + epoch + name); spt-net wraps bytes into TotpSeed. Next: D2c subnet seed store + rotation in spt-store. Check info.rs for the persisted-struct convention.


Updated task #2 status


1	//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
2	//!
3	//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
4	//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
5	//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
6	//!   readers must treat it as "no usable pid").
7	//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
8	//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
9	//!   kinds the spt-core taxonomy drops — see T4).
10	//!
11	//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
12	//! A reader may hit the file mid-write. Two layers guarantee it never observes a
13	//! truncated record:
14	//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
15	//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
16	//!    sees the *complete* old file or the *complete* new file, never a torn one.
17	//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
18	//!    bounded retry and return `None` on persistent failure — never panic.
19
20	use serde::{Deserialize, Serialize};
21	use std::path::Path;
22
23	use crate::atomic::atomic_write_string;
24
25	/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
26	/// listener writes while it holds the perch. Untagged: serializes as a bare
27	/// integer or a bare string. Copied verbatim (sister `PidValue`).
28	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29	#[serde(untagged)]
30	pub enum PidValue {
31	    Numeric(u32),
32	    Busy(String),
33	}
34
35	/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
36	/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
37	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38	pub struct InfoJson {
39	    /// The perch / agent id.
40	    pub id: String,
41	    /// Creation timestamp (opaque string; producer's format).
42	    pub started: String,
43	    /// Owning process pid, or the `"BUSY"` sentinel.
44	    pub pid: PidValue,
45	    /// Harness session id bound to this perch.
46	    pub session_id: String,
47	    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
48	    pub state: String,
49	    #[serde(skip_serializing_if = "Option::is_none")]
50	    pub parent_pid: Option<u32>,
51	    /// Working directory the perch was created in (forward-slash normalized).
52	    #[serde(skip_serializing_if = "Option::is_none")]
53	    pub cwd: Option<String>,
54	    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
55	    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
56	    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
57	    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
58	    /// the per-pid probe. The single liveness resolver
59	    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
60	    #[serde(skip_serializing_if = "Option::is_none")]
61	    pub status: Option<String>,
62	    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
63	    /// endpoint's activity (pulse tick / input / bind). The recency signal the
64	    /// consent gate resolves "most-recently-active live session" against
65	    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
66	    /// stamped (an interim perch, or one with no activity yet); a resolver treats
67	    /// that as least-recent.
68	    #[serde(skip_serializing_if = "Option::is_none")]
69	    pub last_active_ms: Option<u64>,
70	}

//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
//!
//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
//!   readers must treat it as "no usable pid").
//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
//!   kinds the spt-core taxonomy drops — see T4).
//!
//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
//! A reader may hit the file mid-write. Two layers guarantee it never observes a
//! truncated record:
//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
//!    sees the *complete* old file or the *complete* new file, never a torn one.
//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
//!    bounded retry and return `None` on persistent failure — never panic.

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

use crate::atomic::atomic_write_string;

/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
/// listener writes while it holds the perch. Untagged: serializes as a bare
/// integer or a bare string. Copied verbatim (sister `PidValue`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PidValue {
    Numeric(u32),
    Busy(String),
}

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {
    /// The perch / agent id.
    pub id: String,
    /// Creation timestamp (opaque string; producer's format).
    pub started: String,
    /// Owning process pid, or the `"BUSY"` sentinel.
    pub pid: PidValue,
    /// Harness session id bound to this perch.
    pub session_id: String,
    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,
    /// Working directory the perch was created in (forward-slash normalized).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
    /// the per-pid probe. The single liveness resolver
    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
    /// endpoint's activity (pulse tick / input / bind). The recency signal the
    /// consent gate resolves "most-recently-active live session" against
    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
    /// stamped (an interim perch, or one with no activity yet); a resolver treats
    /// that as least-recent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
}


Pattern clear (serde + atomic_write + retry-read→Option). Check STORAGE.md for prescribed on-disk layout of subnet/trust material + spt-store lib.rs exports.


grep -niE "subnet|seed|trust|pairing|epoch|node.key|security material" docs/STORAGE.md | head -30; echo "===lib exports==="; grep -nE "pub mod|pub use" crates/spt-store/src/lib.rs

STORAGE.md subnet layout + spt-store exports


18:    node.key                     Ed25519 node identity (per-node; must not sync)
19:    trust/                       authorized peer pubkeys (trust store)
20:    subnet.json                  subnet name + TOTP seed (replicated via pairing, not git)
41:  registry/                      ── ephemeral ── local cache of the subnet registry
66:| subnet registry (snapshot + distribution) | **BranchStore** | audited; rides the precedence merge driver instead of a separate replication scheme (M4) |
95:**Divergent / unrelated histories are expected.** Each node independently seeds its own `tracked/` repo on first run, so `a-ling` on A and on B share **no common ancestor**. Sync therefore merges with **`--allow-unrelated-histories`** + the driver. History becomes a multi-parent DAG (one merge commit per sync) — cosmetic; periodic `git gc` keeps it tidy.
101:**Scope:** hub mode is the **context-repo sync transport only.** Messaging, the subnet registry, remote-drive, pairing, and presence always ride P2P/Iroh — GitHub never carries real-time agent traffic, and gh-on never removes the P2P requirement.
103:**Hub auth — shared deploy key.** The gh-authed setup node creates the private repo, generates a **read-write deploy key**, registers its public half on the repo, and distributes the **private half as subnet secret material** (via pairing / P2P, like the TOTP seed). Other nodes need **no GitHub account** — they use the deploy key over SSH. New-node linking never blocks (auto key + P2P backstop). Revocation = rotate the key (remove from repo, regenerate, redistribute) when a node is de-trusted or hub mode is disabled.
109:- GitHub's published host key is pre-seeded into a scoped `known_hosts` (safer than blind `accept-new`).
116:The `a-<id>` / `p-<project_id>` branch views are **linked git worktrees** off the bare seed repo. Linked worktrees store **absolute** back-links by default (`/home/<user>/…`, `C:\Users\<user>\…`) → break on relocation, a different user account, or migration. Use **relative worktree paths** (`git config worktree.useRelativePaths true`, Git ≥ 2.48) so `tracked/` is relocatable. **Fallback** for older git: spt-core runs `git worktree repair` after any relocation to re-point links locally (spt-core detects git version and picks). Cross-node *sync* transfers branch **objects**, not worktree plumbing, so sync is already path-agnostic — relative paths matter for `$SPT_HOME` relocation, backup/restore, and legacy migration.
137:- **Not in the subnet registry** — private to the agent↔shell link, not a general messaging surface.
146:- **Back up / migrate:** `identity/` + `tracked/` (+ `history/` if wanted). That's identity, trust, subnet, and the full agent/project mind.
147:- **Regenerate on the target:** `perches/`, `registry/`, `daemon/`, `updates/` rebuild from the durable set + the live subnet.
===lib exports===
14:pub mod atomic;
16:pub mod history;
17:pub mod info;
18:pub mod liveness;
19:pub mod perch;
20:pub mod proc;
21:pub mod registry;
22:pub mod seed;
23:pub mod spool;


STORAGE.md is authoritative on layout. Read lines 10-45 for the exact `identity/` dir structure.


10	4. **Durable vs ephemeral is explicit.** `identity/`, `tracked/`, `history/` are durable (the backup/migrate set). `registry/`, `daemon/`, `updates/` are regenerable. Migration carries the durable set only.
11	5. **One path resolver.** A single centralized, idempotent resolver maps `(endpoint_id[, child][, project_id][, kind])` → path. Nothing constructs paths ad-hoc, so any future relayout carries to all dependents (KNOWN-HAZARDS 6.1 — no flat/nested ambiguity).
12
13	## Layout
14
15	```
16	$SPT_HOME/
17	  identity/                      ── node-local, durable, NEVER git-tracked ──
18	    node.key                     Ed25519 node identity (per-node; must not sync)
19	    trust/                       authorized peer pubkeys (trust store)
20	    subnet.json                  subnet name + TOTP seed (replicated via pairing, not git)
21	  tracked/                       ── git repo; synced across nodes over P2P ──
22	    agents/<endpoint_id>/
23	      meta.json                  endpoint identity metadata: type, ordered adapter history, created
24	      live-context.md            per-agent live context           (branch a-<endpoint_id>)
25	    projects/<project_id>/
26	      <endpoint_id>/
27	        project-context.md       per-(agent,project) context      (branch p-<project_id>)
28	  perches/                       ── node-local runtime, NEVER tracked/synced ──
29	    <endpoint_id>/
30	      info.json                  identity, type, status, CURRENT adapter_name (+ capability set if Shell)
31	      spool.db                   SQLite spool (typed + binary payloads)
32	      status                     active | dormant | suspended    (agent endpoints)
33	      nested/<child_id>/         Worker / Psyche perches (own info.json, spool)
34	      shells/<adapter>-<n>/      Shell perches owned by this endpoint (GameRobot-0, GameRobot-1…)
35	        info.json                type=Shell, owner, adapter_name, status (online|offline), alias?
36	        spool.db                 command + durable text/file (sensory is REST-only, unspooled)
37	  history/                       node-local logs (mirrors perch structure)
38	    <endpoint_id>/
39	      sessions/<session_id>.jsonl    agent Path-B native logs
40	      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
41	  registry/                      ── ephemeral ── local cache of the subnet registry
42	  daemon/                        ── ephemeral ── brain rehydration state, broker handle metadata
43	  updates/                       ── ephemeral ── staged binaries pending handoff
44	  adapters/                      registered adapter manifests / pointers
45	```
46
47	## tracked/ — the context git repo
48
49	- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task").

4. **Durable vs ephemeral is explicit.** `identity/`, `tracked/`, `history/` are durable (the backup/migrate set). `registry/`, `daemon/`, `updates/` are regenerable. Migration carries the durable set only.
5. **One path resolver.** A single centralized, idempotent resolver maps `(endpoint_id[, child][, project_id][, kind])` → path. Nothing constructs paths ad-hoc, so any future relayout carries to all dependents (KNOWN-HAZARDS 6.1 — no flat/nested ambiguity).

## Layout

```
$SPT_HOME/
  identity/                      ── node-local, durable, NEVER git-tracked ──
    node.key                     Ed25519 node identity (per-node; must not sync)
    trust/                       authorized peer pubkeys (trust store)
    subnet.json                  subnet name + TOTP seed (replicated via pairing, not git)
  tracked/                       ── git repo; synced across nodes over P2P ──
    agents/<endpoint_id>/
      meta.json                  endpoint identity metadata: type, ordered adapter history, created
      live-context.md            per-agent live context           (branch a-<endpoint_id>)
    projects/<project_id>/
      <endpoint_id>/
        project-context.md       per-(agent,project) context      (branch p-<project_id>)
  perches/                       ── node-local runtime, NEVER tracked/synced ──
    <endpoint_id>/
      info.json                  identity, type, status, CURRENT adapter_name (+ capability set if Shell)
      spool.db                   SQLite spool (typed + binary payloads)
      status                     active | dormant | suspended    (agent endpoints)
      nested/<child_id>/         Worker / Psyche perches (own info.json, spool)
      shells/<adapter>-<n>/      Shell perches owned by this endpoint (GameRobot-0, GameRobot-1…)
        info.json                type=Shell, owner, adapter_name, status (online|offline), alias?
        spool.db                 command + durable text/file (sensory is REST-only, unspooled)
  history/                       node-local logs (mirrors perch structure)
    <endpoint_id>/
      sessions/<session_id>.jsonl    agent Path-B native logs
      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
  registry/                      ── ephemeral ── local cache of the subnet registry
  daemon/                        ── ephemeral ── brain rehydration state, broker handle metadata
  updates/                       ── ephemeral ── staged binaries pending handoff
  adapters/                      registered adapter manifests / pointers
```

## tracked/ — the context git repo

- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task").


Authoritative layout: `$SPT_HOME/identity/{node.key, trust/, subnet.json}` — durable, never synced. `subnet.json` holds the multi-subnet collection ((subnet,user)-ready). spt-store owns the seed-bytes lifecycle (gen/store/rotate/epoch) via spt-proto's RNG; spt-net wraps loaded bytes into TotpSeed — no cycle. Read info.rs read-with-retry tail to match style.


70	}
71
72	impl InfoJson {
73	    /// Construct a numeric-pid record.
74	    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
75	        InfoJson {
76	            id: id.to_string(),
77	            started: started.to_string(),
78	            pid: PidValue::Numeric(pid),
79	            session_id: session_id.to_string(),
80	            state: state.to_string(),
81	            parent_pid: None,
82	            cwd: None,
83	            status: None,
84	            last_active_ms: None,
85	        }
86	    }
87	}
88
89	/// Set the daemon-managed `status` on an existing perch's `info.json` (the 2.5
90	/// daemon-authoritative liveness marker). Read-modify-write through the atomic
91	/// path, so a concurrent reader still sees a complete old-or-new record. Errors
92	/// if the record is absent/unreadable — the daemon only marks perches it bound.
93	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
94	pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
95	    let mut rec = read_info(perch_path).ok_or_else(|| {
96	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
97	    })?;
98	    rec.status = Some(status.to_string());
99	    write_info(perch_path, &rec)
100	}
101
102	/// Stamp the `last_active_ms` recency marker on an existing perch's `info.json`
103	/// (REQ-UPD-4). Read-modify-write through the atomic path (a concurrent reader
104	/// still sees a complete old-or-new record). Errors if the record is
105	/// absent/unreadable — the daemon only stamps perches it hosts.
106	// [impl->REQ-UPD-4]
107	pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
108	    let mut rec = read_info(perch_path).ok_or_else(|| {
109	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
110	    })?;
111	    rec.last_active_ms = Some(now_ms);
112	    write_info(perch_path, &rec)
113	}
114
115	/// Number of read attempts before giving up (torn-read retry).
116	const READ_ATTEMPTS: u32 = 5;
117
118	/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
119	/// retry via the atomic layer). The perch directory must already exist.
120	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
121	pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
122	    let json = serde_json::to_string(info)
123	        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
124	    atomic_write_string(&perch_path.join("info.json"), &json)
125	}
126
127	/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
128	/// failure (e.g. catching the file mid-replace on a filesystem where that is
129	/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
130	/// persistent absence/corruption — never panics.
131	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
132	pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
133	    let path = perch_path.join("info.json");
134	    for _ in 0..READ_ATTEMPTS {
135	        if let Ok(content) = std::fs::read_to_string(&path) {
136	            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
137	                return Some(info);
138	            }
139	        }
140	    }
141	    None
142	}
143
144	/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so

}

impl InfoJson {
    /// Construct a numeric-pid record.
    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
        InfoJson {
            id: id.to_string(),
            started: started.to_string(),
            pid: PidValue::Numeric(pid),
            session_id: session_id.to_string(),
            state: state.to_string(),
            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
        }
    }
}

/// Set the daemon-managed `status` on an existing perch's `info.json` (the 2.5
/// daemon-authoritative liveness marker). Read-modify-write through the atomic
/// path, so a concurrent reader still sees a complete old-or-new record. Errors
/// if the record is absent/unreadable — the daemon only marks perches it bound.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.status = Some(status.to_string());
    write_info(perch_path, &rec)
}

/// Stamp the `last_active_ms` recency marker on an existing perch's `info.json`
/// (REQ-UPD-4). Read-modify-write through the atomic path (a concurrent reader
/// still sees a complete old-or-new record). Errors if the record is
/// absent/unreadable — the daemon only stamps perches it hosts.
// [impl->REQ-UPD-4]
pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.last_active_ms = Some(now_ms);
    write_info(perch_path, &rec)
}

/// Number of read attempts before giving up (torn-read retry).
const READ_ATTEMPTS: u32 = 5;

/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
/// retry via the atomic layer). The perch directory must already exist.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
    let json = serde_json::to_string(info)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&perch_path.join("info.json"), &json)
}

/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
/// failure (e.g. catching the file mid-replace on a filesystem where that is
/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
/// persistent absence/corruption — never panics.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
                return Some(info);
            }
        }
    }
    None
}

/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so


Full pattern in hand. Check spt-store deps (serde_json, spt-proto presence for the RNG).


1	[package]
2	name = "spt-store"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core persistence layer: atomic writes, spool, perch layout, registry, info.json. Built on spt-proto."
7
8	[dependencies]
9	rusqlite = { version = "0.32", features = ["bundled"] }
10	serde = { version = "1", features = ["derive"] }
11	serde_json = { version = "1", features = ["preserve_order"] }
12	spt-proto = { path = "../spt-proto" }
13
14	[target.'cfg(unix)'.dependencies]
15	libc = "0.2"
16
17	[dev-dependencies]
18	tempfile = "3"
19

[package]
name = "spt-store"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core persistence layer: atomic writes, spool, perch layout, registry, info.json. Built on spt-proto."

[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
spt-proto = { path = "../spt-proto" }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
tempfile = "3"


spt-store has everything (spt-proto RNG, serde_json preserve_order, tempfile). Add path resolvers to perch.rs after owlery_dir.


Edit

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
/// Replicated via pairing/P2P, **not** git.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn subnet_file() -> PathBuf {
    identity_dir().join("subnet.json")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
/// Replicated via pairing/P2P, **not** git.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn subnet_file() -> PathBuf {
    identity_dir().join("subnet.json")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

+/// Node-local **durable** identity/security material: `<spt_home>/identity/`

+/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and

+/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret

+/// material (the node keypair + subnet TOTP seeds). Pure path composition.

+/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`

+/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to

+/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.

+/// Replicated via pairing/P2P, **not** git.

+/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer

+/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.

/// - `<x>-psyche` → [`PerchKind::Psyche`]


D2c: Seed epoch + rotation


Updated task #3 status


Write the subnet seed store (D2c) in spt-store.


Write

//! `subnet.json` — multi-subnet membership + TOTP seed material (ADR-0005/0006, D2c).
//!
//! A node belongs to *N* subnets (each cryptographically its own TOTP seed + trust
//! context — `CONTEXT.md` "subnet membership"), so this one durable file under
//! [`identity/`](crate::perch::identity_dir) holds a **collection** of
//! [`SubnetRecord`]s, not a single subnet. Node-local, durable, **never git-tracked
//! and never synced over the context repo** — it is subnet-joining secret material
//! (it replicates only via the pairing/P2P channel, `docs/STORAGE.md`).
//!
//! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
//!
//! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
//! **#10**: *trust-store delete is not revocation* — the seed is replicated to every
//! trusted node, so a compromised node can re-pair as a "new" node after a mere
//! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
//! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
//! the wrong TOTP code and the [transcript](../../../spt_net/net/pairing/transcript)
//! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
//! responder-authoritative value the pairing ceremony binds.
//!
//! ## `(subnet, user)`-ready shape (cross-user seam)
//!
//! The optional [`user`](SubnetRecord::user) field is the forward seam for the
//! cross-user model (`CONTEXT.md` "Cross-user seam"): v1 has one implicit user
//! (`None`); when cross-user lands, the seed key generalizes to `(subnet, user)` with
//! no re-architecture — force-unlinking a whole user's node-group becomes dropping
//! that user's records.
//
// [impl->REQ-HAZARD-PAIR-SEED-ROTATION]

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

use crate::atomic::atomic_write_string;
use crate::perch::subnet_file;

/// TOTP seed length in bytes (160-bit, RFC 4226/6238). The crypto consumer
/// (`spt-net` `pairing::totp`) wraps these bytes; this layer stores them.
pub const TOTP_SEED_LEN: usize = 20;

/// Failures operating on the subnet store.
#[derive(Debug, PartialEq, Eq)]
pub enum SubnetError {
    /// `create_subnet` for a name (within the same user scope) that already exists.
    Exists(String),
    /// An operation named a subnet not in the store.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for SubnetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
        }
    }
}

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

/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
/// seed epoch, and (forward seam) the owning user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRecord {
    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
    pub name: String,
    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
    pub seed_hex: String,
    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// Creation stamp (epoch-seconds string; opaque).
    pub created: String,
}

impl SubnetRecord {
    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
    /// malformed or the wrong length (defensive — the store always writes valid hex).
    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
        decode_hex(&self.seed_hex)
    }
}

/// The whole `subnet.json` document: every subnet the node is a member of.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubnetStore {
    /// All subnets this node belongs to (multi-subnet — N seeds).
    #[serde(default)]
    pub subnets: Vec<SubnetRecord>,
}

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

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
    /// file should not brick the node — re-provision via pairing).
    pub fn load_from(path: &Path) -> SubnetStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => SubnetStore::default(),
        }
    }

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

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

    /// Find a subnet by name (within the implicit-user scope).
    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
        self.subnets.iter().find(|s| s.name == name)
    }

    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
    /// pairing path — this node becomes the sole seed-holder). Errors if the name
    /// already exists. Returns the new record.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&random_seed()),
            epoch: 1,
            user: None,
            created: now_stamp(),
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// + its current epoch arrive over the pairing channel from a seed-holder). Errors
    /// if the name already exists.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn add_joined(
        &mut self,
        name: &str,
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    ) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&seed),
            epoch,
            user: None,
            created: now_stamp(),
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
    /// response). The old seed can no longer compute a valid current code, and the
    /// epoch bump means even a captured old transcript won't bind. The new seed must
    /// then be redistributed to the *remaining* trusted nodes over the pairing
    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
    /// because a trust-delete alone is not revocation.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        rec.seed_hex = encode_hex(&random_seed());
        rec.epoch += 1;
        Ok(rec)
    }

    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
    /// membership; rotating evicts *another* node from a subnet we keep.)
    pub fn remove(&mut self, name: &str) -> bool {
        let before = self.subnets.len();
        self.subnets.retain(|s| s.name != name);
        self.subnets.len() != before
    }
}

/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
fn random_seed() -> [u8; TOTP_SEED_LEN] {
    let mut out = [0u8; TOTP_SEED_LEN];
    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
    out
}

/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
fn encode_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
/// non-hex digits.
fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
    if s.len() != TOTP_SEED_LEN * 2 {
        return None;
    }
    let mut out = [0u8; TOTP_SEED_LEN];
    for (i, slot) in out.iter_mut().enumerate() {
        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
    }
    Some(out)
}

/// Current unix-seconds as an opaque stamp string.
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-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
    // 20-byte seed that decodes cleanly; a second create of the same name is rejected.
    #[test]
    fn create_subnet_starts_at_epoch_one_unique_name() {
        let mut store = SubnetStore::default();
        let rec = store.create_subnet("home").expect("create");
        assert_eq!(rec.epoch, 1);
        assert_eq!(rec.name, "home");
        assert!(rec.seed_bytes().is_some(), "seed hex decodes to 20 bytes");
        assert_eq!(store.create_subnet("home").unwrap_err(), SubnetError::Exists("home".into()));
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] rotation is the node-removal response:
    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
    // longer compute a valid code (the transcript binds the epoch). Trust-delete
    // alone would leave the seed intact — this is what makes removal real revocation.
    #[test]
    fn rotate_bumps_epoch_and_changes_seed() {
        let mut store = SubnetStore::default();
        let before = store.create_subnet("home").expect("create").clone();
        let after = store.rotate_seed("home").expect("rotate").clone();
        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
        assert_ne!(after.seed_hex, before.seed_hex, "seed material rotates");
        assert_eq!(after.name, before.name, "name preserved");
        // Rotating an unknown subnet is an error, not a silent no-op.
        assert_eq!(store.rotate_seed("ghost").unwrap_err(), SubnetError::NotFound("ghost".into()));
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
    // learned from the seed-holder verbatim (so a node that joined post-rotation is
    // on the current epoch, not epoch 1).
    #[test]
    fn add_joined_preserves_seed_and_epoch() {
        let mut store = SubnetStore::default();
        let seed = [7u8; TOTP_SEED_LEN];
        let rec = store.add_joined("work", seed, 5).expect("join").clone();
        assert_eq!(rec.epoch, 5);
        assert_eq!(rec.seed_bytes(), Some(seed));
        assert_eq!(store.add_joined("work", seed, 6).unwrap_err(), SubnetError::Exists("work".into()));
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] the store round-trips through the file
    // atomically (multi-subnet preserved), and a rotation persists across reload —
    // the epoch survives, so revocation is durable.
    #[test]
    fn store_persists_multi_subnet_and_rotation() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("subnet.json");

        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        store.create_subnet("work").unwrap();
        store.rotate_seed("home").unwrap();
        store.save_to(&path).unwrap();

        let reloaded = SubnetStore::load_from(&path);
        assert_eq!(reloaded.subnets.len(), 2, "multi-subnet preserved");
        assert_eq!(reloaded.find("home").unwrap().epoch, 2, "rotation durable across reload");
        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] an absent or corrupt file degrades to an
    // empty store, never a panic — a damaged secret file must not brick the node
    // (re-provision via pairing).
    #[test]
    fn missing_or_corrupt_file_is_empty_store() {
        let dir = tempfile::tempdir().unwrap();
        let absent = dir.path().join("nope.json");
        assert!(SubnetStore::load_from(&absent).subnets.is_empty());

        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] remove drops our membership and reports
    // whether anything was removed.
    #[test]
    fn remove_drops_membership() {
        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        assert!(store.remove("home"));
        assert!(!store.remove("home"), "second remove is a no-op false");
        assert!(store.find("home").is_none());
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] minted seeds are random (two creates
    // differ) — rotation actually changes key material, not a constant.
    #[test]
    fn minted_seeds_are_random() {
        let mut a = SubnetStore::default();
        let mut b = SubnetStore::default();
        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
        assert_ne!(sa, sb);
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] hex round-trips bytes exactly and rejects
    // bad length / non-hex.
    #[test]
    fn hex_round_trips_and_rejects_bad_input() {
        let bytes = [0xABu8; TOTP_SEED_LEN];
        assert_eq!(decode_hex(&encode_hex(&bytes)), Some(bytes));
        assert_eq!(decode_hex("abcd"), None, "wrong length");
        assert_eq!(decode_hex(&"zz".repeat(TOTP_SEED_LEN)), None, "non-hex digits");
    }
}