1use hashtree_core::{
3 decrypt_object, encrypt_object, nhash_decode, nhash_encode_full, sha256, EncryptionMode,
4 NHashData,
5};
6use serde::{Deserialize, Serialize};
7
8pub const MAX_INVITE_BYTES: usize = 16 * 1024;
9#[derive(Clone, Debug, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11pub struct InviteConnection {
12 pub version: u8,
13 pub inviter: String,
14 pub ephemeral_key: String,
15 pub shared_secret: String,
16 pub owner: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub device_id: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub purpose: Option<String>,
21}
22#[derive(Clone, Debug, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct InviteRecord {
25 pub v: u8,
26 #[serde(rename = "type")]
27 pub kind: String,
28 pub name: String,
29 pub nonce: String,
30 pub invite: InviteConnection,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub group_id: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub token: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub expires_at: Option<u64>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub max_uses: Option<u32>,
39}
40fn hex_key(value: &str) -> bool {
41 value.len() == 64
42 && value
43 .bytes()
44 .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
45}
46impl InviteRecord {
47 pub fn validate(&self) -> anyhow::Result<()> {
48 anyhow::ensure!(
49 self.v == 1
50 && self.invite.version == nostr_double_ratchet::METADATA_PRIVACY_VERSION
51 && matches!(self.kind.as_str(), "personal" | "group"),
52 "Invalid invite version or type"
53 );
54 anyhow::ensure!(
55 self.name.encode_utf16().count() <= 100 && hex_key(&self.nonce),
56 "Invalid invite metadata"
57 );
58 anyhow::ensure!(
59 [
60 &self.invite.inviter,
61 &self.invite.ephemeral_key,
62 &self.invite.shared_secret,
63 &self.invite.owner
64 ]
65 .iter()
66 .all(|v| hex_key(v)),
67 "Invalid invite keys"
68 );
69 anyhow::ensure!(
70 self.invite
71 .device_id
72 .as_ref()
73 .is_none_or(|v| v.encode_utf16().count() <= 128)
74 && self
75 .invite
76 .purpose
77 .as_deref()
78 .is_none_or(|v| matches!(v, "chat" | "private")),
79 "Invalid invite connection"
80 );
81 anyhow::ensure!(
82 self.expires_at
83 .is_none_or(|v| v > 0 && v <= 9_007_199_254_740_991)
84 && self.max_uses.is_none_or(|v| v > 0 && v <= 10000),
85 "Invalid invite limits"
86 );
87 if self.kind == "group" {
88 anyhow::ensure!(
89 self.group_id
90 .as_ref()
91 .is_some_and(|v| !v.is_empty() && v.encode_utf16().count() <= 128)
92 && self.token.as_ref().is_some_and(|v| hex_key(v)),
93 "Invalid group invite"
94 );
95 } else {
96 anyhow::ensure!(
97 self.group_id.is_none() && self.token.is_none() && self.max_uses.is_none(),
98 "Invalid personal invite"
99 );
100 }
101 Ok(())
102 }
103 pub fn legacy_url(&self) -> anyhow::Result<String> {
104 let value = serde_json::to_string(&self.invite)?;
105 let mut url = url::Url::parse("https://chat.iris.to/")?;
106 url.set_fragment(Some(&value));
107 Ok(url.to_string())
108 }
109}
110pub fn encode_record(record: &InviteRecord) -> anyhow::Result<(String, Vec<u8>)> {
112 record.validate()?;
113 let (ciphertext, key) = encrypt_object(&serde_json::to_vec(record)?, EncryptionMode::ChkV1)?;
114 anyhow::ensure!(ciphertext.len() <= MAX_INVITE_BYTES, "Invite is too large");
115 let reference = nhash_encode_full(&NHashData {
116 hash: sha256(&ciphertext),
117 decrypt_key: Some(key),
118 })?;
119 Ok((reference, ciphertext))
120}
121
122pub fn reference_from_input(input: &str) -> Option<&str> {
123 let input = input.trim();
124 let reference = input
125 .rsplit_once('#')
126 .map(|(_, value)| value)
127 .unwrap_or(input)
128 .trim_start_matches('/');
129 reference.starts_with("nhash1").then_some(reference)
130}
131pub fn decode_record(reference: &str, ciphertext: &[u8]) -> anyhow::Result<InviteRecord> {
132 anyhow::ensure!(
133 reference.len() <= 160 && reference.starts_with("nhash1"),
134 "Invalid private invite reference"
135 );
136 let cid = nhash_decode(reference)?;
137 let key = cid
138 .decrypt_key
139 .ok_or_else(|| anyhow::anyhow!("Invite record must be private"))?;
140 anyhow::ensure!(
141 nhash_encode_full(&NHashData {
142 hash: cid.hash,
143 decrypt_key: Some(key)
144 })? == reference,
145 "Invalid private invite reference"
146 );
147 anyhow::ensure!(
148 (16..=MAX_INVITE_BYTES).contains(&ciphertext.len()) && sha256(ciphertext) == cid.hash,
149 "Invalid invite record"
150 );
151 let record: InviteRecord = serde_json::from_slice(&decrypt_object(ciphertext, &key)?)?;
152 record.validate()?;
153 Ok(record)
154}
155pub async fn load_record(reference: &str) -> anyhow::Result<InviteRecord> {
156 anyhow::ensure!(reference.len() <= 160, "Invalid private invite reference");
157 let cid = nhash_decode(reference)?;
158 anyhow::ensure!(cid.decrypt_key.is_some(), "Invite record must be private");
159 let client = reqwest::Client::builder()
160 .timeout(std::time::Duration::from_secs(10))
161 .redirect(reqwest::redirect::Policy::none())
162 .build()?;
163 let mut servers = hashtree_config::Config::load_or_default()
165 .blossom
166 .all_read_servers();
167 servers.truncate(4);
168 for server in servers {
169 let result = async {
170 let mut response = client
171 .get(format!("{server}/{}", hashtree_core::to_hex(&cid.hash)))
172 .send()
173 .await?
174 .error_for_status()?;
175 anyhow::ensure!(
176 response
177 .content_length()
178 .is_none_or(|n| n <= MAX_INVITE_BYTES as u64),
179 "Invite is too large"
180 );
181 let mut data = Vec::new();
182 while let Some(chunk) = response.chunk().await? {
183 anyhow::ensure!(
184 data.len() + chunk.len() <= MAX_INVITE_BYTES,
185 "Invite is too large"
186 );
187 data.extend_from_slice(&chunk);
188 }
189 decode_record(reference, &data)
190 }
191 .await;
192 if let Ok(record) = result {
193 return Ok(record);
194 }
195 }
196 anyhow::bail!("Could not load invite. Check your connection and try again.")
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 fn fixture() -> serde_json::Value {
203 serde_json::from_str(include_str!("private-invite-v1.json")).unwrap()
204 }
205 #[test]
206 fn decrypts_standard_typescript_hashtree_invite() {
207 let fixture = fixture();
208 let reference = fixture["reference"].as_str().unwrap();
209 let ciphertext = fixture["ciphertext"]
210 .as_str()
211 .unwrap()
212 .as_bytes()
213 .as_chunks::<2>()
214 .0
215 .iter()
216 .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
217 .collect::<Vec<_>>();
218 let record = decode_record(reference, &ciphertext).unwrap();
219 assert_eq!(serde_json::to_value(&record).unwrap(), fixture["record"]);
220 assert!(record
221 .legacy_url()
222 .unwrap()
223 .starts_with("https://chat.iris.to/#"));
224 let (versioned, encrypted) = encode_record(&record).unwrap();
225 assert_eq!(encrypted, ciphertext);
226 let legacy = nhash_decode(reference).unwrap().decrypt_key.unwrap();
227 let key = nhash_decode(&versioned).unwrap().decrypt_key.unwrap();
228 assert_eq!(legacy.mode(), EncryptionMode::LegacyChk);
229 assert_eq!(key.mode(), EncryptionMode::ChkV1);
230 assert_eq!(key.raw_key(), legacy.raw_key());
231 assert_eq!(
232 decode_record(&versioned, &encrypted).unwrap().nonce,
233 record.nonce
234 );
235 }
236 #[test]
237 fn reads_randomized_private_invite() {
238 let record = fixture()["record"].clone();
239 let (ciphertext, key) =
240 encrypt_object(&serde_json::to_vec(&record).unwrap(), EncryptionMode::RndV1).unwrap();
241 let reference = nhash_encode_full(&NHashData {
242 hash: sha256(&ciphertext),
243 decrypt_key: Some(key),
244 })
245 .unwrap();
246 assert_eq!(
247 serde_json::to_value(decode_record(&reference, &ciphertext).unwrap()).unwrap(),
248 record
249 );
250 }
251 #[test]
252 fn rejects_public_and_tampered_records() {
253 let fixture = fixture();
254 let reference = fixture["reference"].as_str().unwrap();
255 let mut ciphertext = fixture["ciphertext"]
256 .as_str()
257 .unwrap()
258 .as_bytes()
259 .as_chunks::<2>()
260 .0
261 .iter()
262 .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
263 .collect::<Vec<_>>();
264 let public = hashtree_core::nhash_encode(&sha256(&ciphertext)).unwrap();
265 assert!(decode_record(&public, &ciphertext).is_err());
266 ciphertext[0] ^= 1;
267 assert!(decode_record(reference, &ciphertext).is_err());
268 assert!(decode_record(reference, &vec![0; MAX_INVITE_BYTES + 1]).is_err());
269 }
270 #[test]
271 fn validates_record_limits_and_excludes_group_message_keys() {
272 for (field, value) in [
273 ("v", serde_json::json!(2)),
274 ("maxUses", serde_json::json!(0)),
275 ("secret", serde_json::json!("private")),
276 ("token", serde_json::json!("")),
277 ] {
278 let mut invalid = fixture()["record"].clone();
279 invalid[field] = value;
280 assert!(serde_json::from_value::<InviteRecord>(invalid)
281 .and_then(|r| r.validate().map_err(serde::de::Error::custom))
282 .is_err());
283 }
284 }
285}