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}
76
77fn is_zero(n: &i64) -> bool {
78    *n == 0
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct InviteRoom {
83    pub id: String,
84    pub name: String,
85    pub encrypted: bool,
86    /// Base64 of the room's passphrase salt. Only meaningful for
87    /// encrypted rooms (where the joiner must type the passphrase
88    /// after dialing). `None` for unencrypted.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub salt_b64: Option<String>,
91    pub creator_fingerprint: String,
92    #[serde(default)]
93    pub owner_fingerprints: Vec<String>,
94}
95
96impl InviteLink {
97    /// Deterministic bytes the v2 signature commits to. Field order
98    /// MUST never change once shipped — receivers that ratchet to a
99    /// newer version still need to verify v2 invites.
100    fn signable_bytes(&self) -> Vec<u8> {
101        let mut out = Vec::with_capacity(256);
102        out.extend_from_slice(b"huddle-invite-v2|");
103        out.extend_from_slice(self.host_multiaddr.as_bytes());
104        out.push(b'|');
105        out.extend_from_slice(self.fingerprint.as_bytes());
106        out.push(b'|');
107        out.extend_from_slice(&self.signed_at_ms.to_be_bytes());
108        out.push(b'|');
109        if let Some(r) = &self.room {
110            out.extend_from_slice(b"room|");
111            out.extend_from_slice(r.id.as_bytes());
112            out.push(b'|');
113            out.extend_from_slice(r.name.as_bytes());
114            out.push(b'|');
115            out.push(if r.encrypted { b'E' } else { b'P' });
116            out.push(b'|');
117            if let Some(s) = &r.salt_b64 {
118                out.extend_from_slice(s.as_bytes());
119            }
120            out.push(b'|');
121            out.extend_from_slice(r.creator_fingerprint.as_bytes());
122            out.push(b'|');
123            // owner list: alphabetically sorted + comma joined for
124            // determinism. Anyone constructing the invite must sort
125            // before signing; the receiver re-sorts before verifying.
126            let mut owners = r.owner_fingerprints.clone();
127            owners.sort();
128            out.extend_from_slice(owners.join(",").as_bytes());
129        } else {
130            out.extend_from_slice(b"no-room");
131        }
132        out
133    }
134}
135
136/// huddle 0.7.11: sign an invite. Sets `v=2`, `creator_pubkey_b64`,
137/// `signed_at_ms`, and `signature_b64`. The input invite's `v` and
138/// signature fields are overwritten.
139pub fn sign_invite(identity: &Identity, mut invite: InviteLink) -> Result<InviteLink> {
140    invite.v = 2;
141    invite.creator_pubkey_b64 = Some(B64.encode(identity.public_bytes()));
142    invite.signed_at_ms = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .map(|d| d.as_millis() as i64)
145        .unwrap_or(0);
146    invite.signature_b64 = None;
147    // Canonical sort so the verifier can re-canonicalize identically.
148    if let Some(r) = invite.room.as_mut() {
149        r.owner_fingerprints.sort();
150    }
151    let payload = invite.signable_bytes();
152    let sig = identity.sign(&payload);
153    invite.signature_b64 = Some(B64.encode(sig));
154    Ok(invite)
155}
156
157/// Build the `huddle://invite#...` URL form from a parsed `InviteLink`.
158pub fn encode(invite: &InviteLink) -> Result<String> {
159    let json = serde_json::to_vec(invite)
160        .map_err(|e| HuddleError::Other(format!("invite encode: {e}")))?;
161    Ok(format!("{}{}", INVITE_PREFIX, B64URL.encode(&json)))
162}
163
164/// Parse a `huddle://invite#...` URL back into an `InviteLink`.
165///
166/// huddle 0.7.11: when `v >= 2`, this *also* verifies the signature
167/// against the embedded pubkey, re-derives the fingerprint, and rejects
168/// invites whose `signed_at_ms` is older than `INVITE_MAX_AGE_MS`.
169/// `v == 1` (legacy) parses unchanged so older invites still work,
170/// but callers should display a "this invite is unsigned" warning.
171pub fn decode(url: &str) -> Result<InviteLink> {
172    let body = url
173        .strip_prefix(INVITE_PREFIX)
174        .ok_or_else(|| HuddleError::Other("not a huddle invite link".into()))?;
175    let json = B64URL
176        .decode(body.trim())
177        .map_err(|e| HuddleError::Other(format!("bad base64: {e}")))?;
178    let invite: InviteLink = serde_json::from_slice(&json)
179        .map_err(|e| HuddleError::Other(format!("bad invite json: {e}")))?;
180    match invite.v {
181        1 => Ok(invite),
182        2 => {
183            verify_invite_signature(&invite)?;
184            verify_invite_freshness(&invite)?;
185            Ok(invite)
186        }
187        n => Err(HuddleError::Other(format!(
188            "unsupported invite version: {n}"
189        ))),
190    }
191}
192
193/// True if this invite came in unsigned (legacy `v=1`). UI should show
194/// a "this invite is unsigned — verify the fingerprint out-of-band"
195/// warning when this returns true.
196pub fn is_legacy_unsigned(invite: &InviteLink) -> bool {
197    invite.v < 2
198}
199
200fn verify_invite_signature(invite: &InviteLink) -> Result<()> {
201    let pubkey_b64 = invite
202        .creator_pubkey_b64
203        .as_ref()
204        .ok_or_else(|| HuddleError::Other("v2 invite missing creator_pubkey_b64".into()))?;
205    let sig_b64 = invite
206        .signature_b64
207        .as_ref()
208        .ok_or_else(|| HuddleError::Other("v2 invite missing signature_b64".into()))?;
209    let pubkey_bytes = B64
210        .decode(pubkey_b64)
211        .map_err(|e| HuddleError::Other(format!("bad creator_pubkey_b64: {e}")))?;
212    if pubkey_bytes.len() != 32 {
213        return Err(HuddleError::Other(format!(
214            "creator_pubkey is {} bytes, expected 32",
215            pubkey_bytes.len()
216        )));
217    }
218    let mut pk_arr = [0u8; 32];
219    pk_arr.copy_from_slice(&pubkey_bytes);
220    let derived_fp = compute_fingerprint(&pk_arr);
221    if derived_fp != invite.fingerprint {
222        return Err(HuddleError::Other(format!(
223            "invite fingerprint {} doesn't match pubkey-derived {}",
224            invite.fingerprint, derived_fp
225        )));
226    }
227    let sig_bytes = B64
228        .decode(sig_b64)
229        .map_err(|e| HuddleError::Other(format!("bad signature_b64: {e}")))?;
230    if sig_bytes.len() != 64 {
231        return Err(HuddleError::Other(format!(
232            "signature is {} bytes, expected 64",
233            sig_bytes.len()
234        )));
235    }
236    let mut sig_arr = [0u8; 64];
237    sig_arr.copy_from_slice(&sig_bytes);
238    let signature = Signature::from_bytes(&sig_arr);
239    let vk = VerifyingKey::from_bytes(&pk_arr)
240        .map_err(|e| HuddleError::Other(format!("bad verifying key: {e}")))?;
241    // Re-canonicalize before verification (in case owner_fingerprints
242    // arrived un-sorted from a less-careful sender).
243    let mut canon = invite.clone();
244    if let Some(r) = canon.room.as_mut() {
245        r.owner_fingerprints.sort();
246    }
247    vk.verify_strict(&canon.signable_bytes(), &signature)
248        .map_err(|e| HuddleError::Other(format!("invite signature verify failed: {e}")))?;
249    Ok(())
250}
251
252fn verify_invite_freshness(invite: &InviteLink) -> Result<()> {
253    if invite.signed_at_ms == 0 {
254        return Err(HuddleError::Other(
255            "v2 invite missing signed_at_ms".into(),
256        ));
257    }
258    let now = SystemTime::now()
259        .duration_since(UNIX_EPOCH)
260        .map(|d| d.as_millis() as i64)
261        .unwrap_or(0);
262    let age = now - invite.signed_at_ms;
263    if age > INVITE_MAX_AGE_MS {
264        return Err(HuddleError::Other(format!(
265            "invite is {}h old — re-generate (max {}h)",
266            age / 3_600_000,
267            INVITE_MAX_AGE_MS / 3_600_000
268        )));
269    }
270    Ok(())
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn fixture() -> InviteLink {
278        InviteLink {
279            v: 1,
280            host_multiaddr: "/ip4/1.2.3.4/tcp/9000/p2p/12D3KooW...".into(),
281            fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
282            room: Some(InviteRoom {
283                id: "rid-x".into(),
284                name: "Project Alpha".into(),
285                encrypted: true,
286                salt_b64: Some("AAAAAA==".into()),
287                creator_fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
288                owner_fingerprints: vec!["abcd-1234-efef-5678-9090-1111".into()],
289            }),
290            creator_pubkey_b64: None,
291            signed_at_ms: 0,
292            signature_b64: None,
293        }
294    }
295
296    #[test]
297    fn legacy_v1_round_trip() {
298        let inv = fixture();
299        let url = encode(&inv).unwrap();
300        let back = decode(&url).unwrap();
301        assert_eq!(back.v, 1);
302        assert!(is_legacy_unsigned(&back));
303        assert_eq!(back, inv);
304    }
305
306    #[test]
307    fn signed_v2_round_trip() {
308        let id = Identity::generate().unwrap();
309        let mut inv = fixture();
310        inv.fingerprint = id.fingerprint().to_string();
311        let signed = sign_invite(&id, inv).unwrap();
312        assert_eq!(signed.v, 2);
313        assert!(signed.signature_b64.is_some());
314        let url = encode(&signed).unwrap();
315        let back = decode(&url).unwrap();
316        assert_eq!(back, signed);
317        assert!(!is_legacy_unsigned(&back));
318    }
319
320    #[test]
321    fn tampered_salt_fails() {
322        let id = Identity::generate().unwrap();
323        let mut inv = fixture();
324        inv.fingerprint = id.fingerprint().to_string();
325        let mut signed = sign_invite(&id, inv).unwrap();
326        // Flip the salt; signature should no longer verify.
327        if let Some(r) = signed.room.as_mut() {
328            r.salt_b64 = Some("ZZZZZZZZ==".into());
329        }
330        let url = encode(&signed).unwrap();
331        let err = decode(&url).unwrap_err();
332        assert!(format!("{err}").contains("invite signature verify failed"));
333    }
334
335    #[test]
336    fn tampered_owner_list_fails() {
337        let id = Identity::generate().unwrap();
338        let mut inv = fixture();
339        inv.fingerprint = id.fingerprint().to_string();
340        let mut signed = sign_invite(&id, inv).unwrap();
341        if let Some(r) = signed.room.as_mut() {
342            r.owner_fingerprints.push("attacker-fp".into());
343        }
344        let url = encode(&signed).unwrap();
345        let err = decode(&url).unwrap_err();
346        assert!(format!("{err}").contains("invite signature verify failed"));
347    }
348
349    #[test]
350    fn substituted_pubkey_fails_fp_check() {
351        let alice = Identity::generate().unwrap();
352        let bob = Identity::generate().unwrap();
353        let mut inv = fixture();
354        inv.fingerprint = alice.fingerprint().to_string();
355        let mut signed = sign_invite(&alice, inv).unwrap();
356        // Swap in Bob's pubkey (so the derived fingerprint will not
357        // match alice's — caught before signature verify is attempted).
358        signed.creator_pubkey_b64 = Some(B64.encode(bob.public_bytes()));
359        let url = encode(&signed).unwrap();
360        let err = decode(&url).unwrap_err();
361        assert!(format!("{err}").contains("doesn't match pubkey-derived"));
362    }
363
364    #[test]
365    fn decode_unknown_version_rejects() {
366        let bad = serde_json::json!({
367            "v": 99,
368            "host_multiaddr": "/ip4/1.1.1.1/tcp/1",
369            "fingerprint": "x"
370        });
371        let url = format!("{}{}", INVITE_PREFIX, B64URL.encode(bad.to_string()));
372        assert!(decode(&url).is_err());
373    }
374
375    #[test]
376    fn decode_not_huddle_url_rejects() {
377        assert!(decode("https://example.com/invite").is_err());
378    }
379}