Skip to main content

xmtp_content_types/
remote_attachment.rs

1use std::collections::HashMap;
2
3use prost::Message;
4
5use crate::{
6    CodecError, ContentCodec,
7    attachment::{Attachment, AttachmentCodec},
8    encryption::{self, EncryptedPayload, SECRET_SIZE},
9    utils::get_param_or_default,
10};
11
12use xmtp_proto::xmtp::mls::message_contents::{
13    ContentTypeId, EncodedContent, content_types::RemoteAttachmentInfo,
14};
15
16pub struct RemoteAttachmentCodec {}
17
18/// Result of encrypting an attachment for remote storage.
19///
20/// Contains the encrypted bytes to upload and all metadata needed to create a `RemoteAttachment`.
21#[derive(Debug, Clone)]
22pub struct EncryptedAttachment {
23    /// The encrypted bytes to upload to the remote server
24    pub payload: Vec<u8>,
25    /// SHA-256 digest of the encrypted bytes (hex-encoded)
26    pub content_digest: String,
27    /// The 32-byte secret key needed for decryption
28    pub secret: Vec<u8>,
29    /// The 32-byte salt used in key derivation
30    pub salt: Vec<u8>,
31    /// The 12-byte nonce used in encryption
32    pub nonce: Vec<u8>,
33    /// The length of the encrypted content
34    pub content_length: u32,
35    /// The filename of the attachment
36    pub filename: Option<String>,
37}
38
39/// Encrypts an attachment for storage as a remote attachment.
40pub fn encrypt_attachment(attachment: Attachment) -> Result<EncryptedAttachment, CodecError> {
41    let filename = attachment.filename.clone();
42
43    // Encode the Attachment to EncodedContent
44    let encoded_content = AttachmentCodec::encode(attachment)?;
45
46    // Serialize EncodedContent to bytes
47    let mut encoded_bytes = Vec::new();
48    encoded_content
49        .encode(&mut encoded_bytes)
50        .map_err(|e| CodecError::Encode(format!("failed to encode attachment: {e}")))?;
51
52    // Generate a random 32-byte secret
53    let secret: [u8; SECRET_SIZE] = xmtp_common::rand_array();
54
55    // Encrypt the encoded content
56    let encrypted_payload = encryption::encrypt(&encoded_bytes, &secret)?;
57
58    // Compute SHA-256 digest of the encrypted payload
59    let digest = encryption::sha256(&encrypted_payload.payload);
60    let content_digest = hex::encode(digest);
61
62    let content_length = u32::try_from(encrypted_payload.payload.len()).map_err(|_| {
63        CodecError::Encode(format!(
64            "attachment size {} exceeds maximum of {} bytes",
65            encrypted_payload.payload.len(),
66            u32::MAX
67        ))
68    })?;
69
70    Ok(EncryptedAttachment {
71        content_length,
72        payload: encrypted_payload.payload,
73        content_digest,
74        secret: secret.to_vec(),
75        salt: encrypted_payload.salt,
76        nonce: encrypted_payload.nonce,
77        filename,
78    })
79}
80
81/// Decrypts an attachment that was encrypted with [`encrypt_attachment`].
82pub fn decrypt_attachment(
83    encrypted_bytes: &[u8],
84    remote_attachment: &RemoteAttachment,
85) -> Result<Attachment, CodecError> {
86    // Verify content digest
87    let actual_digest = hex::encode(encryption::sha256(encrypted_bytes));
88    if actual_digest != remote_attachment.content_digest {
89        return Err(CodecError::Decode(format!(
90            "content digest mismatch: expected {}, got {}",
91            remote_attachment.content_digest, actual_digest
92        )));
93    }
94
95    // Reconstruct the encrypted payload
96    let encrypted_payload = EncryptedPayload {
97        payload: encrypted_bytes.to_vec(),
98        salt: remote_attachment.salt.clone(),
99        nonce: remote_attachment.nonce.clone(),
100    };
101
102    // Decrypt
103    let decrypted_bytes = encryption::decrypt(&encrypted_payload, &remote_attachment.secret)?;
104
105    // Decode the EncodedContent
106    let encoded_content = EncodedContent::decode(decrypted_bytes.as_slice())
107        .map_err(|e| CodecError::Decode(format!("failed to decode EncodedContent: {e}")))?;
108
109    // Decode the Attachment
110    AttachmentCodec::decode(encoded_content)
111}
112
113/// Legacy content type id at <https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-remote-attachment/src/RemoteAttachment.ts>
114impl RemoteAttachmentCodec {
115    const AUTHORITY_ID: &'static str = "xmtp.org";
116    pub const TYPE_ID: &'static str = "remoteStaticAttachment";
117    pub const MAJOR_VERSION: u32 = 1;
118    pub const MINOR_VERSION: u32 = 0;
119}
120
121impl RemoteAttachmentCodec {
122    fn fallback(content: &RemoteAttachment) -> Option<String> {
123        Some(format!(
124            "Can't display {}. This app doesn't support remote attachments.",
125            content
126                .filename
127                .clone()
128                .unwrap_or("this content".to_string())
129        ))
130    }
131}
132
133impl ContentCodec<RemoteAttachment> for RemoteAttachmentCodec {
134    fn content_type() -> ContentTypeId {
135        ContentTypeId {
136            authority_id: Self::AUTHORITY_ID.to_string(),
137            type_id: Self::TYPE_ID.to_string(),
138            version_major: RemoteAttachmentCodec::MAJOR_VERSION,
139            version_minor: RemoteAttachmentCodec::MINOR_VERSION,
140        }
141    }
142
143    fn encode(data: RemoteAttachment) -> Result<EncodedContent, CodecError> {
144        let fallback = Self::fallback(&data);
145        let mut parameters = [
146            ("contentDigest", data.content_digest),
147            ("salt", hex::encode(data.salt)),
148            ("nonce", hex::encode(data.nonce)),
149            ("secret", hex::encode(data.secret)),
150            ("scheme", data.scheme),
151        ]
152        .into_iter()
153        .map(|(k, v)| (k.to_string(), v))
154        .collect::<HashMap<_, _>>();
155
156        if let Some(content_length) = data.content_length {
157            parameters.insert("contentLength".to_string(), content_length.to_string());
158        }
159
160        if let Some(filename) = data.filename {
161            parameters.insert("filename".to_string(), filename);
162        }
163
164        Ok(EncodedContent {
165            r#type: Some(Self::content_type()),
166            parameters,
167            fallback,
168            compression: None,
169            content: data.url.into_bytes(),
170        })
171    }
172
173    fn decode(encoded: EncodedContent) -> Result<RemoteAttachment, CodecError> {
174        // Extract parameters
175        let parameters: &HashMap<String, String> = &encoded.parameters;
176
177        let content_digest = get_param_or_default(parameters, "contentDigest").to_string();
178        let salt = hex::decode(get_param_or_default(parameters, "salt"))
179            .map_err(|e| CodecError::Decode(format!("invalid hex in salt parameter: {e}")))?;
180        let nonce = hex::decode(get_param_or_default(parameters, "nonce"))
181            .map_err(|e| CodecError::Decode(format!("invalid hex in nonce parameter: {e}")))?;
182        let secret = hex::decode(get_param_or_default(parameters, "secret"))
183            .map_err(|e| CodecError::Decode(format!("invalid hex in secret parameter: {e}")))?;
184        let scheme = get_param_or_default(parameters, "scheme").to_string();
185        let content_length = parameters
186            .get("contentLength")
187            .map(|s| {
188                s.parse().map_err(|e| {
189                    CodecError::Decode(format!("invalid contentLength parameter: {e}"))
190                })
191            })
192            .transpose()?;
193
194        let filename = parameters.get("filename").cloned();
195
196        let url =
197            String::from_utf8(encoded.content).map_err(|e| CodecError::Decode(e.to_string()))?;
198
199        Ok(RemoteAttachment {
200            filename,
201            url,
202            content_digest,
203            secret,
204            nonce,
205            salt,
206            scheme,
207            content_length,
208        })
209    }
210
211    fn should_push() -> bool {
212        true
213    }
214}
215
216/// The main content type for remote attachments.
217///
218/// This is a type alias for [`RemoteAttachmentInfo`] from the proto definitions,
219/// allowing the same type to be used for both single remote attachments and
220/// entries in [`MultiRemoteAttachment`].
221pub type RemoteAttachment = RemoteAttachmentInfo;
222
223#[cfg(test)]
224pub(crate) mod tests {
225    use super::*;
226
227    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
228    #[cfg_attr(not(target_arch = "wasm32"), test)]
229    fn test_encode_decode_remote_attachment() {
230        let remote_attachment = RemoteAttachment {
231            filename: Some("test.pdf".to_string()),
232            content_length: Some(1024),
233            url: "https://example.com/file.pdf".to_string(),
234            content_digest: "abc123".to_string(),
235            secret: vec![1, 2, 3, 4],
236            nonce: vec![5, 6, 7, 8],
237            salt: vec![9, 10, 11, 12],
238            scheme: "https".to_string(),
239        };
240
241        let encoded = RemoteAttachmentCodec::encode(remote_attachment.clone()).unwrap();
242        let decoded = RemoteAttachmentCodec::decode(encoded).unwrap();
243
244        assert_eq!(decoded.filename, remote_attachment.filename);
245        assert_eq!(decoded.scheme, remote_attachment.scheme);
246        assert_eq!(decoded.content_length, remote_attachment.content_length);
247        assert_eq!(decoded.url, remote_attachment.url);
248        assert_eq!(decoded.content_digest, remote_attachment.content_digest);
249        assert_eq!(decoded.secret, remote_attachment.secret);
250        assert_eq!(decoded.nonce, remote_attachment.nonce);
251        assert_eq!(decoded.salt, remote_attachment.salt);
252    }
253
254    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
255    #[cfg_attr(not(target_arch = "wasm32"), test)]
256    fn test_encrypt_decrypt_attachment_roundtrip() {
257        let original_content = b"This is a test attachment content";
258        let filename = Some("test.txt".to_string());
259        let mime_type = "text/plain".to_string();
260
261        let attachment = Attachment {
262            filename: filename.clone(),
263            mime_type: mime_type.clone(),
264            content: original_content.to_vec(),
265        };
266
267        // Encrypt the attachment
268        let encrypted = encrypt_attachment(attachment).unwrap();
269
270        // Verify filename is preserved
271        assert_eq!(encrypted.filename, filename);
272
273        // Create a RemoteAttachment with the encryption metadata
274        let remote_attachment = RemoteAttachment {
275            filename: encrypted.filename.clone(),
276            url: "https://example.com/file.txt".to_string(),
277            content_digest: encrypted.content_digest,
278            secret: encrypted.secret,
279            salt: encrypted.salt,
280            nonce: encrypted.nonce,
281            scheme: "https".to_string(),
282            content_length: Some(encrypted.content_length),
283        };
284
285        // Decrypt the attachment
286        let decrypted = decrypt_attachment(&encrypted.payload, &remote_attachment).unwrap();
287
288        assert_eq!(original_content.as_slice(), decrypted.content.as_slice());
289        assert_eq!(decrypted.filename, filename);
290        assert_eq!(decrypted.mime_type, mime_type);
291    }
292
293    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
294    #[cfg_attr(not(target_arch = "wasm32"), test)]
295    fn test_decrypt_with_wrong_digest_fails() {
296        let attachment = Attachment {
297            filename: None,
298            mime_type: "application/octet-stream".to_string(),
299            content: b"Test content".to_vec(),
300        };
301        let encrypted = encrypt_attachment(attachment).unwrap();
302
303        let remote_attachment = RemoteAttachment {
304            filename: None,
305            url: "https://example.com/file".to_string(),
306            content_digest: "wrong_digest".to_string(), // Wrong digest
307            secret: encrypted.secret,
308            salt: encrypted.salt,
309            nonce: encrypted.nonce,
310            scheme: "https".to_string(),
311            content_length: Some(encrypted.content_length),
312        };
313
314        let result = decrypt_attachment(&encrypted.payload, &remote_attachment);
315        assert!(result.is_err());
316        assert!(
317            result
318                .unwrap_err()
319                .to_string()
320                .contains("content digest mismatch")
321        );
322    }
323
324    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
325    #[cfg_attr(not(target_arch = "wasm32"), test)]
326    fn test_decrypt_with_wrong_secret_fails() {
327        let attachment = Attachment {
328            filename: None,
329            mime_type: "application/octet-stream".to_string(),
330            content: b"Test content".to_vec(),
331        };
332        let encrypted = encrypt_attachment(attachment).unwrap();
333
334        let wrong_secret: [u8; SECRET_SIZE] = xmtp_common::rand_array();
335        let remote_attachment = RemoteAttachment {
336            filename: None,
337            url: "https://example.com/file".to_string(),
338            content_digest: encrypted.content_digest,
339            secret: wrong_secret.to_vec(), // Wrong secret
340            salt: encrypted.salt,
341            nonce: encrypted.nonce,
342            scheme: "https".to_string(),
343            content_length: Some(encrypted.content_length),
344        };
345
346        let result = decrypt_attachment(&encrypted.payload, &remote_attachment);
347        assert!(result.is_err());
348    }
349
350    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
351    #[cfg_attr(not(target_arch = "wasm32"), test)]
352    fn test_decode_with_invalid_salt_hex() {
353        let encoded = EncodedContent {
354            r#type: Some(RemoteAttachmentCodec::content_type()),
355            parameters: [
356                ("contentDigest", "abc123"),
357                ("salt", "not_valid_hex"),
358                ("nonce", "0a0b0c0d"),
359                ("secret", "0102030405060708"),
360                ("scheme", "https"),
361                ("contentLength", "100"),
362            ]
363            .into_iter()
364            .map(|(k, v)| (k.to_string(), v.to_string()))
365            .collect(),
366            fallback: None,
367            compression: None,
368            content: b"https://example.com/file".to_vec(),
369        };
370
371        let result = RemoteAttachmentCodec::decode(encoded);
372        assert!(result.is_err());
373        assert!(
374            result
375                .unwrap_err()
376                .to_string()
377                .contains("invalid hex in salt")
378        );
379    }
380
381    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
382    #[cfg_attr(not(target_arch = "wasm32"), test)]
383    fn test_decode_with_invalid_nonce_hex() {
384        let encoded = EncodedContent {
385            r#type: Some(RemoteAttachmentCodec::content_type()),
386            parameters: [
387                ("contentDigest", "abc123"),
388                ("salt", "0a0b0c0d"),
389                ("nonce", "not_valid_hex"),
390                ("secret", "0102030405060708"),
391                ("scheme", "https"),
392                ("contentLength", "100"),
393            ]
394            .into_iter()
395            .map(|(k, v)| (k.to_string(), v.to_string()))
396            .collect(),
397            fallback: None,
398            compression: None,
399            content: b"https://example.com/file".to_vec(),
400        };
401
402        let result = RemoteAttachmentCodec::decode(encoded);
403        assert!(result.is_err());
404        assert!(
405            result
406                .unwrap_err()
407                .to_string()
408                .contains("invalid hex in nonce")
409        );
410    }
411
412    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
413    #[cfg_attr(not(target_arch = "wasm32"), test)]
414    fn test_decode_with_invalid_secret_hex() {
415        let encoded = EncodedContent {
416            r#type: Some(RemoteAttachmentCodec::content_type()),
417            parameters: [
418                ("contentDigest", "abc123"),
419                ("salt", "0a0b0c0d"),
420                ("nonce", "0a0b0c0d"),
421                ("secret", "not_valid_hex"),
422                ("scheme", "https"),
423                ("contentLength", "100"),
424            ]
425            .into_iter()
426            .map(|(k, v)| (k.to_string(), v.to_string()))
427            .collect(),
428            fallback: None,
429            compression: None,
430            content: b"https://example.com/file".to_vec(),
431        };
432
433        let result = RemoteAttachmentCodec::decode(encoded);
434        assert!(result.is_err());
435        assert!(
436            result
437                .unwrap_err()
438                .to_string()
439                .contains("invalid hex in secret")
440        );
441    }
442
443    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
444    #[cfg_attr(not(target_arch = "wasm32"), test)]
445    fn test_decode_with_invalid_content_length() {
446        let encoded = EncodedContent {
447            r#type: Some(RemoteAttachmentCodec::content_type()),
448            parameters: [
449                ("contentDigest", "abc123"),
450                ("salt", "0a0b0c0d"),
451                ("nonce", "0a0b0c0d"),
452                ("secret", "0102030405060708"),
453                ("scheme", "https"),
454                ("contentLength", "not_a_number"),
455            ]
456            .into_iter()
457            .map(|(k, v)| (k.to_string(), v.to_string()))
458            .collect(),
459            fallback: None,
460            compression: None,
461            content: b"https://example.com/file".to_vec(),
462        };
463
464        let result = RemoteAttachmentCodec::decode(encoded);
465        assert!(result.is_err());
466        assert!(
467            result
468                .unwrap_err()
469                .to_string()
470                .contains("invalid contentLength")
471        );
472    }
473}