Skip to main content

huddle_protocol/
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//! - `mlkem_ek_b64` (v4+): optional base64 of the inviter's ML-KEM-768
31//!   encapsulation (public) key. When present it's folded into the
32//!   signed transcript (the header bumps to `huddle-invite-v4|`),
33//!   binding the inviter's post-quantum capability so a relay can't
34//!   silently strip it without breaking the signature. Absent on
35//!   classical invites, which stay byte-compatible with v2/v3.
36//!
37//! Important: the passphrase is NEVER in the link. Encrypted rooms
38//! still require the joiner to type the passphrase separately;
39//! including it would defeat the point of OOB sharing.
40
41use std::time::{SystemTime, UNIX_EPOCH};
42
43use base64::engine::general_purpose::STANDARD as B64;
44use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
45use base64::Engine;
46use ed25519_dalek::{Signature, VerifyingKey};
47use serde::{Deserialize, Serialize};
48
49use crate::error::{ProtocolError, Result};
50use crate::identity::{compute_fingerprint, IdentityKeys};
51
52pub const INVITE_PREFIX: &str = "huddle://invite#";
53
54/// Max accepted age for a v2 signed invite. 24h. Long enough that the
55/// recipient can act in their own time, short enough that captured
56/// invites can't be re-used indefinitely (and they're invalidated
57/// completely after the inviter's listen address changes anyway).
58pub const INVITE_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000;
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct InviteLink {
62    /// 1 = legacy unsigned (huddle 0.7.10 and earlier).
63    /// 2 = signed (huddle 0.7.11+). 3 = signed + `relay_url` (huddle 1.0+).
64    /// 4 = signed + ML-KEM encapsulation-key commitment (huddle 2.0+).
65    pub v: u32,
66    pub host_multiaddr: String,
67    pub fingerprint: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub room: Option<InviteRoom>,
70    /// huddle 0.7.11: creator's raw Ed25519 pubkey, base64. Required
71    /// for v >= 2. The verifier re-derives the fingerprint from this
72    /// and rejects the invite if it doesn't match `fingerprint`.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub creator_pubkey_b64: Option<String>,
75    /// huddle 0.7.11: epoch-ms at signing time.
76    #[serde(default, skip_serializing_if = "is_zero")]
77    pub signed_at_ms: i64,
78    /// huddle 0.7.11: Ed25519 signature over `signable_bytes`. Required
79    /// for v >= 2.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub signature_b64: Option<String>,
82    /// huddle 1.0: optional clearnet relay URL the inviter is reachable on
83    /// (`wss://host/ws` or `ws://ip:port/ws` — e.g. a cloudflared tunnel).
84    /// When present, the joiner can connect to the inviter's relay with zero
85    /// config. Bumps the invite to `v=3` and is covered by the signature, so
86    /// it can't be swapped for an attacker's relay without breaking the sig.
87    /// `None` for relay-less invites (which stay `v=2`, readable by older
88    /// clients).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub relay_url: Option<String>,
91    /// huddle 2.0: optional base64 of the inviter's ML-KEM-768
92    /// encapsulation (public) key. When present, the invite commits to
93    /// the inviter's post-quantum identity in the signed transcript, so a
94    /// relay can't strip the inviter's PQ capability without breaking the
95    /// signature. Bumps the invite to `v=4` and is folded into
96    /// `signable_bytes` only when present, so classical (ML-KEM-less)
97    /// invites stay byte-identical to v2/v3 and readable by older clients.
98    /// `None` for classical invites.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub mlkem_ek_b64: Option<String>,
101}
102
103fn is_zero(n: &i64) -> bool {
104    *n == 0
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct InviteRoom {
109    pub id: String,
110    pub name: String,
111    pub encrypted: bool,
112    /// Base64 of the room's passphrase salt. Only meaningful for
113    /// encrypted rooms (where the joiner must type the passphrase
114    /// after dialing). `None` for unencrypted.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub salt_b64: Option<String>,
117    pub creator_fingerprint: String,
118    #[serde(default)]
119    pub owner_fingerprints: Vec<String>,
120}
121
122impl InviteLink {
123    /// Deterministic bytes the v2 signature commits to. Field order
124    /// MUST never change once shipped — receivers that ratchet to a
125    /// newer version still need to verify v2 invites.
126    fn signable_bytes(&self) -> Vec<u8> {
127        let mut out = Vec::with_capacity(256);
128        // huddle 2.0: the transcript header bumps to v4 only when the
129        // invite commits to the inviter's ML-KEM encapsulation key, so the
130        // signed bytes differ from a classical invite. ML-KEM-less invites
131        // keep the exact `huddle-invite-v2|` header (and tail) so their
132        // signed input stays byte-identical to pre-2.0 v2/v3 signers and
133        // still verifies across versions.
134        if self.mlkem_ek_b64.is_some() {
135            out.extend_from_slice(b"huddle-invite-v4|");
136        } else {
137            out.extend_from_slice(b"huddle-invite-v2|");
138        }
139        out.extend_from_slice(self.host_multiaddr.as_bytes());
140        out.push(b'|');
141        out.extend_from_slice(self.fingerprint.as_bytes());
142        out.push(b'|');
143        out.extend_from_slice(&self.signed_at_ms.to_be_bytes());
144        out.push(b'|');
145        if let Some(r) = &self.room {
146            out.extend_from_slice(b"room|");
147            out.extend_from_slice(r.id.as_bytes());
148            out.push(b'|');
149            out.extend_from_slice(r.name.as_bytes());
150            out.push(b'|');
151            out.push(if r.encrypted { b'E' } else { b'P' });
152            out.push(b'|');
153            if let Some(s) = &r.salt_b64 {
154                out.extend_from_slice(s.as_bytes());
155            }
156            out.push(b'|');
157            out.extend_from_slice(r.creator_fingerprint.as_bytes());
158            out.push(b'|');
159            // owner list: alphabetically sorted + comma joined for
160            // determinism. Anyone constructing the invite must sort
161            // before signing; the receiver re-sorts before verifying.
162            let mut owners = r.owner_fingerprints.clone();
163            owners.sort();
164            out.extend_from_slice(owners.join(",").as_bytes());
165        } else {
166            out.extend_from_slice(b"no-room");
167        }
168        // huddle 1.0: append the relay only when present, so relay-less
169        // invites produce byte-identical input to pre-1.0 v2 signers and
170        // still verify across versions. Signer and verifier append it
171        // identically, so it's covered by the signature.
172        if let Some(relay) = &self.relay_url {
173            out.extend_from_slice(b"|relay|");
174            out.extend_from_slice(relay.as_bytes());
175        }
176        // huddle 2.0: append the ML-KEM encapsulation key only when present,
177        // mirroring the relay tail above. Classical invites omit it entirely
178        // (byte-compatible with v2/v3); when present, signer and verifier
179        // append it identically so it's covered by the signature — a relay
180        // can't strip or swap the inviter's PQ key without breaking the sig.
181        if let Some(ek) = &self.mlkem_ek_b64 {
182            out.extend_from_slice(b"|mlkem-ek|");
183            out.extend_from_slice(ek.as_bytes());
184        }
185        out
186    }
187}
188
189/// huddle 0.7.11: sign an invite. Sets `v=2`, `creator_pubkey_b64`,
190/// `signed_at_ms`, and `signature_b64`. The input invite's `v` and
191/// signature fields are overwritten.
192pub fn sign_invite(identity: &IdentityKeys, mut invite: InviteLink) -> Result<InviteLink> {
193    // huddle 2.0: v=4 when the invite commits to the inviter's ML-KEM
194    // encapsulation key. Otherwise huddle 1.0's rule applies: v=3 when the
195    // invite carries a relay URL, else v=2 so the plainest invites stay
196    // readable by pre-1.0 clients. (An ML-KEM invite may also carry a relay;
197    // the v4 header already supersedes v3 in that case.)
198    invite.v = if invite.mlkem_ek_b64.is_some() {
199        4
200    } else if invite.relay_url.is_some() {
201        3
202    } else {
203        2
204    };
205    invite.creator_pubkey_b64 = Some(B64.encode(identity.public_bytes()));
206    invite.signed_at_ms = SystemTime::now()
207        .duration_since(UNIX_EPOCH)
208        .map(|d| d.as_millis() as i64)
209        .unwrap_or(0);
210    invite.signature_b64 = None;
211    // Canonical sort so the verifier can re-canonicalize identically.
212    if let Some(r) = invite.room.as_mut() {
213        r.owner_fingerprints.sort();
214    }
215    let payload = invite.signable_bytes();
216    let sig = identity.sign(&payload);
217    invite.signature_b64 = Some(B64.encode(sig));
218    Ok(invite)
219}
220
221/// Build the `huddle://invite#...` URL form from a parsed `InviteLink`.
222pub fn encode(invite: &InviteLink) -> Result<String> {
223    let json = serde_json::to_vec(invite)
224        .map_err(|e| ProtocolError::Other(format!("invite encode: {e}")))?;
225    Ok(format!("{}{}", INVITE_PREFIX, B64URL.encode(&json)))
226}
227
228/// Parse a `huddle://invite#...` URL back into an `InviteLink`.
229///
230/// huddle 0.7.11: when `v >= 2`, this *also* verifies the signature
231/// against the embedded pubkey, re-derives the fingerprint, and rejects
232/// invites whose `signed_at_ms` is older than `INVITE_MAX_AGE_MS`.
233/// `v == 1` (legacy) parses unchanged so older invites still work,
234/// but callers should display a "this invite is unsigned" warning.
235pub fn decode(url: &str) -> Result<InviteLink> {
236    let body = url
237        .strip_prefix(INVITE_PREFIX)
238        .ok_or_else(|| ProtocolError::Other("not a huddle invite link".into()))?;
239    let json = B64URL
240        .decode(body.trim())
241        .map_err(|e| ProtocolError::Other(format!("bad base64: {e}")))?;
242    let invite: InviteLink = serde_json::from_slice(&json)
243        .map_err(|e| ProtocolError::Other(format!("bad invite json: {e}")))?;
244    match invite.v {
245        1 => {
246            // huddle 1.3.4: refuse a v2/v3 → v1 downgrade. The `v` field is
247            // NOT covered by `signable_bytes`, so an attacker can flip a
248            // signed v2/v3 invite to `v=1` to skip `verify_invite_signature`
249            // / `verify_invite_freshness` entirely, then swap the relay URL,
250            // fingerprint, or room. A *genuine* legacy v1 invite predates
251            // signing and therefore carries none of the signature fields, so
252            // the presence of any of them means this is a stripped signed
253            // invite — reject it instead of accepting it unverified.
254            if invite.creator_pubkey_b64.is_some()
255                || invite.signature_b64.is_some()
256                || invite.signed_at_ms != 0
257            {
258                return Err(ProtocolError::Other(
259                    "invite claims legacy v1 but carries signature fields \
260                     (possible version-downgrade attack) — refusing"
261                        .into(),
262                ));
263            }
264            Ok(invite)
265        }
266        // huddle 1.0: v3 adds the signed `relay_url`; it verifies identically
267        // to v2 because `signable_bytes` already folds the relay in when present.
268        // huddle 2.0: v4 adds the signed `mlkem_ek_b64`; it likewise verifies
269        // identically because `signable_bytes` swaps to the `huddle-invite-v4|`
270        // header and folds the ML-KEM key in whenever it's present. Stripping
271        // the key (a PQ-downgrade) changes the reconstructed bytes and fails
272        // the signature — exactly the defense we want.
273        2 | 3 | 4 => {
274            verify_invite_signature(&invite)?;
275            verify_invite_freshness(&invite)?;
276            Ok(invite)
277        }
278        n => Err(ProtocolError::Other(format!(
279            "unsupported invite version: {n}"
280        ))),
281    }
282}
283
284/// True if this invite came in unsigned (legacy `v=1`). UI should show
285/// a "this invite is unsigned — verify the fingerprint out-of-band"
286/// warning when this returns true.
287pub fn is_legacy_unsigned(invite: &InviteLink) -> bool {
288    invite.v < 2
289}
290
291fn verify_invite_signature(invite: &InviteLink) -> Result<()> {
292    let pubkey_b64 = invite
293        .creator_pubkey_b64
294        .as_ref()
295        .ok_or_else(|| ProtocolError::Other("v2 invite missing creator_pubkey_b64".into()))?;
296    let sig_b64 = invite
297        .signature_b64
298        .as_ref()
299        .ok_or_else(|| ProtocolError::Other("v2 invite missing signature_b64".into()))?;
300    let pubkey_bytes = B64
301        .decode(pubkey_b64)
302        .map_err(|e| ProtocolError::Other(format!("bad creator_pubkey_b64: {e}")))?;
303    if pubkey_bytes.len() != 32 {
304        return Err(ProtocolError::Other(format!(
305            "creator_pubkey is {} bytes, expected 32",
306            pubkey_bytes.len()
307        )));
308    }
309    let mut pk_arr = [0u8; 32];
310    pk_arr.copy_from_slice(&pubkey_bytes);
311    let derived_fp = compute_fingerprint(&pk_arr);
312    if derived_fp != invite.fingerprint {
313        return Err(ProtocolError::Other(format!(
314            "invite fingerprint {} doesn't match pubkey-derived {}",
315            invite.fingerprint, derived_fp
316        )));
317    }
318    let sig_bytes = B64
319        .decode(sig_b64)
320        .map_err(|e| ProtocolError::Other(format!("bad signature_b64: {e}")))?;
321    if sig_bytes.len() != 64 {
322        return Err(ProtocolError::Other(format!(
323            "signature is {} bytes, expected 64",
324            sig_bytes.len()
325        )));
326    }
327    let mut sig_arr = [0u8; 64];
328    sig_arr.copy_from_slice(&sig_bytes);
329    let signature = Signature::from_bytes(&sig_arr);
330    let vk = VerifyingKey::from_bytes(&pk_arr)
331        .map_err(|e| ProtocolError::Other(format!("bad verifying key: {e}")))?;
332    // Re-canonicalize before verification (in case owner_fingerprints
333    // arrived un-sorted from a less-careful sender).
334    let mut canon = invite.clone();
335    if let Some(r) = canon.room.as_mut() {
336        r.owner_fingerprints.sort();
337    }
338    vk.verify_strict(&canon.signable_bytes(), &signature)
339        .map_err(|e| ProtocolError::Other(format!("invite signature verify failed: {e}")))?;
340    Ok(())
341}
342
343fn verify_invite_freshness(invite: &InviteLink) -> Result<()> {
344    if invite.signed_at_ms == 0 {
345        return Err(ProtocolError::Other(
346            "v2 invite missing signed_at_ms".into(),
347        ));
348    }
349    let now = SystemTime::now()
350        .duration_since(UNIX_EPOCH)
351        .map(|d| d.as_millis() as i64)
352        .unwrap_or(0);
353    // huddle 2.0.2 (audit L-21): compute the age in i128 so an attacker-chosen
354    // `signed_at_ms` near i64::MIN/MAX can't overflow the subtraction (a debug
355    // panic / release wrap that would bypass the freshness check). Also reject
356    // future-dated invites outright instead of letting a wrapped value pass.
357    let age = (now as i128) - (invite.signed_at_ms as i128);
358    if age < 0 {
359        return Err(ProtocolError::Other(
360            "invite timestamp is in the future — check the system clock".into(),
361        ));
362    }
363    if age > INVITE_MAX_AGE_MS as i128 {
364        return Err(ProtocolError::Other(format!(
365            "invite is {}h old — re-generate (max {}h)",
366            age / 3_600_000,
367            INVITE_MAX_AGE_MS / 3_600_000
368        )));
369    }
370    Ok(())
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn fixture() -> InviteLink {
378        InviteLink {
379            v: 1,
380            host_multiaddr: "/ip4/1.2.3.4/tcp/9000/p2p/12D3KooW...".into(),
381            fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
382            room: Some(InviteRoom {
383                id: "rid-x".into(),
384                name: "Project Alpha".into(),
385                encrypted: true,
386                salt_b64: Some("AAAAAA==".into()),
387                creator_fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
388                owner_fingerprints: vec!["abcd-1234-efef-5678-9090-1111".into()],
389            }),
390            creator_pubkey_b64: None,
391            signed_at_ms: 0,
392            signature_b64: None,
393            relay_url: None,
394            mlkem_ek_b64: None,
395        }
396    }
397
398    #[test]
399    fn legacy_v1_round_trip() {
400        let inv = fixture();
401        let url = encode(&inv).unwrap();
402        let back = decode(&url).unwrap();
403        assert_eq!(back.v, 1);
404        assert!(is_legacy_unsigned(&back));
405        assert_eq!(back, inv);
406    }
407
408    #[test]
409    fn signed_v2_round_trip() {
410        let id = IdentityKeys::generate().unwrap();
411        let mut inv = fixture();
412        inv.fingerprint = id.fingerprint().to_string();
413        let signed = sign_invite(&id, inv).unwrap();
414        assert_eq!(signed.v, 2);
415        assert!(signed.signature_b64.is_some());
416        let url = encode(&signed).unwrap();
417        let back = decode(&url).unwrap();
418        assert_eq!(back, signed);
419        assert!(!is_legacy_unsigned(&back));
420    }
421
422    #[test]
423    fn tampered_salt_fails() {
424        let id = IdentityKeys::generate().unwrap();
425        let mut inv = fixture();
426        inv.fingerprint = id.fingerprint().to_string();
427        let mut signed = sign_invite(&id, inv).unwrap();
428        // Flip the salt; signature should no longer verify.
429        if let Some(r) = signed.room.as_mut() {
430            r.salt_b64 = Some("ZZZZZZZZ==".into());
431        }
432        let url = encode(&signed).unwrap();
433        let err = decode(&url).unwrap_err();
434        assert!(format!("{err}").contains("invite signature verify failed"));
435    }
436
437    #[test]
438    fn tampered_owner_list_fails() {
439        let id = IdentityKeys::generate().unwrap();
440        let mut inv = fixture();
441        inv.fingerprint = id.fingerprint().to_string();
442        let mut signed = sign_invite(&id, inv).unwrap();
443        if let Some(r) = signed.room.as_mut() {
444            r.owner_fingerprints.push("attacker-fp".into());
445        }
446        let url = encode(&signed).unwrap();
447        let err = decode(&url).unwrap_err();
448        assert!(format!("{err}").contains("invite signature verify failed"));
449    }
450
451    #[test]
452    fn substituted_pubkey_fails_fp_check() {
453        let alice = IdentityKeys::generate().unwrap();
454        let bob = IdentityKeys::generate().unwrap();
455        let mut inv = fixture();
456        inv.fingerprint = alice.fingerprint().to_string();
457        let mut signed = sign_invite(&alice, inv).unwrap();
458        // Swap in Bob's pubkey (so the derived fingerprint will not
459        // match alice's — caught before signature verify is attempted).
460        signed.creator_pubkey_b64 = Some(B64.encode(bob.public_bytes()));
461        let url = encode(&signed).unwrap();
462        let err = decode(&url).unwrap_err();
463        assert!(format!("{err}").contains("doesn't match pubkey-derived"));
464    }
465
466    #[test]
467    fn decode_unknown_version_rejects() {
468        let bad = serde_json::json!({
469            "v": 99,
470            "host_multiaddr": "/ip4/1.1.1.1/tcp/1",
471            "fingerprint": "x"
472        });
473        let url = format!("{}{}", INVITE_PREFIX, B64URL.encode(bad.to_string()));
474        assert!(decode(&url).is_err());
475    }
476
477    #[test]
478    fn decode_not_huddle_url_rejects() {
479        assert!(decode("https://example.com/invite").is_err());
480    }
481
482    // huddle 1.0: v3 invites carry a signed clearnet relay URL.
483    #[test]
484    fn signed_v3_with_relay_round_trips() {
485        let id = IdentityKeys::generate().unwrap();
486        let mut inv = fixture();
487        inv.fingerprint = id.fingerprint().to_string();
488        inv.relay_url = Some("wss://abc.trycloudflare.com/ws".into());
489        let signed = sign_invite(&id, inv).unwrap();
490        assert_eq!(signed.v, 3, "an invite with a relay must be v3");
491        let url = encode(&signed).unwrap();
492        let back = decode(&url).unwrap();
493        assert_eq!(back, signed);
494        assert_eq!(
495            back.relay_url.as_deref(),
496            Some("wss://abc.trycloudflare.com/ws")
497        );
498    }
499
500    #[test]
501    fn relay_less_invite_stays_v2() {
502        // A relay-less invite must remain v2 so older clients still accept it.
503        let id = IdentityKeys::generate().unwrap();
504        let mut inv = fixture();
505        inv.fingerprint = id.fingerprint().to_string();
506        assert!(inv.relay_url.is_none());
507        let signed = sign_invite(&id, inv).unwrap();
508        assert_eq!(signed.v, 2);
509    }
510
511    #[test]
512    fn tampered_relay_fails() {
513        let id = IdentityKeys::generate().unwrap();
514        let mut inv = fixture();
515        inv.fingerprint = id.fingerprint().to_string();
516        inv.relay_url = Some("wss://mine.example/ws".into());
517        let mut signed = sign_invite(&id, inv).unwrap();
518        // Swap in an attacker's relay; the signature must no longer verify.
519        signed.relay_url = Some("wss://attacker.example/ws".into());
520        let url = encode(&signed).unwrap();
521        let err = decode(&url).unwrap_err();
522        assert!(format!("{err}").contains("invite signature verify failed"));
523    }
524
525    // huddle 1.3.4: a signed v2/v3 invite flipped to v=1 must be rejected,
526    // not silently accepted unverified. The `v` field is not signature-bound,
527    // so this is the version-downgrade attack — caught by the "v1 may not
528    // carry signature fields" guard in `decode`.
529    #[test]
530    fn version_downgrade_to_v1_is_rejected() {
531        let id = IdentityKeys::generate().unwrap();
532        let mut inv = fixture();
533        inv.fingerprint = id.fingerprint().to_string();
534        inv.relay_url = Some("wss://mine.example/ws".into());
535        let mut signed = sign_invite(&id, inv).unwrap();
536        assert_eq!(signed.v, 3);
537        // Attacker flips the (unsigned) version field and swaps the relay.
538        signed.v = 1;
539        signed.relay_url = Some("wss://attacker.example/ws".into());
540        let url = encode(&signed).unwrap();
541        let err = decode(&url).unwrap_err();
542        assert!(
543            format!("{err}").contains("downgrade"),
544            "expected a downgrade rejection, got: {err}"
545        );
546    }
547
548    // A v2 invite downgraded to v1 (no relay) is likewise refused because it
549    // still carries the signature fields a real legacy v1 never had.
550    #[test]
551    fn v2_stripped_to_v1_is_rejected() {
552        let id = IdentityKeys::generate().unwrap();
553        let mut inv = fixture();
554        inv.fingerprint = id.fingerprint().to_string();
555        let mut signed = sign_invite(&id, inv).unwrap();
556        assert_eq!(signed.v, 2);
557        signed.v = 1;
558        let url = encode(&signed).unwrap();
559        assert!(decode(&url).is_err());
560    }
561
562    // huddle 2.0: a v4 invite commits to the inviter's ML-KEM encapsulation
563    // key in the signed transcript and round-trips through encode/decode.
564    #[test]
565    fn signed_v4_with_mlkem_round_trips() {
566        let id = IdentityKeys::generate().unwrap();
567        let mut inv = fixture();
568        inv.fingerprint = id.fingerprint().to_string();
569        inv.mlkem_ek_b64 = Some(B64.encode([7u8; 1184])); // ML-KEM-768 ek size
570        let signed = sign_invite(&id, inv).unwrap();
571        assert_eq!(
572            signed.v, 4,
573            "an invite committing to an ML-KEM key must be v4"
574        );
575        let url = encode(&signed).unwrap();
576        let back = decode(&url).unwrap();
577        assert_eq!(back, signed);
578        assert_eq!(back.mlkem_ek_b64, Some(B64.encode([7u8; 1184])));
579        assert!(!is_legacy_unsigned(&back));
580    }
581
582    // An invite carrying both a relay and an ML-KEM key is v4 (the PQ header
583    // supersedes v3) and still round-trips with the relay intact.
584    #[test]
585    fn v4_with_relay_and_mlkem_round_trips() {
586        let id = IdentityKeys::generate().unwrap();
587        let mut inv = fixture();
588        inv.fingerprint = id.fingerprint().to_string();
589        inv.relay_url = Some("wss://abc.trycloudflare.com/ws".into());
590        inv.mlkem_ek_b64 = Some(B64.encode([3u8; 1184]));
591        let signed = sign_invite(&id, inv).unwrap();
592        assert_eq!(signed.v, 4);
593        let url = encode(&signed).unwrap();
594        let back = decode(&url).unwrap();
595        assert_eq!(back, signed);
596        assert_eq!(
597            back.relay_url.as_deref(),
598            Some("wss://abc.trycloudflare.com/ws")
599        );
600        assert!(back.mlkem_ek_b64.is_some());
601    }
602
603    // Tampering with the committed ML-KEM key breaks the signature — this is
604    // the PQ-downgrade defense: a relay can't swap the inviter's PQ key.
605    #[test]
606    fn tampered_mlkem_fails() {
607        let id = IdentityKeys::generate().unwrap();
608        let mut inv = fixture();
609        inv.fingerprint = id.fingerprint().to_string();
610        inv.mlkem_ek_b64 = Some(B64.encode([1u8; 1184]));
611        let mut signed = sign_invite(&id, inv).unwrap();
612        // Swap in an attacker's ML-KEM key; the signature must no longer verify.
613        signed.mlkem_ek_b64 = Some(B64.encode([2u8; 1184]));
614        let url = encode(&signed).unwrap();
615        let err = decode(&url).unwrap_err();
616        assert!(format!("{err}").contains("invite signature verify failed"));
617    }
618
619    // Stripping the ML-KEM key from a v4 invite (the actual PQ-downgrade) is
620    // caught: the verifier reconstructs the classical `huddle-invite-v2|`
621    // bytes without the key tail, which no longer matches the v4 signature.
622    // Flipping the (unsigned) version field too doesn't help the attacker.
623    #[test]
624    fn stripped_mlkem_downgrade_is_rejected() {
625        let id = IdentityKeys::generate().unwrap();
626        let mut inv = fixture();
627        inv.fingerprint = id.fingerprint().to_string();
628        inv.mlkem_ek_b64 = Some(B64.encode([9u8; 1184]));
629        let mut signed = sign_invite(&id, inv).unwrap();
630        assert_eq!(signed.v, 4);
631        // Attacker strips the PQ commitment and flips the version to v2.
632        signed.mlkem_ek_b64 = None;
633        signed.v = 2;
634        let url = encode(&signed).unwrap();
635        let err = decode(&url).unwrap_err();
636        assert!(format!("{err}").contains("invite signature verify failed"));
637    }
638
639    // A classical invite (no ML-KEM key) keeps the exact pre-2.0 v2 signed
640    // bytes, so its signature is byte-compatible across versions: signing the
641    // same invite without and with an unrelated relay must still produce v2/v3,
642    // never v4.
643    #[test]
644    fn classical_invite_stays_v2_or_v3() {
645        let id = IdentityKeys::generate().unwrap();
646        let mut inv = fixture();
647        inv.fingerprint = id.fingerprint().to_string();
648        let signed = sign_invite(&id, inv).unwrap();
649        assert_eq!(signed.v, 2);
650        assert!(signed.mlkem_ek_b64.is_none());
651
652        let mut inv = fixture();
653        inv.fingerprint = id.fingerprint().to_string();
654        inv.relay_url = Some("wss://mine.example/ws".into());
655        let signed = sign_invite(&id, inv).unwrap();
656        assert_eq!(signed.v, 3);
657        assert!(signed.mlkem_ek_b64.is_none());
658    }
659}