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}
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 #[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 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 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
136pub 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 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
157pub 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
164pub 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
193pub 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 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 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 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}