use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::error::{HuddleError, Result};
use crate::identity::{compute_fingerprint, Identity};
pub const INVITE_PREFIX: &str = "huddle://invite#";
pub const INVITE_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InviteLink {
pub v: u32,
pub host_multiaddr: String,
pub fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub room: Option<InviteRoom>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creator_pubkey_b64: Option<String>,
#[serde(default, skip_serializing_if = "is_zero")]
pub signed_at_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_url: Option<String>,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InviteRoom {
pub id: String,
pub name: String,
pub encrypted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub salt_b64: Option<String>,
pub creator_fingerprint: String,
#[serde(default)]
pub owner_fingerprints: Vec<String>,
}
impl InviteLink {
fn signable_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(b"huddle-invite-v2|");
out.extend_from_slice(self.host_multiaddr.as_bytes());
out.push(b'|');
out.extend_from_slice(self.fingerprint.as_bytes());
out.push(b'|');
out.extend_from_slice(&self.signed_at_ms.to_be_bytes());
out.push(b'|');
if let Some(r) = &self.room {
out.extend_from_slice(b"room|");
out.extend_from_slice(r.id.as_bytes());
out.push(b'|');
out.extend_from_slice(r.name.as_bytes());
out.push(b'|');
out.push(if r.encrypted { b'E' } else { b'P' });
out.push(b'|');
if let Some(s) = &r.salt_b64 {
out.extend_from_slice(s.as_bytes());
}
out.push(b'|');
out.extend_from_slice(r.creator_fingerprint.as_bytes());
out.push(b'|');
let mut owners = r.owner_fingerprints.clone();
owners.sort();
out.extend_from_slice(owners.join(",").as_bytes());
} else {
out.extend_from_slice(b"no-room");
}
if let Some(relay) = &self.relay_url {
out.extend_from_slice(b"|relay|");
out.extend_from_slice(relay.as_bytes());
}
out
}
}
pub fn sign_invite(identity: &Identity, mut invite: InviteLink) -> Result<InviteLink> {
invite.v = if invite.relay_url.is_some() { 3 } else { 2 };
invite.creator_pubkey_b64 = Some(B64.encode(identity.public_bytes()));
invite.signed_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
invite.signature_b64 = None;
if let Some(r) = invite.room.as_mut() {
r.owner_fingerprints.sort();
}
let payload = invite.signable_bytes();
let sig = identity.sign(&payload);
invite.signature_b64 = Some(B64.encode(sig));
Ok(invite)
}
pub fn encode(invite: &InviteLink) -> Result<String> {
let json = serde_json::to_vec(invite)
.map_err(|e| HuddleError::Other(format!("invite encode: {e}")))?;
Ok(format!("{}{}", INVITE_PREFIX, B64URL.encode(&json)))
}
pub fn decode(url: &str) -> Result<InviteLink> {
let body = url
.strip_prefix(INVITE_PREFIX)
.ok_or_else(|| HuddleError::Other("not a huddle invite link".into()))?;
let json = B64URL
.decode(body.trim())
.map_err(|e| HuddleError::Other(format!("bad base64: {e}")))?;
let invite: InviteLink = serde_json::from_slice(&json)
.map_err(|e| HuddleError::Other(format!("bad invite json: {e}")))?;
match invite.v {
1 => Ok(invite),
2 | 3 => {
verify_invite_signature(&invite)?;
verify_invite_freshness(&invite)?;
Ok(invite)
}
n => Err(HuddleError::Other(format!(
"unsupported invite version: {n}"
))),
}
}
pub fn is_legacy_unsigned(invite: &InviteLink) -> bool {
invite.v < 2
}
fn verify_invite_signature(invite: &InviteLink) -> Result<()> {
let pubkey_b64 = invite
.creator_pubkey_b64
.as_ref()
.ok_or_else(|| HuddleError::Other("v2 invite missing creator_pubkey_b64".into()))?;
let sig_b64 = invite
.signature_b64
.as_ref()
.ok_or_else(|| HuddleError::Other("v2 invite missing signature_b64".into()))?;
let pubkey_bytes = B64
.decode(pubkey_b64)
.map_err(|e| HuddleError::Other(format!("bad creator_pubkey_b64: {e}")))?;
if pubkey_bytes.len() != 32 {
return Err(HuddleError::Other(format!(
"creator_pubkey is {} bytes, expected 32",
pubkey_bytes.len()
)));
}
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pubkey_bytes);
let derived_fp = compute_fingerprint(&pk_arr);
if derived_fp != invite.fingerprint {
return Err(HuddleError::Other(format!(
"invite fingerprint {} doesn't match pubkey-derived {}",
invite.fingerprint, derived_fp
)));
}
let sig_bytes = B64
.decode(sig_b64)
.map_err(|e| HuddleError::Other(format!("bad signature_b64: {e}")))?;
if sig_bytes.len() != 64 {
return Err(HuddleError::Other(format!(
"signature is {} bytes, expected 64",
sig_bytes.len()
)));
}
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let signature = Signature::from_bytes(&sig_arr);
let vk = VerifyingKey::from_bytes(&pk_arr)
.map_err(|e| HuddleError::Other(format!("bad verifying key: {e}")))?;
let mut canon = invite.clone();
if let Some(r) = canon.room.as_mut() {
r.owner_fingerprints.sort();
}
vk.verify_strict(&canon.signable_bytes(), &signature)
.map_err(|e| HuddleError::Other(format!("invite signature verify failed: {e}")))?;
Ok(())
}
fn verify_invite_freshness(invite: &InviteLink) -> Result<()> {
if invite.signed_at_ms == 0 {
return Err(HuddleError::Other(
"v2 invite missing signed_at_ms".into(),
));
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let age = now - invite.signed_at_ms;
if age > INVITE_MAX_AGE_MS {
return Err(HuddleError::Other(format!(
"invite is {}h old — re-generate (max {}h)",
age / 3_600_000,
INVITE_MAX_AGE_MS / 3_600_000
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> InviteLink {
InviteLink {
v: 1,
host_multiaddr: "/ip4/1.2.3.4/tcp/9000/p2p/12D3KooW...".into(),
fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
room: Some(InviteRoom {
id: "rid-x".into(),
name: "Project Alpha".into(),
encrypted: true,
salt_b64: Some("AAAAAA==".into()),
creator_fingerprint: "abcd-1234-efef-5678-9090-1111".into(),
owner_fingerprints: vec!["abcd-1234-efef-5678-9090-1111".into()],
}),
creator_pubkey_b64: None,
signed_at_ms: 0,
signature_b64: None,
relay_url: None,
}
}
#[test]
fn legacy_v1_round_trip() {
let inv = fixture();
let url = encode(&inv).unwrap();
let back = decode(&url).unwrap();
assert_eq!(back.v, 1);
assert!(is_legacy_unsigned(&back));
assert_eq!(back, inv);
}
#[test]
fn signed_v2_round_trip() {
let id = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = id.fingerprint().to_string();
let signed = sign_invite(&id, inv).unwrap();
assert_eq!(signed.v, 2);
assert!(signed.signature_b64.is_some());
let url = encode(&signed).unwrap();
let back = decode(&url).unwrap();
assert_eq!(back, signed);
assert!(!is_legacy_unsigned(&back));
}
#[test]
fn tampered_salt_fails() {
let id = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = id.fingerprint().to_string();
let mut signed = sign_invite(&id, inv).unwrap();
if let Some(r) = signed.room.as_mut() {
r.salt_b64 = Some("ZZZZZZZZ==".into());
}
let url = encode(&signed).unwrap();
let err = decode(&url).unwrap_err();
assert!(format!("{err}").contains("invite signature verify failed"));
}
#[test]
fn tampered_owner_list_fails() {
let id = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = id.fingerprint().to_string();
let mut signed = sign_invite(&id, inv).unwrap();
if let Some(r) = signed.room.as_mut() {
r.owner_fingerprints.push("attacker-fp".into());
}
let url = encode(&signed).unwrap();
let err = decode(&url).unwrap_err();
assert!(format!("{err}").contains("invite signature verify failed"));
}
#[test]
fn substituted_pubkey_fails_fp_check() {
let alice = Identity::generate().unwrap();
let bob = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = alice.fingerprint().to_string();
let mut signed = sign_invite(&alice, inv).unwrap();
signed.creator_pubkey_b64 = Some(B64.encode(bob.public_bytes()));
let url = encode(&signed).unwrap();
let err = decode(&url).unwrap_err();
assert!(format!("{err}").contains("doesn't match pubkey-derived"));
}
#[test]
fn decode_unknown_version_rejects() {
let bad = serde_json::json!({
"v": 99,
"host_multiaddr": "/ip4/1.1.1.1/tcp/1",
"fingerprint": "x"
});
let url = format!("{}{}", INVITE_PREFIX, B64URL.encode(bad.to_string()));
assert!(decode(&url).is_err());
}
#[test]
fn decode_not_huddle_url_rejects() {
assert!(decode("https://example.com/invite").is_err());
}
#[test]
fn signed_v3_with_relay_round_trips() {
let id = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = id.fingerprint().to_string();
inv.relay_url = Some("wss://abc.trycloudflare.com/ws".into());
let signed = sign_invite(&id, inv).unwrap();
assert_eq!(signed.v, 3, "an invite with a relay must be v3");
let url = encode(&signed).unwrap();
let back = decode(&url).unwrap();
assert_eq!(back, signed);
assert_eq!(back.relay_url.as_deref(), Some("wss://abc.trycloudflare.com/ws"));
}
#[test]
fn relay_less_invite_stays_v2() {
let id = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = id.fingerprint().to_string();
assert!(inv.relay_url.is_none());
let signed = sign_invite(&id, inv).unwrap();
assert_eq!(signed.v, 2);
}
#[test]
fn tampered_relay_fails() {
let id = Identity::generate().unwrap();
let mut inv = fixture();
inv.fingerprint = id.fingerprint().to_string();
inv.relay_url = Some("wss://mine.example/ws".into());
let mut signed = sign_invite(&id, inv).unwrap();
signed.relay_url = Some("wss://attacker.example/ws".into());
let url = encode(&signed).unwrap();
let err = decode(&url).unwrap_err();
assert!(format!("{err}").contains("invite signature verify failed"));
}
}