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//! - `v`: format version. `1` was the original unsigned shape (still
9//!   accepted on decode for back-compat); `2` (huddle 0.7.11+) adds an
10//!   Ed25519 signature over the rest of the payload so tampering with
11//!   any field — salt, owner list, room name — is detected by the
12//!   receiver. `host_multiaddr`'s `/p2p/<peer-id>` suffix remains the
13//!   primary MITM defense at the libp2p layer, but the signature now
14//!   also catches edits that don't touch the multiaddr.
15//! - `host_multiaddr`: the dial target, WITH `/p2p/<peer-id>` suffix —
16//!   libp2p enforces remote-pubkey-matches-this on dial, so this is
17//!   the cryptographic anchor for "who you actually connect to."
18//! - `fingerprint`: the host's 24-char Ed25519 fingerprint, shown in
19//!   the confirmation modal so the receiver can verify ("yep, that's
20//!   the fp Alice texted me out-of-band").
21//! - `room`: optional — when present, the receiver auto-joins after
22//!   the dial completes and the room announcement arrives.
23//! - `creator_pubkey_b64` (v2+): the host's raw Ed25519 pubkey. The
24//!   receiver re-derives the fingerprint from it and rejects the
25//!   invite if `compute_fingerprint(pubkey) != fingerprint`.
26//! - `signed_at_ms` (v2+): epoch-ms at signing time. Used to keep
27//!   replays of stale invites loosely bounded (24h window by default).
28//! - `signature_b64` (v2+): Ed25519 signature over a deterministic
29//!   serialization of the other fields.
30//!
31//! Important: the passphrase is NEVER in the link. Encrypted rooms
32//! still require the joiner to type the passphrase separately;
33//! including it would defeat the point of OOB sharing.
34
35use std::time::{SystemTime, UNIX_EPOCH};
36
37use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
38use base64::engine::general_purpose::STANDARD as B64;
39use base64::Engine;
40use ed25519_dalek::{Signature, VerifyingKey};
41use serde::{Deserialize, Serialize};
42
43use crate::error::{HuddleError, Result};
44use crate::identity::{compute_fingerprint, Identity};
45
46pub const INVITE_PREFIX: &str = "huddle://invite#";
47
48/// Max accepted age for a v2 signed invite. 24h. Long enough that the
49/// recipient can act in their own time, short enough that captured
50/// invites can't be re-used indefinitely (and they're invalidated
51/// completely after the inviter's listen address changes anyway).
52pub const INVITE_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000;
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct InviteLink {
56    /// 1 = legacy unsigned (huddle 0.7.10 and earlier).
57    /// 2 = signed (huddle 0.7.11+).
58    pub v: u32,
59    pub host_multiaddr: String,
60    pub fingerprint: String,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub room: Option<InviteRoom>,
63    /// huddle 0.7.11: creator's raw Ed25519 pubkey, base64. Required
64    /// for v >= 2. The verifier re-derives the fingerprint from this
65    /// and rejects the invite if it doesn't match `fingerprint`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub creator_pubkey_b64: Option<String>,
68    /// huddle 0.7.11: epoch-ms at signing time.
69    #[serde(default, skip_serializing_if = "is_zero")]
70    pub signed_at_ms: i64,
71    /// huddle 0.7.11: Ed25519 signature over `signable_bytes`. Required
72    /// for v >= 2.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub signature_b64: Option<String>,
75    /// huddle 1.0: optional clearnet relay URL the inviter is reachable on
76    /// (`wss://host/ws` or `ws://ip:port/ws` — e.g. a cloudflared tunnel).
77    /// When present, the joiner can connect to the inviter's relay with zero
78    /// config. Bumps the invite to `v=3` and is covered by the signature, so
79    /// it can't be swapped for an attacker's relay without breaking the sig.
80    /// `None` for relay-less invites (which stay `v=2`, readable by older
81    /// clients).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub relay_url: Option<String>,
84}
85
86fn is_zero(n: &i64) -> bool {
87    *n == 0
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct InviteRoom {
92    pub id: String,
93    pub name: String,
94    pub encrypted: bool,
95    /// Base64 of the room's passphrase salt. Only meaningful for
96    /// encrypted rooms (where the joiner must type the passphrase
97    /// after dialing). `None` for unencrypted.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub salt_b64: Option<String>,
100    pub creator_fingerprint: String,
101    #[serde(default)]
102    pub owner_fingerprints: Vec<String>,
103}
104
105impl InviteLink {
106    /// Deterministic bytes the v2 signature commits to. Field order
107    /// MUST never change once shipped — receivers that ratchet to a
108    /// newer version still need to verify v2 invites.
109    fn signable_bytes(&self) -> Vec<u8> {
110        let mut out = Vec::with_capacity(256);
111        out.extend_from_slice(b"huddle-invite-v2|");
112        out.extend_from_slice(self.host_multiaddr.as_bytes());
113        out.push(b'|');
114        out.extend_from_slice(self.fingerprint.as_bytes());
115        out.push(b'|');
116        out.extend_from_slice(&self.signed_at_ms.to_be_bytes());
117        out.push(b'|');
118        if let Some(r) = &self.room {
119            out.extend_from_slice(b"room|");
120            out.extend_from_slice(r.id.as_bytes());
121            out.push(b'|');
122            out.extend_from_slice(r.name.as_bytes());
123            out.push(b'|');
124            out.push(if r.encrypted { b'E' } else { b'P' });
125            out.push(b'|');
126            if let Some(s) = &r.salt_b64 {
127                out.extend_from_slice(s.as_bytes());
128            }
129            out.push(b'|');
130            out.extend_from_slice(r.creator_fingerprint.as_bytes());
131            out.push(b'|');
132            // owner list: alphabetically sorted + comma joined for
133            // determinism. Anyone constructing the invite must sort
134            // before signing; the receiver re-sorts before verifying.
135            let mut owners = r.owner_fingerprints.clone();
136            owners.sort();
137            out.extend_from_slice(owners.join(",").as_bytes());
138        } else {
139            out.extend_from_slice(b"no-room");
140        }
141        // huddle 1.0: append the relay only when present, so relay-less
142        // invites produce byte-identical input to pre-1.0 v2 signers and
143        // still verify across versions. Signer and verifier append it
144        // identically, so it's covered by the signature.
145        if let Some(relay) = &self.relay_url {
146            out.extend_from_slice(b"|relay|");
147            out.extend_from_slice(relay.as_bytes());
148        }
149        out
150    }
151}
152
153/// huddle 0.7.11: sign an invite. Sets `v=2`, `creator_pubkey_b64`,
154/// `signed_at_ms`, and `signature_b64`. The input invite's `v` and
155/// signature fields are overwritten.
156pub fn sign_invite(identity: &Identity, mut invite: InviteLink) -> Result<InviteLink> {
157    // huddle 1.0: v=3 when the invite carries a relay URL, else v=2 so
158    // relay-less invites stay readable by pre-1.0 clients.
159    invite.v = if invite.relay_url.is_some() { 3 } else { 2 };
160    invite.creator_pubkey_b64 = Some(B64.encode(identity.public_bytes()));
161    invite.signed_at_ms = SystemTime::now()
162        .duration_since(UNIX_EPOCH)
163        .map(|d| d.as_millis() as i64)
164        .unwrap_or(0);
165    invite.signature_b64 = None;
166    // Canonical sort so the verifier can re-canonicalize identically.
167    if let Some(r) = invite.room.as_mut() {
168        r.owner_fingerprints.sort();
169    }
170    let payload = invite.signable_bytes();
171    let sig = identity.sign(&payload);
172    invite.signature_b64 = Some(B64.encode(sig));
173    Ok(invite)
174}
175
176/// Build the `huddle://invite#...` URL form from a parsed `InviteLink`.
177pub fn encode(invite: &InviteLink) -> Result<String> {
178    let json = serde_json::to_vec(invite)
179        .map_err(|e| HuddleError::Other(format!("invite encode: {e}")))?;
180    Ok(format!("{}{}", INVITE_PREFIX, B64URL.encode(&json)))
181}
182
183/// Parse a `huddle://invite#...` URL back into an `InviteLink`.
184///
185/// huddle 0.7.11: when `v >= 2`, this *also* verifies the signature
186/// against the embedded pubkey, re-derives the fingerprint, and rejects
187/// invites whose `signed_at_ms` is older than `INVITE_MAX_AGE_MS`.
188/// `v == 1` (legacy) parses unchanged so older invites still work,
189/// but callers should display a "this invite is unsigned" warning.
190pub fn decode(url: &str) -> Result<InviteLink> {
191    let body = url
192        .strip_prefix(INVITE_PREFIX)
193        .ok_or_else(|| HuddleError::Other("not a huddle invite link".into()))?;
194    let json = B64URL
195        .decode(body.trim())
196        .map_err(|e| HuddleError::Other(format!("bad base64: {e}")))?;
197    let invite: InviteLink = serde_json::from_slice(&json)
198        .map_err(|e| HuddleError::Other(format!("bad invite json: {e}")))?;
199    match invite.v {
200        1 => Ok(invite),
201        // huddle 1.0: v3 adds the signed `relay_url`; it verifies identically
202        // to v2 because `signable_bytes` already folds the relay in when present.
203        2 | 3 => {
204            verify_invite_signature(&invite)?;
205            verify_invite_freshness(&invite)?;
206            Ok(invite)
207        }
208        n => Err(HuddleError::Other(format!(
209            "unsupported invite version: {n}"
210        ))),
211    }
212}
213
214/// True if this invite came in unsigned (legacy `v=1`). UI should show
215/// a "this invite is unsigned — verify the fingerprint out-of-band"
216/// warning when this returns true.
217pub fn is_legacy_unsigned(invite: &InviteLink) -> bool {
218    invite.v < 2
219}
220
221fn verify_invite_signature(invite: &InviteLink) -> Result<()> {
222    let pubkey_b64 = invite
223        .creator_pubkey_b64
224        .as_ref()
225        .ok_or_else(|| HuddleError::Other("v2 invite missing creator_pubkey_b64".into()))?;
226    let sig_b64 = invite
227        .signature_b64
228        .as_ref()
229        .ok_or_else(|| HuddleError::Other("v2 invite missing signature_b64".into()))?;
230    let pubkey_bytes = B64
231        .decode(pubkey_b64)
232        .map_err(|e| HuddleError::Other(format!("bad creator_pubkey_b64: {e}")))?;
233    if pubkey_bytes.len() != 32 {
234        return Err(HuddleError::Other(format!(
235            "creator_pubkey is {} bytes, expected 32",
236            pubkey_bytes.len()
237        )));
238    }
239    let mut pk_arr = [0u8; 32];
240    pk_arr.copy_from_slice(&pubkey_bytes);
241    let derived_fp = compute_fingerprint(&pk_arr);
242    if derived_fp != invite.fingerprint {
243        return Err(HuddleError::Other(format!(
244            "invite fingerprint {} doesn't match pubkey-derived {}",
245            invite.fingerprint, derived_fp
246        )));
247    }
248    let sig_bytes = B64
249        .decode(sig_b64)
250        .map_err(|e| HuddleError::Other(format!("bad signature_b64: {e}")))?;
251    if sig_bytes.len() != 64 {
252        return Err(HuddleError::Other(format!(
253            "signature is {} bytes, expected 64",
254            sig_bytes.len()
255        )));
256    }
257    let mut sig_arr = [0u8; 64];
258    sig_arr.copy_from_slice(&sig_bytes);
259    let signature = Signature::from_bytes(&sig_arr);
260    let vk = VerifyingKey::from_bytes(&pk_arr)
261        .map_err(|e| HuddleError::Other(format!("bad verifying key: {e}")))?;
262    // Re-canonicalize before verification (in case owner_fingerprints
263    // arrived un-sorted from a less-careful sender).
264    let mut canon = invite.clone();
265    if let Some(r) = canon.room.as_mut() {
266        r.owner_fingerprints.sort();
267    }
268    vk.verify_strict(&canon.signable_bytes(), &signature)
269        .map_err(|e| HuddleError::Other(format!("invite signature verify failed: {e}")))?;
270    Ok(())
271}
272
273fn verify_invite_freshness(invite: &InviteLink) -> Result<()> {
274    if invite.signed_at_ms == 0 {
275        return Err(HuddleError::Other(
276            "v2 invite missing signed_at_ms".into(),
277        ));
278    }
279    let now = SystemTime::now()
280        .duration_since(UNIX_EPOCH)
281        .map(|d| d.as_millis() as i64)
282        .unwrap_or(0);
283    let age = now - invite.signed_at_ms;
284    if age > INVITE_MAX_AGE_MS {
285        return Err(HuddleError::Other(format!(
286            "invite is {}h old — re-generate (max {}h)",
287            age / 3_600_000,
288            INVITE_MAX_AGE_MS / 3_600_000
289        )));
290    }
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn fixture() -> InviteLink {
299        InviteLink {
300            v: 1,
301            host_multiaddr: "/ip4/1.2.3.4/tcp/9000/p2p/12D3KooW...".into(),
302            fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
303            room: Some(InviteRoom {
304                id: "rid-x".into(),
305                name: "Project Alpha".into(),
306                encrypted: true,
307                salt_b64: Some("AAAAAA==".into()),
308                creator_fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
309                owner_fingerprints: vec!["abcd-1234-efef-5678-9090-1111".into()],
310            }),
311            creator_pubkey_b64: None,
312            signed_at_ms: 0,
313            signature_b64: None,
314            relay_url: None,
315        }
316    }
317
318    #[test]
319    fn legacy_v1_round_trip() {
320        let inv = fixture();
321        let url = encode(&inv).unwrap();
322        let back = decode(&url).unwrap();
323        assert_eq!(back.v, 1);
324        assert!(is_legacy_unsigned(&back));
325        assert_eq!(back, inv);
326    }
327
328    #[test]
329    fn signed_v2_round_trip() {
330        let id = Identity::generate().unwrap();
331        let mut inv = fixture();
332        inv.fingerprint = id.fingerprint().to_string();
333        let signed = sign_invite(&id, inv).unwrap();
334        assert_eq!(signed.v, 2);
335        assert!(signed.signature_b64.is_some());
336        let url = encode(&signed).unwrap();
337        let back = decode(&url).unwrap();
338        assert_eq!(back, signed);
339        assert!(!is_legacy_unsigned(&back));
340    }
341
342    #[test]
343    fn tampered_salt_fails() {
344        let id = Identity::generate().unwrap();
345        let mut inv = fixture();
346        inv.fingerprint = id.fingerprint().to_string();
347        let mut signed = sign_invite(&id, inv).unwrap();
348        // Flip the salt; signature should no longer verify.
349        if let Some(r) = signed.room.as_mut() {
350            r.salt_b64 = Some("ZZZZZZZZ==".into());
351        }
352        let url = encode(&signed).unwrap();
353        let err = decode(&url).unwrap_err();
354        assert!(format!("{err}").contains("invite signature verify failed"));
355    }
356
357    #[test]
358    fn tampered_owner_list_fails() {
359        let id = Identity::generate().unwrap();
360        let mut inv = fixture();
361        inv.fingerprint = id.fingerprint().to_string();
362        let mut signed = sign_invite(&id, inv).unwrap();
363        if let Some(r) = signed.room.as_mut() {
364            r.owner_fingerprints.push("attacker-fp".into());
365        }
366        let url = encode(&signed).unwrap();
367        let err = decode(&url).unwrap_err();
368        assert!(format!("{err}").contains("invite signature verify failed"));
369    }
370
371    #[test]
372    fn substituted_pubkey_fails_fp_check() {
373        let alice = Identity::generate().unwrap();
374        let bob = Identity::generate().unwrap();
375        let mut inv = fixture();
376        inv.fingerprint = alice.fingerprint().to_string();
377        let mut signed = sign_invite(&alice, inv).unwrap();
378        // Swap in Bob's pubkey (so the derived fingerprint will not
379        // match alice's — caught before signature verify is attempted).
380        signed.creator_pubkey_b64 = Some(B64.encode(bob.public_bytes()));
381        let url = encode(&signed).unwrap();
382        let err = decode(&url).unwrap_err();
383        assert!(format!("{err}").contains("doesn't match pubkey-derived"));
384    }
385
386    #[test]
387    fn decode_unknown_version_rejects() {
388        let bad = serde_json::json!({
389            "v": 99,
390            "host_multiaddr": "/ip4/1.1.1.1/tcp/1",
391            "fingerprint": "x"
392        });
393        let url = format!("{}{}", INVITE_PREFIX, B64URL.encode(bad.to_string()));
394        assert!(decode(&url).is_err());
395    }
396
397    #[test]
398    fn decode_not_huddle_url_rejects() {
399        assert!(decode("https://example.com/invite").is_err());
400    }
401
402    // huddle 1.0: v3 invites carry a signed clearnet relay URL.
403    #[test]
404    fn signed_v3_with_relay_round_trips() {
405        let id = Identity::generate().unwrap();
406        let mut inv = fixture();
407        inv.fingerprint = id.fingerprint().to_string();
408        inv.relay_url = Some("wss://abc.trycloudflare.com/ws".into());
409        let signed = sign_invite(&id, inv).unwrap();
410        assert_eq!(signed.v, 3, "an invite with a relay must be v3");
411        let url = encode(&signed).unwrap();
412        let back = decode(&url).unwrap();
413        assert_eq!(back, signed);
414        assert_eq!(back.relay_url.as_deref(), Some("wss://abc.trycloudflare.com/ws"));
415    }
416
417    #[test]
418    fn relay_less_invite_stays_v2() {
419        // A relay-less invite must remain v2 so older clients still accept it.
420        let id = Identity::generate().unwrap();
421        let mut inv = fixture();
422        inv.fingerprint = id.fingerprint().to_string();
423        assert!(inv.relay_url.is_none());
424        let signed = sign_invite(&id, inv).unwrap();
425        assert_eq!(signed.v, 2);
426    }
427
428    #[test]
429    fn tampered_relay_fails() {
430        let id = Identity::generate().unwrap();
431        let mut inv = fixture();
432        inv.fingerprint = id.fingerprint().to_string();
433        inv.relay_url = Some("wss://mine.example/ws".into());
434        let mut signed = sign_invite(&id, inv).unwrap();
435        // Swap in an attacker's relay; the signature must no longer verify.
436        signed.relay_url = Some("wss://attacker.example/ws".into());
437        let url = encode(&signed).unwrap();
438        let err = decode(&url).unwrap_err();
439        assert!(format!("{err}").contains("invite signature verify failed"));
440    }
441}