1use std::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use anyhow::{Context, Result, anyhow, bail};
29use base64::Engine as _;
30use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
31use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34
35use crate::config;
36
37pub const DEFAULT_RELAY: &str = "https://wireup.net";
38pub const DEFAULT_TTL_SECS: u64 = 86_400; pub(crate) fn record_pair_rejection(peer_handle: &str, code: &str, detail: &str) {
48 let line = json!({
49 "ts": std::time::SystemTime::now()
50 .duration_since(std::time::UNIX_EPOCH)
51 .map(|d| d.as_secs())
52 .unwrap_or(0),
53 "peer": peer_handle,
54 "code": code,
55 "detail": detail,
56 });
57 let serialised = match serde_json::to_string(&line) {
58 Ok(s) => s,
59 Err(e) => {
60 eprintln!("wire: could not serialise pair-rejected entry: {e}");
61 return;
62 }
63 };
64 let path = match config::state_dir() {
65 Ok(d) => d.join("pair-rejected.jsonl"),
66 Err(e) => {
67 eprintln!("wire: state_dir unresolved, dropping pair-rejected log: {e}");
68 return;
69 }
70 };
71 if let Some(parent) = path.parent()
72 && let Err(e) = std::fs::create_dir_all(parent)
73 {
74 eprintln!("wire: could not create {parent:?}: {e}");
75 return;
76 }
77 use std::io::Write;
78 match std::fs::OpenOptions::new()
79 .create(true)
80 .append(true)
81 .open(&path)
82 {
83 Ok(mut f) => {
84 if let Err(e) = writeln!(f, "{serialised}") {
85 eprintln!("wire: could not append pair-rejected to {path:?}: {e}");
86 }
87 }
88 Err(e) => {
89 eprintln!("wire: could not open {path:?}: {e}");
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct InvitePayload {
97 pub v: u32,
99 pub did: String,
101 pub card: Value,
103 pub relay_url: String,
105 pub slot_id: String,
107 pub slot_token: String,
109 pub nonce: String,
111 pub exp: u64,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PendingInvite {
118 pub nonce: String,
119 pub exp: u64,
120 pub uses_remaining: u32,
121 pub accepted_by: Vec<String>,
123 pub created_at: String,
124}
125
126fn open_mode_enabled() -> bool {
130 let path = match config::config_dir() {
131 Ok(p) => p.join("policy.json"),
132 Err(_) => return true,
133 };
134 let bytes = match std::fs::read(&path) {
135 Ok(b) => b,
136 Err(_) => return true,
137 };
138 let v: Value = match serde_json::from_slice(&bytes) {
139 Ok(v) => v,
140 Err(_) => return true,
141 };
142 v.get("accept_unknown_pair_drops")
143 .and_then(Value::as_bool)
144 .unwrap_or(true)
145}
146
147pub fn pending_invites_dir() -> Result<PathBuf> {
148 Ok(config::state_dir()?.join("pending-invites"))
149}
150
151fn now_unix() -> u64 {
152 SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .map(|d| d.as_secs())
155 .unwrap_or(0)
156}
157
158fn default_handle() -> String {
161 let raw = hostname::get()
162 .ok()
163 .and_then(|s| s.into_string().ok())
164 .unwrap_or_else(|| "wire-user".into());
165 let sanitized: String = raw
166 .chars()
167 .map(|c| {
168 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
169 c
170 } else {
171 '-'
172 }
173 })
174 .collect();
175 if sanitized.is_empty() {
176 "wire-user".into()
177 } else {
178 sanitized
179 }
180}
181
182pub fn ensure_self_with_relay(
185 preferred_relay: Option<&str>,
186) -> Result<(String, String, String, String)> {
187 let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
188
189 if !config::is_initialized()? {
190 let handle = default_handle();
191 crate::init::init_self_idempotent(&handle, None, Some(relay))
192 .with_context(|| format!("auto-init as did:wire:{handle}"))?;
193 }
194
195 let card = config::read_agent_card()?;
196 let did = card
197 .get("did")
198 .and_then(Value::as_str)
199 .ok_or_else(|| anyhow!("agent-card missing did"))?
200 .to_string();
201
202 let mut relay_state = config::read_relay_state()?;
203
204 let existing = crate::endpoints::self_endpoints(&relay_state);
212 if !existing.is_empty() {
213 let ep = existing
214 .iter()
215 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
216 .cloned()
217 .unwrap_or_else(|| existing[0].clone());
218 return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
219 }
220
221 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
222
223 if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
224 let client = crate::relay_client::RelayClient::new(relay);
225 client.check_healthz()?;
226 let handle = crate::agent_card::display_handle_from_did(&did);
227 let alloc = client.allocate_slot(Some(handle))?;
228 relay_state["self"] = json!({
229 "relay_url": relay,
230 "slot_id": alloc.slot_id,
231 "slot_token": alloc.slot_token,
232 });
233 config::write_relay_state(&relay_state)?;
234 }
235
236 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
237 let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
238 let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
239 let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
240 if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
241 bail!("self relay state incomplete after auto-allocate");
242 }
243 Ok((did, relay_url, slot_id, slot_token))
244}
245
246pub fn mint_invite(
248 ttl_secs: Option<u64>,
249 uses: u32,
250 preferred_relay: Option<&str>,
251) -> Result<String> {
252 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
253
254 let card = config::read_agent_card()?;
255 let sk_seed = config::read_private_key()?;
256
257 let mut nonce_bytes = [0u8; 32];
258 use rand::RngCore;
259 rand::thread_rng().fill_bytes(&mut nonce_bytes);
260 let nonce = hex::encode(nonce_bytes);
261
262 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
263 let exp = now_unix() + ttl;
264
265 let payload = InvitePayload {
266 v: 1,
267 did: did.clone(),
268 card,
269 relay_url,
270 slot_id,
271 slot_token,
272 nonce: nonce.clone(),
273 exp,
274 };
275 let payload_bytes = serde_json::to_vec(&payload)?;
276
277 let mut sk_arr = [0u8; 32];
278 sk_arr.copy_from_slice(&sk_seed[..32]);
279 let sk = SigningKey::from_bytes(&sk_arr);
280 let sig = sk.sign(&payload_bytes);
281
282 let token = format!(
283 "{}.{}",
284 B64URL.encode(&payload_bytes),
285 B64URL.encode(sig.to_bytes())
286 );
287 let url = format!("wire://pair?v=1&inv={token}");
288
289 let now = time::OffsetDateTime::now_utc()
290 .format(&time::format_description::well_known::Rfc3339)
291 .unwrap_or_default();
292 let pending = PendingInvite {
293 nonce: nonce.clone(),
294 exp,
295 uses_remaining: uses.max(1),
296 accepted_by: vec![],
297 created_at: now,
298 };
299 let dir = pending_invites_dir()?;
300 std::fs::create_dir_all(&dir)?;
301 let path = dir.join(format!("{nonce}.json"));
302 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
303
304 Ok(url)
305}
306
307pub fn parse_invite(url: &str) -> Result<InvitePayload> {
310 let rest = url
311 .strip_prefix("wire://pair?")
312 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
313 let mut inv = None;
314 for part in rest.split('&') {
315 if let Some(v) = part.strip_prefix("inv=") {
316 inv = Some(v);
317 }
318 }
319 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
320 let (payload_b64, sig_b64) = token
321 .split_once('.')
322 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
323 let payload_bytes = B64URL
324 .decode(payload_b64)
325 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
326 let sig_bytes = B64URL
327 .decode(sig_b64)
328 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
329
330 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
331 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
332
333 if payload.v != 1 {
334 bail!("invite schema version {} not supported", payload.v);
335 }
336 if now_unix() > payload.exp {
337 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
338 }
339
340 crate::agent_card::verify_agent_card(&payload.card)
342 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
343
344 let pk_b64 = payload
345 .card
346 .get("verify_keys")
347 .and_then(Value::as_object)
348 .and_then(|m| m.values().next())
349 .and_then(|v| v.get("key"))
350 .and_then(Value::as_str)
351 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
352 let pk_bytes = crate::signing::b64decode(pk_b64)?;
353 let mut pk_arr = [0u8; 32];
354 if pk_bytes.len() != 32 {
355 bail!("issuer pubkey wrong length");
356 }
357 pk_arr.copy_from_slice(&pk_bytes);
358 let vk = VerifyingKey::from_bytes(&pk_arr)
359 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
360 let mut sig_arr = [0u8; 64];
361 if sig_bytes.len() != 64 {
362 bail!("invite sig wrong length");
363 }
364 sig_arr.copy_from_slice(&sig_bytes);
365 let sig = Signature::from_bytes(&sig_arr);
366 vk.verify(&payload_bytes, &sig)
367 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
368
369 Ok(payload)
370}
371
372pub fn accept_invite(url: &str) -> Result<Value> {
375 let payload = parse_invite(url)?;
376
377 let (our_did, our_relay, our_slot_id, our_slot_token) =
379 ensure_self_with_relay(Some(&payload.relay_url))?;
380
381 if our_did == payload.did {
382 bail!("refusing to accept own invite (issuer DID matches self)");
383 }
384
385 let mut trust = config::read_trust()?;
387 crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
388 config::write_trust(&trust)?;
389
390 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
391 let mut relay_state = config::read_relay_state()?;
392 crate::endpoints::pin_peer_endpoints(
396 &mut relay_state,
397 &peer_handle,
398 &[crate::endpoints::Endpoint::federation(
399 payload.relay_url.clone(),
400 payload.slot_id.clone(),
401 payload.slot_token.clone(),
402 )],
403 )?;
404 config::write_relay_state(&relay_state)?;
405
406 let our_card = config::read_agent_card()?;
410 let sk_seed = config::read_private_key()?;
411 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
412 let pk_b64 = our_card
413 .get("verify_keys")
414 .and_then(Value::as_object)
415 .and_then(|m| m.values().next())
416 .and_then(|v| v.get("key"))
417 .and_then(Value::as_str)
418 .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
419 let pk_bytes = crate::signing::b64decode(pk_b64)?;
420
421 let now = time::OffsetDateTime::now_utc()
422 .format(&time::format_description::well_known::Rfc3339)
423 .unwrap_or_default();
424 let event = json!({
425 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
426 "timestamp": now,
427 "from": our_did,
428 "to": payload.did,
429 "type": "pair_drop",
430 "kind": 1100u32,
431 "body": {
432 "card": our_card,
433 "relay_url": our_relay,
434 "slot_id": our_slot_id,
435 "slot_token": our_slot_token,
436 "pair_nonce": payload.nonce,
437 },
438 });
439 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
440 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
441
442 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
443 client
444 .post_event(&payload.slot_id, &payload.slot_token, &signed)
445 .with_context(|| {
446 format!(
447 "POST pair_drop to {} slot {}",
448 payload.relay_url, payload.slot_id
449 )
450 })?;
451
452 Ok(json!({
453 "paired_with": payload.did,
454 "peer_handle": peer_handle,
455 "event_id": event_id,
456 "status": "drop_sent",
457 }))
458}
459
460pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
465 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
466 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
467 if kind != 1100 || type_str != "pair_drop" {
468 return Ok(None);
469 }
470 let body = match event.get("body") {
471 Some(b) => b,
472 None => return Ok(None),
473 };
474
475 let nonce_opt = body
481 .get("pair_nonce")
482 .and_then(Value::as_str)
483 .map(str::to_string);
484 let mut pending: Option<PendingInvite> = None;
485 let mut invite_path: Option<std::path::PathBuf> = None;
486 if let Some(nonce) = nonce_opt.as_deref() {
487 let dir = pending_invites_dir()?;
488 let path = dir.join(format!("{nonce}.json"));
489 if path.exists() {
490 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
491 .with_context(|| format!("reading pending invite {path:?}"))?;
492 if now_unix() > p.exp {
493 if let Err(e) = std::fs::remove_file(&path) {
496 eprintln!("wire: could not delete expired invite {path:?}: {e}");
497 }
498 return Ok(None);
499 }
500 pending = Some(p);
501 invite_path = Some(path);
502 } else if !open_mode_enabled() {
503 return Ok(None);
507 }
508 } else if !open_mode_enabled() {
509 return Ok(None);
512 }
513
514 let peer_card = body
515 .get("card")
516 .cloned()
517 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
518 crate::agent_card::verify_agent_card(&peer_card)
519 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
520
521 let peer_did = peer_card
522 .get("did")
523 .and_then(Value::as_str)
524 .ok_or_else(|| anyhow!("peer card missing did"))?
525 .to_string();
526 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
527
528 let mut tmp_trust = config::read_trust()?;
533 crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
534 crate::signing::verify_message_v31(event, &tmp_trust)
535 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
536
537 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
538 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
539 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
540 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
541 bail!("pair_drop body missing relay_url/slot_id/slot_token");
542 }
543
544 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
549 .get("endpoints")
550 .and_then(Value::as_array)
551 .map(|arr| {
552 arr.iter()
553 .filter_map(|e| {
554 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
555 })
556 .collect()
557 })
558 .unwrap_or_else(|| {
559 vec![crate::endpoints::Endpoint::federation(
560 peer_relay.to_string(),
561 peer_slot_id.to_string(),
562 peer_slot_token.to_string(),
563 )]
564 });
565
566 if nonce_opt.is_some() {
584 config::write_trust(&tmp_trust)?;
586 let mut relay_state = config::read_relay_state()?;
587 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
591 config::write_relay_state(&relay_state)?;
592
593 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
595 if pending.uses_remaining <= 1 {
596 if let Err(e) = std::fs::remove_file(&invite_path) {
597 eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
598 }
599 } else {
600 let mut updated = pending.clone();
601 updated.uses_remaining -= 1;
602 updated.accepted_by.push(peer_did.clone());
603 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
604 }
605 }
606 crate::os_notify::toast(
607 &format!("wire — paired with {peer_handle}"),
608 "Invite accepted. Ready to send + receive.",
609 );
610 return Ok(Some(peer_did));
611 }
612
613 let blocklist = crate::blocklist::Blocklist::load();
621 if let Some(blocked_did) = blocklist.blocks_card(&peer_card) {
622 record_pair_rejection(
623 &peer_handle,
624 "blocked_peer",
625 &format!(
626 "inbound pair from locally-blocked DID {blocked_did}; dropped (wire block-peer)"
627 ),
628 );
629 return Ok(None);
630 }
631
632 if let Some(org_did) =
640 org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
641 {
642 let mut trust = crate::config::read_trust()?;
643 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("ORG_VERIFIED"));
644 crate::config::write_trust(&trust)?;
645
646 let endpoints_to_pin = if peer_endpoints.is_empty() {
647 vec![crate::endpoints::Endpoint::federation(
648 peer_relay.to_string(),
649 peer_slot_id.to_string(),
650 peer_slot_token.to_string(),
651 )]
652 } else {
653 peer_endpoints.clone()
654 };
655 let mut relay_state = crate::config::read_relay_state()?;
656 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
657 crate::config::write_relay_state(&relay_state)?;
658
659 send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
660 .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
661
662 crate::os_notify::toast_dedup(
663 &format!("org-pair:{peer_handle}"),
664 &format!("wire — auto-paired {peer_handle}"),
665 &format!(
666 "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
667 ),
668 );
669 return Ok(Some(peer_did));
670 }
671
672 let now_iso = time::OffsetDateTime::now_utc()
673 .format(&time::format_description::well_known::Rfc3339)
674 .unwrap_or_default();
675 let event_id = event
676 .get("event_id")
677 .and_then(Value::as_str)
678 .unwrap_or("")
679 .to_string();
680 let event_timestamp = event
681 .get("timestamp")
682 .and_then(Value::as_str)
683 .unwrap_or("")
684 .to_string();
685 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
686 peer_handle: peer_handle.clone(),
687 peer_did: peer_did.clone(),
688 peer_card: peer_card.clone(),
689 peer_relay_url: peer_relay.to_string(),
690 peer_slot_id: peer_slot_id.to_string(),
691 peer_slot_token: peer_slot_token.to_string(),
692 peer_endpoints: peer_endpoints.clone(),
693 event_id,
694 event_timestamp,
695 received_at: now_iso,
696 };
697 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
698
699 match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
706 Some(org_did) => crate::os_notify::toast_dedup(
707 &format!("notify-pair:{peer_handle}"),
708 &format!("wire — org-verified pair request from {peer_handle}"),
709 &format!(
710 "verified member of {org_did} (your org_policies.json says `notify`). run `wire accept {peer_handle}` to pin VERIFIED, or `wire reject {peer_handle}`",
711 ),
712 ),
713 None => crate::os_notify::toast(
714 &format!("wire — pair request from {peer_handle}"),
715 &format!(
716 "run `wire accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire reject {peer_handle}` to refuse",
717 ),
718 ),
719 }
720
721 Ok(Some(peer_did))
722}
723
724fn org_auto_pin_decision(
730 card: &Value,
731 policy: &dyn crate::pair_decision::OrgPolicy,
732) -> Option<String> {
733 match crate::pair_decision::decide(
734 &crate::org_membership::evaluate_card_membership(card),
735 policy,
736 ) {
737 crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
738 _ => None,
739 }
740}
741
742fn org_notify_decision(
752 card: &Value,
753 policy: &dyn crate::pair_decision::OrgPolicy,
754) -> Option<String> {
755 match crate::pair_decision::decide(
756 &crate::org_membership::evaluate_card_membership(card),
757 policy,
758 ) {
759 crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
760 _ => None,
761 }
762}
763
764pub fn send_pair_drop_ack(
786 peer_handle: &str,
787 peer_endpoints: &[crate::endpoints::Endpoint],
788) -> Result<()> {
789 let our_card = config::read_agent_card()?;
791 let our_did = our_card
792 .get("did")
793 .and_then(Value::as_str)
794 .ok_or_else(|| anyhow!("our card missing did"))?
795 .to_string();
796 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
797 let relay_state = config::read_relay_state()?;
798 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
799 let mut our_relay = self_state
808 .get("relay_url")
809 .and_then(Value::as_str)
810 .unwrap_or("")
811 .to_string();
812 let mut our_slot_id = self_state
813 .get("slot_id")
814 .and_then(Value::as_str)
815 .unwrap_or("")
816 .to_string();
817 let mut our_slot_token = self_state
818 .get("slot_token")
819 .and_then(Value::as_str)
820 .unwrap_or("")
821 .to_string();
822 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
823 let eps = crate::endpoints::self_endpoints(&relay_state);
828 if let Some(ep) = eps.first() {
829 our_relay = ep.relay_url.clone();
830 our_slot_id = ep.slot_id.clone();
831 our_slot_token = ep.slot_token.clone();
832 }
833 }
834 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
835 bail!(
840 "this session has no inbound slot configured — peers cannot deliver to us.\n\
841 Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
842 (allocates a slot and re-publishes our card to all pinned peers).\n\
843 Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
844 );
845 }
846
847 let sk_seed = config::read_private_key()?;
848 let pk_b64 = our_card
849 .get("verify_keys")
850 .and_then(Value::as_object)
851 .and_then(|m| m.values().next())
852 .and_then(|v| v.get("key"))
853 .and_then(Value::as_str)
854 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
855 let pk_bytes = crate::signing::b64decode(pk_b64)?;
856
857 let now = time::OffsetDateTime::now_utc()
858 .format(&time::format_description::well_known::Rfc3339)
859 .unwrap_or_default();
860 let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
864 let mut body = json!({
865 "relay_url": our_relay,
866 "slot_id": our_slot_id,
867 "slot_token": our_slot_token,
868 });
869 if !our_endpoints.is_empty() {
870 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
871 }
872 let event = json!({
873 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
874 "timestamp": now,
875 "from": our_did,
876 "to": format!("did:wire:{peer_handle}"),
877 "type": "pair_drop_ack",
878 "kind": 1101u32,
879 "body": body,
880 });
881 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
882
883 let (delivered_ep, _resp) =
889 crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
890 crate::relay_client::post_event_to_endpoint(ep, ev)
891 })
892 .with_context(|| {
893 format!(
894 "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
895 peer_endpoints.len()
896 )
897 })?;
898 let _ = delivered_ep; Ok(())
900}
901
902pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
906 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
907 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
908 if kind != 1101 || type_str != "pair_drop_ack" {
909 return Ok(false);
910 }
911 let body = match event.get("body") {
912 Some(b) => b,
913 None => return Ok(false),
914 };
915 let from = event
916 .get("from")
917 .and_then(Value::as_str)
918 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
919 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
920 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
921 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
922 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
923 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
924 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
925 }
926 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
930 .get("endpoints")
931 .and_then(Value::as_array)
932 .map(|arr| {
933 arr.iter()
934 .filter_map(|e| {
935 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
936 })
937 .collect()
938 })
939 .unwrap_or_else(|| {
940 vec![crate::endpoints::Endpoint::federation(
941 peer_relay.to_string(),
942 peer_slot_id.to_string(),
943 peer_slot_token.to_string(),
944 )]
945 });
946 let mut relay_state = config::read_relay_state()?;
947 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
948 if let Some(peer_entry) = relay_state
957 .get_mut("peers")
958 .and_then(Value::as_object_mut)
959 .and_then(|m| m.get_mut(&peer_handle))
960 .and_then(Value::as_object_mut)
961 {
962 peer_entry
963 .entry("bilateral_completed_at".to_string())
964 .or_insert_with(|| {
965 Value::String(
966 time::OffsetDateTime::now_utc()
967 .format(&time::format_description::well_known::Rfc3339)
968 .unwrap_or_default(),
969 )
970 });
971 }
972 config::write_relay_state(&relay_state)?;
973 if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
983 eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
986 }
987 crate::os_notify::toast(
988 &format!("wire — pair complete with {peer_handle}"),
989 "Both sides bound. Ready to send + receive.",
990 );
991 Ok(true)
992}
993
994#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 struct AutoFor(String);
1006 impl crate::pair_decision::OrgPolicy for AutoFor {
1007 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1008 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
1009 }
1010 }
1011 struct EmptyPolicy;
1012 impl crate::pair_decision::OrgPolicy for EmptyPolicy {
1013 fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
1014 None
1015 }
1016 }
1017
1018 fn org_verified_card() -> (Value, String) {
1020 let (op_sk, op_pk) = crate::signing::generate_keypair();
1021 let (org_sk, org_pk) = crate::signing::generate_keypair();
1022 let (sess_sk, sess_pk) = crate::signing::generate_keypair();
1023 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
1024 let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
1025 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
1026 let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1027 let session_did = base
1028 .get("did")
1029 .and_then(|v| v.as_str())
1030 .unwrap()
1031 .to_string();
1032 let claims = crate::enroll::build_member_claims(
1033 "darby",
1034 &op_sk,
1035 &op_pk,
1036 &session_did,
1037 &[crate::enroll::MemberOf {
1038 org_did: org_did.clone(),
1039 org_pubkey: org_pk,
1040 member_cert,
1041 }],
1042 None,
1043 )
1044 .unwrap();
1045 let card = crate::agent_card::sign_agent_card(
1046 &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1047 &sess_sk,
1048 );
1049 (card, org_did)
1050 }
1051
1052 #[test]
1053 fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1054 let (card, org_did) = org_verified_card();
1055 assert_eq!(
1057 org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1058 Some(org_did.clone())
1059 );
1060 assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1062 }
1063
1064 #[test]
1065 fn org_auto_pin_decision_none_for_plain_card() {
1066 let plain = serde_json::json!({
1069 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1070 });
1071 assert_eq!(
1072 org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1073 None
1074 );
1075 }
1076
1077 struct NotifyFor(String);
1080 impl crate::pair_decision::OrgPolicy for NotifyFor {
1081 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1082 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1083 }
1084 }
1085
1086 #[test]
1087 fn org_notify_decision_notify_only_when_policy_opts_in() {
1088 let (card, org_did) = org_verified_card();
1089 assert_eq!(
1091 org_notify_decision(&card, &NotifyFor(org_did.clone())),
1092 Some(org_did.clone())
1093 );
1094 assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1096 }
1097
1098 #[test]
1099 fn org_notify_decision_returns_none_when_policy_is_auto() {
1100 let (card, org_did) = org_verified_card();
1104 assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1105 }
1106
1107 #[test]
1108 fn org_notify_decision_none_for_plain_card() {
1109 let plain = serde_json::json!({
1112 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1113 });
1114 assert_eq!(
1115 org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1116 None
1117 );
1118 }
1119 use crate::config;
1120
1121 #[test]
1122 fn record_pair_rejection_writes_jsonl_under_state_dir() {
1123 config::test_support::with_temp_home(|| {
1127 super::record_pair_rejection(
1128 "slancha-spark",
1129 "pair_drop_ack_send_failed",
1130 "POST returned 502",
1131 );
1132 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1133 assert!(path.exists(), "record_pair_rejection must create {path:?}");
1134 let body = std::fs::read_to_string(&path).unwrap();
1135 let line = body.lines().last().expect("at least one line");
1136 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1137 assert_eq!(parsed["peer"], "slancha-spark");
1138 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1139 assert_eq!(parsed["detail"], "POST returned 502");
1140 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1141 });
1142 }
1143
1144 #[test]
1145 fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1146 config::test_support::with_temp_home(|| {
1154 let peer_handle = "test-peer";
1155 let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1156 let pending = crate::pending_inbound_pair::PendingInboundPair {
1157 peer_handle: peer_handle.to_string(),
1158 peer_did: peer_did.clone(),
1159 peer_card: serde_json::json!({"did": peer_did.clone()}),
1160 peer_relay_url: "https://example.test".into(),
1161 peer_slot_id: "slot-aaaa".into(),
1162 peer_slot_token: "token-bbbb".into(),
1163 peer_endpoints: vec![],
1164 event_id: "evt-0001".into(),
1165 event_timestamp: "2026-06-01T20:00:00Z".into(),
1166 received_at: "2026-06-01T20:00:01Z".into(),
1167 };
1168 crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1169 assert!(
1170 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1171 .unwrap()
1172 .is_some(),
1173 "precondition: pending record exists"
1174 );
1175 let ack_event = serde_json::json!({
1176 "kind": 1101,
1177 "type": "pair_drop_ack",
1178 "from": peer_did,
1179 "body": {
1180 "relay_url": "https://example.test",
1181 "slot_id": "slot-cccc",
1182 "slot_token": "token-dddd",
1183 },
1184 });
1185 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1186 assert!(consumed, "pair_drop_ack should be consumed");
1187 assert!(
1188 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1189 .unwrap()
1190 .is_none(),
1191 "stale pending-inbound record must be cleared on bilateral completion"
1192 );
1193 });
1194 }
1195
1196 #[test]
1197 fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1198 config::test_support::with_temp_home(|| {
1203 let peer_handle = "fresh-peer";
1204 let peer_did = format!("did:wire:{peer_handle}-12345678");
1205 let ack_event = serde_json::json!({
1206 "kind": 1101,
1207 "type": "pair_drop_ack",
1208 "from": peer_did,
1209 "body": {
1210 "relay_url": "https://example.test",
1211 "slot_id": "slot-eeee",
1212 "slot_token": "token-ffff",
1213 },
1214 });
1215 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1216 assert!(consumed, "ack must still be consumed (the pinning path)");
1217 });
1218 }
1219
1220 #[test]
1221 fn record_pair_rejection_appends_multiple_lines() {
1222 config::test_support::with_temp_home(|| {
1225 super::record_pair_rejection("a", "code_a", "detail_a");
1226 super::record_pair_rejection("b", "code_b", "detail_b");
1227 super::record_pair_rejection("c", "code_c", "detail_c");
1228 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1229 let body = std::fs::read_to_string(&path).unwrap();
1230 let lines: Vec<&str> = body.lines().collect();
1231 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1232 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1233 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1234 assert_eq!(parsed["peer"], *peer);
1235 }
1236 });
1237 }
1238}