Skip to main content

huddle_core/
invite.rs

1//! Phase C: invite-link encoding / decoding.
2//!
3//! Format: `huddle://invite#<base64url-no-pad JSON>`. The fragment
4//! (`#`) keeps the payload out of HTTP `Referer` headers if someone
5//! pastes through a web form somewhere.
6//!
7//! What's in the JSON:
8//! - `host_multiaddr`: the dial target, WITH `/p2p/<peer-id>` suffix —
9//!   libp2p enforces remote-pubkey-matches-this on dial, so this is
10//!   the actual MITM defense (not the fingerprint string below).
11//! - `fingerprint`: the host's 24-char Ed25519 fingerprint, shown in
12//!   the confirmation modal so the receiver can verify ("yep, that's
13//!   the fp Alice texted me out-of-band").
14//! - `room`: optional — when present, the receiver auto-joins after
15//!   the dial completes and the room announcement arrives.
16//!
17//! Important: the passphrase is NEVER in the link. Encrypted rooms
18//! still require the joiner to type the passphrase separately;
19//! including it would defeat the point of OOB sharing.
20
21use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
22use base64::Engine;
23use serde::{Deserialize, Serialize};
24
25use crate::error::{HuddleError, Result};
26
27pub const INVITE_PREFIX: &str = "huddle://invite#";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct InviteLink {
31    /// Always 1 in this version. Receiver rejects unknown versions —
32    /// a bumped invite format gets a new number.
33    pub v: u32,
34    pub host_multiaddr: String,
35    pub fingerprint: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub room: Option<InviteRoom>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct InviteRoom {
42    pub id: String,
43    pub name: String,
44    pub encrypted: bool,
45    /// Base64 of the room's passphrase salt. Only meaningful for
46    /// encrypted rooms (where the joiner must type the passphrase
47    /// after dialing). `None` for unencrypted.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub salt_b64: Option<String>,
50    pub creator_fingerprint: String,
51    #[serde(default)]
52    pub owner_fingerprints: Vec<String>,
53}
54
55/// Build the `huddle://invite#...` URL form from a parsed `InviteLink`.
56pub fn encode(invite: &InviteLink) -> Result<String> {
57    let json = serde_json::to_vec(invite)
58        .map_err(|e| HuddleError::Other(format!("invite encode: {e}")))?;
59    Ok(format!("{}{}", INVITE_PREFIX, B64.encode(&json)))
60}
61
62/// Parse a `huddle://invite#...` URL back into an `InviteLink`.
63pub fn decode(url: &str) -> Result<InviteLink> {
64    let body = url
65        .strip_prefix(INVITE_PREFIX)
66        .ok_or_else(|| HuddleError::Other("not a huddle invite link".into()))?;
67    let json = B64
68        .decode(body.trim())
69        .map_err(|e| HuddleError::Other(format!("bad base64: {e}")))?;
70    let invite: InviteLink = serde_json::from_slice(&json)
71        .map_err(|e| HuddleError::Other(format!("bad invite json: {e}")))?;
72    if invite.v != 1 {
73        return Err(HuddleError::Other(format!(
74            "unsupported invite version: {}",
75            invite.v
76        )));
77    }
78    Ok(invite)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn round_trip_peer_only() {
87        let inv = InviteLink {
88            v: 1,
89            host_multiaddr: "/ip4/1.2.3.4/tcp/9000/p2p/12D3KooW...".into(),
90            fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
91            room: None,
92        };
93        let url = encode(&inv).unwrap();
94        assert!(url.starts_with(INVITE_PREFIX));
95        let back = decode(&url).unwrap();
96        assert_eq!(back, inv);
97    }
98
99    #[test]
100    fn round_trip_with_room() {
101        let inv = InviteLink {
102            v: 1,
103            host_multiaddr: "/ip4/1.2.3.4/tcp/9000/p2p/12D3KooW...".into(),
104            fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
105            room: Some(InviteRoom {
106                id: "rid-x".into(),
107                name: "Project Alpha".into(),
108                encrypted: true,
109                salt_b64: Some("AAAAAA==".into()),
110                creator_fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
111                owner_fingerprints: vec!["abcd-1234-efef-5678-9090-1111".into()],
112            }),
113        };
114        let url = encode(&inv).unwrap();
115        let back = decode(&url).unwrap();
116        assert_eq!(back, inv);
117    }
118
119    #[test]
120    fn decode_unknown_version_rejects() {
121        // Hand-craft a JSON with v=99 and verify decode bails.
122        let bad = serde_json::json!({
123            "v": 99,
124            "host_multiaddr": "/ip4/1.1.1.1/tcp/1",
125            "fingerprint": "x"
126        });
127        let url = format!("{}{}", INVITE_PREFIX, B64.encode(bad.to_string()));
128        assert!(decode(&url).is_err());
129    }
130
131    #[test]
132    fn decode_not_huddle_url_rejects() {
133        assert!(decode("https://example.com/invite").is_err());
134    }
135}