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#[derive(Debug, Clone)]
22pub struct EncryptedAttachment {
23 pub payload: Vec<u8>,
25 pub content_digest: String,
27 pub secret: Vec<u8>,
29 pub salt: Vec<u8>,
31 pub nonce: Vec<u8>,
33 pub content_length: u32,
35 pub filename: Option<String>,
37}
38
39pub fn encrypt_attachment(attachment: Attachment) -> Result<EncryptedAttachment, CodecError> {
41 let filename = attachment.filename.clone();
42
43 let encoded_content = AttachmentCodec::encode(attachment)?;
45
46 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 let secret: [u8; SECRET_SIZE] = xmtp_common::rand_array();
54
55 let encrypted_payload = encryption::encrypt(&encoded_bytes, &secret)?;
57
58 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
81pub fn decrypt_attachment(
83 encrypted_bytes: &[u8],
84 remote_attachment: &RemoteAttachment,
85) -> Result<Attachment, CodecError> {
86 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 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 let decrypted_bytes = encryption::decrypt(&encrypted_payload, &remote_attachment.secret)?;
104
105 let encoded_content = EncodedContent::decode(decrypted_bytes.as_slice())
107 .map_err(|e| CodecError::Decode(format!("failed to decode EncodedContent: {e}")))?;
108
109 AttachmentCodec::decode(encoded_content)
111}
112
113impl 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 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
216pub 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 let encrypted = encrypt_attachment(attachment).unwrap();
269
270 assert_eq!(encrypted.filename, filename);
272
273 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 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(), 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(), 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}