1use std::path::PathBuf;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use anyhow::{Context, Result, anyhow, bail};
27use base64::Engine as _;
28use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
29use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32
33use crate::config;
34
35pub const DEFAULT_RELAY: &str = "https://wireup.net";
36pub const DEFAULT_TTL_SECS: u64 = 86_400; pub(crate) fn record_pair_rejection(peer_handle: &str, code: &str, detail: &str) {
46 let line = json!({
47 "ts": std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.as_secs())
50 .unwrap_or(0),
51 "peer": peer_handle,
52 "code": code,
53 "detail": detail,
54 });
55 let serialised = match serde_json::to_string(&line) {
56 Ok(s) => s,
57 Err(e) => {
58 eprintln!("wire: could not serialise pair-rejected entry: {e}");
59 return;
60 }
61 };
62 let path = match config::state_dir() {
63 Ok(d) => d.join("pair-rejected.jsonl"),
64 Err(e) => {
65 eprintln!("wire: state_dir unresolved, dropping pair-rejected log: {e}");
66 return;
67 }
68 };
69 if let Some(parent) = path.parent() {
70 if let Err(e) = std::fs::create_dir_all(parent) {
71 eprintln!("wire: could not create {parent:?}: {e}");
72 return;
73 }
74 }
75 use std::io::Write;
76 match std::fs::OpenOptions::new()
77 .create(true)
78 .append(true)
79 .open(&path)
80 {
81 Ok(mut f) => {
82 if let Err(e) = writeln!(f, "{serialised}") {
83 eprintln!("wire: could not append pair-rejected to {path:?}: {e}");
84 }
85 }
86 Err(e) => {
87 eprintln!("wire: could not open {path:?}: {e}");
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct InvitePayload {
95 pub v: u32,
97 pub did: String,
99 pub card: Value,
101 pub relay_url: String,
103 pub slot_id: String,
105 pub slot_token: String,
107 pub nonce: String,
109 pub exp: u64,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PendingInvite {
116 pub nonce: String,
117 pub exp: u64,
118 pub uses_remaining: u32,
119 pub accepted_by: Vec<String>,
121 pub created_at: String,
122}
123
124fn open_mode_enabled() -> bool {
128 let path = match config::config_dir() {
129 Ok(p) => p.join("policy.json"),
130 Err(_) => return true,
131 };
132 let bytes = match std::fs::read(&path) {
133 Ok(b) => b,
134 Err(_) => return true,
135 };
136 let v: Value = match serde_json::from_slice(&bytes) {
137 Ok(v) => v,
138 Err(_) => return true,
139 };
140 v.get("accept_unknown_pair_drops")
141 .and_then(Value::as_bool)
142 .unwrap_or(true)
143}
144
145pub fn pending_invites_dir() -> Result<PathBuf> {
146 Ok(config::state_dir()?.join("pending-invites"))
147}
148
149fn now_unix() -> u64 {
150 SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .map(|d| d.as_secs())
153 .unwrap_or(0)
154}
155
156fn default_handle() -> String {
159 let raw = hostname::get()
160 .ok()
161 .and_then(|s| s.into_string().ok())
162 .unwrap_or_else(|| "wire-user".into());
163 let sanitized: String = raw
164 .chars()
165 .map(|c| {
166 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
167 c
168 } else {
169 '-'
170 }
171 })
172 .collect();
173 if sanitized.is_empty() {
174 "wire-user".into()
175 } else {
176 sanitized
177 }
178}
179
180pub fn ensure_self_with_relay(
183 preferred_relay: Option<&str>,
184) -> Result<(String, String, String, String)> {
185 let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
186
187 if !config::is_initialized()? {
188 let handle = default_handle();
189 crate::pair_session::init_self_idempotent(&handle, None, Some(relay))
190 .with_context(|| format!("auto-init as did:wire:{handle}"))?;
191 }
192
193 let card = config::read_agent_card()?;
194 let did = card
195 .get("did")
196 .and_then(Value::as_str)
197 .ok_or_else(|| anyhow!("agent-card missing did"))?
198 .to_string();
199
200 let mut relay_state = config::read_relay_state()?;
201 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
202
203 if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
204 let client = crate::relay_client::RelayClient::new(relay);
205 client.check_healthz()?;
206 let handle = crate::agent_card::display_handle_from_did(&did);
207 let alloc = client.allocate_slot(Some(handle))?;
208 relay_state["self"] = json!({
209 "relay_url": relay,
210 "slot_id": alloc.slot_id,
211 "slot_token": alloc.slot_token,
212 });
213 config::write_relay_state(&relay_state)?;
214 }
215
216 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
217 let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
218 let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
219 let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
220 if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
221 bail!("self relay state incomplete after auto-allocate");
222 }
223 Ok((did, relay_url, slot_id, slot_token))
224}
225
226pub fn mint_invite(
228 ttl_secs: Option<u64>,
229 uses: u32,
230 preferred_relay: Option<&str>,
231) -> Result<String> {
232 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
233
234 let card = config::read_agent_card()?;
235 let sk_seed = config::read_private_key()?;
236
237 let mut nonce_bytes = [0u8; 32];
238 use rand::RngCore;
239 rand::thread_rng().fill_bytes(&mut nonce_bytes);
240 let nonce = hex::encode(nonce_bytes);
241
242 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
243 let exp = now_unix() + ttl;
244
245 let payload = InvitePayload {
246 v: 1,
247 did: did.clone(),
248 card,
249 relay_url,
250 slot_id,
251 slot_token,
252 nonce: nonce.clone(),
253 exp,
254 };
255 let payload_bytes = serde_json::to_vec(&payload)?;
256
257 let mut sk_arr = [0u8; 32];
258 sk_arr.copy_from_slice(&sk_seed[..32]);
259 let sk = SigningKey::from_bytes(&sk_arr);
260 let sig = sk.sign(&payload_bytes);
261
262 let token = format!(
263 "{}.{}",
264 B64URL.encode(&payload_bytes),
265 B64URL.encode(sig.to_bytes())
266 );
267 let url = format!("wire://pair?v=1&inv={token}");
268
269 let now = time::OffsetDateTime::now_utc()
270 .format(&time::format_description::well_known::Rfc3339)
271 .unwrap_or_default();
272 let pending = PendingInvite {
273 nonce: nonce.clone(),
274 exp,
275 uses_remaining: uses.max(1),
276 accepted_by: vec![],
277 created_at: now,
278 };
279 let dir = pending_invites_dir()?;
280 std::fs::create_dir_all(&dir)?;
281 let path = dir.join(format!("{nonce}.json"));
282 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
283
284 Ok(url)
285}
286
287pub fn parse_invite(url: &str) -> Result<InvitePayload> {
290 let rest = url
291 .strip_prefix("wire://pair?")
292 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
293 let mut inv = None;
294 for part in rest.split('&') {
295 if let Some(v) = part.strip_prefix("inv=") {
296 inv = Some(v);
297 }
298 }
299 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
300 let (payload_b64, sig_b64) = token
301 .split_once('.')
302 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
303 let payload_bytes = B64URL
304 .decode(payload_b64)
305 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
306 let sig_bytes = B64URL
307 .decode(sig_b64)
308 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
309
310 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
311 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
312
313 if payload.v != 1 {
314 bail!("invite schema version {} not supported", payload.v);
315 }
316 if now_unix() > payload.exp {
317 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
318 }
319
320 crate::agent_card::verify_agent_card(&payload.card)
322 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
323
324 let pk_b64 = payload
325 .card
326 .get("verify_keys")
327 .and_then(Value::as_object)
328 .and_then(|m| m.values().next())
329 .and_then(|v| v.get("key"))
330 .and_then(Value::as_str)
331 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
332 let pk_bytes = crate::signing::b64decode(pk_b64)?;
333 let mut pk_arr = [0u8; 32];
334 if pk_bytes.len() != 32 {
335 bail!("issuer pubkey wrong length");
336 }
337 pk_arr.copy_from_slice(&pk_bytes);
338 let vk = VerifyingKey::from_bytes(&pk_arr)
339 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
340 let mut sig_arr = [0u8; 64];
341 if sig_bytes.len() != 64 {
342 bail!("invite sig wrong length");
343 }
344 sig_arr.copy_from_slice(&sig_bytes);
345 let sig = Signature::from_bytes(&sig_arr);
346 vk.verify(&payload_bytes, &sig)
347 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
348
349 Ok(payload)
350}
351
352pub fn accept_invite(url: &str) -> Result<Value> {
355 let payload = parse_invite(url)?;
356
357 let (our_did, our_relay, our_slot_id, our_slot_token) =
359 ensure_self_with_relay(Some(&payload.relay_url))?;
360
361 if our_did == payload.did {
362 bail!("refusing to accept own invite (issuer DID matches self)");
363 }
364
365 let mut trust = config::read_trust()?;
367 crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
368 config::write_trust(&trust)?;
369
370 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
371 let mut relay_state = config::read_relay_state()?;
372 relay_state["peers"][&peer_handle] = json!({
373 "relay_url": payload.relay_url,
374 "slot_id": payload.slot_id,
375 "slot_token": payload.slot_token,
376 });
377 config::write_relay_state(&relay_state)?;
378
379 let our_card = config::read_agent_card()?;
383 let sk_seed = config::read_private_key()?;
384 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
385 let pk_b64 = our_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!("our agent-card missing verify_keys[*].key"))?;
392 let pk_bytes = crate::signing::b64decode(pk_b64)?;
393
394 let now = time::OffsetDateTime::now_utc()
395 .format(&time::format_description::well_known::Rfc3339)
396 .unwrap_or_default();
397 let event = json!({
398 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
399 "timestamp": now,
400 "from": our_did,
401 "to": payload.did,
402 "type": "pair_drop",
403 "kind": 1100u32,
404 "body": {
405 "card": our_card,
406 "relay_url": our_relay,
407 "slot_id": our_slot_id,
408 "slot_token": our_slot_token,
409 "pair_nonce": payload.nonce,
410 },
411 });
412 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
413 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
414
415 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
416 client
417 .post_event(&payload.slot_id, &payload.slot_token, &signed)
418 .with_context(|| {
419 format!(
420 "POST pair_drop to {} slot {}",
421 payload.relay_url, payload.slot_id
422 )
423 })?;
424
425 Ok(json!({
426 "paired_with": payload.did,
427 "peer_handle": peer_handle,
428 "event_id": event_id,
429 "status": "drop_sent",
430 }))
431}
432
433pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
438 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
439 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
440 if kind != 1100 || type_str != "pair_drop" {
441 return Ok(None);
442 }
443 let body = match event.get("body") {
444 Some(b) => b,
445 None => return Ok(None),
446 };
447
448 let nonce_opt = body
454 .get("pair_nonce")
455 .and_then(Value::as_str)
456 .map(str::to_string);
457 let mut pending: Option<PendingInvite> = None;
458 let mut invite_path: Option<std::path::PathBuf> = None;
459 if let Some(nonce) = nonce_opt.as_deref() {
460 let dir = pending_invites_dir()?;
461 let path = dir.join(format!("{nonce}.json"));
462 if path.exists() {
463 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
464 .with_context(|| format!("reading pending invite {path:?}"))?;
465 if now_unix() > p.exp {
466 if let Err(e) = std::fs::remove_file(&path) {
469 eprintln!(
470 "wire: could not delete expired invite {path:?}: {e}"
471 );
472 }
473 return Ok(None);
474 }
475 pending = Some(p);
476 invite_path = Some(path);
477 } else if !open_mode_enabled() {
478 return Ok(None);
482 }
483 } else if !open_mode_enabled() {
484 return Ok(None);
487 }
488
489 let peer_card = body
490 .get("card")
491 .cloned()
492 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
493 crate::agent_card::verify_agent_card(&peer_card)
494 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
495
496 let peer_did = peer_card
497 .get("did")
498 .and_then(Value::as_str)
499 .ok_or_else(|| anyhow!("peer card missing did"))?
500 .to_string();
501 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
502
503 let mut tmp_trust = config::read_trust()?;
505 crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
506 crate::signing::verify_message_v31(event, &tmp_trust)
507 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
508
509 config::write_trust(&tmp_trust)?;
511 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
512 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
513 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
514 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
515 bail!("pair_drop body missing relay_url/slot_id/slot_token");
516 }
517 let mut relay_state = config::read_relay_state()?;
518 relay_state["peers"][&peer_handle] = json!({
519 "relay_url": peer_relay,
520 "slot_id": peer_slot_id,
521 "slot_token": peer_slot_token,
522 });
523 config::write_relay_state(&relay_state)?;
524
525 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
528 if pending.uses_remaining <= 1 {
529 if let Err(e) = std::fs::remove_file(&invite_path) {
533 eprintln!(
534 "wire: could not delete consumed invite {invite_path:?}: {e}"
535 );
536 }
537 } else {
538 let mut updated = pending.clone();
539 updated.uses_remaining -= 1;
540 updated.accepted_by.push(peer_did.clone());
541 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
542 }
543 }
544
545 crate::os_notify::toast(
546 &format!("wire — paired with {peer_handle}"),
547 "Invite accepted. Ready to send + receive.",
548 );
549
550 if nonce_opt.is_none() {
562 if let Err(e) =
563 send_pair_drop_ack(&peer_handle, peer_relay, peer_slot_id, peer_slot_token)
564 {
565 eprintln!(
566 "wire: pair_drop_ack send to {peer_handle} @ {peer_relay} slot {peer_slot_id} FAILED: {e}. \
567 inbound pin succeeded but peer cannot bilateral-pin without our slot_token. \
568 retry with `wire add {peer_handle}@<their-relay>` or have peer re-add us."
569 );
570 record_pair_rejection(
571 &peer_handle,
572 "pair_drop_ack_send_failed",
573 &e.to_string(),
574 );
575 }
576 }
577
578 Ok(Some(peer_did))
579}
580
581fn send_pair_drop_ack(
587 peer_handle: &str,
588 peer_relay: &str,
589 peer_slot_id: &str,
590 peer_slot_token: &str,
591) -> Result<()> {
592 let our_card = config::read_agent_card()?;
594 let our_did = our_card
595 .get("did")
596 .and_then(Value::as_str)
597 .ok_or_else(|| anyhow!("our card missing did"))?
598 .to_string();
599 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
600 let relay_state = config::read_relay_state()?;
601 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
602 let our_relay = self_state
603 .get("relay_url")
604 .and_then(Value::as_str)
605 .unwrap_or("")
606 .to_string();
607 let our_slot_id = self_state
608 .get("slot_id")
609 .and_then(Value::as_str)
610 .unwrap_or("")
611 .to_string();
612 let our_slot_token = self_state
613 .get("slot_token")
614 .and_then(Value::as_str)
615 .unwrap_or("")
616 .to_string();
617 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
618 bail!("self relay state incomplete; cannot emit pair_drop_ack");
619 }
620
621 let sk_seed = config::read_private_key()?;
622 let pk_b64 = our_card
623 .get("verify_keys")
624 .and_then(Value::as_object)
625 .and_then(|m| m.values().next())
626 .and_then(|v| v.get("key"))
627 .and_then(Value::as_str)
628 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
629 let pk_bytes = crate::signing::b64decode(pk_b64)?;
630
631 let now = time::OffsetDateTime::now_utc()
632 .format(&time::format_description::well_known::Rfc3339)
633 .unwrap_or_default();
634 let event = json!({
635 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
636 "timestamp": now,
637 "from": our_did,
638 "to": format!("did:wire:{peer_handle}"),
639 "type": "pair_drop_ack",
640 "kind": 1101u32,
641 "body": {
642 "relay_url": our_relay,
643 "slot_id": our_slot_id,
644 "slot_token": our_slot_token,
645 },
646 });
647 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
648 let client = crate::relay_client::RelayClient::new(peer_relay);
649 client
650 .post_event(peer_slot_id, peer_slot_token, &signed)
651 .with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
652 Ok(())
653}
654
655pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
659 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
660 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
661 if kind != 1101 || type_str != "pair_drop_ack" {
662 return Ok(false);
663 }
664 let body = match event.get("body") {
665 Some(b) => b,
666 None => return Ok(false),
667 };
668 let from = event
669 .get("from")
670 .and_then(Value::as_str)
671 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
672 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
673 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
674 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
675 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
676 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
677 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
678 }
679 let mut relay_state = config::read_relay_state()?;
680 relay_state["peers"][&peer_handle] = json!({
681 "relay_url": peer_relay,
682 "slot_id": peer_slot_id,
683 "slot_token": peer_slot_token,
684 });
685 config::write_relay_state(&relay_state)?;
686 crate::os_notify::toast(
687 &format!("wire — pair complete with {peer_handle}"),
688 "Both sides bound. Ready to send + receive.",
689 );
690 Ok(true)
691}
692
693#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::config;
702
703 #[test]
704 fn record_pair_rejection_writes_jsonl_under_state_dir() {
705 config::test_support::with_temp_home(|| {
709 super::record_pair_rejection(
710 "slancha-spark",
711 "pair_drop_ack_send_failed",
712 "POST returned 502",
713 );
714 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
715 assert!(
716 path.exists(),
717 "record_pair_rejection must create {path:?}"
718 );
719 let body = std::fs::read_to_string(&path).unwrap();
720 let line = body.lines().last().expect("at least one line");
721 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
722 assert_eq!(parsed["peer"], "slancha-spark");
723 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
724 assert_eq!(parsed["detail"], "POST returned 502");
725 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
726 });
727 }
728
729 #[test]
730 fn record_pair_rejection_appends_multiple_lines() {
731 config::test_support::with_temp_home(|| {
734 super::record_pair_rejection("a", "code_a", "detail_a");
735 super::record_pair_rejection("b", "code_b", "detail_b");
736 super::record_pair_rejection("c", "code_c", "detail_c");
737 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
738 let body = std::fs::read_to_string(&path).unwrap();
739 let lines: Vec<&str> = body.lines().collect();
740 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
741 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
742 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
743 assert_eq!(parsed["peer"], *peer);
744 }
745 });
746 }
747}