Skip to main content

wire/
pair_session.rs

1//! Staged pair session for MCP.
2//!
3//! Splits the magic-wormhole pair flow (`cli::pair_orchestrate`) into discrete
4//! stages so an MCP server can drive it across multiple JSON-RPC tool calls.
5//!
6//! State machine:
7//!
8//! ```text
9//!     pair_session_open
10//!            │
11//!            ▼
12//!    ┌─Opened ─────────┐
13//!    │  (peer not in)  │  pair_session_try_sas (idempotent poll)
14//!    └─────────────────┘
15//!            │ peer's SPAKE2 msg arrives
16//!            ▼
17//!     SasReady (sas + aead_key cached)
18//!            │ user types digits → pair_session_confirm_sas
19//!            ▼
20//!     Confirmed
21//!            │ pair_session_finalize
22//!            ▼
23//!     Finalized (peer pinned, relay coords saved)
24//!
25//!  Aborted = SAS mismatch / TTL expired / peer aborted (terminal, removed from store)
26//! ```
27//!
28//! Concurrency: each session is keyed by its relay `pair_id` (unique per
29//! pairing). Sessions are independent — pairing with N peers concurrently
30//! creates N sessions in the store, each with its own pair_id at the relay
31//! and its own `Mutex<PairSessionState>`. No cross-session locking.
32
33use std::collections::HashMap;
34use std::sync::{Arc, Mutex, OnceLock};
35use std::time::{Duration, Instant};
36
37use anyhow::{Result, anyhow, bail};
38use serde_json::{Value, json};
39use sha2::{Digest, Sha256};
40
41use crate::sas::{
42    PakeSide, compute_sas_pake, derive_aead_key, generate_code_phrase, open_bootstrap,
43    parse_code_phrase, seal_bootstrap,
44};
45
46/// Session evicted after this much wall time. 10min covers human-pace OOB
47/// code-phrase sharing (voice/text) plus AEAD bootstrap exchange comfortably,
48/// matches the relay pair-slot TTL ceiling, and forces a fresh `pair_initiate`
49/// if a session has been abandoned.
50pub const SESSION_TTL: Duration = Duration::from_secs(600);
51
52/// One in-flight pair session held in the MCP server process.
53///
54/// Public fields are read-only contracts; mutate only via the staged
55/// `pair_session_*` functions below so invariants stay coherent.
56pub struct PairSessionState {
57    pub role: String, // "host" or "guest"
58    pub relay_url: String,
59    pub pair_id: String,   // == public session_id
60    pub code: String,      // human-readable code phrase
61    pub code_hash: String, // hex of SHA-256(b"wire/v1 code-phrase" || code)
62    pub pake: PakeSide,    // SPAKE2 side; .finish() consumes inner state
63    pub our_slot_id: String,
64    pub our_slot_token: String,
65    pub spake_key: Option<[u8; 32]>,
66    pub aead_key: Option<[u8; 32]>,
67    /// Raw 6-digit SAS, no dash. Display as "{first 3}-{last 3}".
68    pub sas: Option<String>,
69    pub sas_confirmed: bool,
70    pub bootstrap_sealed_sent: bool,
71    pub finalized: bool,
72    pub aborted: Option<String>,
73    pub created_at: Instant,
74    /// Seed used to construct `pake` deterministically. Persisted by the
75    /// daemon's pending-pair file so a daemon restart can reconstruct the
76    /// same SPAKE2 state and continue the handshake instead of aborting.
77    /// SECURITY: keep in user-only-readable files.
78    pub spake2_seed: [u8; 32],
79}
80
81impl PairSessionState {
82    pub fn session_id(&self) -> &str {
83        &self.pair_id
84    }
85    pub fn formatted_sas(&self) -> Option<String> {
86        self.sas
87            .as_ref()
88            .map(|d| format!("{}-{}", &d[..3], &d[3..]))
89    }
90}
91
92// ---------- module-private store ----------
93
94type Store = Mutex<HashMap<String, Arc<Mutex<PairSessionState>>>>;
95static STORE: OnceLock<Store> = OnceLock::new();
96
97fn store() -> &'static Store {
98    STORE.get_or_init(|| Mutex::new(HashMap::new()))
99}
100
101/// Insert a fresh session, returning the public session_id.
102/// SHA-256 hex of the domain-tagged code phrase. Both the host and guest
103/// derive the same value from the shared code, and the relay uses it as the
104/// pair-slot lookup key.
105pub fn derive_code_hash(code: &str) -> String {
106    let mut h = Sha256::new();
107    h.update(b"wire/v1 code-phrase");
108    h.update(code.as_bytes());
109    hex::encode(h.finalize())
110}
111
112pub fn store_insert(s: PairSessionState) -> String {
113    let id = s.pair_id.clone();
114    let arc = Arc::new(Mutex::new(s));
115    store().lock().unwrap().insert(id.clone(), arc);
116    id
117}
118
119/// Look up a session by id. Returns None if missing or evicted.
120pub fn store_get(session_id: &str) -> Option<Arc<Mutex<PairSessionState>>> {
121    store().lock().unwrap().get(session_id).cloned()
122}
123
124/// Remove a session. Used after finalize, abort, or TTL eviction.
125pub fn store_remove(session_id: &str) {
126    store().lock().unwrap().remove(session_id);
127}
128
129/// Sweep expired sessions. Called opportunistically before each public op.
130pub fn store_sweep_expired() {
131    let mut g = store().lock().unwrap();
132    g.retain(|_, arc| {
133        // Try lock; if a session is mid-op, leave it. Eviction will retry next sweep.
134        match arc.try_lock() {
135            Ok(s) => s.created_at.elapsed() < SESSION_TTL,
136            Err(_) => true,
137        }
138    });
139}
140
141#[cfg(test)]
142pub fn store_clear_for_test() {
143    store().lock().unwrap().clear();
144}
145
146// ---------- staged operations ----------
147
148/// **Stage 1.** Open a pair session at the relay.
149///
150/// Side effects:
151///   - Allocates a relay slot for self if not already bound (matches
152///     existing `cli::cmd_pair_*` first-run behavior).
153///   - Generates code phrase (host) or accepts the typed one (guest).
154///   - Posts our SPAKE2 message to `/v1/pair`.
155///
156/// Returns a `PairSessionState` you should feed to `store_insert` and then
157/// drive forward via `pair_session_try_sas` and `pair_session_finalize`.
158pub fn pair_session_open(
159    role: &str,
160    relay_url: &str,
161    code_in: Option<&str>,
162) -> Result<PairSessionState> {
163    if !crate::config::is_initialized()? {
164        bail!("not initialized — operator must run `wire init <handle>` first");
165    }
166    if role != "host" && role != "guest" {
167        bail!("role must be 'host' or 'guest' (got {role:?})");
168    }
169
170    // Auto-bind relay slot if we don't have one for this URL.
171    let mut relay_state = crate::config::read_relay_state()?;
172    let need_alloc = relay_state["self"].is_null()
173        || relay_state["self"]["relay_url"].as_str() != Some(relay_url);
174
175    let card = crate::config::read_agent_card()?;
176    let did = card
177        .get("did")
178        .and_then(Value::as_str)
179        .unwrap_or("")
180        .to_string();
181    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
182
183    if need_alloc {
184        let client = crate::relay_client::RelayClient::new(relay_url);
185        client.check_healthz()?;
186        let alloc = client.allocate_slot(Some(&handle))?;
187        relay_state["self"] = json!({
188            "relay_url": relay_url,
189            "slot_id": alloc.slot_id,
190            "slot_token": alloc.slot_token,
191        });
192        crate::config::write_relay_state(&relay_state)?;
193    }
194    let our_slot_id = relay_state["self"]["slot_id"]
195        .as_str()
196        .ok_or_else(|| anyhow!("relay-state self.slot_id missing"))?
197        .to_string();
198    let our_slot_token = relay_state["self"]["slot_token"]
199        .as_str()
200        .ok_or_else(|| anyhow!("relay-state self.slot_token missing"))?
201        .to_string();
202
203    let code = match code_in {
204        Some(c) => parse_code_phrase(c)?.to_string(),
205        None => generate_code_phrase(),
206    };
207
208    let code_hash = derive_code_hash(&code);
209
210    // Generate a fresh SPAKE2 seed; PakeSide derives deterministically from it.
211    // Persisting this seed lets the daemon reconstruct the handshake after a
212    // crash/restart instead of marking pending pairs aborted_restart.
213    let mut spake2_seed = [0u8; 32];
214    use rand::RngCore;
215    rand::rngs::OsRng.fill_bytes(&mut spake2_seed);
216    let pake = PakeSide::from_seed(&code, code_hash.as_bytes(), spake2_seed);
217    let our_msg_b64 = crate::signing::b64encode(&pake.msg_out);
218
219    let client = crate::relay_client::RelayClient::new(relay_url);
220    let pair_id = client.pair_open(&code_hash, &our_msg_b64, role)?;
221
222    Ok(PairSessionState {
223        role: role.to_string(),
224        relay_url: relay_url.to_string(),
225        pair_id,
226        code,
227        code_hash,
228        pake,
229        our_slot_id,
230        our_slot_token,
231        spake_key: None,
232        aead_key: None,
233        sas: None,
234        sas_confirmed: false,
235        bootstrap_sealed_sent: false,
236        finalized: false,
237        aborted: None,
238        created_at: Instant::now(),
239        spake2_seed,
240    })
241}
242
243/// Reconstruct a `PairSessionState` from persisted fields without any network
244/// I/O. The caller (typically the daemon's `cleanup_on_startup`) has already
245/// recovered `seed`, `code`, `code_hash`, `pair_id`, slot info, and `role` from
246/// the on-disk pending-pair file; this builds an in-memory state that can
247/// resume `pair_session_try_sas` from where the prior daemon left off.
248///
249/// Idempotent w.r.t. the relay — does not call pair_open. The relay already
250/// has our msg_out from the prior pair_open call; we just need a PakeSide
251/// that produces the same scalar to compute the SAS once the peer's msg
252/// arrives.
253#[allow(clippy::too_many_arguments)]
254pub fn restore_pair_session(
255    role: &str,
256    relay_url: &str,
257    pair_id: &str,
258    code: &str,
259    code_hash: &str,
260    our_slot_id: &str,
261    our_slot_token: &str,
262    seed: [u8; 32],
263) -> Result<PairSessionState> {
264    if role != "host" && role != "guest" {
265        bail!("role must be 'host' or 'guest' (got {role:?})");
266    }
267    let pake = PakeSide::from_seed(code, code_hash.as_bytes(), seed);
268    Ok(PairSessionState {
269        role: role.to_string(),
270        relay_url: relay_url.to_string(),
271        pair_id: pair_id.to_string(),
272        code: code.to_string(),
273        code_hash: code_hash.to_string(),
274        pake,
275        our_slot_id: our_slot_id.to_string(),
276        our_slot_token: our_slot_token.to_string(),
277        spake_key: None,
278        aead_key: None,
279        sas: None,
280        sas_confirmed: false,
281        bootstrap_sealed_sent: false,
282        finalized: false,
283        aborted: None,
284        created_at: Instant::now(),
285        spake2_seed: seed,
286    })
287}
288
289/// **Stage 2.** Try to advance a session to SAS-ready. Single non-blocking
290/// poll of the relay. Idempotent: re-calling after SAS is cached returns the
291/// same digits without further network I/O.
292///
293/// Returns:
294///   - `Ok(Some(formatted))` — `"ABC-DEF"` six-digit SAS, ready to display
295///   - `Ok(None)` — peer's SPAKE2 message hasn't landed yet; try again later
296///   - `Err(...)` — relay error, or peer sent malformed message
297pub fn pair_session_try_sas(s: &mut PairSessionState) -> Result<Option<String>> {
298    if let Some(formatted) = s.formatted_sas() {
299        return Ok(Some(formatted));
300    }
301    if s.aborted.is_some() {
302        bail!(
303            "session aborted: {}",
304            s.aborted.as_deref().unwrap_or("unknown")
305        );
306    }
307    let client = crate::relay_client::RelayClient::new(&s.relay_url);
308    let (peer_msg, _) = client.pair_get(&s.pair_id, &s.role)?;
309    let peer_msg_b64 = match peer_msg {
310        Some(m) => m,
311        None => return Ok(None),
312    };
313    let peer_msg_bytes = crate::signing::b64decode(&peer_msg_b64)?;
314    let spake_key = s.pake.finish(&peer_msg_bytes)?;
315    let sas = compute_sas_pake(&spake_key, &spake_key[..16], &spake_key[16..]);
316    let aead_key = derive_aead_key(&spake_key, s.code_hash.as_bytes());
317    s.spake_key = Some(spake_key);
318    s.aead_key = Some(aead_key);
319    s.sas = Some(sas);
320    Ok(s.formatted_sas())
321}
322
323/// **Stage 2 helper.** Bounded loop wrapping `try_sas`. Used both by CLI
324/// (long timeout, blocking) and MCP (short timeout, fall through to async poll).
325pub fn pair_session_wait_for_sas(
326    s: &mut PairSessionState,
327    max_wait_secs: u64,
328    poll_interval: Duration,
329) -> Result<Option<String>> {
330    let deadline = Instant::now() + Duration::from_secs(max_wait_secs);
331    loop {
332        if let Some(sas) = pair_session_try_sas(s)? {
333            return Ok(Some(sas));
334        }
335        if Instant::now() >= deadline {
336            return Ok(None);
337        }
338        std::thread::sleep(poll_interval);
339    }
340}
341
342/// **Stage 3.** Validate the user-typed digits against the cached SAS.
343///
344/// `typed` may be `"384217"`, `"384-217"`, or `"384 217"` — non-digits are
345/// stripped before compare. Mismatch sets `s.aborted` and the session must
346/// be discarded; no retries (forces fresh `pair_initiate`).
347pub fn pair_session_confirm_sas(s: &mut PairSessionState, typed: &str) -> Result<()> {
348    let cached = s
349        .sas
350        .as_ref()
351        .ok_or_else(|| anyhow!("session not in sas_ready state"))?
352        .clone();
353    if s.sas_confirmed {
354        bail!("SAS already confirmed for this session");
355    }
356    if s.aborted.is_some() {
357        bail!(
358            "session aborted: {}",
359            s.aborted.as_deref().unwrap_or("unknown")
360        );
361    }
362    let normalized: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
363    if normalized.len() != 6 {
364        s.aborted = Some(format!(
365            "user typed {} digits, expected 6",
366            normalized.len()
367        ));
368        bail!("expected 6 digits (got {})", normalized.len());
369    }
370    if normalized != cached {
371        // Constant-ish compare — both strings are short and known length, but
372        // we still want to NOT leak via early-return divergence.
373        let mut diff = 0u8;
374        for (a, b) in normalized.bytes().zip(cached.bytes()) {
375            diff |= a ^ b;
376        }
377        if diff != 0 {
378            s.aborted = Some("SAS mismatch — user-typed digits did not match".into());
379            bail!(
380                "phyllis: wrong dial-back — the operator is hanging up the line (start a fresh pair-initiate)"
381            );
382        }
383    }
384    s.sas_confirmed = true;
385    Ok(())
386}
387
388/// **Stage 4.** Seal+exchange bootstrap, AEAD-open peer's, pin peer.
389///
390/// Caller must have called `pair_session_confirm_sas` first (or, for CLI
391/// where the y/n prompt serves the same role, must set `s.sas_confirmed`
392/// before calling this).
393///
394/// Returns a JSON summary suitable for printing or returning as MCP result.
395pub fn pair_session_finalize(s: &mut PairSessionState, timeout_secs: u64) -> Result<Value> {
396    if !s.sas_confirmed {
397        bail!("SAS not confirmed — call pair_session_confirm_sas first");
398    }
399    if s.aborted.is_some() {
400        bail!(
401            "session aborted: {}",
402            s.aborted.as_deref().unwrap_or("unknown")
403        );
404    }
405    let aead_key = s
406        .aead_key
407        .ok_or_else(|| anyhow!("session not ready: no aead_key cached"))?;
408    let card = crate::config::read_agent_card()?;
409
410    if !s.bootstrap_sealed_sent {
411        let bootstrap_payload = json!({
412            "card": card.clone(),
413            "relay_url": s.relay_url,
414            "slot_id": s.our_slot_id,
415            "slot_token": s.our_slot_token,
416        });
417        let plaintext = serde_json::to_vec(&bootstrap_payload)?;
418        let sealed = seal_bootstrap(&aead_key, &plaintext)?;
419        let client = crate::relay_client::RelayClient::new(&s.relay_url);
420        client.pair_bootstrap(&s.pair_id, &s.role, &crate::signing::b64encode(&sealed))?;
421        s.bootstrap_sealed_sent = true;
422    }
423
424    let client = crate::relay_client::RelayClient::new(&s.relay_url);
425    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
426    let peer_bootstrap_b64 = loop {
427        let (_, peer_bootstrap) = client.pair_get(&s.pair_id, &s.role)?;
428        if let Some(b) = peer_bootstrap {
429            break b;
430        }
431        if Instant::now() >= deadline {
432            bail!("timeout after {timeout_secs}s waiting for peer's sealed bootstrap");
433        }
434        std::thread::sleep(Duration::from_millis(250));
435    };
436    let peer_sealed = crate::signing::b64decode(&peer_bootstrap_b64)?;
437    let peer_plain = open_bootstrap(&aead_key, &peer_sealed)
438        .map_err(|e| anyhow!("AEAD open failed — wrong code, MITM, or peer aborted: {e}"))?;
439    let peer_payload: Value = serde_json::from_slice(&peer_plain)?;
440    let peer_card = peer_payload
441        .get("card")
442        .cloned()
443        .ok_or_else(|| anyhow!("peer bootstrap missing card"))?;
444    crate::agent_card::verify_agent_card(&peer_card)
445        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
446
447    let mut trust = crate::config::read_trust()?;
448    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
449    crate::config::write_trust(&trust)?;
450
451    let peer_did = peer_card
452        .get("did")
453        .and_then(Value::as_str)
454        .unwrap_or("")
455        .to_string();
456    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
457    let peer_relay_url = peer_payload
458        .get("relay_url")
459        .and_then(Value::as_str)
460        .unwrap_or("")
461        .to_string();
462    let peer_slot_id = peer_payload
463        .get("slot_id")
464        .and_then(Value::as_str)
465        .unwrap_or("")
466        .to_string();
467    let peer_slot_token = peer_payload
468        .get("slot_token")
469        .and_then(Value::as_str)
470        .unwrap_or("")
471        .to_string();
472
473    let mut relay_state = crate::config::read_relay_state()?;
474    relay_state["peers"][&peer_handle] = json!({
475        "relay_url": peer_relay_url,
476        "slot_id": peer_slot_id,
477        "slot_token": peer_slot_token,
478    });
479    crate::config::write_relay_state(&relay_state)?;
480
481    s.finalized = true;
482    let formatted_sas = s.formatted_sas().unwrap_or_default();
483
484    Ok(json!({
485        "paired_with": peer_did,
486        "peer_handle": peer_handle,
487        "peer_relay_url": peer_relay_url,
488        "peer_slot_id": peer_slot_id,
489        "sas": formatted_sas,
490    }))
491}
492
493// ---------- idempotent init ----------
494
495/// MCP-callable init: idempotent if already inited under the same handle,
496/// errors on different-handle conflict, accepts optional --relay binding.
497///
498/// This is the only writeable identity-creation entry point safe to expose
499/// to agents — it can't change the operator's existing identity.
500pub fn init_self_idempotent(
501    handle: &str,
502    name: Option<&str>,
503    relay: Option<&str>,
504) -> Result<Value> {
505    use crate::agent_card::{build_agent_card, sign_agent_card};
506    use crate::signing::{fingerprint, generate_keypair, make_key_id};
507    use crate::trust::{add_self_to_trust, empty_trust};
508
509    if !handle
510        .chars()
511        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
512    {
513        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
514    }
515
516    if crate::config::is_initialized()? {
517        let card = crate::config::read_agent_card()?;
518        let existing_did = card
519            .get("did")
520            .and_then(Value::as_str)
521            .unwrap_or("")
522            .to_string();
523        // Prefer the explicit `handle` field on the card (v0.5.7+);
524        // fall back to the DID prefix-and-pubkey-suffix strip for legacy.
525        let existing_handle = card
526            .get("handle")
527            .and_then(Value::as_str)
528            .map(str::to_string)
529            .unwrap_or_else(|| {
530                crate::agent_card::display_handle_from_did(&existing_did).to_string()
531            });
532        if existing_handle != handle {
533            bail!(
534                "already initialized as {existing_did}; refusing to re-init with different handle {handle:?}. \
535                 Operator must explicitly delete config to re-init."
536            );
537        }
538        let pk_b64 = card
539            .get("verify_keys")
540            .and_then(Value::as_object)
541            .and_then(|m| m.values().next())
542            .and_then(|v| v.get("key"))
543            .and_then(Value::as_str)
544            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
545        let pk_bytes = crate::signing::b64decode(pk_b64)?;
546        let mut out = json!({
547            "did": existing_did,
548            "handle": handle,
549            "fingerprint": fingerprint(&pk_bytes),
550            "key_id": make_key_id(handle, &pk_bytes),
551            "config_dir": crate::config::config_dir()?.to_string_lossy(),
552            "already_initialized": true,
553        });
554        let relay_state = crate::config::read_relay_state()?;
555        if let Some(url) = relay {
556            if relay_state["self"].is_null() {
557                let client = crate::relay_client::RelayClient::new(url);
558                client.check_healthz()?;
559                let alloc = client.allocate_slot(Some(handle))?;
560                let mut rs = relay_state;
561                rs["self"] = json!({
562                    "relay_url": url,
563                    "slot_id": alloc.slot_id.clone(),
564                    "slot_token": alloc.slot_token,
565                });
566                crate::config::write_relay_state(&rs)?;
567                out["relay_url"] = json!(url);
568                out["slot_id"] = json!(alloc.slot_id);
569            } else if let Some(existing_url) = relay_state["self"]["relay_url"].as_str() {
570                out["relay_url"] = json!(existing_url);
571                out["slot_id"] = relay_state["self"]["slot_id"].clone();
572            }
573        }
574        return Ok(out);
575    }
576
577    crate::config::ensure_dirs()?;
578    let (sk_seed, pk_bytes) = generate_keypair();
579    crate::config::write_private_key(&sk_seed)?;
580    let card = build_agent_card(handle, &pk_bytes, name, None, None);
581    let signed = sign_agent_card(&card, &sk_seed);
582    crate::config::write_agent_card(&signed)?;
583    let mut trust = empty_trust();
584    add_self_to_trust(&mut trust, handle, &pk_bytes);
585    crate::config::write_trust(&trust)?;
586
587    let mut out = json!({
588        "did": crate::agent_card::did_for_with_key(handle, &pk_bytes),
589        "handle": handle,
590        "fingerprint": fingerprint(&pk_bytes),
591        "key_id": make_key_id(handle, &pk_bytes),
592        "config_dir": crate::config::config_dir()?.to_string_lossy(),
593        "already_initialized": false,
594    });
595
596    if let Some(url) = relay {
597        let client = crate::relay_client::RelayClient::new(url);
598        client.check_healthz()?;
599        let alloc = client.allocate_slot(Some(handle))?;
600        let mut rs = crate::config::read_relay_state()?;
601        rs["self"] = json!({
602            "relay_url": url,
603            "slot_id": alloc.slot_id.clone(),
604            "slot_token": alloc.slot_token,
605        });
606        crate::config::write_relay_state(&rs)?;
607        out["relay_url"] = json!(url);
608        out["slot_id"] = json!(alloc.slot_id);
609    }
610
611    Ok(out)
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn confirm_sas_strips_dash_and_spaces() {
620        let mut s = mk_sas_ready_state("384217");
621        pair_session_confirm_sas(&mut s, "384-217").unwrap();
622        assert!(s.sas_confirmed);
623    }
624
625    #[test]
626    fn confirm_sas_mismatch_aborts_session() {
627        let mut s = mk_sas_ready_state("384217");
628        let err = pair_session_confirm_sas(&mut s, "999999").unwrap_err();
629        assert!(err.to_string().contains("wrong dial-back"));
630        assert!(s.aborted.is_some());
631        assert!(!s.sas_confirmed);
632    }
633
634    #[test]
635    fn confirm_sas_wrong_length_aborts() {
636        let mut s = mk_sas_ready_state("384217");
637        let err = pair_session_confirm_sas(&mut s, "12345").unwrap_err();
638        assert!(err.to_string().contains("6 digits"));
639        assert!(s.aborted.is_some());
640    }
641
642    #[test]
643    fn confirm_sas_double_confirm_rejected() {
644        let mut s = mk_sas_ready_state("384217");
645        pair_session_confirm_sas(&mut s, "384217").unwrap();
646        let err = pair_session_confirm_sas(&mut s, "384217").unwrap_err();
647        assert!(err.to_string().contains("already confirmed"));
648    }
649
650    #[test]
651    fn store_holds_independent_sessions() {
652        store_clear_for_test();
653        let s1 = mk_sas_ready_state("111111");
654        let s2 = mk_sas_ready_state("222222");
655        let id1 = store_insert(s1);
656        let id2 = store_insert(s2);
657        assert_ne!(id1, id2);
658        assert!(store_get(&id1).is_some());
659        assert!(store_get(&id2).is_some());
660        store_remove(&id1);
661        assert!(store_get(&id1).is_none());
662        assert!(store_get(&id2).is_some());
663        store_clear_for_test();
664    }
665
666    fn mk_sas_ready_state(sas: &str) -> PairSessionState {
667        // Build a synthetic session bypassing the relay — only the post-SAS
668        // helpers (confirm/abort) are exercised in unit tests; integration
669        // tests cover the full wire including relay I/O.
670        let pair_id = format!(
671            "test-{}-{:?}",
672            sas,
673            std::time::Instant::now().elapsed().as_nanos()
674        );
675        PairSessionState {
676            role: "host".into(),
677            relay_url: "http://invalid".into(),
678            pair_id,
679            // Code phrase format is "NN-XXXXXX" (2 digits, dash, 6 base32 chars).
680            code: "12-ABCDEF".into(),
681            code_hash: "deadbeef".into(),
682            pake: PakeSide::new("12-ABCDEF", b"test"),
683            our_slot_id: "slot-self".into(),
684            our_slot_token: "tok-self".into(),
685            spake_key: Some([0u8; 32]),
686            aead_key: Some([0u8; 32]),
687            sas: Some(sas.into()),
688            sas_confirmed: false,
689            bootstrap_sealed_sent: false,
690            finalized: false,
691            aborted: None,
692            created_at: Instant::now(),
693            spake2_seed: [0u8; 32],
694        }
695    }
696}