1use 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
46pub const SESSION_TTL: Duration = Duration::from_secs(600);
51
52pub struct PairSessionState {
57 pub role: String, pub relay_url: String,
59 pub pair_id: String, pub code: String, pub code_hash: String, pub pake: PakeSide, 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 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 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
92type 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
101pub 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
119pub fn store_get(session_id: &str) -> Option<Arc<Mutex<PairSessionState>>> {
121 store().lock().unwrap().get(session_id).cloned()
122}
123
124pub fn store_remove(session_id: &str) {
126 store().lock().unwrap().remove(session_id);
127}
128
129pub fn store_sweep_expired() {
131 let mut g = store().lock().unwrap();
132 g.retain(|_, arc| {
133 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
146pub 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 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 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#[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
289pub 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
323pub 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
342pub 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 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
388pub 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
493pub 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 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 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: "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}