Skip to main content

iris_chat_core/
invite_record.rs

1//! Private invitation records: one standard CHK-encrypted hashtree leaf.
2use 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}
107/// Encode exactly one private CHK leaf, compatible with the web invite codec.
108pub 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    // Storage locations come from local configuration, never from the invite.
161    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}