use iroh::{EndpointAddr, TransportAddr};
pub const MAX_PAYLOAD: usize = 128_000;
pub const MAX_GOSSIP_MESSAGE: usize = 128 * 1024;
pub const NODE_KEY_LEN: usize = 32;
pub const TRAILER_LEN: usize = 4 + NODE_KEY_LEN;
pub fn seal_frame(mut payload: Vec<u8>, seq: i32, node_key: &[u8; NODE_KEY_LEN]) -> Vec<u8> {
payload.reserve(TRAILER_LEN);
payload.extend_from_slice(&seq.to_le_bytes());
payload.extend_from_slice(node_key);
payload
}
pub fn open_frame(frame: &[u8]) -> Option<(&[u8], [u8; NODE_KEY_LEN])> {
let body = frame.len().checked_sub(TRAILER_LEN)?;
let mut key = [0u8; NODE_KEY_LEN];
key.copy_from_slice(&frame[body + 4..]);
Some((&frame[..body], key))
}
pub use crate::webxdc::{base32_nopad_decode, base32_nopad_encode};
pub fn decode_topic(s: &str) -> Result<[u8; 32], String> {
let bytes = base32_nopad_decode(s.as_bytes())?;
bytes.try_into().map_err(|_| "a topic id is 32 bytes".to_string())
}
pub fn encode_topic(topic: &[u8; 32]) -> String {
base32_nopad_encode(topic)
}
pub fn fallback_topic(app_name: &str, chat_id: &str, message_id: &str) -> [u8; 32] {
let mut input = Vec::with_capacity(20 + app_name.len() + chat_id.len() + message_id.len());
input.extend_from_slice(b"webxdc-realtime-v1:");
input.extend_from_slice(app_name.as_bytes());
input.push(b':');
input.extend_from_slice(chat_id.as_bytes());
input.push(b':');
input.extend_from_slice(message_id.as_bytes());
crate::crypto::sha256::digest(&input)
}
pub fn encode_node_addr(addr: &EndpointAddr) -> Result<String, String> {
let json = serde_json::to_string(addr).map_err(|e| e.to_string())?;
Ok(base32_nopad_encode(json.as_bytes()))
}
pub fn decode_node_addr(s: &str) -> Result<EndpointAddr, String> {
if s.len() > 2048 {
return Err("node address too long".into());
}
let bytes = base32_nopad_decode(s.as_bytes())?;
let addr: EndpointAddr = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
Ok(relay_only(addr))
}
pub fn dialable(peer: EndpointAddr) -> EndpointAddr {
let mut addr = relay_only(peer);
addr.addrs.retain(|a| matches!(a, TransportAddr::Relay(url) if is_default_relay(url)));
#[cfg(target_arch = "wasm32")]
{
addr.addrs = addr
.addrs
.into_iter()
.map(|a| match a {
TransportAddr::Relay(url) => TransportAddr::Relay(browser_relay(&url)),
other => other,
})
.collect();
}
addr
}
pub fn relay_only(addr: EndpointAddr) -> EndpointAddr {
EndpointAddr {
id: addr.id,
addrs: addr.addrs.into_iter().filter(|a| matches!(a, TransportAddr::Relay(_))).collect(),
}
}
pub fn is_default_relay(url: &iroh::RelayUrl) -> bool {
let host = |u: &iroh::RelayUrl| u.host_str().map(|h| h.trim_end_matches('.').to_ascii_lowercase());
let theirs = host(url);
theirs.is_some()
&& iroh::defaults::prod::default_relay_map()
.urls::<Vec<iroh::RelayUrl>>()
.iter()
.any(|d| host(d) == theirs)
}
pub fn browser_relay(url: &iroh::RelayUrl) -> iroh::RelayUrl {
let mut u = (**url).clone();
if let Some(host) = u.host_str().and_then(|h| h.strip_suffix('.')).map(str::to_string) {
let _ = u.set_host(Some(&host));
}
iroh::RelayUrl::from(u)
}
pub fn relay_mode() -> iroh::RelayMode {
#[cfg(not(target_arch = "wasm32"))]
{
iroh::RelayMode::Default
}
#[cfg(target_arch = "wasm32")]
{
iroh::RelayMode::Custom(iroh::defaults::prod::default_relay_map().urls::<Vec<iroh::RelayUrl>>().iter().map(browser_relay).collect())
}
}
pub fn relay_urls(addr: &EndpointAddr) -> String {
let urls: Vec<String> = addr
.addrs
.iter()
.filter_map(|a| match a {
TransportAddr::Relay(u) => Some(u.to_string()),
_ => None,
})
.collect();
if urls.is_empty() { "none".into() } else { urls.join(", ") }
}
#[cfg(test)]
mod tests {
#[test]
fn a_browser_dials_the_relay_without_its_trailing_dot() {
let dotted: iroh::RelayUrl = "https://euc1-1.relay.n0.iroh.link./".parse().unwrap();
let plain = browser_relay(&dotted);
assert_eq!(plain.host_str(), Some("euc1-1.relay.n0.iroh.link"));
assert!(is_default_relay(&plain), "still one of the default relays");
}
#[test]
fn the_fallback_topic_is_the_one_every_client_derives() {
assert_eq!(encode_topic(&fallback_topic("Chess", "npub1chat", "msg1")), "YFLKK3KDFMLO5GJXVLY6CIMNTMMN53BKOYAJ7LXBSW4DOMFWTCXA");
}
use super::*;
#[test]
fn a_frame_round_trips_its_payload_and_sender() {
let key = [7u8; 32];
let frame = seal_frame(b"{\"t\":\"hello\"}".to_vec(), 3, &key);
assert_eq!(frame.len(), 13 + TRAILER_LEN);
assert_eq!(&frame[13..17], &3i32.to_le_bytes());
let (payload, sender) = open_frame(&frame).unwrap();
assert_eq!(payload, b"{\"t\":\"hello\"}");
assert_eq!(sender, key);
}
#[test]
fn an_empty_payload_is_a_valid_frame_and_a_short_one_is_not() {
let frame = seal_frame(Vec::new(), 1, &[1u8; 32]);
assert_eq!(open_frame(&frame).unwrap().0, b"");
assert!(open_frame(&frame[1..]).is_none());
}
#[test]
fn topics_round_trip_and_reject_the_wrong_length() {
let t = [0xABu8; 32];
let s = encode_topic(&t);
assert_eq!(s.len(), 52);
assert_eq!(decode_topic(&s).unwrap(), t);
assert!(decode_topic("MZXW6").is_err());
assert!(decode_topic(&"1".repeat(52)).is_err());
}
#[test]
fn only_the_default_relays_are_dialled() {
for url in iroh::defaults::prod::default_relay_map().urls::<Vec<iroh::RelayUrl>>() {
assert!(is_default_relay(&url));
let undotted: iroh::RelayUrl = url.as_str().replacen(".iroh.link./", ".iroh.link/", 1).parse().unwrap();
assert!(is_default_relay(&undotted), "{undotted} is the same server");
}
for evil in ["https://attacker.example./", "https://euc1-1.relay.n0.iroh.link.evil.example/", "http://127.0.0.1:3340/"] {
assert!(!is_default_relay(&evil.parse().unwrap()), "{evil}");
}
}
#[test]
fn a_dialable_address_keeps_only_the_default_relays() {
let default = iroh::defaults::prod::default_relay_map().urls::<Vec<iroh::RelayUrl>>()[0].clone();
let evil: iroh::RelayUrl = "https://attacker.example./".parse().unwrap();
let addr = EndpointAddr {
id: iroh::SecretKey::from([1u8; 32]).public(),
addrs: [
TransportAddr::Relay(default.clone()),
TransportAddr::Relay(evil),
TransportAddr::Ip("203.0.113.7:4433".parse().unwrap()),
]
.into_iter()
.collect(),
};
let kept: Vec<_> = dialable(addr).addrs.into_iter().collect();
assert_eq!(kept, vec![TransportAddr::Relay(default)]);
}
#[test]
fn a_minted_topic_decodes() {
let minted = crate::webxdc::mint_topic_id("hash", "sender");
assert!(decode_topic(&minted).is_ok());
}
}