crates/spt-store/src/perch.rs:24:/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
crates/spt-store/src/perch.rs:32:/// override (`$SPT_HOME`) is the relocation knob either way.
crates/spt-store/src/perch.rs:34:pub fn spt_home() -> PathBuf {
crates/spt-store/src/perch.rs:35:    if let Ok(over) = std::env::var("SPT_HOME") {
crates/spt-store/src/perch.rs:62:pub fn owlery_dir() -> PathBuf {
crates/spt-store/src/perch.rs:192:pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:261:            let prev = std::env::var("SPT_HOME").ok();
crates/spt-store/src/perch.rs:262:            std::env::set_var("SPT_HOME", tmp);
crates/spt-store/src/perch.rs:269:                Some(v) => std::env::set_var("SPT_HOME", v),
crates/spt-store/src/perch.rs:270:                None => std::env::remove_var("SPT_HOME"),
===registry head===
//! SQLite registry for agent address discovery — `<owlery>/.registry`.
//!
//! Schema + surface copied verbatim from sister `registry.rs` (ADR-0001). Maps
//! an agent id → its TCP address + owning pid. This is the **local** registry
//! (one machine's perches); cross-node subnet resolution (REQ-INST-7 full) is
//! M4. M0 delivers local persistence + the stale-entry invariant only.
//!
//! ## Stale-entry invariant (KNOWN-HAZARDS 4.3, REQ-HAZARD-REGISTRY-STALE-CLEAN)
//! A registered pid can die without unregistering (crash, kill). Resolving its
//! stale address would deliver to a dead/wrong listener. So [`clean_stale_entries`]
//! drops dead-pid rows, and [`resolve_address`] cleans **before** lookup. The
//! clean step is best-effort: a cleanup failure never hard-fails resolution
//! (the spool is the safe fallback on a TCP miss).

use rusqlite::{params, Connection};
use std::path::Path;

use crate::liveness;

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;
    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas (busy_timeout
    // first, then WAL-with-retry) so concurrent ReadyAgent registrations don't
    // fail with "database is locked".
    crate::db::tune_connection(&conn)?;
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS agents (
            id TEXT PRIMARY KEY,
            address TEXT NOT NULL,
            pid INTEGER NOT NULL,
            started TEXT NOT NULL
        )",
    )?;
    Ok(conn)
}

fn now_stamp() -> String {
    std::time::SystemTime::now()


spt_home is the state root. Read its definition + CONTEXT subnet/seed glossary to align D2 naming + state location.


24	/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
25	/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
26	/// **Pure** — reads env, composes a path, creates nothing.
27	///
28	/// The default lives under `spt-core/`, **deliberately distinct from the modern
29	/// spt runtime's `spt/` home**, so a dev build never collides with a running
30	/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
31	/// full parity with modern spt this may simply stay `spt-core/` for good; the
32	/// override (`$SPT_HOME`) is the relocation knob either way.
33	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
34	pub fn spt_home() -> PathBuf {
35	    if let Ok(over) = std::env::var("SPT_HOME") {
36	        if !over.is_empty() {
37	            return PathBuf::from(over);
38	        }
39	    }
40	    platform_default_spt_home()
41	}
42
43	#[cfg(windows)]
44	fn platform_default_spt_home() -> PathBuf {
45	    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46	        if !local.is_empty() {
47	            return PathBuf::from(local).join("spt-core");
48	        }
49	    }
50	    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
51	    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
52	}
53
54	#[cfg(unix)]
55	fn platform_default_spt_home() -> PathBuf {
56	    let home = std::env::var("HOME").expect("HOME must be set on Unix");
57	    PathBuf::from(home).join(".spt-core")
58	}
59
60	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
61	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
62	pub fn owlery_dir() -> PathBuf {
63	    spt_home().join("owlery")
64	}
65
66	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
67	///
68	/// - `<x>-psyche` → [`PerchKind::Psyche`]

/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
/// **Pure** — reads env, composes a path, creates nothing.
///
/// The default lives under `spt-core/`, **deliberately distinct from the modern
/// spt runtime's `spt/` home**, so a dev build never collides with a running
/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
/// full parity with modern spt this may simply stay `spt-core/` for good; the
/// override (`$SPT_HOME`) is the relocation knob either way.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn spt_home() -> PathBuf {
    if let Ok(over) = std::env::var("SPT_HOME") {
        if !over.is_empty() {
            return PathBuf::from(over);
        }
    }
    platform_default_spt_home()
}

#[cfg(windows)]
fn platform_default_spt_home() -> PathBuf {
    if let Ok(local) = std::env::var("LOCALAPPDATA") {
        if !local.is_empty() {
            return PathBuf::from(local).join("spt-core");
        }
    }
    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
}

#[cfg(unix)]
fn platform_default_spt_home() -> PathBuf {
    let home = std::env::var("HOME").expect("HOME must be set on Unix");
    PathBuf::from(home).join(".spt-core")
}

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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
///
/// - `<x>-psyche` → [`PerchKind::Psyche`]


Bash

grep -niE "subnet|TOTP|SPAKE|seed epoch|pairing|trust store|rendezvous" CONTEXT.md | head -40

CONTEXT subnet/pairing glossary


26:The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.
109:**capability declaration** — which endpoint types a harness/node can host (a Pi node might host only Shells, never a LiveAgent). Static manifest list, consumed by the subnet registry so a node advertises its hostable types. Exact shape is design-open.
185:A machine's participation in an SPT subnet, identified by an Ed25519 public key generated on first run. First-class so networking primitives can address nodes directly as message targets, not only as transport peers. The node identity and network endpoint are hosted by the machine's `spt-daemon` (see Networking), not a separate process.
233:**Broadcast policy** (manifest): the shell advertises its availability to all agents on the subnet, only same-node agents, or not at all (agents learn of it only by user instruction). Shells run only on nodes where the shell adapter is installed/advertised.
252:**Discovery scope (what "available shells" resolves to):** the set surfaced to an agent (injected + `shell list`) is **(1)** its **own instances** — alias · canonical `<adapter>-<n>` · status — always; plus **(2) instantiable adapters**: shell adapters **registered on the agent's own node** with `broadcast ∈ {subnet, same-node}`, and shell adapters on *other* subnet nodes with `broadcast = subnet`. `broadcast = none` shells are **never** surfaced proactively — spawnable only by explicit user/agent instruction (if registered locally / reachable). Discovering an *other-node* shell does **not** imply free instantiation: spawning it rides the deferred **instantiate-anywhere** gate. (Discovery is gated by the same reach gates as everything else — an agent only sees shells on nodes/subnets it can address.)
254:**Not in the subnet registry.** Shell perches are private to the agent↔shell link, not a general messaging surface — they are nested under the owner (`perches/<endpoint_id>/shells/<adapter>-<n>/`) and not registry-advertised like agent endpoints. The perch `info.json` carries only `type=Shell`, `owner`, `adapter_name`, `status` (online|offline), and an optional `alias` — the capability set is resolved from the shell adapter manifest by `adapter_name`, not duplicated on the perch.
269:**Wake resolution (state-keyed):** find any *reachable* instance of the owner endpoint on the subnet, then —
298:- **Two access modes over one transport.** The digest is a *parsed projection of the same PTY stream the broker already owns for byte-stream remote terminal (R-TERM-3)*, so it is offered both ways: **snapshot pull** — `spt digest <[subnet:]id[@node]>` returns the current structured buffer (a Shell/CLI/GUI on-demand render; feeds the frontend's "latest session output" pane) — and a **broker-pushed structured-delta stream** — a subscriber (live Shell pane / GUI) receives only *changes* (new turn, new tool entry, collapse update), not the whole buffer, for resource-efficient near-realtime reflection. The delta-stream is a **structured overlay riding the same R-TERM-3 streaming substrate** as the raw byte-stream (raw bytes vs parsed digest = two channels on the one session surface). **Access is address-gated** — fetch/subscribe is allowed for anyone who can address the endpoint (visible + resolvable per the resolution policy), the same gate as messaging.
303:WAN cross-machine messaging is a **first-class, day-one capability** of spt-core — not a follow-on project. Goal: minimal-to-zero config, "just works" cross-machine SPT. The transport stack (Iroh + mDNS LAN discovery + pairing) is baked into the core library crates, behind a compile-time feature flag (`net`) that is default-on in the reference binary and optional for embedded/stripped library consumers.
318:**Sync scope is subnet-exclusive by default.** An endpoint's mind replicates only within **one subnet** unless told otherwise. Endpoint config carries a **subnet-membership list** for sync: the context syncs across the nodes of *every* subnet in that list, defaulting to just the endpoint's home subnet. This is distinct from registry *visibility* (*endpoint visibility*) — being addressable in a subnet does not imply replicating your mind there; mind-replication into a subnet requires that subnet to be in the sync list. The default keeps a `home`-fleet agent's mind off the `work` fleet even when a node bridges both. (The subnet scope composes with the live/project tier split below: a tier syncs to the eligible instances *that are also in an in-scope subnet*.)
322:- **Hub (opt-in, `spt context-github-setup`):** every node pull→merge→pushes against a private GitHub remote — an always-online sync hub *and* a convenient GUI view. Uses a **shared deploy key** distributed as subnet secret material, so only the setup node needs a GitHub account; other nodes use the key over SSH, with **P2P as backstop**. Hub mode is the **context-repo sync transport only** — messaging, registry, remote-drive, pairing, and presence always ride P2P; gh-on never removes the P2P requirement (ADR-0002, amended).
326:### Subnet notifications
329:A first-class kind **distinct from an inter-agent message**: a **user-directed, dismissable, resurfacing** subnet event (e.g. *new node paired*, *external-subnet pairing request*, *update available*, *consent needed*). Messages route agent→agent and are consumed once; notifs route **to the user** — via whichever endpoint is active — and **persist until dismissed**. The notif is the general primitive that the previously-ad-hoc "deliver to the user's most-recently-active session" flows are special cases of: **the self-update prompt and the consent escalation are refactored to be notif producers** (one primitive, many producers — kills the duplicated delivery paths; the registry-resolution precursor to PresenceChannel is this primitive's delivery step).
331:- **Spool: per-subnet, replicated across that subnet's nodes** (reuses subnet-registry distribution). Each notif is tagged with its subnet; **dismiss-state replicates subnet-wide**, eventually consistent (dismiss on node A clears it on node B). "Node notif spool" = each node's local replica. *(Forward seam: dismissal — and the spool key — generalize to **per-(subnet, user)** when the cross-user model lands.)*
332:- **First-fire** targets the user's most-recently-active endpoint *in that subnet* via presence resolution.
335:- **Scope:** an endpoint only ever sees/resurfaces notifs for subnets it is **visible in** (excluded endpoints never receive that subnet's notifs).
337:- **Producers (open set):** system events (node-paired, pairing-request, update-available, consent-needed) + agent-issued `spt notify`. New producers register later.
338:- **Envelope `from`** — for agent-issued notifs carries the issuer's **endpoint id, node, and subnet** (the subnet is **surfaced to the receiver only if its node is on multiple subnets** — otherwise redundant). Makes notifs attributable now and targetable once the per-(subnet, user) model lands.
340:**`spt notify` (agent-issued subnet notif)**:
341:A command letting any agent **issue a subnet-wide notif** to the user. v1: it **reaches the user on their active endpoint from any agent** (the default broadcast-to-user). *Forward:* gains targeting — **all subnet users (default) or specific subnet users** — once the per-(subnet, user) model lands; the `from` field is what makes targeted/attributed delivery possible.
347:**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).
360:The logical identity (`ling`). Shared subnet-wide. The thing you address. **Adapter-agnostic**: the same ID can run under different adapters over its life (start on `claude-spt`, later revive on `spt-codex` or `spt-pi`) — adapters expose the same SPT-relevant capability subset, so the ID is not bound to an adapter. The currently-running `adapter_name` is a property of the live instance, not part of the identity. **At most one instance of an ID per node** is allowed; a second attempt on the same node (any adapter) is rejected. **Identity is node-global, advertised per-subnet** (see *subnet membership*): one `ling` exists on a node (one mind, one `tracked/` context) and is advertised into each subnet the node belongs to, subject to per-subnet visibility (see *endpoint visibility*). The same bare name may exist as *distinct* endpoints in different subnets; the resolution policy forces qualification when both are visible. Within a single subnet a bare id is unique — enforced by a **join-time collision check**: when a node carrying endpoint `X` joins/advertises into a subnet that already has a different `X`, the clash is surfaced and resolved (rename one — below) before advertisement. An endpoint keeps an **ordered adapter history** (in `tracked/agents/<id>/meta.json`, synced) driving the resume UX: latest adapter is the default resume offer, then next-latest, … oldest, then "choose a different adapter". (Adapter-agnosticism is an *agent-endpoint* property; Shells are adapter/platform-bound — see Shells.)
363:At **creation**, the adapter is chosen from the node's **registered `kind="harness"` adapters whose `hostable_types` includes the endpoint's type** — **auto if exactly one qualifies, else chosen** (`--adapter` / picker): the same *auto-if-one-ask-if-many* rule as *home subnet*. The choice seeds the **head of the ordered adapter history** and the live `adapter_name`. **Changing** an endpoint's adapter is **not** a standalone operation — adapter is a *live-instance* property, so you change it by **launching/resuming the endpoint under a different registered adapter** (the resume UX surfaces the adapter history: head = default, then prior adapters, then "choose a different adapter"). For a currently-active instance this is a **resume-under-the-new-adapter** carrying context via the *fresh-with-preload* psyche-download (a session cycle, not a hot swap — the mind is adapter-agnostic). Same gate both times: the target adapter must be **registered on the node** and its `hostable_types` must include the endpoint's type.
389:**`spt rename <id> <new_id>`** (endpoint rename, **rippled**): change an endpoint's logical ID and propagate the change to **all its instances** subnet-wide — the registry entries, every node's perch, and the synced context branches (`a-<id>` → `a-<new_id>`, and project-view references). The rename is one logical operation against the eventually-consistent registry; it must collision-check `new_id` against every subnet the endpoint is advertised into (the join-time check above), and reconcile concurrent renames by the same precedence marker the context merge uses (node + newest-wins, KNOWN-HAZARDS 6.5). The adapter-agnostic, node-anchored identity (ADR-0003) is what makes a clean rename possible; without it the ID would be entangled with per-adapter or per-session state.
391:**`spt fork <id> [--into <subnet>] [--as <new_id>] [--delete-source]`** (cross-subnet clone):
392:Clone an endpoint into a **new, identity-distinct endpoint** on another subnet — the sanctioned way to place an agent's mind in a different subnet (home subnet being immutable). The fork's **home = the target subnet**; it is **seeded with a one-time copy** of the source's mind (the live + project *Cross-node Psyche sync* tiers), after which the two **diverge** — there is **no ongoing sync**. A fork is therefore **not an instance**: an *instance* is the **same** identity on another node with a **synced** mind; a *fork* is a **new** identity with a **copied-then-independent** mind. The new id defaults to the source's bare name if free in the target subnet (the join-time collision check; else prompt/rename); the adapter follows the normal creation rule (*adapter selection*). The **source is untouched**, optionally deleted afterward (`--delete-source`, or a later delete). Distinct from **rename** (same identity, new label, rippled) and **instantiate-anywhere** (same identity, new *node*, consent-gated).
405:**subnet registry**:
406:Distributed, eventually-consistent map `endpoint_id → [instances: (node, perch, type, status)]`, **per subnet**. Every `spt-daemon` participates in the registry of each subnet it belongs to. This is the "agent name resolution" the sister project deferred — now first-class and mandatory.
408:**endpoint visibility (per-(endpoint, subnet))**:
409:Whether an endpoint is exposed to a given subnet. **Hidden means *excluded*** — not advertised in that subnet's registry **and not routable from it** (addressing it from that subnet fails until explicitly revealed), not merely unlisted. This makes hiding a real boundary, ready for the cross-user (b) seam, not a silent non-boundary. Computed as: endpoint `E` is **hidden in subnet `S` iff** `S.hide_new_endpoints` (a per-subnet policy captured at the node's **join time**) **OR** `E.default_hide_from_new_subnets` (a per-endpoint setting) — **unless** an explicit per-`(E,S)` override says otherwise (the override always wins). Both defaults ship **OFF** (visible); hiding is opt-in. **Visibility gates sync:** `hidden ⟹ not synced`, and replicating a mind into `S` (the sync-membership list under *Cross-node Psyche sync*) requires `E` be visible in `S` — visibility is the outer gate, sync-membership the inner opt-in within visible subnets.
411:**home subnet (per endpoint)**:
412:The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).
414:**resource advertisement (subnet resource registry)**:
415:A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt resources set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).
418:How bare `ling` resolves when multiple instances exist. Rule: local-node instance if present → else most-recently-active → explicit override always available. **Two qualifier syntaxes**, combinable: **subnet-qualified** `home:ling` and **node-qualified** `ling@hfenduleam` (→ `home:ling@hfenduleam`). **Multi-subnet ambiguity rule:** under node-global identity (endpoint ID below), a node on several subnets may see two *distinct* endpoints that share a bare name (a `home:ling` and an unrelated `work:ling`). When a bare `id` is ambiguous across the visible subnets, resolution **refuses and forces qualification** (by subnet or node) rather than guessing. Within a single subnet, bare ids stay unique (join-time collision check, below).
443:- **In v1:** the endpoint-ID / instance data-model split; the subnet registry; resolution policy; remote-drive of *already-running* instances (network session-surface); cross-node Psyche sync (full — replaces gh-repo-sync); cross-instance context-freshness auto-feed; off-node file transfer; off-node remote-drive detection.
451:Layering (bottom → top): `spt-proto` (wire envelope grammar, endpoint types, message framing, Ed25519 identity types, typed+binary payload schema — pure types, no I/O) → `spt-store` (spool, perch layout, trust store, registry persistence) → `spt-msg` (delivery TCP+spool-fallback, routing, send/ring) → `spt-net` (Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution; `net` feature-flagged) and `spt-term` (session-surface trait, PTY impl, broker) and `spt-runtime` (`AgentRuntime` trait, `ManifestRuntime`, manifest schema) → `spt-live` (Psyche/pulse/commune/signoff) → `spt-daemon` (the brain: ties all, hosts broker, update orchestration, instance registry) → `spt` (the `spt.exe` binary; thin CLI).
457:## Pairing & trust
459:How a single user's own nodes come to trust each other and form a subnet. Cross-*user* subnet (two *different* users' subnets interoperating) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept), but the model is **built with the seam for it** (see *subnet membership*).
461:**subnet membership (multi-subnet)**:


