use hashtree_core::{
decrypt_object, encrypt_object, nhash_decode, nhash_encode_full, sha256, EncryptionMode,
NHashData,
};
use serde::{Deserialize, Serialize};
pub const MAX_INVITE_BYTES: usize = 16 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InviteConnection {
pub version: u8,
pub inviter: String,
pub ephemeral_key: String,
pub shared_secret: String,
pub owner: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InviteRecord {
pub v: u8,
#[serde(rename = "type")]
pub kind: String,
pub name: String,
pub nonce: String,
pub invite: InviteConnection,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_uses: Option<u32>,
}
fn hex_key(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
impl InviteRecord {
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.v == 1
&& self.invite.version == nostr_double_ratchet::METADATA_PRIVACY_VERSION
&& matches!(self.kind.as_str(), "personal" | "group"),
"Invalid invite version or type"
);
anyhow::ensure!(
self.name.encode_utf16().count() <= 100 && hex_key(&self.nonce),
"Invalid invite metadata"
);
anyhow::ensure!(
[
&self.invite.inviter,
&self.invite.ephemeral_key,
&self.invite.shared_secret,
&self.invite.owner
]
.iter()
.all(|v| hex_key(v)),
"Invalid invite keys"
);
anyhow::ensure!(
self.invite
.device_id
.as_ref()
.is_none_or(|v| v.encode_utf16().count() <= 128)
&& self
.invite
.purpose
.as_deref()
.is_none_or(|v| matches!(v, "chat" | "private")),
"Invalid invite connection"
);
anyhow::ensure!(
self.expires_at
.is_none_or(|v| v > 0 && v <= 9_007_199_254_740_991)
&& self.max_uses.is_none_or(|v| v > 0 && v <= 10000),
"Invalid invite limits"
);
if self.kind == "group" {
anyhow::ensure!(
self.group_id
.as_ref()
.is_some_and(|v| !v.is_empty() && v.encode_utf16().count() <= 128)
&& self.token.as_ref().is_some_and(|v| hex_key(v)),
"Invalid group invite"
);
} else {
anyhow::ensure!(
self.group_id.is_none() && self.token.is_none() && self.max_uses.is_none(),
"Invalid personal invite"
);
}
Ok(())
}
pub fn legacy_url(&self) -> anyhow::Result<String> {
let value = serde_json::to_string(&self.invite)?;
let mut url = url::Url::parse("https://chat.iris.to/")?;
url.set_fragment(Some(&value));
Ok(url.to_string())
}
}
pub fn encode_record(record: &InviteRecord) -> anyhow::Result<(String, Vec<u8>)> {
record.validate()?;
let (ciphertext, key) = encrypt_object(&serde_json::to_vec(record)?, EncryptionMode::ChkV1)?;
anyhow::ensure!(ciphertext.len() <= MAX_INVITE_BYTES, "Invite is too large");
let reference = nhash_encode_full(&NHashData {
hash: sha256(&ciphertext),
decrypt_key: Some(key),
})?;
Ok((reference, ciphertext))
}
pub fn reference_from_input(input: &str) -> Option<&str> {
let input = input.trim();
let reference = input
.rsplit_once('#')
.map(|(_, value)| value)
.unwrap_or(input)
.trim_start_matches('/');
reference.starts_with("nhash1").then_some(reference)
}
pub fn decode_record(reference: &str, ciphertext: &[u8]) -> anyhow::Result<InviteRecord> {
anyhow::ensure!(
reference.len() <= 160 && reference.starts_with("nhash1"),
"Invalid private invite reference"
);
let cid = nhash_decode(reference)?;
let key = cid
.decrypt_key
.ok_or_else(|| anyhow::anyhow!("Invite record must be private"))?;
anyhow::ensure!(
nhash_encode_full(&NHashData {
hash: cid.hash,
decrypt_key: Some(key)
})? == reference,
"Invalid private invite reference"
);
anyhow::ensure!(
(16..=MAX_INVITE_BYTES).contains(&ciphertext.len()) && sha256(ciphertext) == cid.hash,
"Invalid invite record"
);
let record: InviteRecord = serde_json::from_slice(&decrypt_object(ciphertext, &key)?)?;
record.validate()?;
Ok(record)
}
pub async fn load_record(reference: &str) -> anyhow::Result<InviteRecord> {
anyhow::ensure!(reference.len() <= 160, "Invalid private invite reference");
let cid = nhash_decode(reference)?;
anyhow::ensure!(cid.decrypt_key.is_some(), "Invite record must be private");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()?;
let mut servers = hashtree_config::Config::load_or_default()
.blossom
.all_read_servers();
servers.truncate(4);
for server in servers {
let result = async {
let mut response = client
.get(format!("{server}/{}", hashtree_core::to_hex(&cid.hash)))
.send()
.await?
.error_for_status()?;
anyhow::ensure!(
response
.content_length()
.is_none_or(|n| n <= MAX_INVITE_BYTES as u64),
"Invite is too large"
);
let mut data = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
data.len() + chunk.len() <= MAX_INVITE_BYTES,
"Invite is too large"
);
data.extend_from_slice(&chunk);
}
decode_record(reference, &data)
}
.await;
if let Ok(record) = result {
return Ok(record);
}
}
anyhow::bail!("Could not load invite. Check your connection and try again.")
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> serde_json::Value {
serde_json::from_str(include_str!("private-invite-v1.json")).unwrap()
}
#[test]
fn decrypts_standard_typescript_hashtree_invite() {
let fixture = fixture();
let reference = fixture["reference"].as_str().unwrap();
let ciphertext = fixture["ciphertext"]
.as_str()
.unwrap()
.as_bytes()
.as_chunks::<2>()
.0
.iter()
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect::<Vec<_>>();
let record = decode_record(reference, &ciphertext).unwrap();
assert_eq!(serde_json::to_value(&record).unwrap(), fixture["record"]);
assert!(record
.legacy_url()
.unwrap()
.starts_with("https://chat.iris.to/#"));
let (versioned, encrypted) = encode_record(&record).unwrap();
assert_eq!(encrypted, ciphertext);
let legacy = nhash_decode(reference).unwrap().decrypt_key.unwrap();
let key = nhash_decode(&versioned).unwrap().decrypt_key.unwrap();
assert_eq!(legacy.mode(), EncryptionMode::LegacyChk);
assert_eq!(key.mode(), EncryptionMode::ChkV1);
assert_eq!(key.raw_key(), legacy.raw_key());
assert_eq!(
decode_record(&versioned, &encrypted).unwrap().nonce,
record.nonce
);
}
#[test]
fn reads_randomized_private_invite() {
let record = fixture()["record"].clone();
let (ciphertext, key) =
encrypt_object(&serde_json::to_vec(&record).unwrap(), EncryptionMode::RndV1).unwrap();
let reference = nhash_encode_full(&NHashData {
hash: sha256(&ciphertext),
decrypt_key: Some(key),
})
.unwrap();
assert_eq!(
serde_json::to_value(decode_record(&reference, &ciphertext).unwrap()).unwrap(),
record
);
}
#[test]
fn rejects_public_and_tampered_records() {
let fixture = fixture();
let reference = fixture["reference"].as_str().unwrap();
let mut ciphertext = fixture["ciphertext"]
.as_str()
.unwrap()
.as_bytes()
.as_chunks::<2>()
.0
.iter()
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect::<Vec<_>>();
let public = hashtree_core::nhash_encode(&sha256(&ciphertext)).unwrap();
assert!(decode_record(&public, &ciphertext).is_err());
ciphertext[0] ^= 1;
assert!(decode_record(reference, &ciphertext).is_err());
assert!(decode_record(reference, &vec![0; MAX_INVITE_BYTES + 1]).is_err());
}
#[test]
fn validates_record_limits_and_excludes_group_message_keys() {
for (field, value) in [
("v", serde_json::json!(2)),
("maxUses", serde_json::json!(0)),
("secret", serde_json::json!("private")),
("token", serde_json::json!("")),
] {
let mut invalid = fixture()["record"].clone();
invalid[field] = value;
assert!(serde_json::from_value::<InviteRecord>(invalid)
.and_then(|r| r.validate().map_err(serde::de::Error::custom))
.is_err());
}
}
}