1use 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
48pub const INVITE_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000;
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct InviteLink {
56 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 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub creator_pubkey_b64: Option<String>,
68 #[serde(default, skip_serializing_if = "is_zero")]
70 pub signed_at_ms: i64,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub signature_b64: Option<String>,
75 #[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 #[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 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 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 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
153pub fn sign_invite(identity: &Identity, mut invite: InviteLink) -> Result<InviteLink> {
157 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 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
176pub 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
183pub 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 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
214pub 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 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 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 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 #[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 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 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}