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
182fn pick_reusable_self_endpoint<'a>(
195 existing: &'a [crate::endpoints::Endpoint],
196 preferred_relay: Option<&str>,
197) -> Option<&'a crate::endpoints::Endpoint> {
198 let norm = |u: &str| u.trim_end_matches('/').to_string();
199 match preferred_relay {
200 Some(p) => existing.iter().find(|e| norm(&e.relay_url) == norm(p)),
201 None => existing
202 .iter()
203 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
204 .or_else(|| existing.first()),
205 }
206}
207
208pub fn ensure_self_with_relay(
211 preferred_relay: Option<&str>,
212) -> Result<(String, String, String, String)> {
213 let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
214
215 if !config::is_initialized()? {
216 let handle = default_handle();
217 crate::init::init_self_idempotent(&handle, None, Some(relay))
218 .with_context(|| format!("auto-init as did:wire:{handle}"))?;
219 }
220
221 let card = config::read_agent_card()?;
222 let did = card
223 .get("did")
224 .and_then(Value::as_str)
225 .ok_or_else(|| anyhow!("agent-card missing did"))?
226 .to_string();
227
228 let mut relay_state = config::read_relay_state()?;
229
230 let existing = crate::endpoints::self_endpoints(&relay_state);
246 if let Some(ep) = pick_reusable_self_endpoint(&existing, preferred_relay).cloned() {
247 return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
248 }
249
250 let client = crate::relay_client::RelayClient::new(relay);
255 client.check_healthz()?;
256 let handle = crate::agent_card::display_handle_from_did(&did);
257 let alloc = client.allocate_slot(Some(handle))?;
258 let scope = crate::endpoints::infer_scope_from_url(relay);
259 let ep = match scope {
260 crate::endpoints::EndpointScope::Local => crate::endpoints::Endpoint::local(
261 relay.to_string(),
262 alloc.slot_id.clone(),
263 alloc.slot_token.clone(),
264 ),
265 crate::endpoints::EndpointScope::Lan => crate::endpoints::Endpoint::lan(
266 relay.to_string(),
267 alloc.slot_id.clone(),
268 alloc.slot_token.clone(),
269 ),
270 crate::endpoints::EndpointScope::Uds => crate::endpoints::Endpoint::uds(
271 relay.to_string(),
272 alloc.slot_id.clone(),
273 alloc.slot_token.clone(),
274 ),
275 crate::endpoints::EndpointScope::Federation => crate::endpoints::Endpoint::federation(
276 relay.to_string(),
277 alloc.slot_id.clone(),
278 alloc.slot_token.clone(),
279 ),
280 };
281 crate::endpoints::upsert_self_endpoint(&mut relay_state, ep);
282 config::write_relay_state(&relay_state)?;
283 Ok((did, relay.to_string(), alloc.slot_id, alloc.slot_token))
284}
285
286pub fn mint_invite(
288 ttl_secs: Option<u64>,
289 uses: u32,
290 preferred_relay: Option<&str>,
291) -> Result<String> {
292 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
293
294 let card = config::read_agent_card()?;
295 let sk_seed = config::read_private_key()?;
296
297 let mut nonce_bytes = [0u8; 32];
298 use rand::RngCore;
299 rand::thread_rng().fill_bytes(&mut nonce_bytes);
300 let nonce = hex::encode(nonce_bytes);
301
302 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
303 let exp = now_unix() + ttl;
304
305 let payload = InvitePayload {
306 v: 1,
307 did: did.clone(),
308 card,
309 relay_url,
310 slot_id,
311 slot_token,
312 nonce: nonce.clone(),
313 exp,
314 };
315 let payload_bytes = serde_json::to_vec(&payload)?;
316
317 let mut sk_arr = [0u8; 32];
318 sk_arr.copy_from_slice(&sk_seed[..32]);
319 let sk = SigningKey::from_bytes(&sk_arr);
320 let sig = sk.sign(&payload_bytes);
321
322 let token = format!(
323 "{}.{}",
324 B64URL.encode(&payload_bytes),
325 B64URL.encode(sig.to_bytes())
326 );
327 let url = format!("wire://pair?v=1&inv={token}");
328
329 let now = time::OffsetDateTime::now_utc()
330 .format(&time::format_description::well_known::Rfc3339)
331 .unwrap_or_default();
332 let pending = PendingInvite {
333 nonce: nonce.clone(),
334 exp,
335 uses_remaining: uses.max(1),
336 accepted_by: vec![],
337 created_at: now,
338 };
339 let dir = pending_invites_dir()?;
340 std::fs::create_dir_all(&dir)?;
341 let path = dir.join(format!("{nonce}.json"));
342 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
343
344 Ok(url)
345}
346
347pub fn parse_invite(url: &str) -> Result<InvitePayload> {
350 let rest = url
351 .strip_prefix("wire://pair?")
352 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
353 let mut inv = None;
354 for part in rest.split('&') {
355 if let Some(v) = part.strip_prefix("inv=") {
356 inv = Some(v);
357 }
358 }
359 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
360 let (payload_b64, sig_b64) = token
361 .split_once('.')
362 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
363 let payload_bytes = B64URL
364 .decode(payload_b64)
365 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
366 let sig_bytes = B64URL
367 .decode(sig_b64)
368 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
369
370 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
371 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
372
373 if payload.v != 1 {
374 bail!("invite schema version {} not supported", payload.v);
375 }
376 if now_unix() > payload.exp {
377 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
378 }
379
380 crate::agent_card::verify_agent_card(&payload.card)
382 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
383
384 let pk_b64 = payload
385 .card
386 .get("verify_keys")
387 .and_then(Value::as_object)
388 .and_then(|m| m.values().next())
389 .and_then(|v| v.get("key"))
390 .and_then(Value::as_str)
391 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
392 let pk_bytes = crate::signing::b64decode(pk_b64)?;
393 let mut pk_arr = [0u8; 32];
394 if pk_bytes.len() != 32 {
395 bail!("issuer pubkey wrong length");
396 }
397 pk_arr.copy_from_slice(&pk_bytes);
398 let vk = VerifyingKey::from_bytes(&pk_arr)
399 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
400 let mut sig_arr = [0u8; 64];
401 if sig_bytes.len() != 64 {
402 bail!("invite sig wrong length");
403 }
404 sig_arr.copy_from_slice(&sig_bytes);
405 let sig = Signature::from_bytes(&sig_arr);
406 vk.verify(&payload_bytes, &sig)
407 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
408
409 Ok(payload)
410}
411
412pub fn accept_invite(url: &str) -> Result<Value> {
415 let payload = parse_invite(url)?;
416
417 let (our_did, our_relay, our_slot_id, our_slot_token) =
419 ensure_self_with_relay(Some(&payload.relay_url))?;
420
421 if our_did == payload.did {
422 bail!("refusing to accept own invite (issuer DID matches self)");
423 }
424
425 config::update_trust(|trust| {
429 crate::trust::add_agent_card_pin(trust, &payload.card, Some("VERIFIED"))
430 .map_err(anyhow::Error::msg)
431 })?;
432
433 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
434 let mut relay_state = config::read_relay_state()?;
435 crate::endpoints::pin_peer_endpoints(
439 &mut relay_state,
440 &peer_handle,
441 &[crate::endpoints::Endpoint::federation(
442 payload.relay_url.clone(),
443 payload.slot_id.clone(),
444 payload.slot_token.clone(),
445 )],
446 )?;
447 config::write_relay_state(&relay_state)?;
448
449 let our_card = config::read_agent_card()?;
453 let sk_seed = config::read_private_key()?;
454 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
455 let pk_b64 = our_card
456 .get("verify_keys")
457 .and_then(Value::as_object)
458 .and_then(|m| m.values().next())
459 .and_then(|v| v.get("key"))
460 .and_then(Value::as_str)
461 .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
462 let pk_bytes = crate::signing::b64decode(pk_b64)?;
463
464 let now = time::OffsetDateTime::now_utc()
465 .format(&time::format_description::well_known::Rfc3339)
466 .unwrap_or_default();
467 let event = json!({
468 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
469 "timestamp": now,
470 "from": our_did,
471 "to": payload.did,
472 "type": "pair_drop",
473 "kind": 1100u32,
474 "body": {
475 "card": our_card,
476 "relay_url": our_relay,
477 "slot_id": our_slot_id,
478 "slot_token": our_slot_token,
479 "pair_nonce": payload.nonce,
480 },
481 });
482 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
483 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
484
485 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
486 client
487 .post_event(&payload.slot_id, &payload.slot_token, &signed)
488 .with_context(|| {
489 format!(
490 "POST pair_drop to {} slot {}",
491 payload.relay_url, payload.slot_id
492 )
493 })?;
494
495 Ok(json!({
496 "paired_with": payload.did,
497 "peer_handle": peer_handle,
498 "event_id": event_id,
499 "status": "drop_sent",
500 }))
501}
502
503pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
508 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
509 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
510 if kind != 1100 || type_str != "pair_drop" {
511 return Ok(None);
512 }
513 let body = match event.get("body") {
514 Some(b) => b,
515 None => return Ok(None),
516 };
517
518 let nonce_opt = body
524 .get("pair_nonce")
525 .and_then(Value::as_str)
526 .map(str::to_string);
527 let mut pending: Option<PendingInvite> = None;
528 let mut invite_path: Option<std::path::PathBuf> = None;
529 if let Some(nonce) = nonce_opt.as_deref() {
530 let dir = pending_invites_dir()?;
531 let path = dir.join(format!("{nonce}.json"));
532 if path.exists() {
533 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
534 .with_context(|| format!("reading pending invite {path:?}"))?;
535 if now_unix() > p.exp {
536 if let Err(e) = std::fs::remove_file(&path) {
539 eprintln!("wire: could not delete expired invite {path:?}: {e}");
540 }
541 return Ok(None);
542 }
543 pending = Some(p);
544 invite_path = Some(path);
545 } else if !open_mode_enabled() {
546 return Ok(None);
550 }
551 } else if !open_mode_enabled() {
552 return Ok(None);
555 }
556
557 let peer_card = body
558 .get("card")
559 .cloned()
560 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
561 crate::agent_card::verify_agent_card(&peer_card)
562 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
563
564 let peer_did = peer_card
565 .get("did")
566 .and_then(Value::as_str)
567 .ok_or_else(|| anyhow!("peer card missing did"))?
568 .to_string();
569 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
570
571 let mut tmp_trust = config::read_trust()?;
576 let _ = crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
581 crate::signing::verify_message_v31(event, &tmp_trust)
582 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
583
584 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
585 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
586 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
587 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
588 bail!("pair_drop body missing relay_url/slot_id/slot_token");
589 }
590
591 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
596 .get("endpoints")
597 .and_then(Value::as_array)
598 .map(|arr| {
599 arr.iter()
600 .filter_map(|e| {
601 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
602 })
603 .collect()
604 })
605 .unwrap_or_else(|| {
606 vec![crate::endpoints::Endpoint::federation(
607 peer_relay.to_string(),
608 peer_slot_id.to_string(),
609 peer_slot_token.to_string(),
610 )]
611 });
612
613 if nonce_opt.is_some() {
631 config::write_trust(&tmp_trust)?;
633 let mut relay_state = config::read_relay_state()?;
634 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
638 config::write_relay_state(&relay_state)?;
639
640 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
642 if pending.uses_remaining <= 1 {
643 if let Err(e) = std::fs::remove_file(&invite_path) {
644 eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
645 }
646 } else {
647 let mut updated = pending.clone();
648 updated.uses_remaining -= 1;
649 updated.accepted_by.push(peer_did.clone());
650 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
651 }
652 }
653 crate::os_notify::toast(
654 &format!("wire — paired with {peer_handle}"),
655 "Invite accepted. Ready to send + receive.",
656 );
657 return Ok(Some(peer_did));
658 }
659
660 let blocklist = crate::blocklist::Blocklist::load();
668 if let Some(blocked_did) = blocklist.blocks_card(&peer_card) {
669 record_pair_rejection(
670 &peer_handle,
671 "blocked_peer",
672 &format!(
673 "inbound pair from locally-blocked DID {blocked_did}; dropped (wire block-peer)"
674 ),
675 );
676 return Ok(None);
677 }
678
679 if let Some(org_did) =
687 org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
688 {
689 crate::config::update_trust(|trust| {
690 crate::trust::add_agent_card_pin(trust, &peer_card, Some("ORG_VERIFIED"))
691 .map_err(anyhow::Error::msg)
692 })?;
693
694 let endpoints_to_pin = if peer_endpoints.is_empty() {
695 vec![crate::endpoints::Endpoint::federation(
696 peer_relay.to_string(),
697 peer_slot_id.to_string(),
698 peer_slot_token.to_string(),
699 )]
700 } else {
701 peer_endpoints.clone()
702 };
703 let mut relay_state = crate::config::read_relay_state()?;
704 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
705 crate::config::write_relay_state(&relay_state)?;
706
707 send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
708 .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
709
710 crate::os_notify::toast_dedup(
711 &format!("org-pair:{peer_handle}"),
712 &format!("wire — auto-paired {peer_handle}"),
713 &format!(
714 "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
715 ),
716 );
717 return Ok(Some(peer_did));
718 }
719
720 if crate::same_machine::auto_pin_decision(&peer_card).is_some() {
729 crate::config::update_trust(|trust| {
730 crate::trust::add_agent_card_pin(trust, &peer_card, Some("ORG_VERIFIED"))
731 .map_err(anyhow::Error::msg)
732 })?;
733
734 let endpoints_to_pin = if peer_endpoints.is_empty() {
735 vec![crate::endpoints::Endpoint::federation(
736 peer_relay.to_string(),
737 peer_slot_id.to_string(),
738 peer_slot_token.to_string(),
739 )]
740 } else {
741 peer_endpoints.clone()
742 };
743 let mut relay_state = crate::config::read_relay_state()?;
744 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
745 crate::config::write_relay_state(&relay_state)?;
746
747 send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
748 .with_context(|| format!("same-machine pair_drop_ack send to {peer_handle} failed"))?;
749
750 crate::os_notify::toast_dedup(
751 &format!("same-machine:{peer_handle}"),
752 &format!("wire — auto-paired {peer_handle}"),
753 "Same operator, same machine; pinned ORG_VERIFIED (same-machine attestation).",
754 );
755 return Ok(Some(peer_did));
756 }
757
758 {
768 let trust = config::read_trust()?;
769 let existing = trust.get("agents").and_then(|a| a.get(&peer_handle));
770 let existing_did = existing.and_then(|e| e.get("did")).and_then(Value::as_str);
771 let existing_tier = existing
772 .and_then(|e| e.get("tier"))
773 .and_then(Value::as_str)
774 .unwrap_or("UNTRUSTED");
775 if existing_did == Some(peer_did.as_str()) && existing_tier != "UNTRUSTED" {
776 let endpoints_to_pin = if peer_endpoints.is_empty() {
777 vec![crate::endpoints::Endpoint::federation(
778 peer_relay.to_string(),
779 peer_slot_id.to_string(),
780 peer_slot_token.to_string(),
781 )]
782 } else {
783 peer_endpoints.clone()
784 };
785 let mut relay_state = config::read_relay_state()?;
786 crate::endpoints::pin_peer_endpoints(
787 &mut relay_state,
788 &peer_handle,
789 &endpoints_to_pin,
790 )?;
791 config::write_relay_state(&relay_state)?;
792 let tier_owned = existing_tier.to_string();
795 config::update_trust(|t| {
796 crate::trust::add_agent_card_pin(t, &peer_card, Some(&tier_owned))
797 .map_err(anyhow::Error::msg)
798 })?;
799 send_pair_drop_ack(&peer_handle, &endpoints_to_pin).with_context(|| {
800 format!("rotation-refresh pair_drop_ack to {peer_handle} failed")
801 })?;
802 crate::os_notify::toast_dedup(
803 &format!("rotate-refresh:{peer_handle}"),
804 &format!("wire — {peer_handle} rotated; refreshed"),
805 "Re-acked their new relay slot (already-trusted identity).",
806 );
807 return Ok(Some(peer_did));
808 }
809 }
810
811 let now_iso = time::OffsetDateTime::now_utc()
812 .format(&time::format_description::well_known::Rfc3339)
813 .unwrap_or_default();
814 let event_id = event
815 .get("event_id")
816 .and_then(Value::as_str)
817 .unwrap_or("")
818 .to_string();
819 let event_timestamp = event
820 .get("timestamp")
821 .and_then(Value::as_str)
822 .unwrap_or("")
823 .to_string();
824 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
825 peer_handle: peer_handle.clone(),
826 peer_did: peer_did.clone(),
827 peer_card: peer_card.clone(),
828 peer_relay_url: peer_relay.to_string(),
829 peer_slot_id: peer_slot_id.to_string(),
830 peer_slot_token: peer_slot_token.to_string(),
831 peer_endpoints: peer_endpoints.clone(),
832 event_id,
833 event_timestamp,
834 received_at: now_iso,
835 };
836 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
837
838 match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
845 Some(org_did) => crate::os_notify::toast_dedup(
846 &format!("notify-pair:{peer_handle}"),
847 &format!("wire — org-verified pair request from {peer_handle}"),
848 &format!(
849 "verified member of {org_did} (your org_policies.json says `notify`). run `wire accept {peer_handle}` to pin VERIFIED, or `wire reject {peer_handle}`",
850 ),
851 ),
852 None => crate::os_notify::toast(
853 &format!("wire — pair request from {peer_handle}"),
854 &format!(
855 "run `wire accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire reject {peer_handle}` to refuse",
856 ),
857 ),
858 }
859
860 Ok(Some(peer_did))
861}
862
863fn org_auto_pin_decision(
869 card: &Value,
870 policy: &dyn crate::pair_decision::OrgPolicy,
871) -> Option<String> {
872 match crate::pair_decision::decide(
873 &crate::org_membership::evaluate_card_membership(card),
874 policy,
875 ) {
876 crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
877 _ => None,
878 }
879}
880
881fn org_notify_decision(
891 card: &Value,
892 policy: &dyn crate::pair_decision::OrgPolicy,
893) -> Option<String> {
894 match crate::pair_decision::decide(
895 &crate::org_membership::evaluate_card_membership(card),
896 policy,
897 ) {
898 crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
899 _ => None,
900 }
901}
902
903pub fn send_pair_drop_ack(
925 peer_handle: &str,
926 peer_endpoints: &[crate::endpoints::Endpoint],
927) -> Result<()> {
928 let our_card = config::read_agent_card()?;
930 let our_did = our_card
931 .get("did")
932 .and_then(Value::as_str)
933 .ok_or_else(|| anyhow!("our card missing did"))?
934 .to_string();
935 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
936 let relay_state = config::read_relay_state()?;
937 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
938 let mut our_relay = self_state
947 .get("relay_url")
948 .and_then(Value::as_str)
949 .unwrap_or("")
950 .to_string();
951 let mut our_slot_id = self_state
952 .get("slot_id")
953 .and_then(Value::as_str)
954 .unwrap_or("")
955 .to_string();
956 let mut our_slot_token = self_state
957 .get("slot_token")
958 .and_then(Value::as_str)
959 .unwrap_or("")
960 .to_string();
961 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
962 let eps = crate::endpoints::self_endpoints(&relay_state);
967 if let Some(ep) = eps.first() {
968 our_relay = ep.relay_url.clone();
969 our_slot_id = ep.slot_id.clone();
970 our_slot_token = ep.slot_token.clone();
971 }
972 }
973 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
974 bail!(
979 "this session has no inbound slot configured — peers cannot deliver to us.\n\
980 Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
981 (allocates a slot and re-publishes our card to all pinned peers).\n\
982 Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
983 );
984 }
985
986 let sk_seed = config::read_private_key()?;
987 let pk_b64 = our_card
988 .get("verify_keys")
989 .and_then(Value::as_object)
990 .and_then(|m| m.values().next())
991 .and_then(|v| v.get("key"))
992 .and_then(Value::as_str)
993 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
994 let pk_bytes = crate::signing::b64decode(pk_b64)?;
995
996 let now = time::OffsetDateTime::now_utc()
997 .format(&time::format_description::well_known::Rfc3339)
998 .unwrap_or_default();
999 let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
1003 let mut body = json!({
1004 "relay_url": our_relay,
1005 "slot_id": our_slot_id,
1006 "slot_token": our_slot_token,
1007 });
1008 if !our_endpoints.is_empty() {
1009 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
1010 }
1011 let event = json!({
1012 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1013 "timestamp": now,
1014 "from": our_did,
1015 "to": format!("did:wire:{peer_handle}"),
1016 "type": "pair_drop_ack",
1017 "kind": 1101u32,
1018 "body": body,
1019 });
1020 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
1021
1022 let (delivered_ep, _resp) =
1028 crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
1029 crate::relay_client::post_event_to_endpoint(ep, ev)
1030 })
1031 .with_context(|| {
1032 format!(
1033 "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
1034 peer_endpoints.len()
1035 )
1036 })?;
1037 let _ = delivered_ep; Ok(())
1039}
1040
1041pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
1045 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
1046 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
1047 if kind != 1101 || type_str != "pair_drop_ack" {
1048 return Ok(false);
1049 }
1050 let body = match event.get("body") {
1051 Some(b) => b,
1052 None => return Ok(false),
1053 };
1054 let from = event
1055 .get("from")
1056 .and_then(Value::as_str)
1057 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
1058 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
1059 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
1060 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
1061 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
1062 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
1063 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
1064 }
1065 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
1069 .get("endpoints")
1070 .and_then(Value::as_array)
1071 .map(|arr| {
1072 arr.iter()
1073 .filter_map(|e| {
1074 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
1075 })
1076 .collect()
1077 })
1078 .unwrap_or_else(|| {
1079 vec![crate::endpoints::Endpoint::federation(
1080 peer_relay.to_string(),
1081 peer_slot_id.to_string(),
1082 peer_slot_token.to_string(),
1083 )]
1084 });
1085 let mut relay_state = config::read_relay_state()?;
1086 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
1087 if let Some(peer_entry) = relay_state
1096 .get_mut("peers")
1097 .and_then(Value::as_object_mut)
1098 .and_then(|m| m.get_mut(&peer_handle))
1099 .and_then(Value::as_object_mut)
1100 {
1101 peer_entry
1102 .entry("bilateral_completed_at".to_string())
1103 .or_insert_with(|| {
1104 Value::String(
1105 time::OffsetDateTime::now_utc()
1106 .format(&time::format_description::well_known::Rfc3339)
1107 .unwrap_or_default(),
1108 )
1109 });
1110 }
1111 config::write_relay_state(&relay_state)?;
1112 if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
1122 eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
1125 }
1126 crate::os_notify::toast(
1127 &format!("wire — pair complete with {peer_handle}"),
1128 "Both sides bound. Ready to send + receive.",
1129 );
1130 Ok(true)
1131}
1132
1133#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use crate::endpoints::Endpoint;
1142
1143 #[test]
1146 fn pick_reusable_some_relay_only_matches_that_relay() {
1147 let existing = vec![
1152 Endpoint::local("http://127.0.0.1:18791".into(), "loc".into(), "lt".into()),
1153 Endpoint::federation("https://wireup.net".into(), "fed".into(), "ft".into()),
1154 ];
1155 let pick = pick_reusable_self_endpoint(&existing, Some("https://wireup.net")).unwrap();
1156 assert_eq!(pick.slot_id, "fed");
1157 let pick2 = pick_reusable_self_endpoint(&existing, Some("https://wireup.net/")).unwrap();
1159 assert_eq!(pick2.slot_id, "fed");
1160 assert!(pick_reusable_self_endpoint(&existing, Some("https://other.example")).is_none());
1162 }
1163
1164 #[test]
1165 fn pick_reusable_loopback_only_does_not_satisfy_federation_request() {
1166 let existing = vec![Endpoint::local(
1170 "http://127.0.0.1:18791".into(),
1171 "loc".into(),
1172 "lt".into(),
1173 )];
1174 assert!(pick_reusable_self_endpoint(&existing, Some("https://wireup.net")).is_none());
1175 }
1176
1177 #[test]
1178 fn pick_reusable_none_prefers_federation_then_first() {
1179 let local_only = vec![Endpoint::local(
1182 "http://127.0.0.1:8771".into(),
1183 "loc".into(),
1184 "lt".into(),
1185 )];
1186 assert_eq!(
1187 pick_reusable_self_endpoint(&local_only, None)
1188 .unwrap()
1189 .slot_id,
1190 "loc"
1191 );
1192 let dual = vec![
1193 Endpoint::local("http://127.0.0.1:8771".into(), "loc".into(), "lt".into()),
1194 Endpoint::federation("https://wireup.net".into(), "fed".into(), "ft".into()),
1195 ];
1196 assert_eq!(
1197 pick_reusable_self_endpoint(&dual, None).unwrap().slot_id,
1198 "fed"
1199 );
1200 assert!(pick_reusable_self_endpoint(&[], None).is_none());
1201 }
1202
1203 struct AutoFor(String);
1206 impl crate::pair_decision::OrgPolicy for AutoFor {
1207 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1208 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
1209 }
1210 }
1211 struct EmptyPolicy;
1212 impl crate::pair_decision::OrgPolicy for EmptyPolicy {
1213 fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
1214 None
1215 }
1216 }
1217
1218 fn org_verified_card() -> (Value, String) {
1220 let (op_sk, op_pk) = crate::signing::generate_keypair();
1221 let (org_sk, org_pk) = crate::signing::generate_keypair();
1222 let (sess_sk, sess_pk) = crate::signing::generate_keypair();
1223 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
1224 let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
1225 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
1226 let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1227 let session_did = base
1228 .get("did")
1229 .and_then(|v| v.as_str())
1230 .unwrap()
1231 .to_string();
1232 let claims = crate::enroll::build_member_claims(
1233 "darby",
1234 &op_sk,
1235 &op_pk,
1236 &session_did,
1237 &[crate::enroll::MemberOf {
1238 org_did: org_did.clone(),
1239 org_pubkey: org_pk,
1240 member_cert,
1241 }],
1242 None,
1243 )
1244 .unwrap();
1245 let card = crate::agent_card::sign_agent_card(
1246 &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1247 &sess_sk,
1248 );
1249 (card, org_did)
1250 }
1251
1252 #[test]
1253 fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1254 let (card, org_did) = org_verified_card();
1255 assert_eq!(
1257 org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1258 Some(org_did.clone())
1259 );
1260 assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1262 }
1263
1264 #[test]
1265 fn org_auto_pin_decision_none_for_plain_card() {
1266 let plain = serde_json::json!({
1269 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1270 });
1271 assert_eq!(
1272 org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1273 None
1274 );
1275 }
1276
1277 struct NotifyFor(String);
1280 impl crate::pair_decision::OrgPolicy for NotifyFor {
1281 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1282 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1283 }
1284 }
1285
1286 #[test]
1287 fn org_notify_decision_notify_only_when_policy_opts_in() {
1288 let (card, org_did) = org_verified_card();
1289 assert_eq!(
1291 org_notify_decision(&card, &NotifyFor(org_did.clone())),
1292 Some(org_did.clone())
1293 );
1294 assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1296 }
1297
1298 #[test]
1299 fn org_notify_decision_returns_none_when_policy_is_auto() {
1300 let (card, org_did) = org_verified_card();
1304 assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1305 }
1306
1307 #[test]
1308 fn org_notify_decision_none_for_plain_card() {
1309 let plain = serde_json::json!({
1312 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1313 });
1314 assert_eq!(
1315 org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1316 None
1317 );
1318 }
1319 use crate::config;
1320
1321 #[test]
1322 fn record_pair_rejection_writes_jsonl_under_state_dir() {
1323 config::test_support::with_temp_home(|| {
1327 super::record_pair_rejection(
1328 "slancha-spark",
1329 "pair_drop_ack_send_failed",
1330 "POST returned 502",
1331 );
1332 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1333 assert!(path.exists(), "record_pair_rejection must create {path:?}");
1334 let body = std::fs::read_to_string(&path).unwrap();
1335 let line = body.lines().last().expect("at least one line");
1336 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1337 assert_eq!(parsed["peer"], "slancha-spark");
1338 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1339 assert_eq!(parsed["detail"], "POST returned 502");
1340 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1341 });
1342 }
1343
1344 #[test]
1345 fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1346 config::test_support::with_temp_home(|| {
1354 let peer_handle = "test-peer";
1355 let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1356 let pending = crate::pending_inbound_pair::PendingInboundPair {
1357 peer_handle: peer_handle.to_string(),
1358 peer_did: peer_did.clone(),
1359 peer_card: serde_json::json!({"did": peer_did.clone()}),
1360 peer_relay_url: "https://example.test".into(),
1361 peer_slot_id: "slot-aaaa".into(),
1362 peer_slot_token: "token-bbbb".into(),
1363 peer_endpoints: vec![],
1364 event_id: "evt-0001".into(),
1365 event_timestamp: "2026-06-01T20:00:00Z".into(),
1366 received_at: "2026-06-01T20:00:01Z".into(),
1367 };
1368 crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1369 assert!(
1370 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1371 .unwrap()
1372 .is_some(),
1373 "precondition: pending record exists"
1374 );
1375 let ack_event = serde_json::json!({
1376 "kind": 1101,
1377 "type": "pair_drop_ack",
1378 "from": peer_did,
1379 "body": {
1380 "relay_url": "https://example.test",
1381 "slot_id": "slot-cccc",
1382 "slot_token": "token-dddd",
1383 },
1384 });
1385 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1386 assert!(consumed, "pair_drop_ack should be consumed");
1387 assert!(
1388 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1389 .unwrap()
1390 .is_none(),
1391 "stale pending-inbound record must be cleared on bilateral completion"
1392 );
1393 });
1394 }
1395
1396 #[test]
1397 fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1398 config::test_support::with_temp_home(|| {
1403 let peer_handle = "fresh-peer";
1404 let peer_did = format!("did:wire:{peer_handle}-12345678");
1405 let ack_event = serde_json::json!({
1406 "kind": 1101,
1407 "type": "pair_drop_ack",
1408 "from": peer_did,
1409 "body": {
1410 "relay_url": "https://example.test",
1411 "slot_id": "slot-eeee",
1412 "slot_token": "token-ffff",
1413 },
1414 });
1415 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1416 assert!(consumed, "ack must still be consumed (the pinning path)");
1417 });
1418 }
1419
1420 #[test]
1421 fn record_pair_rejection_appends_multiple_lines() {
1422 config::test_support::with_temp_home(|| {
1425 super::record_pair_rejection("a", "code_a", "detail_a");
1426 super::record_pair_rejection("b", "code_b", "detail_b");
1427 super::record_pair_rejection("c", "code_c", "detail_c");
1428 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1429 let body = std::fs::read_to_string(&path).unwrap();
1430 let lines: Vec<&str> = body.lines().collect();
1431 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1432 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1433 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1434 assert_eq!(parsed["peer"], *peer);
1435 }
1436 });
1437 }
1438}