Skip to main content

iris_chat_core/
invite_record.rs

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