1use 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
54pub const INVITE_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000;
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct InviteLink {
62 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 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub creator_pubkey_b64: Option<String>,
75 #[serde(default, skip_serializing_if = "is_zero")]
77 pub signed_at_ms: i64,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub signature_b64: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub relay_url: Option<String>,
91 #[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 #[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 fn signable_bytes(&self) -> Vec<u8> {
127 let mut out = Vec::with_capacity(256);
128 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 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 if let Some(relay) = &self.relay_url {
173 out.extend_from_slice(b"|relay|");
174 out.extend_from_slice(relay.as_bytes());
175 }
176 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
189pub fn sign_invite(identity: &IdentityKeys, mut invite: InviteLink) -> Result<InviteLink> {
193 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 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
221pub 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
228pub 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 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 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
284pub 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 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 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 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 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 #[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 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 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 #[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 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 #[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 #[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])); 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 #[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 #[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 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 #[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 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 #[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}