Skip to main content

wire/
pending_inbound_pair.rs

1//! Pending-inbound pair store (v0.5.14).
2//!
3//! When a stranger POSTs a signed `pair_drop` (kind=1100) to our auth-free
4//! `/v1/handle/intro/<nick>` endpoint, **the receiver does not auto-pin**.
5//! The drop lands here, awaiting the operator's explicit consent: running
6//! `wire add <peer>@<relay>` on the receiver side promotes the entry to
7//! `VERIFIED` trust and ships our slot_token back via `pair_drop_ack`.
8//! Running `wire pair-reject <peer>` deletes the entry without pairing.
9//!
10//! This restores the bilateral-required semantic to zero-paste pairing:
11//! `wire add` must fire on both sides before any capability flows. The
12//! v0.5.13-and-earlier behaviour (receiver auto-pinned the stranger as
13//! VERIFIED and emitted slot_token in the ack) was a phonebook-scrape
14//! spam vector — see the v0.5.14 CHANGELOG entry and the security
15//! disclosure issue on this repo.
16//!
17//! Storage layout: `state/wire/pending-inbound-pairs/<peer-handle>.json`.
18//! One file per pending peer, deleted atomically on accept or reject.
19
20use crate::config;
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::path::PathBuf;
25
26/// One pending-inbound pair-request awaiting receiver-side `wire add`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PendingInboundPair {
29    /// Bare handle (no `@<relay>` suffix). Matches the on-disk key.
30    pub peer_handle: String,
31    /// Full DID of the peer (e.g. `did:wire:alice-abc12345`).
32    pub peer_did: String,
33    /// Peer's signed agent-card from the pair_drop body. Already
34    /// signature-verified at write time.
35    pub peer_card: Value,
36    /// Peer's relay URL — where we'd POST our ack and future events.
37    pub peer_relay_url: String,
38    /// Peer's slot_id on their relay — write target for ack + sends.
39    pub peer_slot_id: String,
40    /// Peer's slot_token — they shipped it in the drop so we can write
41    /// back. Holding this without acting on it is intentional: capability
42    /// only flows when operator runs `wire add` to accept.
43    pub peer_slot_token: String,
44    /// v0.5.17: full set of endpoints the peer advertised (federation +
45    /// optional local). When the operator accepts via `wire pair-accept`,
46    /// every endpoint here gets pinned into relay_state via
47    /// `endpoints::pin_peer_endpoints`. Absent on records written by
48    /// v0.5.16-and-earlier code paths; downstream code synthesizes a
49    /// single federation entry from the legacy fields in that case.
50    #[serde(default)]
51    pub peer_endpoints: Vec<crate::endpoints::Endpoint>,
52    /// Original pair_drop event_id (SHA-256 over canonical body). Used to
53    /// dedupe repeated drops from the same key.
54    pub event_id: String,
55    /// RFC3339 timestamp from the pair_drop event itself.
56    pub event_timestamp: String,
57    /// RFC3339 timestamp of when we wrote this pending record.
58    pub received_at: String,
59}
60
61/// `state/wire/pending-inbound-pairs/` — operator-visible directory.
62pub fn pending_inbound_dir() -> Result<PathBuf> {
63    Ok(config::state_dir()?.join("pending-inbound-pairs"))
64}
65
66fn pending_inbound_path(peer_handle: &str) -> Result<PathBuf> {
67    Ok(pending_inbound_dir()?.join(format!("{peer_handle}.json")))
68}
69
70/// Write a pending-inbound record. Overwrites any existing record for
71/// the same handle (repeated pair_drops from same peer collapse to one
72/// pending entry; latest payload wins).
73pub fn write_pending_inbound(p: &PendingInboundPair) -> Result<()> {
74    let dir = pending_inbound_dir()?;
75    std::fs::create_dir_all(&dir)
76        .with_context(|| format!("creating {dir:?}"))?;
77    let path = pending_inbound_path(&p.peer_handle)?;
78    let body = serde_json::to_vec_pretty(p)?;
79    std::fs::write(&path, body)
80        .with_context(|| format!("writing pending-inbound record {path:?}"))?;
81    Ok(())
82}
83
84/// Read a pending-inbound record by bare handle. Returns `Ok(None)` if
85/// no pending entry exists for that handle.
86pub fn read_pending_inbound(peer_handle: &str) -> Result<Option<PendingInboundPair>> {
87    let path = pending_inbound_path(peer_handle)?;
88    if !path.exists() {
89        return Ok(None);
90    }
91    let body = std::fs::read(&path)
92        .with_context(|| format!("reading pending-inbound record {path:?}"))?;
93    let p: PendingInboundPair = serde_json::from_slice(&body)
94        .with_context(|| format!("parsing pending-inbound record {path:?}"))?;
95    Ok(Some(p))
96}
97
98/// List all pending-inbound records. Sorted by `received_at` ascending
99/// (oldest first) so operators see the longest-waiting requests first.
100pub fn list_pending_inbound() -> Result<Vec<PendingInboundPair>> {
101    let dir = pending_inbound_dir()?;
102    if !dir.exists() {
103        return Ok(Vec::new());
104    }
105    let mut entries: Vec<PendingInboundPair> = Vec::new();
106    for entry in std::fs::read_dir(&dir)?.flatten() {
107        let path = entry.path();
108        if path.extension().and_then(|x| x.to_str()) != Some("json") {
109            continue;
110        }
111        let body = match std::fs::read(&path) {
112            Ok(b) => b,
113            Err(_) => continue,
114        };
115        if let Ok(p) = serde_json::from_slice::<PendingInboundPair>(&body) {
116            entries.push(p);
117        }
118    }
119    entries.sort_by(|a, b| a.received_at.cmp(&b.received_at));
120    Ok(entries)
121}
122
123/// Delete a pending-inbound record (called from `wire add` on bilateral
124/// completion and from `wire pair-reject`). Idempotent — `Ok(())` if the
125/// record didn't exist.
126pub fn consume_pending_inbound(peer_handle: &str) -> Result<()> {
127    let path = pending_inbound_path(peer_handle)?;
128    if path.exists() {
129        std::fs::remove_file(&path)
130            .with_context(|| format!("deleting pending-inbound record {path:?}"))?;
131    }
132    Ok(())
133}
134
135// Note (v0.5.14): unit tests for this module were removed because they
136// mutate process-global `WIRE_HOME` and race with other modules' tests
137// (diag, ensure_up, config) that share the same env var. The integration
138// tests in `tests/cli.rs` exercise pending-inbound end-to-end via the
139// subprocess CLI (each subprocess has its own env), which is the correct
140// isolation pattern for env-dependent state.