NOTE FROM THE USER:proceed with next steps.


CONTEXT line 451: trust store lives in spt-store. Read full Pairing & trust section to align D2.


457	## Pairing & trust
458
459	How a single user's own nodes come to trust each other and form a subnet. Cross-*user* subnet (two *different* users' subnets interoperating) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept), but the model is **built with the seam for it** (see *subnet membership*).
460
461	**subnet membership (multi-subnet)**:
462	A node may be a member of **multiple subnets at once** — but, for v1, only multiple subnets *of the same user* (e.g. a `home` fleet and a `work` fleet, each cryptographically its own TOTP seed + trust context). A node simply holds *N* seeds / trust-contexts rather than one. The per-subnet trust boundary is unchanged: within each subnet, all nodes are the one user's own mutually-trusted nodes (no stranger-auth). **Cross-*user* membership (joining another person's subnet) stays deferred**, but the membership model is multiplicity-ready so it can drop in later without re-architecture. An **external subnet** (relative to a given subnet) means *another subnet the node also belongs to* — in v1, always another of the user's own.
463
464	**Cross-user seam (forward design, not v1):** the seed key generalizes from *per-subnet* to **per-(subnet, user)** — a seed then doubles as an identifier+authenticator for *whose* it is. The pairing/code prompt becomes **2-stage** (select user / create-new → then select subnet / create-new); in v1 the single implicit user collapses it back to the 1-stage prompt. This makes node↔user attribution fall out for free ("node `enlyzeam` is using Brandon's `work` seed → that machine's active endpoint is Brandon's") and makes **force-unlink of an entire user's node-group** a single operation (drop that user's seeds). v1 stores seeds in a shape that already accommodates the (subnet, user) key.
465
466	**TOTP-seeded SPAKE2 pairing**:
467	The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.
468
469	Why this construction (not plain TOTP-verification, not plain Magic Wormhole):
470	- **Not TOTP-as-bearer-token** — a 6-digit code sent over the wire for verification is ~20 bits, replayable within its 30s window, and doesn't bind to the key being exchanged (real-time MITM/replay seam). PAKE makes the low-entropy code MITM-resistant and limits attackers to one online guess per attempt (no offline brute force).
471	- **Better UX than plain Magic Wormhole** — Wormhole needs a fresh code on one node typed into the other, requiring a human/relay at *both* ends each pairing. With the persistent TOTP seed replicated to online trusted nodes, the trusted side is automatic; **the user only ever interacts with the new node** (read phone, type into new machine).
472
473	**trust store**:
474	Each node keeps a local store of authorized peer pubkeys. TOFU after pairing: accept on first connect post-ceremony, warn on key change. Lives near `node.key`.
475
476	**link discovery (TOTP + name)**:
477	The TOTP code is **also the link-discovery selector**, not only the PAKE password — it routes the two not-yet-trusted nodes to the same pairing rendezvous over the relay's pairing ALPN. Because a 6-digit code is a weak global selector, discovery+link takes **two inputs: the TOTP code + the subnet-name** (the R-PAIR-4 human label), which namespaces the rendezvous. **The subnet-name is the second input in all cases** (the unified rule):
478	- **join-existing** → enter the target subnet's name + its current code. Any online seed-holder of that subnet listens on that rendezvous and completes the PAKE; the joiner never enumerates the subnet's nodes.
479	- **create-new** → the subnet is **named at creation**: one machine generates the new seed *and names the subnet* up front (becoming the sole seed-holder), then the joiner uses that subnet-name + the new seed's code. No node-name mode — naming simply happens at link start rather than after.
480
481	The name only namespaces the rendezvous; a collision (two unrelated pairings sharing a code + name) merely fails the PAKE (wrong password) — no security break, just a retry. **Rendezvous-token hashing (under the hood):** the relay routes the *pre-trust* pairing by a rendezvous token; the payload is already E2E-encrypted (R-NET-2), but the token itself is relay-visible, so spt-core derives it as `H(subnet-name ‖ TOTP-epoch)` rather than the plaintext label — the user/agent still enters the **raw name + code**, the hash is internal.
482
483	**fetch-code-from-any-node (per-subnet, QR-optional)**:
484	Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).
485
486	**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).
487
488	**subnet naming**:
489	The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.
490
491	**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.
492
493	Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).
494
495	**local-link authentication**:
496	Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.
497
498	**binary-trust disclosure (accepted risk)**:
499	A shell binary is the least-trusted code in the system (3rd-party, runs on the user's node, agent-controllable) — but the same is true of harness binaries. The capability toolset bounds what an *agent* may ask; it does not sandbox what the *binary* may do with its OS permissions. Optional **shell-binary sandboxing** is deferred (gated like instantiate-anywhere/remote-exec). The baseline stance: **running any adapter or shell binary means trusting it — a disclosed, accepted risk for all spt-core users.**
500
501	## Consent & security gates
502
503	Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).
504
505	**Consent model — hybrid (grants + interactive escalation):**
506	- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
507	- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion "Other") for refinement.
508	- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).
509
510	**What is gated vs not:**
511	- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).
512	- **Pre-consented by flag:** shell owner-shutdown, shell wake-spawn-anywhere.
513	- **Ungated:** remote-drive of your *own running* instance (low risk — your own instance; a light "node X is driving `ling@desktop`" notification suffices), cross-node context/Psyche sync (your own data).
514
515	**endpoint access whitelist** (distinct from the grant store — the outer reach gate):
516	A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:

