Skip to main content

wire/
pair_invite.rs

1//! Invite-URL pair flow (v0.4.0). Single-paste, zero-config pairing.
2//!
3//! Flow:
4//!   A: `wire invite` → URL.
5//!   A pastes URL into any channel (Discord, SMS, voice-read).
6//!   B: `wire accept <URL>` → done. Both pinned.
7//!
8//! The invite URL is a self-contained bearer credential carrying A's signed
9//! agent-card, relay coords, slot_token, and a single-use pair_nonce. B parses
10//! it locally (no relay round-trip yet), pins A from the URL contents, then
11//! POSTs a signed kind=1100 `pair_drop` event to A's slot using the slot_token
12//! the URL granted. A's daemon (run_sync_pull) recognizes pair_drop events
13//! that carry a matching pending_invite nonce, verifies the embedded card,
14//! pins B, and consumes the nonce. Both sides paired.
15//!
16//! Trust model: pasting = trusting. Equivalent to Discord invite link, Zoom
17//! join URL, Signal group invite. Operator's act of moving the URL between
18//! channels IS the authentication ceremony. No SAS digits, no PAKE.
19//!
20//! The legacy SPAKE2 + SAS flow remains available via `wire pair --require-sas`
21//! for operators who want the stronger MITM-resistance model.
22
23use 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; // 24 hours
37
38/// P0.2 (0.5.11): write a structured rejection record for `wire doctor`
39/// to surface later. Best-effort — if we can't even open the file, fall
40/// back to stderr so the operator at least sees the failure mode in their
41/// shell. Anything is better than silent.
42///
43/// Lives at `$WIRE_HOME/state/wire/pair-rejected.jsonl`. One JSON line per
44/// rejected pair event. Append-only.
45pub(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/// Decoded contents of an invite URL.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct InvitePayload {
95    /// Schema version. Currently 1.
96    pub v: u32,
97    /// Issuer DID, e.g. `did:wire:paul`.
98    pub did: String,
99    /// Issuer's signed agent-card (full JSON).
100    pub card: Value,
101    /// Relay URL hosting the issuer's slot.
102    pub relay_url: String,
103    /// Issuer's slot id (32 hex chars).
104    pub slot_id: String,
105    /// Issuer's slot token (bearer auth for POSTing events to that slot).
106    pub slot_token: String,
107    /// Single-use nonce (32 random bytes hex).
108    pub nonce: String,
109    /// Unix timestamp after which this invite is invalid.
110    pub exp: u64,
111}
112
113/// On-disk record for a minted invite, awaiting acceptance.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PendingInvite {
116    pub nonce: String,
117    pub exp: u64,
118    pub uses_remaining: u32,
119    /// DIDs of peers who have already paired via this invite (for multi-use).
120    pub accepted_by: Vec<String>,
121    pub created_at: String,
122}
123
124/// Default-on policy: accept signed pair_drops from unknown peers (v0.5
125/// zero-paste discovery). Operator can opt out by writing
126/// `$WIRE_HOME/config/wire/policy.json` containing `{"accept_unknown_pair_drops": false}`.
127fn 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
156/// Hostname-derived default handle for auto-init. Falls back to "wire-user"
157/// if hostname is unavailable. Sanitized to ASCII alphanumeric / '-' / '_'.
158fn 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
180/// Ensure this node has an identity + relay slot. Idempotent.
181/// Returns (did, relay_url, slot_id, slot_token).
182pub 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
226/// Mint a fresh invite URL. Auto-inits + auto-allocates relay slot if needed.
227pub 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
287/// Parse an invite URL and verify the embedded signature against the embedded
288/// card's first active verify key.
289pub 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    // Verify the URL signature against the issuer's card key.
321    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
352/// Accept an invite URL. Auto-inits + auto-allocates if needed. Pins issuer
353/// from URL contents, then POSTs a signed pair_drop event to issuer's slot.
354pub fn accept_invite(url: &str) -> Result<Value> {
355    let payload = parse_invite(url)?;
356
357    // Auto-init self on the issuer's relay (or env-default if reachable).
358    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    // Pin issuer in trust + relay-state.
366    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    // Build signed pair_drop event carrying our own card + slot coords +
380    // the issuer's pair_nonce. Issuer's daemon will look it up against
381    // pending-invites and complete the bilateral pin.
382    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
433/// Consume a pair_drop event during daemon pull. Returns `Ok(Some(peer_did))`
434/// if the event matched a pending invite and the peer was pinned. Returns
435/// `Ok(None)` if not a pair_drop or no matching invite. Errors only on real
436/// problems (bad sig over event, IO failure).
437pub 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    // v0.5: accept handle-initiated pair_drops too (no pair_nonce). These
449    // come via `wire add <handle>` → POST /v1/handle/intro. Anchored only
450    // by the embedded signed card. Gated by config `accept_unknown_pair_drops`
451    // (default true). For nonce-bearing drops the existing v0.4 invite-URL
452    // path stays in force.
453    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                // P0.2: warn if cleanup fails — orphaned expired invites in
467                // `pending-invites/` will pile up and confuse `wire doctor`.
468                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            // Nonce present but unknown locally, and open mode disabled →
479            // refuse silently (the event will fall through to the normal
480            // verify path which won't trust the sender yet).
481            return Ok(None);
482        }
483    } else if !open_mode_enabled() {
484        // No nonce + open mode disabled → ignore. Operator must opt in to
485        // be discoverable via zero-paste `wire add`.
486        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    // Verify the event signature against the peer's embedded pubkey. We need
504    // a transient trust pin to drive the verifier, but for the handle path
505    // (no nonce) this is the ONLY trust-write we'd make and we throw it away
506    // immediately — see the bilateral-required branch below.
507    let mut tmp_trust = config::read_trust()?;
508    crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
509    crate::signing::verify_message_v31(event, &tmp_trust)
510        .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
511
512    let peer_relay = body
513        .get("relay_url")
514        .and_then(Value::as_str)
515        .unwrap_or("");
516    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
517    let peer_slot_token = body
518        .get("slot_token")
519        .and_then(Value::as_str)
520        .unwrap_or("");
521    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
522        bail!("pair_drop body missing relay_url/slot_id/slot_token");
523    }
524
525    // ---------- v0.5.14 bilateral-required split ----------
526    //
527    // SPAKE2 invite-URL path (`pair_nonce` present): the operator already
528    // gave the sender an invite-URL out-of-band; possession of the nonce IS
529    // the consent gesture. Pin trust, write relay_state, send the ack —
530    // unchanged from v0.5.13.
531    //
532    // Handle path (no nonce, zero-paste `wire add`): the sender knows
533    // nothing more than the public phonebook entry. Receiver consent has
534    // not been gestured. **Do NOT pin trust. Do NOT write our slot_token
535    // back. Do NOT advertise relay coords.** Stash the request in pending-
536    // inbound and prompt the operator. Bilateral pin completes only when
537    // the operator runs `wire add <peer>@<their-relay>` to accept.
538    //
539    // This closes the v0.5.13 phonebook-scrape spam vector: an attacker
540    // can deposit one entry in N victims' `wire pair-list --pending`, but
541    // no slot_token leaks and no message-write capability accrues.
542    if nonce_opt.is_some() {
543        // ----- SPAKE2 invite-URL path (unchanged) -----
544        config::write_trust(&tmp_trust)?;
545        let mut relay_state = config::read_relay_state()?;
546        relay_state["peers"][&peer_handle] = json!({
547            "relay_url": peer_relay,
548            "slot_id": peer_slot_id,
549            "slot_token": peer_slot_token,
550        });
551        config::write_relay_state(&relay_state)?;
552
553        // Consume invite (single-use default; decrement uses for multi-use).
554        if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
555            if pending.uses_remaining <= 1 {
556                if let Err(e) = std::fs::remove_file(&invite_path) {
557                    eprintln!(
558                        "wire: could not delete consumed invite {invite_path:?}: {e}"
559                    );
560                }
561            } else {
562                let mut updated = pending.clone();
563                updated.uses_remaining -= 1;
564                updated.accepted_by.push(peer_did.clone());
565                std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
566            }
567        }
568        crate::os_notify::toast(
569            &format!("wire — paired with {peer_handle}"),
570            "Invite accepted. Ready to send + receive.",
571        );
572        return Ok(Some(peer_did));
573    }
574
575    // ----- Handle path: stash in pending-inbound, no capability flows -----
576    let now_iso = time::OffsetDateTime::now_utc()
577        .format(&time::format_description::well_known::Rfc3339)
578        .unwrap_or_default();
579    let event_id = event
580        .get("event_id")
581        .and_then(Value::as_str)
582        .unwrap_or("")
583        .to_string();
584    let event_timestamp = event
585        .get("timestamp")
586        .and_then(Value::as_str)
587        .unwrap_or("")
588        .to_string();
589    let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
590        peer_handle: peer_handle.clone(),
591        peer_did: peer_did.clone(),
592        peer_card: peer_card.clone(),
593        peer_relay_url: peer_relay.to_string(),
594        peer_slot_id: peer_slot_id.to_string(),
595        peer_slot_token: peer_slot_token.to_string(),
596        event_id,
597        event_timestamp,
598        received_at: now_iso,
599    };
600    crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
601    crate::os_notify::toast(
602        &format!("wire — pair request from {peer_handle}"),
603        &format!(
604            "run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
605        ),
606    );
607
608    Ok(Some(peer_did))
609}
610
611/// Send a `pair_drop_ack` event (kind=1101) carrying OUR slot_token to a peer
612/// who just intro'd to us via `/v1/handle/intro/<nick>`. Completes the
613/// zero-paste bidirectional pin. Best-effort: errors are logged but don't
614/// propagate, since the inbound pair_drop pin already succeeded and the
615/// operator can retry from either side.
616/// Send a `pair_drop_ack` (kind=1101) carrying our slot_token to a peer.
617/// Used by the SPAKE2 invite-URL path (auto-called) and by the bilateral
618/// completion path in `cmd_add` (operator-driven). Failures propagate so
619/// the caller can surface the failure loudly.
620pub fn send_pair_drop_ack(
621    peer_handle: &str,
622    peer_relay: &str,
623    peer_slot_id: &str,
624    peer_slot_token: &str,
625) -> Result<()> {
626    // Load our own card + relay coords.
627    let our_card = config::read_agent_card()?;
628    let our_did = our_card
629        .get("did")
630        .and_then(Value::as_str)
631        .ok_or_else(|| anyhow!("our card missing did"))?
632        .to_string();
633    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
634    let relay_state = config::read_relay_state()?;
635    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
636    let our_relay = self_state
637        .get("relay_url")
638        .and_then(Value::as_str)
639        .unwrap_or("")
640        .to_string();
641    let our_slot_id = self_state
642        .get("slot_id")
643        .and_then(Value::as_str)
644        .unwrap_or("")
645        .to_string();
646    let our_slot_token = self_state
647        .get("slot_token")
648        .and_then(Value::as_str)
649        .unwrap_or("")
650        .to_string();
651    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
652        bail!("self relay state incomplete; cannot emit pair_drop_ack");
653    }
654
655    let sk_seed = config::read_private_key()?;
656    let pk_b64 = our_card
657        .get("verify_keys")
658        .and_then(Value::as_object)
659        .and_then(|m| m.values().next())
660        .and_then(|v| v.get("key"))
661        .and_then(Value::as_str)
662        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
663    let pk_bytes = crate::signing::b64decode(pk_b64)?;
664
665    let now = time::OffsetDateTime::now_utc()
666        .format(&time::format_description::well_known::Rfc3339)
667        .unwrap_or_default();
668    let event = json!({
669        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
670        "timestamp": now,
671        "from": our_did,
672        "to": format!("did:wire:{peer_handle}"),
673        "type": "pair_drop_ack",
674        "kind": 1101u32,
675        "body": {
676            "relay_url": our_relay,
677            "slot_id": our_slot_id,
678            "slot_token": our_slot_token,
679        },
680    });
681    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
682    let client = crate::relay_client::RelayClient::new(peer_relay);
683    client
684        .post_event(peer_slot_id, peer_slot_token, &signed)
685        .with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
686    Ok(())
687}
688
689/// Consume a `pair_drop_ack` event during daemon pull. Updates
690/// relay-state.peers[<peer>] with the ack's slot_token so we can `wire send`
691/// to the peer. Returns `Ok(true)` if applied. Idempotent.
692pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
693    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
694    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
695    if kind != 1101 || type_str != "pair_drop_ack" {
696        return Ok(false);
697    }
698    let body = match event.get("body") {
699        Some(b) => b,
700        None => return Ok(false),
701    };
702    let from = event
703        .get("from")
704        .and_then(Value::as_str)
705        .ok_or_else(|| anyhow!("ack missing 'from'"))?;
706    let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
707    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
708    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
709    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
710    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
711        bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
712    }
713    let mut relay_state = config::read_relay_state()?;
714    relay_state["peers"][&peer_handle] = json!({
715        "relay_url": peer_relay,
716        "slot_id": peer_slot_id,
717        "slot_token": peer_slot_token,
718    });
719    config::write_relay_state(&relay_state)?;
720    crate::os_notify::toast(
721        &format!("wire — pair complete with {peer_handle}"),
722        "Both sides bound. Ready to send + receive.",
723    );
724    Ok(true)
725}
726
727// Earlier note: "tests removed because of WIRE_HOME race." That's no longer
728// true — `config::test_support::with_temp_home` serialises env-mutating
729// tests behind a process-wide mutex, so unit tests here are safe again.
730// Keep e2e coverage in `tests/e2e_invite_pair.rs` for full-flow paranoia.
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::config;
736
737    #[test]
738    fn record_pair_rejection_writes_jsonl_under_state_dir() {
739        // P0.2: silent fails must leave a trace. This is what `wire doctor`
740        // (P1.6) will surface. If the file isn't written, `wire doctor`
741        // can't see the problem — same silent-fail class we're fixing.
742        config::test_support::with_temp_home(|| {
743            super::record_pair_rejection(
744                "slancha-spark",
745                "pair_drop_ack_send_failed",
746                "POST returned 502",
747            );
748            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
749            assert!(
750                path.exists(),
751                "record_pair_rejection must create {path:?}"
752            );
753            let body = std::fs::read_to_string(&path).unwrap();
754            let line = body.lines().last().expect("at least one line");
755            let parsed: Value = serde_json::from_str(line).expect("valid JSON");
756            assert_eq!(parsed["peer"], "slancha-spark");
757            assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
758            assert_eq!(parsed["detail"], "POST returned 502");
759            assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
760        });
761    }
762
763    #[test]
764    fn record_pair_rejection_appends_multiple_lines() {
765        // Multiple silent fails in one session must each leave a record —
766        // it's append-only, not a single most-recent slot.
767        config::test_support::with_temp_home(|| {
768            super::record_pair_rejection("a", "code_a", "detail_a");
769            super::record_pair_rejection("b", "code_b", "detail_b");
770            super::record_pair_rejection("c", "code_c", "detail_c");
771            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
772            let body = std::fs::read_to_string(&path).unwrap();
773            let lines: Vec<&str> = body.lines().collect();
774            assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
775            for (i, peer) in ["a", "b", "c"].iter().enumerate() {
776                let parsed: Value = serde_json::from_str(lines[i]).unwrap();
777                assert_eq!(parsed["peer"], *peer);
778            }
779        });
780    }
781}