Skip to main content

vector_core/
webxdc.rs

1//! WebXDC Mini App helpers shared across transports (DM + Community).
2//!
3//! The realtime-channel topic for a Mini App is minted ONCE at send time and
4//! carried on the file event as a `webxdc-topic` tag, so every participant
5//! joins the SAME gossip topic. Locally-derived topics are asymmetric in DMs
6//! (each side's chat_id is the other party's npub), which silently splits the
7//! players onto disjoint topics — the tag is the single source of truth.
8
9/// Mint a fresh realtime-channel topic id for an outbound `.xdc` attachment.
10///
11/// 32 bytes of SHA-256 over a domain separator + file hash + sender + send-time
12/// nanos + a per-process counter, encoded base32 (RFC 4648, no padding) — the
13/// same codec the miniapp realtime layer's `decode_topic_id` expects, so the tag
14/// value round-trips into an iroh `TopicId`.
15///
16/// The counter is what actually guarantees re-sends are distinct sessions. The
17/// clock cannot: `SystemTime::now()` reports nanos but does not RESOLVE them, so
18/// two sends of the same file inside one tick hashed identical input and minted
19/// the same topic — the "fresh" session silently rejoined the previous one.
20pub fn mint_topic_id(file_hash: &str, sender_hex: &str) -> String {
21    use sha2::{Digest, Sha256};
22    use std::sync::atomic::{AtomicU64, Ordering};
23    static MINTED: AtomicU64 = AtomicU64::new(0);
24    let nanos = std::time::SystemTime::now()
25        .duration_since(std::time::UNIX_EPOCH)
26        .map(|d| d.as_nanos())
27        .unwrap_or(0);
28    let seq = MINTED.fetch_add(1, Ordering::Relaxed);
29    let mut hasher = Sha256::new();
30    hasher.update(b"webxdc-realtime-v1:");
31    hasher.update(file_hash.as_bytes());
32    hasher.update(b":");
33    hasher.update(sender_hex.as_bytes());
34    hasher.update(b":");
35    hasher.update(nanos.to_le_bytes());
36    hasher.update(b":");
37    hasher.update(seq.to_le_bytes());
38    base32_nopad_encode(&hasher.finalize())
39}
40
41/// Derive the realtime topic for a URL-shared Mini App.
42///
43/// A pasted `.xdc` URL has no file event to carry a minted topic, so every
44/// recipient derives the same one from what the message already gives them:
45/// the URL string and the message id (which keeps re-shares distinct
46/// sessions, the role nanos+counter play in `mint_topic_id`). Deliberately
47/// NOT the content hash: servers rebuild identical apps into new bytes, and
48/// players who tapped the same card at different times must still share a
49/// session — the message is the session anchor, the URL only the source.
50pub fn derive_url_topic_id(url: &str, msg_id: &str) -> String {
51    use sha2::{Digest, Sha256};
52    let mut hasher = Sha256::new();
53    hasher.update(b"webxdc-url-realtime-v1:");
54    hasher.update(url.as_bytes());
55    hasher.update(b":");
56    hasher.update(msg_id.as_bytes());
57    base32_nopad_encode(&hasher.finalize())
58}
59
60/// BASE32 no-pad encoding (RFC 4648). Mirrors the miniapp realtime layer's
61/// codec exactly — the two must agree for topic tags to decode.
62pub fn base32_nopad_encode(bytes: &[u8]) -> String {
63    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
64    let mut out = String::with_capacity((bytes.len() * 8 + 4) / 5);
65    let mut buf: u64 = 0;
66    let mut bits: u32 = 0;
67    for &b in bytes {
68        buf = (buf << 8) | b as u64;
69        bits += 8;
70        while bits >= 5 {
71            bits -= 5;
72            out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
73        }
74    }
75    if bits > 0 {
76        out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
77    }
78    out
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn minted_topic_is_32_bytes_base32() {
87        let t = mint_topic_id("abc123", "deadbeef");
88        // 32 bytes → ceil(256/5) = 52 base32 chars
89        assert_eq!(t.len(), 52);
90        assert!(t.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
91    }
92
93    #[test]
94    fn resends_mint_distinct_topics() {
95        let a = mint_topic_id("abc123", "deadbeef");
96        let b = mint_topic_id("abc123", "deadbeef");
97        assert_ne!(a, b, "same file re-sent must start a fresh session topic");
98    }
99
100    #[test]
101    fn base32_matches_rfc4648_vectors() {
102        // RFC 4648 §10 test vectors (padding stripped)
103        assert_eq!(base32_nopad_encode(b""), "");
104        assert_eq!(base32_nopad_encode(b"f"), "MY");
105        assert_eq!(base32_nopad_encode(b"fo"), "MZXQ");
106        assert_eq!(base32_nopad_encode(b"foo"), "MZXW6");
107        assert_eq!(base32_nopad_encode(b"foob"), "MZXW6YQ");
108        assert_eq!(base32_nopad_encode(b"fooba"), "MZXW6YTB");
109        assert_eq!(base32_nopad_encode(b"foobar"), "MZXW6YTBOI");
110    }
111}
112
113/// The kind-3310 peer-signal content, shared by every community transport (the
114/// v1 channel plane and the v2 chat plane must stay byte-compatible):
115/// `{"op":"ad","topic":..,"addr":..}` advertises an Iroh node, `{"op":"left",..}`
116/// departs.
117pub fn peer_signal_content(topic_id: &str, node_addr: Option<&str>) -> String {
118    match node_addr {
119        Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
120        None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
121    }
122}
123
124/// Parse + bound a kind-3310 peer signal: `Some((topic, Some(addr)))` for an
125/// advertisement, `Some((topic, None))` for a departure. Both fields are
126/// author-controlled: the topic must be a 52-char base32 TopicId and the addr is
127/// size-bounded — the realtime layer's decode is the final word.
128pub fn parse_peer_signal(content: &str) -> Option<(String, Option<String>)> {
129    let v: serde_json::Value = serde_json::from_str(content).ok()?;
130    let topic_id = v
131        .get("topic")
132        .and_then(|t| t.as_str())
133        .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
134        .to_string();
135    let node_addr = match v.get("op").and_then(|o| o.as_str())? {
136        "ad" => Some(v.get("addr").and_then(|a| a.as_str()).filter(|a| !a.is_empty() && a.len() <= 2048)?.to_string()),
137        "left" => None,
138        _ => return None,
139    };
140    Some((topic_id, node_addr))
141}
142
143#[cfg(test)]
144mod url_topic_tests {
145    use super::*;
146
147    #[test]
148    fn a_url_topic_is_deterministic_per_message_and_survives_server_rebuilds() {
149        let a = derive_url_topic_id("https://x.org/app.xdc", "msg1");
150        assert_eq!(a, derive_url_topic_id("https://x.org/app.xdc", "msg1"));
151        assert_eq!(a.len(), 52, "must be a valid 52-char base32 TopicId");
152        assert!(parse_peer_signal(&peer_signal_content(&a, Some("iroh:x"))).is_some());
153        assert_ne!(a, derive_url_topic_id("https://x.org/other.xdc", "msg1"), "different URL = disjoint topics");
154        assert_ne!(a, derive_url_topic_id("https://x.org/app.xdc", "msg2"), "re-share = fresh session");
155    }
156}
157
158#[cfg(test)]
159mod peer_signal_tests {
160    use super::*;
161
162    #[test]
163    fn peer_signal_round_trips_and_bounds() {
164        let topic = "A".repeat(52);
165        let ad = peer_signal_content(&topic, Some("iroh:node/abc"));
166        assert_eq!(parse_peer_signal(&ad), Some((topic.clone(), Some("iroh:node/abc".into()))));
167        let left = peer_signal_content(&topic, None);
168        assert_eq!(parse_peer_signal(&left), Some((topic.clone(), None)));
169
170        // Author-controlled fields are bounded: bad topic, oversized addr, junk op.
171        assert_eq!(parse_peer_signal(&peer_signal_content("short", Some("a"))), None);
172        let oversized = "a".repeat(2049);
173        assert_eq!(parse_peer_signal(&peer_signal_content(&topic, Some(&oversized))), None);
174        assert_eq!(parse_peer_signal(&format!("{{\"op\":\"warp\",\"topic\":\"{topic}\"}}")), None);
175        assert_eq!(parse_peer_signal("not json"), None);
176    }
177}