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        && let Err(e) = std::fs::create_dir_all(parent)
71    {
72        eprintln!("wire: could not create {parent:?}: {e}");
73        return;
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
202    // v0.6.6: prefer an existing endpoint over allocating a new one.
203    // `--local-only` sessions don't have legacy `self.slot_id` but DO
204    // have `self.endpoints[]` with a local slot — those should be
205    // honored, not stomped with a fresh federation allocation. Without
206    // this guard, `wire pair-accept` on a local-only session would
207    // auto-allocate a federation slot at DEFAULT_RELAY (wireup.net)
208    // every time, silently turning local-only sessions into dual-slot.
209    let existing = crate::endpoints::self_endpoints(&relay_state);
210    if !existing.is_empty() {
211        let ep = existing
212            .iter()
213            .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
214            .cloned()
215            .unwrap_or_else(|| existing[0].clone());
216        return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
217    }
218
219    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
220
221    if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
222        let client = crate::relay_client::RelayClient::new(relay);
223        client.check_healthz()?;
224        let handle = crate::agent_card::display_handle_from_did(&did);
225        let alloc = client.allocate_slot(Some(handle))?;
226        relay_state["self"] = json!({
227            "relay_url": relay,
228            "slot_id": alloc.slot_id,
229            "slot_token": alloc.slot_token,
230        });
231        config::write_relay_state(&relay_state)?;
232    }
233
234    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
235    let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
236    let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
237    let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
238    if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
239        bail!("self relay state incomplete after auto-allocate");
240    }
241    Ok((did, relay_url, slot_id, slot_token))
242}
243
244/// Mint a fresh invite URL. Auto-inits + auto-allocates relay slot if needed.
245pub fn mint_invite(
246    ttl_secs: Option<u64>,
247    uses: u32,
248    preferred_relay: Option<&str>,
249) -> Result<String> {
250    let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
251
252    let card = config::read_agent_card()?;
253    let sk_seed = config::read_private_key()?;
254
255    let mut nonce_bytes = [0u8; 32];
256    use rand::RngCore;
257    rand::thread_rng().fill_bytes(&mut nonce_bytes);
258    let nonce = hex::encode(nonce_bytes);
259
260    let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
261    let exp = now_unix() + ttl;
262
263    let payload = InvitePayload {
264        v: 1,
265        did: did.clone(),
266        card,
267        relay_url,
268        slot_id,
269        slot_token,
270        nonce: nonce.clone(),
271        exp,
272    };
273    let payload_bytes = serde_json::to_vec(&payload)?;
274
275    let mut sk_arr = [0u8; 32];
276    sk_arr.copy_from_slice(&sk_seed[..32]);
277    let sk = SigningKey::from_bytes(&sk_arr);
278    let sig = sk.sign(&payload_bytes);
279
280    let token = format!(
281        "{}.{}",
282        B64URL.encode(&payload_bytes),
283        B64URL.encode(sig.to_bytes())
284    );
285    let url = format!("wire://pair?v=1&inv={token}");
286
287    let now = time::OffsetDateTime::now_utc()
288        .format(&time::format_description::well_known::Rfc3339)
289        .unwrap_or_default();
290    let pending = PendingInvite {
291        nonce: nonce.clone(),
292        exp,
293        uses_remaining: uses.max(1),
294        accepted_by: vec![],
295        created_at: now,
296    };
297    let dir = pending_invites_dir()?;
298    std::fs::create_dir_all(&dir)?;
299    let path = dir.join(format!("{nonce}.json"));
300    std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
301
302    Ok(url)
303}
304
305/// Parse an invite URL and verify the embedded signature against the embedded
306/// card's first active verify key.
307pub fn parse_invite(url: &str) -> Result<InvitePayload> {
308    let rest = url
309        .strip_prefix("wire://pair?")
310        .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
311    let mut inv = None;
312    for part in rest.split('&') {
313        if let Some(v) = part.strip_prefix("inv=") {
314            inv = Some(v);
315        }
316    }
317    let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
318    let (payload_b64, sig_b64) = token
319        .split_once('.')
320        .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
321    let payload_bytes = B64URL
322        .decode(payload_b64)
323        .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
324    let sig_bytes = B64URL
325        .decode(sig_b64)
326        .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
327
328    let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
329        .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
330
331    if payload.v != 1 {
332        bail!("invite schema version {} not supported", payload.v);
333    }
334    if now_unix() > payload.exp {
335        bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
336    }
337
338    // Verify the URL signature against the issuer's card key.
339    crate::agent_card::verify_agent_card(&payload.card)
340        .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
341
342    let pk_b64 = payload
343        .card
344        .get("verify_keys")
345        .and_then(Value::as_object)
346        .and_then(|m| m.values().next())
347        .and_then(|v| v.get("key"))
348        .and_then(Value::as_str)
349        .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
350    let pk_bytes = crate::signing::b64decode(pk_b64)?;
351    let mut pk_arr = [0u8; 32];
352    if pk_bytes.len() != 32 {
353        bail!("issuer pubkey wrong length");
354    }
355    pk_arr.copy_from_slice(&pk_bytes);
356    let vk = VerifyingKey::from_bytes(&pk_arr)
357        .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
358    let mut sig_arr = [0u8; 64];
359    if sig_bytes.len() != 64 {
360        bail!("invite sig wrong length");
361    }
362    sig_arr.copy_from_slice(&sig_bytes);
363    let sig = Signature::from_bytes(&sig_arr);
364    vk.verify(&payload_bytes, &sig)
365        .map_err(|_| anyhow!("invite URL signature did not verify"))?;
366
367    Ok(payload)
368}
369
370/// Accept an invite URL. Auto-inits + auto-allocates if needed. Pins issuer
371/// from URL contents, then POSTs a signed pair_drop event to issuer's slot.
372pub fn accept_invite(url: &str) -> Result<Value> {
373    let payload = parse_invite(url)?;
374
375    // Auto-init self on the issuer's relay (or env-default if reachable).
376    let (our_did, our_relay, our_slot_id, our_slot_token) =
377        ensure_self_with_relay(Some(&payload.relay_url))?;
378
379    if our_did == payload.did {
380        bail!("refusing to accept own invite (issuer DID matches self)");
381    }
382
383    // Pin issuer in trust + relay-state.
384    let mut trust = config::read_trust()?;
385    crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
386    config::write_trust(&trust)?;
387
388    let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
389    let mut relay_state = config::read_relay_state()?;
390    relay_state["peers"][&peer_handle] = json!({
391        "relay_url": payload.relay_url,
392        "slot_id": payload.slot_id,
393        "slot_token": payload.slot_token,
394    });
395    config::write_relay_state(&relay_state)?;
396
397    // Build signed pair_drop event carrying our own card + slot coords +
398    // the issuer's pair_nonce. Issuer's daemon will look it up against
399    // pending-invites and complete the bilateral pin.
400    let our_card = config::read_agent_card()?;
401    let sk_seed = config::read_private_key()?;
402    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
403    let pk_b64 = our_card
404        .get("verify_keys")
405        .and_then(Value::as_object)
406        .and_then(|m| m.values().next())
407        .and_then(|v| v.get("key"))
408        .and_then(Value::as_str)
409        .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
410    let pk_bytes = crate::signing::b64decode(pk_b64)?;
411
412    let now = time::OffsetDateTime::now_utc()
413        .format(&time::format_description::well_known::Rfc3339)
414        .unwrap_or_default();
415    let event = json!({
416        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
417        "timestamp": now,
418        "from": our_did,
419        "to": payload.did,
420        "type": "pair_drop",
421        "kind": 1100u32,
422        "body": {
423            "card": our_card,
424            "relay_url": our_relay,
425            "slot_id": our_slot_id,
426            "slot_token": our_slot_token,
427            "pair_nonce": payload.nonce,
428        },
429    });
430    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
431    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
432
433    let client = crate::relay_client::RelayClient::new(&payload.relay_url);
434    client
435        .post_event(&payload.slot_id, &payload.slot_token, &signed)
436        .with_context(|| {
437            format!(
438                "POST pair_drop to {} slot {}",
439                payload.relay_url, payload.slot_id
440            )
441        })?;
442
443    Ok(json!({
444        "paired_with": payload.did,
445        "peer_handle": peer_handle,
446        "event_id": event_id,
447        "status": "drop_sent",
448    }))
449}
450
451/// Consume a pair_drop event during daemon pull. Returns `Ok(Some(peer_did))`
452/// if the event matched a pending invite and the peer was pinned. Returns
453/// `Ok(None)` if not a pair_drop or no matching invite. Errors only on real
454/// problems (bad sig over event, IO failure).
455pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
456    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
457    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
458    if kind != 1100 || type_str != "pair_drop" {
459        return Ok(None);
460    }
461    let body = match event.get("body") {
462        Some(b) => b,
463        None => return Ok(None),
464    };
465
466    // v0.5: accept handle-initiated pair_drops too (no pair_nonce). These
467    // come via `wire add <handle>` → POST /v1/handle/intro. Anchored only
468    // by the embedded signed card. Gated by config `accept_unknown_pair_drops`
469    // (default true). For nonce-bearing drops the existing v0.4 invite-URL
470    // path stays in force.
471    let nonce_opt = body
472        .get("pair_nonce")
473        .and_then(Value::as_str)
474        .map(str::to_string);
475    let mut pending: Option<PendingInvite> = None;
476    let mut invite_path: Option<std::path::PathBuf> = None;
477    if let Some(nonce) = nonce_opt.as_deref() {
478        let dir = pending_invites_dir()?;
479        let path = dir.join(format!("{nonce}.json"));
480        if path.exists() {
481            let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
482                .with_context(|| format!("reading pending invite {path:?}"))?;
483            if now_unix() > p.exp {
484                // P0.2: warn if cleanup fails — orphaned expired invites in
485                // `pending-invites/` will pile up and confuse `wire doctor`.
486                if let Err(e) = std::fs::remove_file(&path) {
487                    eprintln!("wire: could not delete expired invite {path:?}: {e}");
488                }
489                return Ok(None);
490            }
491            pending = Some(p);
492            invite_path = Some(path);
493        } else if !open_mode_enabled() {
494            // Nonce present but unknown locally, and open mode disabled →
495            // refuse silently (the event will fall through to the normal
496            // verify path which won't trust the sender yet).
497            return Ok(None);
498        }
499    } else if !open_mode_enabled() {
500        // No nonce + open mode disabled → ignore. Operator must opt in to
501        // be discoverable via zero-paste `wire add`.
502        return Ok(None);
503    }
504
505    let peer_card = body
506        .get("card")
507        .cloned()
508        .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
509    crate::agent_card::verify_agent_card(&peer_card)
510        .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
511
512    let peer_did = peer_card
513        .get("did")
514        .and_then(Value::as_str)
515        .ok_or_else(|| anyhow!("peer card missing did"))?
516        .to_string();
517    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
518
519    // Verify the event signature against the peer's embedded pubkey. We need
520    // a transient trust pin to drive the verifier, but for the handle path
521    // (no nonce) this is the ONLY trust-write we'd make and we throw it away
522    // immediately — see the bilateral-required branch below.
523    let mut tmp_trust = config::read_trust()?;
524    crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
525    crate::signing::verify_message_v31(event, &tmp_trust)
526        .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
527
528    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
529    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
530    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
531    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
532        bail!("pair_drop body missing relay_url/slot_id/slot_token");
533    }
534
535    // v0.5.17: peer may advertise multiple endpoints (federation +
536    // optional local). Parse `body.endpoints[]` if present. Falls back
537    // to a single federation endpoint from the legacy fields above for
538    // v0.5.16-and-earlier senders.
539    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
540        .get("endpoints")
541        .and_then(Value::as_array)
542        .map(|arr| {
543            arr.iter()
544                .filter_map(|e| {
545                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
546                })
547                .collect()
548        })
549        .unwrap_or_else(|| {
550            vec![crate::endpoints::Endpoint::federation(
551                peer_relay.to_string(),
552                peer_slot_id.to_string(),
553                peer_slot_token.to_string(),
554            )]
555        });
556
557    // ---------- v0.5.14 bilateral-required split ----------
558    //
559    // SPAKE2 invite-URL path (`pair_nonce` present): the operator already
560    // gave the sender an invite-URL out-of-band; possession of the nonce IS
561    // the consent gesture. Pin trust, write relay_state, send the ack —
562    // unchanged from v0.5.13.
563    //
564    // Handle path (no nonce, zero-paste `wire add`): the sender knows
565    // nothing more than the public phonebook entry. Receiver consent has
566    // not been gestured. **Do NOT pin trust. Do NOT write our slot_token
567    // back. Do NOT advertise relay coords.** Stash the request in pending-
568    // inbound and prompt the operator. Bilateral pin completes only when
569    // the operator runs `wire add <peer>@<their-relay>` to accept.
570    //
571    // This closes the v0.5.13 phonebook-scrape spam vector: an attacker
572    // can deposit one entry in N victims' `wire pair-list --pending`, but
573    // no slot_token leaks and no message-write capability accrues.
574    if nonce_opt.is_some() {
575        // ----- SPAKE2 invite-URL path (unchanged) -----
576        config::write_trust(&tmp_trust)?;
577        let mut relay_state = config::read_relay_state()?;
578        // v0.5.17: pin all advertised endpoints (federation + optional
579        // local). Top-level legacy fields still point at the federation
580        // endpoint for back-compat readers.
581        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
582        config::write_relay_state(&relay_state)?;
583
584        // Consume invite (single-use default; decrement uses for multi-use).
585        if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
586            if pending.uses_remaining <= 1 {
587                if let Err(e) = std::fs::remove_file(&invite_path) {
588                    eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
589                }
590            } else {
591                let mut updated = pending.clone();
592                updated.uses_remaining -= 1;
593                updated.accepted_by.push(peer_did.clone());
594                std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
595            }
596        }
597        crate::os_notify::toast(
598            &format!("wire — paired with {peer_handle}"),
599            "Invite accepted. Ready to send + receive.",
600        );
601        return Ok(Some(peer_did));
602    }
603
604    // ----- Handle path: stash in pending-inbound, no capability flows -----
605    let now_iso = time::OffsetDateTime::now_utc()
606        .format(&time::format_description::well_known::Rfc3339)
607        .unwrap_or_default();
608    let event_id = event
609        .get("event_id")
610        .and_then(Value::as_str)
611        .unwrap_or("")
612        .to_string();
613    let event_timestamp = event
614        .get("timestamp")
615        .and_then(Value::as_str)
616        .unwrap_or("")
617        .to_string();
618    let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
619        peer_handle: peer_handle.clone(),
620        peer_did: peer_did.clone(),
621        peer_card: peer_card.clone(),
622        peer_relay_url: peer_relay.to_string(),
623        peer_slot_id: peer_slot_id.to_string(),
624        peer_slot_token: peer_slot_token.to_string(),
625        peer_endpoints: peer_endpoints.clone(),
626        event_id,
627        event_timestamp,
628        received_at: now_iso,
629    };
630    crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
631    crate::os_notify::toast(
632        &format!("wire — pair request from {peer_handle}"),
633        &format!(
634            "run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
635        ),
636    );
637
638    Ok(Some(peer_did))
639}
640
641/// Send a `pair_drop_ack` event (kind=1101) carrying OUR slot_token to a peer
642/// who just intro'd to us via `/v1/handle/intro/<nick>`. Completes the
643/// zero-paste bidirectional pin. Best-effort: errors are logged but don't
644/// propagate, since the inbound pair_drop pin already succeeded and the
645/// operator can retry from either side.
646/// Send a `pair_drop_ack` (kind=1101) carrying our slot_token to a peer.
647/// Used by the SPAKE2 invite-URL path (auto-called) and by the bilateral
648/// completion path in `cmd_add` (operator-driven). Failures propagate so
649/// the caller can surface the failure loudly.
650pub fn send_pair_drop_ack(
651    peer_handle: &str,
652    peer_relay: &str,
653    peer_slot_id: &str,
654    peer_slot_token: &str,
655) -> Result<()> {
656    // Load our own card + relay coords.
657    let our_card = config::read_agent_card()?;
658    let our_did = our_card
659        .get("did")
660        .and_then(Value::as_str)
661        .ok_or_else(|| anyhow!("our card missing did"))?
662        .to_string();
663    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
664    let relay_state = config::read_relay_state()?;
665    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
666    let our_relay = self_state
667        .get("relay_url")
668        .and_then(Value::as_str)
669        .unwrap_or("")
670        .to_string();
671    let our_slot_id = self_state
672        .get("slot_id")
673        .and_then(Value::as_str)
674        .unwrap_or("")
675        .to_string();
676    let our_slot_token = self_state
677        .get("slot_token")
678        .and_then(Value::as_str)
679        .unwrap_or("")
680        .to_string();
681    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
682        bail!("self relay state incomplete; cannot emit pair_drop_ack");
683    }
684
685    let sk_seed = config::read_private_key()?;
686    let pk_b64 = our_card
687        .get("verify_keys")
688        .and_then(Value::as_object)
689        .and_then(|m| m.values().next())
690        .and_then(|v| v.get("key"))
691        .and_then(Value::as_str)
692        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
693    let pk_bytes = crate::signing::b64decode(pk_b64)?;
694
695    let now = time::OffsetDateTime::now_utc()
696        .format(&time::format_description::well_known::Rfc3339)
697        .unwrap_or_default();
698    // v0.5.17: also advertise our endpoints[] in the ack so the peer can
699    // pin both our federation and local endpoints. Back-compat: top-level
700    // legacy fields above stay populated for v0.5.16-and-earlier readers.
701    let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
702    let mut body = json!({
703        "relay_url": our_relay,
704        "slot_id": our_slot_id,
705        "slot_token": our_slot_token,
706    });
707    if !our_endpoints.is_empty() {
708        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
709    }
710    let event = json!({
711        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
712        "timestamp": now,
713        "from": our_did,
714        "to": format!("did:wire:{peer_handle}"),
715        "type": "pair_drop_ack",
716        "kind": 1101u32,
717        "body": body,
718    });
719    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
720    let client = crate::relay_client::RelayClient::new(peer_relay);
721    client
722        .post_event(peer_slot_id, peer_slot_token, &signed)
723        .with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
724    Ok(())
725}
726
727/// Consume a `pair_drop_ack` event during daemon pull. Updates
728/// relay-state.peers[<peer>] with the ack's slot_token so we can `wire send`
729/// to the peer. Returns `Ok(true)` if applied. Idempotent.
730pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
731    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
732    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
733    if kind != 1101 || type_str != "pair_drop_ack" {
734        return Ok(false);
735    }
736    let body = match event.get("body") {
737        Some(b) => b,
738        None => return Ok(false),
739    };
740    let from = event
741        .get("from")
742        .and_then(Value::as_str)
743        .ok_or_else(|| anyhow!("ack missing 'from'"))?;
744    let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
745    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
746    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
747    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
748    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
749        bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
750    }
751    // v0.5.17: parse endpoints[] if present (peer ran v0.5.17+ and has
752    // dual slots); fall back to a single federation entry synthesized
753    // from the legacy fields for v0.5.16-and-earlier acks.
754    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
755        .get("endpoints")
756        .and_then(Value::as_array)
757        .map(|arr| {
758            arr.iter()
759                .filter_map(|e| {
760                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
761                })
762                .collect()
763        })
764        .unwrap_or_else(|| {
765            vec![crate::endpoints::Endpoint::federation(
766                peer_relay.to_string(),
767                peer_slot_id.to_string(),
768                peer_slot_token.to_string(),
769            )]
770        });
771    let mut relay_state = config::read_relay_state()?;
772    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
773    config::write_relay_state(&relay_state)?;
774    crate::os_notify::toast(
775        &format!("wire — pair complete with {peer_handle}"),
776        "Both sides bound. Ready to send + receive.",
777    );
778    Ok(true)
779}
780
781// Earlier note: "tests removed because of WIRE_HOME race." That's no longer
782// true — `config::test_support::with_temp_home` serialises env-mutating
783// tests behind a process-wide mutex, so unit tests here are safe again.
784// Keep e2e coverage in `tests/e2e_invite_pair.rs` for full-flow paranoia.
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::config;
790
791    #[test]
792    fn record_pair_rejection_writes_jsonl_under_state_dir() {
793        // P0.2: silent fails must leave a trace. This is what `wire doctor`
794        // (P1.6) will surface. If the file isn't written, `wire doctor`
795        // can't see the problem — same silent-fail class we're fixing.
796        config::test_support::with_temp_home(|| {
797            super::record_pair_rejection(
798                "slancha-spark",
799                "pair_drop_ack_send_failed",
800                "POST returned 502",
801            );
802            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
803            assert!(path.exists(), "record_pair_rejection must create {path:?}");
804            let body = std::fs::read_to_string(&path).unwrap();
805            let line = body.lines().last().expect("at least one line");
806            let parsed: Value = serde_json::from_str(line).expect("valid JSON");
807            assert_eq!(parsed["peer"], "slancha-spark");
808            assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
809            assert_eq!(parsed["detail"], "POST returned 502");
810            assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
811        });
812    }
813
814    #[test]
815    fn record_pair_rejection_appends_multiple_lines() {
816        // Multiple silent fails in one session must each leave a record —
817        // it's append-only, not a single most-recent slot.
818        config::test_support::with_temp_home(|| {
819            super::record_pair_rejection("a", "code_a", "detail_a");
820            super::record_pair_rejection("b", "code_b", "detail_b");
821            super::record_pair_rejection("c", "code_c", "detail_c");
822            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
823            let body = std::fs::read_to_string(&path).unwrap();
824            let lines: Vec<&str> = body.lines().collect();
825            assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
826            for (i, peer) in ["a", "b", "c"].iter().enumerate() {
827                let parsed: Value = serde_json::from_str(lines[i]).unwrap();
828                assert_eq!(parsed["peer"], *peer);
829            }
830        });
831    }
832}