## Pairing & trust

How a single user's own nodes come to trust each other and form a subnet. Cross-*user* subnet (two *different* users' subnets interoperating) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept), but the model is **built with the seam for it** (see *subnet membership*).

**subnet membership (multi-subnet)**:
A node may be a member of **multiple subnets at once** — but, for v1, only multiple subnets *of the same user* (e.g. a `home` fleet and a `work` fleet, each cryptographically its own TOTP seed + trust context). A node simply holds *N* seeds / trust-contexts rather than one. The per-subnet trust boundary is unchanged: within each subnet, all nodes are the one user's own mutually-trusted nodes (no stranger-auth). **Cross-*user* membership (joining another person's subnet) stays deferred**, but the membership model is multiplicity-ready so it can drop in later without re-architecture. An **external subnet** (relative to a given subnet) means *another subnet the node also belongs to* — in v1, always another of the user's own.

**Cross-user seam (forward design, not v1):** the seed key generalizes from *per-subnet* to **per-(subnet, user)** — a seed then doubles as an identifier+authenticator for *whose* it is. The pairing/code prompt becomes **2-stage** (select user / create-new → then select subnet / create-new); in v1 the single implicit user collapses it back to the 1-stage prompt. This makes node↔user attribution fall out for free ("node `enlyzeam` is using Brandon's `work` seed → that machine's active endpoint is Brandon's") and makes **force-unlink of an entire user's node-group** a single operation (drop that user's seeds). v1 stores seeds in a shape that already accommodates the (subnet, user) key.

**TOTP-seeded SPAKE2 pairing**:
The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.

Why this construction (not plain TOTP-verification, not plain Magic Wormhole):
- **Not TOTP-as-bearer-token** — a 6-digit code sent over the wire for verification is ~20 bits, replayable within its 30s window, and doesn't bind to the key being exchanged (real-time MITM/replay seam). PAKE makes the low-entropy code MITM-resistant and limits attackers to one online guess per attempt (no offline brute force).
- **Better UX than plain Magic Wormhole** — Wormhole needs a fresh code on one node typed into the other, requiring a human/relay at *both* ends each pairing. With the persistent TOTP seed replicated to online trusted nodes, the trusted side is automatic; **the user only ever interacts with the new node** (read phone, type into new machine).

**trust store**:
Each node keeps a local store of authorized peer pubkeys. TOFU after pairing: accept on first connect post-ceremony, warn on key change. Lives near `node.key`.

**link discovery (TOTP + name)**:
The TOTP code is **also the link-discovery selector**, not only the PAKE password — it routes the two not-yet-trusted nodes to the same pairing rendezvous over the relay's pairing ALPN. Because a 6-digit code is a weak global selector, discovery+link takes **two inputs: the TOTP code + the subnet-name** (the R-PAIR-4 human label), which namespaces the rendezvous. **The subnet-name is the second input in all cases** (the unified rule):
- **join-existing** → enter the target subnet's name + its current code. Any online seed-holder of that subnet listens on that rendezvous and completes the PAKE; the joiner never enumerates the subnet's nodes.
- **create-new** → the subnet is **named at creation**: one machine generates the new seed *and names the subnet* up front (becoming the sole seed-holder), then the joiner uses that subnet-name + the new seed's code. No node-name mode — naming simply happens at link start rather than after.

The name only namespaces the rendezvous; a collision (two unrelated pairings sharing a code + name) merely fails the PAKE (wrong password) — no security break, just a retry. **Rendezvous-token hashing (under the hood):** the relay routes the *pre-trust* pairing by a rendezvous token; the payload is already E2E-encrypted (R-NET-2), but the token itself is relay-visible, so spt-core derives it as `H(subnet-name ‖ TOTP-epoch)` rather than the plaintext label — the user/agent still enters the **raw name + code**, the hash is internal.

**fetch-code-from-any-node (per-subnet, QR-optional)**:
Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).

**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).

**subnet naming**:
The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.

**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.

Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).

**local-link authentication**:
Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.

**binary-trust disclosure (accepted risk)**:
A shell binary is the least-trusted code in the system (3rd-party, runs on the user's node, agent-controllable) — but the same is true of harness binaries. The capability toolset bounds what an *agent* may ask; it does not sandbox what the *binary* may do with its OS permissions. Optional **shell-binary sandboxing** is deferred (gated like instantiate-anywhere/remote-exec). The baseline stance: **running any adapter or shell binary means trusting it — a disclosed, accepted risk for all spt-core users.**

## Consent & security gates

Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).

**Consent model — hybrid (grants + interactive escalation):**
- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion "Other") for refinement.
- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).

**What is gated vs not:**
- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).
- **Pre-consented by flag:** shell owner-shutdown, shell wake-spawn-anywhere.
- **Ungated:** remote-drive of your *own running* instance (low risk — your own instance; a light "node X is driving `ling@desktop`" notification suffices), cross-node context/Psyche sync (your own data).

