use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::config;
pub const DEFAULT_RELAY: &str = "https://wireup.net";
pub const DEFAULT_TTL_SECS: u64 = 86_400;
pub(crate) fn record_pair_rejection(peer_handle: &str, code: &str, detail: &str) {
let line = json!({
"ts": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
"peer": peer_handle,
"code": code,
"detail": detail,
});
let serialised = match serde_json::to_string(&line) {
Ok(s) => s,
Err(e) => {
eprintln!("wire: could not serialise pair-rejected entry: {e}");
return;
}
};
let path = match config::state_dir() {
Ok(d) => d.join("pair-rejected.jsonl"),
Err(e) => {
eprintln!("wire: state_dir unresolved, dropping pair-rejected log: {e}");
return;
}
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("wire: could not create {parent:?}: {e}");
return;
}
}
use std::io::Write;
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(mut f) => {
if let Err(e) = writeln!(f, "{serialised}") {
eprintln!("wire: could not append pair-rejected to {path:?}: {e}");
}
}
Err(e) => {
eprintln!("wire: could not open {path:?}: {e}");
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvitePayload {
pub v: u32,
pub did: String,
pub card: Value,
pub relay_url: String,
pub slot_id: String,
pub slot_token: String,
pub nonce: String,
pub exp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingInvite {
pub nonce: String,
pub exp: u64,
pub uses_remaining: u32,
pub accepted_by: Vec<String>,
pub created_at: String,
}
fn open_mode_enabled() -> bool {
let path = match config::config_dir() {
Ok(p) => p.join("policy.json"),
Err(_) => return true,
};
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return true,
};
let v: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return true,
};
v.get("accept_unknown_pair_drops")
.and_then(Value::as_bool)
.unwrap_or(true)
}
pub fn pending_invites_dir() -> Result<PathBuf> {
Ok(config::state_dir()?.join("pending-invites"))
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn default_handle() -> String {
let raw = hostname::get()
.ok()
.and_then(|s| s.into_string().ok())
.unwrap_or_else(|| "wire-user".into());
let sanitized: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
if sanitized.is_empty() {
"wire-user".into()
} else {
sanitized
}
}
pub fn ensure_self_with_relay(
preferred_relay: Option<&str>,
) -> Result<(String, String, String, String)> {
let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
if !config::is_initialized()? {
let handle = default_handle();
crate::pair_session::init_self_idempotent(&handle, None, Some(relay))
.with_context(|| format!("auto-init as did:wire:{handle}"))?;
}
let card = config::read_agent_card()?;
let did = card
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("agent-card missing did"))?
.to_string();
let mut relay_state = config::read_relay_state()?;
let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
let client = crate::relay_client::RelayClient::new(relay);
client.check_healthz()?;
let handle = crate::agent_card::display_handle_from_did(&did);
let alloc = client.allocate_slot(Some(handle))?;
relay_state["self"] = json!({
"relay_url": relay,
"slot_id": alloc.slot_id,
"slot_token": alloc.slot_token,
});
config::write_relay_state(&relay_state)?;
}
let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
bail!("self relay state incomplete after auto-allocate");
}
Ok((did, relay_url, slot_id, slot_token))
}
pub fn mint_invite(
ttl_secs: Option<u64>,
uses: u32,
preferred_relay: Option<&str>,
) -> Result<String> {
let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
let card = config::read_agent_card()?;
let sk_seed = config::read_private_key()?;
let mut nonce_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
let exp = now_unix() + ttl;
let payload = InvitePayload {
v: 1,
did: did.clone(),
card,
relay_url,
slot_id,
slot_token,
nonce: nonce.clone(),
exp,
};
let payload_bytes = serde_json::to_vec(&payload)?;
let mut sk_arr = [0u8; 32];
sk_arr.copy_from_slice(&sk_seed[..32]);
let sk = SigningKey::from_bytes(&sk_arr);
let sig = sk.sign(&payload_bytes);
let token = format!(
"{}.{}",
B64URL.encode(&payload_bytes),
B64URL.encode(sig.to_bytes())
);
let url = format!("wire://pair?v=1&inv={token}");
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let pending = PendingInvite {
nonce: nonce.clone(),
exp,
uses_remaining: uses.max(1),
accepted_by: vec![],
created_at: now,
};
let dir = pending_invites_dir()?;
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{nonce}.json"));
std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
Ok(url)
}
pub fn parse_invite(url: &str) -> Result<InvitePayload> {
let rest = url
.strip_prefix("wire://pair?")
.ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
let mut inv = None;
for part in rest.split('&') {
if let Some(v) = part.strip_prefix("inv=") {
inv = Some(v);
}
}
let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
let (payload_b64, sig_b64) = token
.split_once('.')
.ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
let payload_bytes = B64URL
.decode(payload_b64)
.map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
let sig_bytes = B64URL
.decode(sig_b64)
.map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
.map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
if payload.v != 1 {
bail!("invite schema version {} not supported", payload.v);
}
if now_unix() > payload.exp {
bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
}
crate::agent_card::verify_agent_card(&payload.card)
.map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
let pk_b64 = payload
.card
.get("verify_keys")
.and_then(Value::as_object)
.and_then(|m| m.values().next())
.and_then(|v| v.get("key"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
let pk_bytes = crate::signing::b64decode(pk_b64)?;
let mut pk_arr = [0u8; 32];
if pk_bytes.len() != 32 {
bail!("issuer pubkey wrong length");
}
pk_arr.copy_from_slice(&pk_bytes);
let vk = VerifyingKey::from_bytes(&pk_arr)
.map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
let mut sig_arr = [0u8; 64];
if sig_bytes.len() != 64 {
bail!("invite sig wrong length");
}
sig_arr.copy_from_slice(&sig_bytes);
let sig = Signature::from_bytes(&sig_arr);
vk.verify(&payload_bytes, &sig)
.map_err(|_| anyhow!("invite URL signature did not verify"))?;
Ok(payload)
}
pub fn accept_invite(url: &str) -> Result<Value> {
let payload = parse_invite(url)?;
let (our_did, our_relay, our_slot_id, our_slot_token) =
ensure_self_with_relay(Some(&payload.relay_url))?;
if our_did == payload.did {
bail!("refusing to accept own invite (issuer DID matches self)");
}
let mut trust = config::read_trust()?;
crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
config::write_trust(&trust)?;
let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
let mut relay_state = config::read_relay_state()?;
relay_state["peers"][&peer_handle] = json!({
"relay_url": payload.relay_url,
"slot_id": payload.slot_id,
"slot_token": payload.slot_token,
});
config::write_relay_state(&relay_state)?;
let our_card = config::read_agent_card()?;
let sk_seed = config::read_private_key()?;
let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
let pk_b64 = our_card
.get("verify_keys")
.and_then(Value::as_object)
.and_then(|m| m.values().next())
.and_then(|v| v.get("key"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
let pk_bytes = crate::signing::b64decode(pk_b64)?;
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let event = json!({
"schema_version": crate::signing::EVENT_SCHEMA_VERSION,
"timestamp": now,
"from": our_did,
"to": payload.did,
"type": "pair_drop",
"kind": 1100u32,
"body": {
"card": our_card,
"relay_url": our_relay,
"slot_id": our_slot_id,
"slot_token": our_slot_token,
"pair_nonce": payload.nonce,
},
});
let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
let client = crate::relay_client::RelayClient::new(&payload.relay_url);
client
.post_event(&payload.slot_id, &payload.slot_token, &signed)
.with_context(|| {
format!(
"POST pair_drop to {} slot {}",
payload.relay_url, payload.slot_id
)
})?;
Ok(json!({
"paired_with": payload.did,
"peer_handle": peer_handle,
"event_id": event_id,
"status": "drop_sent",
}))
}
pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
if kind != 1100 || type_str != "pair_drop" {
return Ok(None);
}
let body = match event.get("body") {
Some(b) => b,
None => return Ok(None),
};
let nonce_opt = body
.get("pair_nonce")
.and_then(Value::as_str)
.map(str::to_string);
let mut pending: Option<PendingInvite> = None;
let mut invite_path: Option<std::path::PathBuf> = None;
if let Some(nonce) = nonce_opt.as_deref() {
let dir = pending_invites_dir()?;
let path = dir.join(format!("{nonce}.json"));
if path.exists() {
let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
.with_context(|| format!("reading pending invite {path:?}"))?;
if now_unix() > p.exp {
if let Err(e) = std::fs::remove_file(&path) {
eprintln!(
"wire: could not delete expired invite {path:?}: {e}"
);
}
return Ok(None);
}
pending = Some(p);
invite_path = Some(path);
} else if !open_mode_enabled() {
return Ok(None);
}
} else if !open_mode_enabled() {
return Ok(None);
}
let peer_card = body
.get("card")
.cloned()
.ok_or_else(|| anyhow!("pair_drop body missing card"))?;
crate::agent_card::verify_agent_card(&peer_card)
.map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
let peer_did = peer_card
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("peer card missing did"))?
.to_string();
let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
let mut tmp_trust = config::read_trust()?;
crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
crate::signing::verify_message_v31(event, &tmp_trust)
.map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
let peer_relay = body
.get("relay_url")
.and_then(Value::as_str)
.unwrap_or("");
let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
let peer_slot_token = body
.get("slot_token")
.and_then(Value::as_str)
.unwrap_or("");
if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
bail!("pair_drop body missing relay_url/slot_id/slot_token");
}
if nonce_opt.is_some() {
config::write_trust(&tmp_trust)?;
let mut relay_state = config::read_relay_state()?;
relay_state["peers"][&peer_handle] = json!({
"relay_url": peer_relay,
"slot_id": peer_slot_id,
"slot_token": peer_slot_token,
});
config::write_relay_state(&relay_state)?;
if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
if pending.uses_remaining <= 1 {
if let Err(e) = std::fs::remove_file(&invite_path) {
eprintln!(
"wire: could not delete consumed invite {invite_path:?}: {e}"
);
}
} else {
let mut updated = pending.clone();
updated.uses_remaining -= 1;
updated.accepted_by.push(peer_did.clone());
std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
}
}
crate::os_notify::toast(
&format!("wire — paired with {peer_handle}"),
"Invite accepted. Ready to send + receive.",
);
return Ok(Some(peer_did));
}
let now_iso = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let event_id = event
.get("event_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let event_timestamp = event
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
peer_handle: peer_handle.clone(),
peer_did: peer_did.clone(),
peer_card: peer_card.clone(),
peer_relay_url: peer_relay.to_string(),
peer_slot_id: peer_slot_id.to_string(),
peer_slot_token: peer_slot_token.to_string(),
event_id,
event_timestamp,
received_at: now_iso,
};
crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
crate::os_notify::toast(
&format!("wire — pair request from {peer_handle}"),
&format!(
"run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
),
);
Ok(Some(peer_did))
}
pub fn send_pair_drop_ack(
peer_handle: &str,
peer_relay: &str,
peer_slot_id: &str,
peer_slot_token: &str,
) -> Result<()> {
let our_card = config::read_agent_card()?;
let our_did = our_card
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("our card missing did"))?
.to_string();
let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
let relay_state = config::read_relay_state()?;
let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
let our_relay = self_state
.get("relay_url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let our_slot_id = self_state
.get("slot_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let our_slot_token = self_state
.get("slot_token")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
bail!("self relay state incomplete; cannot emit pair_drop_ack");
}
let sk_seed = config::read_private_key()?;
let pk_b64 = our_card
.get("verify_keys")
.and_then(Value::as_object)
.and_then(|m| m.values().next())
.and_then(|v| v.get("key"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
let pk_bytes = crate::signing::b64decode(pk_b64)?;
let now = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let event = json!({
"schema_version": crate::signing::EVENT_SCHEMA_VERSION,
"timestamp": now,
"from": our_did,
"to": format!("did:wire:{peer_handle}"),
"type": "pair_drop_ack",
"kind": 1101u32,
"body": {
"relay_url": our_relay,
"slot_id": our_slot_id,
"slot_token": our_slot_token,
},
});
let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
let client = crate::relay_client::RelayClient::new(peer_relay);
client
.post_event(peer_slot_id, peer_slot_token, &signed)
.with_context(|| format!("POST pair_drop_ack to {peer_relay} slot {peer_slot_id}"))?;
Ok(())
}
pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
if kind != 1101 || type_str != "pair_drop_ack" {
return Ok(false);
}
let body = match event.get("body") {
Some(b) => b,
None => return Ok(false),
};
let from = event
.get("from")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("ack missing 'from'"))?;
let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
}
let mut relay_state = config::read_relay_state()?;
relay_state["peers"][&peer_handle] = json!({
"relay_url": peer_relay,
"slot_id": peer_slot_id,
"slot_token": peer_slot_token,
});
config::write_relay_state(&relay_state)?;
crate::os_notify::toast(
&format!("wire — pair complete with {peer_handle}"),
"Both sides bound. Ready to send + receive.",
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
#[test]
fn record_pair_rejection_writes_jsonl_under_state_dir() {
config::test_support::with_temp_home(|| {
super::record_pair_rejection(
"slancha-spark",
"pair_drop_ack_send_failed",
"POST returned 502",
);
let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
assert!(
path.exists(),
"record_pair_rejection must create {path:?}"
);
let body = std::fs::read_to_string(&path).unwrap();
let line = body.lines().last().expect("at least one line");
let parsed: Value = serde_json::from_str(line).expect("valid JSON");
assert_eq!(parsed["peer"], "slancha-spark");
assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
assert_eq!(parsed["detail"], "POST returned 502");
assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
});
}
#[test]
fn record_pair_rejection_appends_multiple_lines() {
config::test_support::with_temp_home(|| {
super::record_pair_rejection("a", "code_a", "detail_a");
super::record_pair_rejection("b", "code_b", "detail_b");
super::record_pair_rejection("c", "code_c", "detail_c");
let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
for (i, peer) in ["a", "b", "c"].iter().enumerate() {
let parsed: Value = serde_json::from_str(lines[i]).unwrap();
assert_eq!(parsed["peer"], *peer);
}
});
}
}