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