**endpoint access whitelist** (distinct from the grant store — the outer reach gate):
A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:


Deep D2 context now. Trust store + seeds live near `node.key` in spt-store. Check node.key location + research-agent status.


grep -rn "node.key\|node_key\|fn .*key_path\|fn load_identity\|fn node_identity" crates/ --include=*.rs | grep -iv test | head -15

Find node.key storage


(Bash completed with no output)


grep -rln "Identity::generate\|from_seed\|Identity::from\|persist.*ident\|load.*ident" crates/ --include=*.rs | head; echo "---how identity persisted?---"; grep -rn "Identity" crates/spt-store/src/*.rs crates/spt-daemon/src/*.rs 2>/dev/null | grep -iv "test\|//" | head

Find identity persistence


crates/spt/src/api/auth.rs
crates/spt/src/api/startup.rs
crates/spt-net/src/net/endpoint.rs
crates/spt-proto/src/identity.rs
---how identity persisted?---


1	//! Local-api-auth — every `api` mutation proves association to its target
2	//! endpoint before touching on-disk state (codex red-team #13,
3	//! REQ-HAZARD-LOCAL-API-AUTH). Without this guard any local process could drive
4	//! another agent's perch by id alone.
5	//!
6	//! Two accepted proofs, checked against the target perch:
7	//! 1. the perch's **capability token** (minted when the perch is established,
8	//!    handed to the session out-of-band), or
9	//! 2. the **session_id** recorded in the perch's `info.json` (the harness knows
10	//!    its own session id; an unrelated process does not).
11	//!
12	//! A call with neither — or against a perch that doesn't exist — is refused.
13	//
14	// [impl->REQ-HAZARD-LOCAL-API-AUTH]
15
16	use spt_proto::identity::Identity;
17	use spt_store::info;
18	use spt_store::perch::{self, ParentHint};
19	use std::path::Path;
20
21	/// Per-perch capability-token filename within a perch dir.
22	const TOKEN_FILE: &str = "api.token";
23
24	/// Mint a fresh capability token: 32 hex chars from crypto-grade randomness.
25	/// Reuses spt-proto's Ed25519 seed generator so no extra RNG dependency is
26	/// pulled into the binary.
27	pub fn mint_token() -> String {
28	    let seed = Identity::generate().seed();
29	    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
30	}
31
32	/// Persist a capability token into an (already-created) perch dir.
33	pub fn write_token(perch_path: &Path, token: &str) -> std::io::Result<()> {
34	    std::fs::write(perch_path.join(TOKEN_FILE), token)
35	}
36
37	/// Read a perch's capability token, if one was written.
38	pub fn read_token(perch_path: &Path) -> Option<String> {
39	    std::fs::read_to_string(perch_path.join(TOKEN_FILE))
40	        .ok()
41	        .map(|s| s.trim().to_string())
42	        .filter(|s| !s.is_empty())
43	}
44
45	/// Caller-supplied proof, gathered from flags with env fallback.
46	pub struct Proof {
47	    pub token: Option<String>,
48	    pub session_id: Option<String>,
49	}
50

//! Local-api-auth — every `api` mutation proves association to its target
//! endpoint before touching on-disk state (codex red-team #13,
//! REQ-HAZARD-LOCAL-API-AUTH). Without this guard any local process could drive
//! another agent's perch by id alone.
//!
//! Two accepted proofs, checked against the target perch:
//! 1. the perch's **capability token** (minted when the perch is established,
//!    handed to the session out-of-band), or
//! 2. the **session_id** recorded in the perch's `info.json` (the harness knows
//!    its own session id; an unrelated process does not).
//!
//! A call with neither — or against a perch that doesn't exist — is refused.
//
// [impl->REQ-HAZARD-LOCAL-API-AUTH]

use spt_proto::identity::Identity;
use spt_store::info;
use spt_store::perch::{self, ParentHint};
use std::path::Path;

/// Per-perch capability-token filename within a perch dir.
const TOKEN_FILE: &str = "api.token";

/// Mint a fresh capability token: 32 hex chars from crypto-grade randomness.
/// Reuses spt-proto's Ed25519 seed generator so no extra RNG dependency is
/// pulled into the binary.
pub fn mint_token() -> String {
    let seed = Identity::generate().seed();
    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
}

/// Persist a capability token into an (already-created) perch dir.
pub fn write_token(perch_path: &Path, token: &str) -> std::io::Result<()> {
    std::fs::write(perch_path.join(TOKEN_FILE), token)
}

/// Read a perch's capability token, if one was written.
pub fn read_token(perch_path: &Path) -> Option<String> {
    std::fs::read_to_string(perch_path.join(TOKEN_FILE))
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Caller-supplied proof, gathered from flags with env fallback.
pub struct Proof {
    pub token: Option<String>,
    pub session_id: Option<String>,
}


Node-identity persistence not built yet; D2 introduces fresh state (seeds, trust store) under spt_home. RNG pattern = reuse `Identity::generate().seed()` (no new rand dep). The real D2-code blocker is the SPAKE2 crate decision (research running). Meanwhile finish stated step 1: confirm CI green on bfacfb8.


CI status


in_progress		docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)	ci	dev-freeform	push	26907681335	7m3s	2026-06-03T19:23:03Z


CI still building (iroh ~7min). Set up D2 sub-task tracking while CI + research finish.


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


TaskCreate

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


Task #1 created successfully: D2a: TOTP module (seed gen, code, ±1 window, otpauth URI)


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.


Task #2 created successfully: D2a/b: SPAKE2 handshake + pairing-transcript binding


<task-notification>
<task-id>abc88bead244d3918</task-id>
<tool-use-id>toolu_01HTA5MFoycY5iDmjh7yoEYx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\abc88bead244d3918.output</output-file>
<status>completed</status>
<summary>Agent "Research SPAKE2 + TOTP Rust crates" completed</summary>
<result>I have everything needed. Here are my findings.

---

# Crate selection: TOTP-seeded SPAKE2 node pairing

## Recommendation table

| Concern | Crate | Version | Last release | Why | Risk |
|---|---|---|---|---|---|
| PAKE | `spake2` (RustCrypto) | **0.4.0** (stable) | 2023-07-28 | Only maintained pure-Rust SPAKE2; RustCrypto org (Arcieri + Warner); ~59k dl/mo; Ed25519Group + SHA-256/HKDF; binds both identities + both messages into final key | **No third-party audit** (self-declared). 0.4.0 pulls `curve25519-dalek = "4"` — version-compatible with your stack, but pin ≥4.1.3 (RUSTSEC-2024-0344). **No key-confirmation** — you must add it. |
| TOTP | `totp-rs` (constantoine) | **5.7.1** | 2026-03-09 | Actively maintained (#11 Authentication, ~761k dl/mo); built-in `otpauth` URI + `qr` PNG + `gen_secret`; `hmac`+`sha1` per RFC 6238; exposes `sign(time)` (raw HMAC) | Raw step counter `T` is **not** a public method — see note below. SHA-1 default is RFC-correct, not a flaw. |
| QR / provisioning | `qrcode` | **0.14.1** | 2024-07-05 | ~1.2M dl/mo; unicode terminal (`unicode::Dense1x2`) **and** PNG (via `image`); tiny, deps-light | Maintained but slow cadence. If you already take `totp-rs` with `qr`+`otpauth`, you may not need it standalone. |

### Honorable mentions / alternatives
- **PAKE alternatives**: No production-grade CPace crate exists in Rust; OPAQUE (`opaque-ke`) is heavier and solves augmented-PAKE (server stores no plaintext) — overkill for symmetric node pairing off a shared TOTP seed. `spake2` is the right pick. The newer `spake2 = 0.5.0-pre.0` (2026-01-25) bumps to `curve25519-dalek 5.0.0-pre.x` / `sha2 0.11` / `rand_core 0.10` — **do not use**: it would force a second, incompatible curve25519-dalek major into the tree alongside your ed25519-dalek v2 (which is on curve25519-dalek 4.x). Stay on `spake2 0.4.0`.
- **TOTP alternatives**: `totp-lite` is leaner (just the code generator, no URI/QR) and `totp_rfc6238` exposes low-level APIs, but `totp-rs` already covers everything in one crate. Note that **none** of them expose the raw step counter as a value — same limitation everywhere.

---

## Critical answers

**1. Does `spake2` bind identities/transcript, or do we layer our own MAC?**
Both — and you still need your own confirmation MAC.
- It **does** bind the transcript into the derived key: `key = H( H(pw) ‖ H(idA) ‖ H(idB) ‖ X ‖ Y ‖ K )` where X/Y are both parties' messages. So identities + both pubkeys + password are already in the KDF.
- It **does not** do key confirmation. `finish()` returns a raw shared-secret bytestring and nothing else; there is no confirmation-MAC exchange and no liveness check. The docs explicitly position the output as "an HMAC key for subsequent messages." **You must add an explicit confirmation round** — and that's exactly where you bind your extra context (subnet ID, seed epoch, TOTP step), which `spake2` has no knowledge of.
- Practical consequence: feed what `spake2` natively binds (roles via start_a/start_b, identities) through its `Identity`/`Password` inputs, and bind the rest (subnet ID, seed epoch, TOTP step counter) through your own HMAC-over-transcript confirmation on top of the raw shared secret. Belt-and-suspenders: also stuff subnet/epoch/step into the `Identity` strings so they're inside the PAKE KDF too, not only your outer MAC.

**2. TOTP step counter exposure (the load-bearing TOTP question).**
`totp-rs` does **not** expose `T` (= `time / step`) via any public method. It's computed inline inside `sign()`:
```rust
pub fn sign(&self, time: u64) -> Vec<u8> {
    self.algorithm.sign(self.secret.as_ref(), (time / self.step).to_be_bytes().as_ref())
}
```
`next_step(time)` returns a **timestamp** (`(time/step + 1) * step`), not the counter. So to bind the step into your SPAKE2 transcript you compute it yourself: `let t_step = time / totp.step;`. This is trivial and deterministic — just don't expect the crate to hand you `T`. (This is true of every TOTP crate surveyed; flag accepted, roll the one-line division.)

**3. RUSTSEC.**
- No advisory against `spake2` itself.
- `curve25519-dalek` has **RUSTSEC-2024-0344** (timing variability in `Scalar29/52::sub`), affecting **< 4.1.3**, fixed in **4.1.3**. Since `spake2 0.4.0` requests `"4"`, your lockfile will resolve to ≥4.1.3 by default, but pin/audit it. This is the *same* curve25519-dalek 4.x major your `ed25519-dalek v2` already uses, so there is **no version conflict** — one shared curve25519-dalek 4.x in the tree.
- `ed25519-dalek` had RUSTSEC-2022-0093 (Chalkias double-pubkey-oracle), fixed in v2 — you're already clear.

---

## API sketch — handshake + transcript-binding confirmation

```rust
// Dependencies (Cargo.toml):
//   spake2   = "0.4"          # resolves curve25519-dalek 4.x (>=4.1.3), sha2 0.10, hkdf 0.12
//   totp-rs  = { version = "5.7", features = ["otpauth", "qr", "gen_secret"] }
//   hmac     = "0.12"         # already implied by your sha2 0.10 stack
//   sha2     = "0.10"
//   subtle   = "2"            # constant-time compare for the confirmation tag

use spake2::{Ev = (), Spake2, Ed25519Group, Identity, Password};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;

// ---- 0. Provisioning side (operator scans QR once) ----
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, Secret::generate_secret().to_bytes()?,
                     Some("spt".into()), node_label)?;
let uri  = totp.get_url();          // otpauth://...   (feature = "otpauth")
let qr   = totp.get_qr_base64()?;   // PNG b64         (feature = "qr")
// or terminal render via `qrcode` crate: QrCode::new(uri)?.render::<unicode::Dense1x2>()...

// ---- 1. Derive the PAKE password from the *current* TOTP code ----
// Use the 6-digit code (or the raw sign() bytes) as the low-entropy PAKE password.
// Compute the step ourselves (totp-rs doesn't expose it):
let now    = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let t_step = now / totp.step;                  // <-- the value to bind into the transcript
let code   = totp.generate(now);               // 6-digit string
let pw      = Password::new(code.as_bytes());

// Bind structural context into the PAKE identities too (defense in depth):
let id_a = Identity::new(format!("spt:role=A;subnet={subnet};epoch={epoch};step={t_step}").as_bytes());
let id_b = Identity::new(format!("spt:role=B;subnet={subnet};epoch={epoch};step={t_step}").as_bytes());

// ---- 2. SPAKE2 message exchange (asymmetric: pin roles to dialer/listener) ----
let (state_a, msg_a) = Spake2::<Ed25519Group>::start_a(&pw, &id_a, &id_b);
// send msg_a -> B ; B runs start_b(&pw,&id_a,&id_b) symmetrically and replies msg_b
let shared: Vec<u8> = state_a.finish(&msg_b)?;  // raw shared secret. NO confirmation yet.

// ---- 3. Explicit key confirmation (we add this; spake2 does not) ----
// Confirmation MAC over roles + BOTH pubkeys + subnet + epoch + step, keyed by `shared`.
fn confirm_tag(shared: &[u8], role: &[u8], my_msg: &[u8], peer_msg: &[u8],
               subnet: &[u8], epoch: u64, step: u64) -> Vec<u8> {
    let mut m = HmacSha256::new_from_slice(shared).expect("hmac key");
    m.update(b"spt-pair-confirm-v1");
    m.update(role);
    m.update(my_msg); m.update(peer_msg);     // bind both transcripts
    m.update(subnet);
    m.update(&epoch.to_be_bytes());
    m.update(&step.to_be_bytes());            // TOTP step bound here
    m.finalize().into_bytes().to_vec()
}

let my_tag   = confirm_tag(&shared, b"A", &msg_a, &msg_b, subnet, epoch, t_step);
// send my_tag -> B ; receive peer_tag (B's, computed with role=b"B", msgs swapped)
let want_peer = confirm_tag(&shared, b"B", &msg_b, &msg_a, subnet, epoch, t_step);
if peer_tag.ct_eq(&want_peer).unwrap_u8() != 1 {  // constant-time compare
    return Err("key confirmation failed — abort pairing");
}

// ---- 4. Derive the session key from the *confirmed* shared secret ----
let session_key = Hkdf::<Sha256>::new(Some(subnet), &shared); // expand to taste
```

### Notes that matter for the build
- **±1 window**: don't bake the step into the PAKE password and then try a ±1 window — a mismatched step changes the password AND the transcript, so verification just fails. Instead try the three candidate steps (`t_step-1, t_step, t_step+1`) as separate PAKE attempts, or agree the step out-of-band in the first message. Simplest robust design: dialer sends its `t_step` in the clear; both sides bind that exact value; reject if `|t_step_local - t_step_peer| > 1`.
- **Role pinning**: use `start_a`/`start_b` (asymmetric) for dialer/listener so roles are unambiguous; reserve `start_symmetric` only if you truly can't assign roles.
- **Zeroize** `shared` and the TOTP secret after use (`totp-rs` has a `zeroize` feature; wrap `shared` in `zeroize::Zeroizing`).

Relevant upstream sources: spake2 lib.rs (`C:` n/a — remote) https://github.com/RustCrypto/PAKEs/blob/master/spake2/src/lib.rs ; RUSTSEC-2024-0344 https://rustsec.org/advisories/RUSTSEC-2024-0344.html ; totp-rs https://github.com/constantoine/totp-rs ; qrcode https://docs.rs/qrcode .</result>
<usage><subagent_tokens>42084</subagent_tokens><tool_uses>20</tool_uses><duration_ms>144893</duration_ms></usage>
</task-notification>


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.


Task #3 created successfully: D2c: Seed epoch + rotation

D2c: Seed epoch + rotation


Prompt:
<task-notification>
<task-id>abc88bead244d3918</task-id>
<tool-use-id>toolu_01HTA5MFoycY5iDmjh7yoEYx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\abc88bead244d3918.output</output-file>
<status>completed</status>
<summary>Agent "Research SPAKE2 + TOTP Rust crates" completed</summary>
<result>I have everything needed. Here are my findings.

---

# Crate selection: TOTP-seeded SPAKE2 node pairing

## Recommendation table

| Concern | Crate | Version | Last release | Why | Risk |
|---|---|---|---|---|---|
| PAKE | `spake2` (RustCrypto) | **0.4.0** (stable) | 2023-07-28 | Only maintained pure-Rust SPAKE2; RustCrypto org (Arcieri + Warner); ~59k dl/mo; Ed25519Group + SHA-256/HKDF; binds both identities + both messages into final key | **No third-party audit** (self-declared). 0.4.0 pulls `curve25519-dalek = "4"` — version-compatible with your stack, but pin ≥4.1.3 (RUSTSEC-2024-0344). **No key-confirmation** — you must add it. |
| TOTP | `totp-rs` (constantoine) | **5.7.1** | 2026-03-09 | Actively maintained (#11 Authentication, ~761k dl/mo); built-in `otpauth` URI + `qr` PNG + `gen_secret`; `hmac`+`sha1` per RFC 6238; exposes `sign(time)` (raw HMAC) | Raw step counter `T` is **not** a public method — see note below. SHA-1 default is RFC-correct, not a flaw. |
| QR / provisioning | `qrcode` | **0.14.1** | 2024-07-05 | ~1.2M dl/mo; unicode terminal (`unicode::Dense1x2`) **and** PNG (via `image`); tiny, deps-light | Maintained but slow cadence. If you already take `totp-rs` with `qr`+`otpauth`, you may not need it standalone. |

### Honorable mentions / alternatives
- **PAKE alternatives**: No production-grade CPace crate exists in Rust; OPAQUE (`opaque-ke`) is heavier and solves augmented-PAKE (server stores no plaintext) — overkill for symmetric node pairing off a shared TOTP seed. `spake2` is the right pick. The newer `spake2 = 0.5.0-pre.0` (2026-01-25) bumps to `curve25519-dalek 5.0.0-pre.x` / `sha2 0.11` / `rand_core 0.10` — **do not use**: it would force a second, incompatible curve25519-dalek major into the tree alongside your ed25519-dalek v2 (which is on curve25519-dalek 4.x). Stay on `spake2 0.4.0`.
- **TOTP alternatives**: `totp-lite` is leaner (just the code generator, no URI/QR) and `totp_rfc6238` exposes low-level APIs, but `totp-rs` already covers everything in one crate. Note that **none** of them expose the raw step counter as a value — same limitation everywhere.

---

## Critical answers

**1. Does `spake2` bind identities/transcript, or do we layer our own MAC?**
Both — and you still need your own confirmation MAC.
- It **does** bind the transcript into the derived key: `key = H( H(pw) ‖ H(idA) ‖ H(idB) ‖ X ‖ Y ‖ K )` where X/Y are both parties' messages. So identities + both pubkeys + password are already in the KDF.
- It **does not** do key confirmation. `finish()` returns a raw shared-secret bytestring and nothing else; there is no confirmation-MAC exchange and no liveness check. The docs explicitly position the output as "an HMAC key for subsequent messages." **You must add an explicit confirmation round** — and that's exactly where you bind your extra context (subnet ID, seed epoch, TOTP step), which `spake2` has no knowledge of.
- Practical consequence: feed what `spake2` natively binds (roles via start_a/start_b, identities) through its `Identity`/`Password` inputs, and bind the rest (subnet ID, seed epoch, TOTP step counter) through your own HMAC-over-transcript confirmation on top of the raw shared secret. Belt-and-suspenders: also stuff subnet/epoch/step into the `Identity` strings so they're inside the PAKE KDF too, not only your outer MAC.

**2. TOTP step counter exposure (the load-bearing TOTP question).**
`totp-rs` does **not** expose `T` (= `time / step`) via any public method. It's computed inline inside `sign()`:
```rust
pub fn sign(&self, time: u64) -> Vec<u8> {
    self.algorithm.sign(self.secret.as_ref(), (time / self.step).to_be_bytes().as_ref())
}
```
`next_step(time)` returns a **timestamp** (`(time/step + 1) * step`), not the counter. So to bind the step into your SPAKE2 transcript you compute it yourself: `let t_step = time / totp.step;`. This is trivial and deterministic — just don't expect the crate to hand you `T`. (This is true of every TOTP crate surveyed; flag accepted, roll the one-line division.)

**3. RUSTSEC.**
- No advisory against `spake2` itself.
- `curve25519-dalek` has **RUSTSEC-2024-0344** (timing variability in `Scalar29/52::sub`), affecting **< 4.1.3**, fixed in **4.1.3**. Since `spake2 0.4.0` requests `"4"`, your lockfile will resolve to ≥4.1.3 by default, but pin/audit it. This is the *same* curve25519-dalek 4.x major your `ed25519-dalek v2` already uses, so there is **no version conflict** — one shared curve25519-dalek 4.x in the tree.
- `ed25519-dalek` had RUSTSEC-2022-0093 (Chalkias double-pubkey-oracle), fixed in v2 — you're already clear.

---

## API sketch — handshake + transcript-binding confirmation

```rust
// Dependencies (Cargo.toml):
//   spake2   = "0.4"          # resolves curve25519-dalek 4.x (>=4.1.3), sha2 0.10, hkdf 0.12
//   totp-rs  = { version = "5.7", features = ["otpauth", "qr", "gen_secret"] }
//   hmac     = "0.12"         # already implied by your sha2 0.10 stack
//   sha2     = "0.10"
//   subtle   = "2"            # constant-time compare for the confirmation tag

use spake2::{Ev = (), Spake2, Ed25519Group, Identity, Password};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;

// ---- 0. Provisioning side (operator scans QR once) ----
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, Secret::generate_secret().to_bytes()?,
                     Some("spt".into()), node_label)?;
let uri  = totp.get_url();          // otpauth://...   (feature = "otpauth")
let qr   = totp.get_qr_base64()?;   // PNG b64         (feature = "qr")
// or terminal render via `qrcode` crate: QrCode::new(uri)?.render::<unicode::Dense1x2>()...

// ---- 1. Derive the PAKE password from the *current* TOTP code ----
// Use the 6-digit code (or the raw sign() bytes) as the low-entropy PAKE password.
// Compute the step ourselves (totp-rs doesn't expose it):
let now    = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let t_step = now / totp.step;                  // <-- the value to bind into the transcript
let code   = totp.generate(now);               // 6-digit string
let pw      = Password::new(code.as_bytes());

// Bind structural context into the PAKE identities too (defense in depth):
let id_a = Identity::new(format!("spt:role=A;subnet={subnet};epoch={epoch};step={t_step}").as_bytes());
let id_b = Identity::new(format!("spt:role=B;subnet={subnet};epoch={epoch};step={t_step}").as_bytes());

// ---- 2. SPAKE2 message exchange (asymmetric: pin roles to dialer/listener) ----
let (state_a, msg_a) = Spake2::<Ed25519Group>::start_a(&pw, &id_a, &id_b);
// send msg_a -> B ; B runs start_b(&pw,&id_a,&id_b) symmetrically and replies msg_b
let shared: Vec<u8> = state_a.finish(&msg_b)?;  // raw shared secret. NO confirmation yet.

// ---- 3. Explicit key confirmation (we add this; spake2 does not) ----
// Confirmation MAC over roles + BOTH pubkeys + subnet + epoch + step, keyed by `shared`.
fn confirm_tag(shared: &[u8], role: &[u8], my_msg: &[u8], peer_msg: &[u8],
               subnet: &[u8], epoch: u64, step: u64) -> Vec<u8> {
    let mut m = HmacSha256::new_from_slice(shared).expect("hmac key");
    m.update(b"spt-pair-confirm-v1");
    m.update(role);
    m.update(my_msg); m.update(peer_msg);     // bind both transcripts
    m.update(subnet);
    m.update(&epoch.to_be_bytes());
    m.update(&step.to_be_bytes());            // TOTP step bound here
    m.finalize().into_bytes().to_vec()
}

let my_tag   = confirm_tag(&shared, b"A", &msg_a, &msg_b, subnet, epoch, t_step);
// send my_tag -> B ; receive peer_tag (B's, computed with role=b"B", msgs swapped)
let want_peer = confirm_tag(&shared, b"B", &msg_b, &msg_a, subnet, epoch, t_step);
if peer_tag.ct_eq(&want_peer).unwrap_u8() != 1 {  // constant-time compare
    return Err("key confirmation failed — abort pairing");
}

// ---- 4. Derive the session key from the *confirmed* shared secret ----
let session_key = Hkdf::<Sha256>::new(Some(subnet), &shared); // expand to taste
```

### Notes that matter for the build
- **±1 window**: don't bake the step into the PAKE password and then try a ±1 window — a mismatched step changes the password AND the transcript, so verification just fails. Instead try the three candidate steps (`t_step-1, t_step, t_step+1`) as separate PAKE attempts, or agree the step out-of-band in the first message. Simplest robust design: dialer sends its `t_step` in the clear; both sides bind that exact value; reject if `|t_step_local - t_step_peer| > 1`.
- **Role pinning**: use `start_a`/`start_b` (asymmetric) for dialer/listener so roles are unambiguous; reserve `start_symmetric` only if you truly can't assign roles.
- **Zeroize** `shared` and the TOTP secret after use (`totp-rs` has a `zeroize` feature; wrap `shared` in `zeroize::Zeroizing`).

Relevant upstream sources: spake2 lib.rs (`C:` n/a — remote) https://github.com/RustCrypto/PAKEs/blob/master/spake2/src/lib.rs ; RUSTSEC-2024-0344 https://rustsec.org/advisories/RUSTSEC-2024-0344.html ; totp-rs https://github.com/constantoine/totp-rs ; qrcode https://docs.rs/qrcode .</result>
<usage><subagent_tokens>42084</subagent_tokens><tool_uses>20</tool_uses><duration_ms>144893</duration_ms></usage>
</task-notification>


Research done. Decisions locked:
- **SPAKE2**: `spake2 0.4` (curve25519-dalek 4.x — shares ed25519-dalek v2's, no conflict; pin ≥4.1.3). No built-in key-confirm → I add HMAC-SHA256 confirmation MAC binding subnet/epoch/step = the D2b transcript.
- **TOTP**: roll-own RFC 6238 (sha1+hmac+base32) not totp-rs — workspace's minimal-dep ethos, and totp-rs hides the step counter I must bind anyway. Native step exposure, testable vs RFC vectors.
- Confirm MAC: hmac+sha2(have)+subtle ct_eq. QR deferred to CLI/D9.

Verify curve25519-dalek already resolves to ≥4.1.3 in the tree (iroh + ed25519-dalek), no major conflict.


Bash

grep -A2 'name = "curve25519-dalek"' Cargo.lock; echo "---ed25519---"; grep -A2 'name = "ed25519-dalek"' Cargo.lock; echo "---sha1 present?---"; grep -c 'name = "sha1"' Cargo.lock; echo "---CI---"; gh run list --limit 1 2>&1 | head -2

Check curve25519-dalek version in lock + CI


name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
--
name = "curve25519-dalek"
version = "5.0.0-pre.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
---ed25519---
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
--
name = "ed25519-dalek"
version = "3.0.0-pre.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
---sha1 present?---
0
---CI---
in_progress		docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)	ci	dev-freeform	push	26907681335	7m59s	2026-06-03T19:23:03Z


Tree already carries curve25519-dalek 4.1.3 (alongside iroh's 5.0-pre) — `spake2 0.4` slots into the existing 4.1.3, zero new major. Crate plan clean. CI still grinding on iroh build (independent of my work).

Start D2a: roll-own RFC 6238 TOTP. Mark in_progress, add net-gated deps (sha1/hmac/data-encoding), write module.


Updated task #1 status


Error: File does not exist. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


1	//! spt-core P2P networking — the cross-machine transport (M4).
2	//!
3	//! `spt-net` is the remaining sibling in the acyclic stack
4	//! (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`): the
5	//! WAN/LAN transport every cross-machine capability rides. It owns the Iroh
6	//! endpoint (WAN, with the n0-relay default + a self-host knob — ADR-0002), mDNS
7	//! LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent
8	//! per-subnet registry (ADR-0006), the subnet notification spool (ADR-0007),
9	//! cross-node Psyche sync (ADR-0003 + KNOWN-HAZARDS 6.5), and the update
10	//! peer-propagation transport plugged into the M3c update-engine seam (ADR-0004 §D).
11	//!
12	//! ## Why baked into core (ADR-0002)
13	//!
14	//! The sister project put networking in a separate `spt-node` daemon to decouple it
15	//! from a Claude-Code-tied binary. spt-core is already harness-independent, so that
16	//! reason is gone and WAN messaging becomes a **first-class, zero-config v1
17	//! capability**. The whole stack sits behind the default-on [`net`](#features)
18	//! feature: on in the reference `spt` binary, optional for embedded library
19	//! consumers, so a `--no-default-features` lib build stays Iroh-free.
20	//!
21	//! ## Not public SDK
22	//!
23	//! `spt-net` is internal broker machinery — the M3b/M4 `spt-daemon` programs against
24	//! it; external harness authors do not (R-ARCH-2 keeps the public surface at
25	//! `spt-proto`/`spt-store`/`spt-msg`/`spt-runtime`). The broker (not this crate)
26	//! *owns* the live Iroh endpoint + QUIC streams so they survive a brain restart
27	//! (ADR-0004 §B); `spt-net` provides the mechanism, the daemon owns the process.
28	//!
29	//! ## Build-out (see `M4-PLAN.md` task table)
30	//!
31	//!   D0  scaffold + `net` feature flag                         ← this commit
32	//!   D1  Iroh endpoint + n0 relay/self-host + mDNS discovery    (REQ-NET-1, REQ-NET-2)
33	//!   D2  TOTP-SPAKE2 pairing + transcript/seed-epoch/rate-limit (REQ-PAIR-1..6)
34	//!   D3  per-subnet registry + epoch-lease resolution           (REQ-INST-7,9..13)
35	//!   D4  broker-owned QUIC endpoint (ADR-0004 §B)               (REQ-EP-4 seam)
36	//!   D5  cross-node message + remote-drive + endpoint ACL       (REQ-INST-8, REQ-REACH-1, REQ-SEC-1)
37	//!   D6  cross-node Psyche sync (P2P), node-id precedence       (REQ-NET-3, REQ-INST-2/5)
38	//!   D7  update peer-propagation into the M3c seam              (REQ-UPD-1)
39	//!   D8  subnet notification primitive + `spt notify`           (REQ-NOTIF-1, REQ-NOTIF-2)
40	//
41	// The transport itself is clean-room (the sister had no P2P); only stable
42	// wire/registry *formats* that survive are copy-verbatim per ADR-0001. Reuses the
43	// Spike #2/#3 Iroh learnings (iroh 0.98 API; the loopback QUIC-survival shape).
44
45	// ── net-gated transport tree (empty at D0; populated D1+) ────────────────────────
46	#[cfg(feature = "net")]
47	pub mod net;
48

//! spt-core P2P networking — the cross-machine transport (M4).
//!
//! `spt-net` is the remaining sibling in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`): the
//! WAN/LAN transport every cross-machine capability rides. It owns the Iroh
//! endpoint (WAN, with the n0-relay default + a self-host knob — ADR-0002), mDNS
//! LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent
//! per-subnet registry (ADR-0006), the subnet notification spool (ADR-0007),
//! cross-node Psyche sync (ADR-0003 + KNOWN-HAZARDS 6.5), and the update
//! peer-propagation transport plugged into the M3c update-engine seam (ADR-0004 §D).
//!
//! ## Why baked into core (ADR-0002)
//!
//! The sister project put networking in a separate `spt-node` daemon to decouple it
//! from a Claude-Code-tied binary. spt-core is already harness-independent, so that
//! reason is gone and WAN messaging becomes a **first-class, zero-config v1
//! capability**. The whole stack sits behind the default-on [`net`](#features)
//! feature: on in the reference `spt` binary, optional for embedded library
//! consumers, so a `--no-default-features` lib build stays Iroh-free.
//!
//! ## Not public SDK
//!
//! `spt-net` is internal broker machinery — the M3b/M4 `spt-daemon` programs against
//! it; external harness authors do not (R-ARCH-2 keeps the public surface at
//! `spt-proto`/`spt-store`/`spt-msg`/`spt-runtime`). The broker (not this crate)
//! *owns* the live Iroh endpoint + QUIC streams so they survive a brain restart
//! (ADR-0004 §B); `spt-net` provides the mechanism, the daemon owns the process.
//!
//! ## Build-out (see `M4-PLAN.md` task table)
//!
//!   D0  scaffold + `net` feature flag                         ← this commit
//!   D1  Iroh endpoint + n0 relay/self-host + mDNS discovery    (REQ-NET-1, REQ-NET-2)
//!   D2  TOTP-SPAKE2 pairing + transcript/seed-epoch/rate-limit (REQ-PAIR-1..6)
//!   D3  per-subnet registry + epoch-lease resolution           (REQ-INST-7,9..13)
//!   D4  broker-owned QUIC endpoint (ADR-0004 §B)               (REQ-EP-4 seam)
//!   D5  cross-node message + remote-drive + endpoint ACL       (REQ-INST-8, REQ-REACH-1, REQ-SEC-1)
//!   D6  cross-node Psyche sync (P2P), node-id precedence       (REQ-NET-3, REQ-INST-2/5)
//!   D7  update peer-propagation into the M3c seam              (REQ-UPD-1)
//!   D8  subnet notification primitive + `spt notify`           (REQ-NOTIF-1, REQ-NOTIF-2)
//
// The transport itself is clean-room (the sister had no P2P); only stable
// wire/registry *formats* that survive are copy-verbatim per ADR-0001. Reuses the
// Spike #2/#3 Iroh learnings (iroh 0.98 API; the loopback QUIC-survival shape).

// ── net-gated transport tree (empty at D0; populated D1+) ────────────────────────
#[cfg(feature = "net")]
pub mod net;


Now write D2a. Update net.rs to add pairing module, create pairing.rs + pairing/totp.rs, add deps.


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

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod endpoint;


Write

//! 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;


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

//! 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;


//! RFC 6238 TOTP — the pairing ceremony's low-entropy shared secret (ADR-0005, D2a).
//!
//! The [`TotpSeed`] is the durable per-subnet secret (`CONTEXT.md` "TOTP-seeded
//! SPAKE2 pairing"): generated on the first node, replicated to every trusted node,
//! and provisioned into the user's authenticator app via an [`otpauth://`](TotpSeed::otpauth_uri)
//! URI. The rotating 6-digit [`code_at`](TotpSeed::code_at) becomes the SPAKE2
//! password — never a bearer token over the wire.
//!
//! ## Why roll RFC 6238 here instead of a TOTP crate
//!
//! The pairing transcript MUST bind the **raw time-step counter** `T` (= unix/period)
//! so a stale-step pairing is rejectable (ADR-0005 #12). Every surveyed TOTP crate
//! (`totp-rs`, `totp-lite`, …) hides `T` behind its code-string API — we'd recompute
//! the division anyway. Rolling the ~RFC algorithm on the workspace's existing
//! HMAC/SHA primitives keeps the step counter first-class, avoids a heavyweight
//! dependency (URL/QR/image), and matches the crate's dependency-minimal convention.
//! Verified against the RFC 6238 Appendix-B SHA-1 test vectors (see tests).
//!
//! ## ±1 window (ADR-0005 #11 interaction)
//!
//! Clock skew between the two pairing machines is absorbed by accepting the adjacent
//! steps — but the step is **bound into the transcript**, not folded into the
//! password (a changed step would change the password *and* the transcript, so a
//! windowed retry can't work password-side). The dialer announces its step in the
//! clear; both sides bind that exact value and reject `|Δstep| > 1`
//! ([`accepts_step`]). The ±1 widening of the valid-password space is the reason the
//! ceremony also needs subnet-global rate limiting (D2d, #11).
//
// [impl->REQ-PAIR-1]

use hmac::{Hmac, Mac};
use sha1::Sha1;
use spt_proto::identity::Identity;

type HmacSha1 = Hmac<Sha1>;

/// TOTP period in seconds (RFC 6238 default `X`). 30s is the authenticator-app norm.
pub const PERIOD_SECS: u64 = 30;

/// Digits in a pairing code (RFC 6238 default). 6 is the authenticator-app norm; the
/// PAKE — not the digit count — supplies the cryptographic strength (ADR-0005).
pub const DIGITS: u32 = 6;

/// TOTP seed length in bytes. 160 bits = the RFC 4226/6238 recommended HMAC-SHA-1
/// key size and what authenticator apps expect from a base32 `secret`.
pub const SEED_LEN: usize = 20;

/// A subnet's durable TOTP seed — the shared secret behind the rotating code.
///
/// Held by every trusted node in the subnet and (via [`otpauth_uri`](Self::otpauth_uri))
/// by the user's authenticator app. Stored near the node's trust material, never in
/// the synced context repo. The [seed epoch](super) (D2c) lives alongside it; this
/// type is the raw key material only.
#[derive(Clone, PartialEq, Eq)]
pub struct TotpSeed([u8; SEED_LEN]);

impl TotpSeed {
    /// Mint a fresh random seed. Reuses spt-proto's Ed25519 seed generator (OsRng)
    /// as the CSPRNG source — the same no-extra-`rand`-dependency trick the local-api
    /// token mint uses — taking the first [`SEED_LEN`] of its 32 random bytes.
    pub fn generate() -> TotpSeed {
        let mut bytes = [0u8; SEED_LEN];
        bytes.copy_from_slice(&Identity::generate().seed()[..SEED_LEN]);
        TotpSeed(bytes)
    }

    /// Wrap raw seed bytes (e.g. read back from durable storage).
    pub fn from_bytes(bytes: [u8; SEED_LEN]) -> TotpSeed {
        TotpSeed(bytes)
    }

    /// The raw seed bytes — for persistence and as the HMAC key.
    pub fn as_bytes(&self) -> &[u8; SEED_LEN] {
        &self.0
    }

    /// Encode the seed as the unpadded uppercase base32 an `otpauth://` `secret`
    /// and authenticator apps expect (RFC 4648 / 3548).
    pub fn to_base32(&self) -> String {
        data_encoding::BASE32_NOPAD.encode(&self.0)
    }

    /// Decode a base32 `secret` back into a seed. Tolerates padding and lowercase
    /// (authenticator-app exports vary). `None` if it isn't valid base32 of the
    /// right length.
    pub fn from_base32(s: &str) -> Option<TotpSeed> {
        let cleaned: String = s.chars().filter(|c| *c != '=').flat_map(char::to_uppercase).collect();
        let raw = data_encoding::BASE32_NOPAD.decode(cleaned.as_bytes()).ok()?;
        let bytes: [u8; SEED_LEN] = raw.try_into().ok()?;
        Some(TotpSeed(bytes))
    }

    /// The 6-digit code at a given absolute time-step `T`. Pure RFC 4226 HOTP with the
    /// step as the counter (RFC 6238 §4): HMAC-SHA-1 over the big-endian step, dynamic
    /// truncation, modulo `10^digits`. The [step](time_step) is the value the
    /// transcript binds.
    pub fn code_at_step(&self, step: u64, digits: u32) -> u32 {
        let mut mac = HmacSha1::new_from_slice(&self.0).expect("HMAC accepts any key length");
        mac.update(&step.to_be_bytes());
        let hash = mac.finalize().into_bytes();
        // Dynamic truncation (RFC 4226 §5.3): low nibble of the last byte selects a
        // 4-byte window; mask the high bit; reduce to the requested digit count.
        let offset = (hash[hash.len() - 1] & 0x0f) as usize;
        let bin = u32::from_be_bytes([hash[offset], hash[offset + 1], hash[offset + 2], hash[offset + 3]])
            & 0x7fff_ffff;
        bin % 10u32.pow(digits)
    }

    /// The current [`DIGITS`]-digit code for `unix_secs`, zero-padded to a fixed-width
    /// string — the form the user reads off their phone and the SPAKE2 password.
    pub fn code_at(&self, unix_secs: u64) -> String {
        let n = self.code_at_step(time_step(unix_secs), DIGITS);
        format!("{n:0width$}", width = DIGITS as usize)
    }

    /// An `otpauth://totp/...` provisioning URI binding this seed to a subnet label,
    /// for QR / authenticator-app import (ADR-0005, `CONTEXT.md` fetch-code-from-any-node).
    /// `subnet_name` is the human label shown in the app; `issuer` is the product tag.
    pub fn otpauth_uri(&self, issuer: &str, subnet_name: &str) -> String {
        let label = percent_encode(&format!("{issuer}:{subnet_name}"));
        let iss = percent_encode(issuer);
        let secret = self.to_base32();
        format!(
            "otpauth://totp/{label}?secret={secret}&issuer={iss}&algorithm=SHA1&digits={DIGITS}&period={PERIOD_SECS}"
        )
    }
}

/// Hide the raw key in debug output — a seed is subnet-joining secret material.
impl std::fmt::Debug for TotpSeed {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("TotpSeed").field(&"<redacted>").finish()
    }
}

/// The absolute TOTP time-step `T` for a unix timestamp: `floor(unix / period)`.
/// This is the counter bound into the pairing transcript (ADR-0005 #12), exposed
/// first-class because the ceremony needs it, not just the rendered code.
pub fn time_step(unix_secs: u64) -> u64 {
    unix_secs / PERIOD_SECS
}

/// The ±1 acceptance window around a step: `[step-1, step, step+1]` (saturating at 0).
/// A pairing peer's announced step must be one of these relative to the local step.
pub fn window_steps(step: u64) -> [u64; 3] {
    [step.saturating_sub(1), step, step + 1]
}

/// Whether a peer-announced `peer_step` is within the ±1 window of `local_step`
/// (ADR-0005 #11): the skew tolerance, gated by rate-limiting (D2d) because it
/// triples the valid-password space.
pub fn accepts_step(local_step: u64, peer_step: u64) -> bool {
    local_step.abs_diff(peer_step) <= 1
}

/// Minimal RFC 3986 percent-encoding for the `otpauth://` label/issuer (the only
/// values we emit). Encodes everything outside the unreserved set so a subnet name
/// with spaces or punctuation produces a valid URI without pulling a URL crate.
fn percent_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

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

    /// The RFC 6238 Appendix-B SHA-1 reference seed: ASCII "12345678901234567890".
    fn rfc_seed() -> TotpSeed {
        let bytes: [u8; SEED_LEN] = *b"12345678901234567890";
        TotpSeed::from_bytes(bytes)
    }

    // [unit->REQ-PAIR-1] the code generator matches the RFC 6238 Appendix-B SHA-1
    // test vectors exactly (8-digit form, to pin the full truncation), proving the
    // HOTP-over-step algorithm is correct — the property the whole PAKE password
    // rests on.
    #[test]
    fn matches_rfc6238_appendix_b_vectors() {
        let seed = rfc_seed();
        // (unix time, expected 8-digit TOTP) from RFC 6238 Appendix B (SHA-1 column).
        let vectors = [
            (59u64, 94287082u32),
            (1111111109, 7081804),
            (1111111111, 14050471),
            (1234567890, 89005924),
            (2000000000, 69279037),
            (20000000000, 65353130),
        ];
        for (t, want) in vectors {
            let got = seed.code_at_step(time_step(t), 8);
            assert_eq!(got, want, "RFC 6238 vector at t={t}");
        }
    }

    // [unit->REQ-PAIR-1] the user-facing code is the 6-digit zero-padded suffix of
    // the truncation, derived from the same step — what gets typed as the PAKE
    // password. (6-digit = full 8-digit value mod 1e6.)
    #[test]
    fn code_at_is_six_zero_padded_digits() {
        let seed = rfc_seed();
        // t=1111111109 → 8-digit 07081804 → 6-digit 081804.
        let code = seed.code_at(1111111109);
        assert_eq!(code, "081804");
        assert_eq!(code.len(), DIGITS as usize, "fixed-width, zero-padded");
        // Numeric equivalence with the 6-digit truncation path.
        assert_eq!(seed.code_at_step(time_step(1111111109), DIGITS), 81804);
    }

    // [unit->REQ-PAIR-1] the time-step is floor(unix/period): the counter the
    // transcript binds. Two times in the same 30s window share a step; the boundary
    // advances it.
    #[test]
    fn time_step_is_floor_unix_over_period() {
        assert_eq!(time_step(0), 0);
        assert_eq!(time_step(29), 0);
        assert_eq!(time_step(30), 1);
        assert_eq!(time_step(59), 1);
        assert_eq!(time_step(60), 2);
    }

    // [unit->REQ-PAIR-1] the ±1 window accepts exactly the adjacent steps and
    // rejects a 2-step skew — the clock-tolerance the dialer/listener agree on,
    // and the widening that mandates rate-limiting (#11).
    #[test]
    fn window_accepts_plus_minus_one_only() {
        assert_eq!(window_steps(10), [9, 10, 11]);
        assert_eq!(window_steps(0), [0, 0, 1], "saturates at zero");
        assert!(accepts_step(10, 10));
        assert!(accepts_step(10, 9));
        assert!(accepts_step(10, 11));
        assert!(!accepts_step(10, 12), "two steps out is rejected");
        assert!(!accepts_step(10, 8));
    }

    // [unit->REQ-PAIR-1] a generated seed round-trips through base32 (the otpauth
    // `secret` form) and tolerates padded/lowercase authenticator-app exports.
    #[test]
    fn seed_round_trips_through_base32() {
        let seed = TotpSeed::generate();
        let b32 = seed.to_base32();
        assert_eq!(TotpSeed::from_base32(&b32), Some(seed.clone()));
        // Padded + lowercased variant (as some apps export) still decodes.
        let lower_padded = format!("{}====", b32.to_lowercase());
        assert_eq!(TotpSeed::from_base32(&lower_padded), Some(seed));
        // Junk is rejected, not panicked.
        assert_eq!(TotpSeed::from_base32("not-base32!"), None);
    }

    // [unit->REQ-PAIR-1] two freshly generated seeds differ — the CSPRNG source is
    // actually random, not a fixed/zero seed.
    #[test]
    fn generated_seeds_are_distinct() {
        assert_ne!(TotpSeed::generate(), TotpSeed::generate());
    }

    // [unit->REQ-PAIR-1] the otpauth URI carries the secret, algorithm, digits and
    // period, and percent-encodes a subnet label with spaces — directly importable
    // by an authenticator app.
    #[test]
    fn otpauth_uri_is_well_formed_and_encoded() {
        let seed = rfc_seed();
        let uri = seed.otpauth_uri("spt", "home fleet");
        assert!(uri.starts_with("otpauth://totp/spt:home%20fleet?"), "label percent-encoded: {uri}");
        assert!(uri.contains(&format!("secret={}", seed.to_base32())));
        assert!(uri.contains("algorithm=SHA1"));
        assert!(uri.contains(&format!("digits={DIGITS}")));
        assert!(uri.contains(&format!("period={PERIOD_SECS}")));
        assert!(uri.contains("issuer=spt"));
    }

    // [unit->REQ-PAIR-1] a seed never leaks its key material through Debug — pairing
    // secrets must not land in logs.
    #[test]
    fn debug_redacts_seed() {
        let dbg = format!("{:?}", rfc_seed());
        assert!(dbg.contains("redacted"), "must not print key bytes: {dbg}");
        assert!(!dbg.contains("12345"), "raw seed must not appear: {dbg}");
    }
}