1#[cfg(feature = "e2e-encryption")]
16use std::io::Read;
17use std::{
18 fmt::{Debug, Formatter},
19 sync::Arc,
20};
21
22use api::{
23 download::{
24 encrypted::DownloadAndScanEncryptedMediaRequest, unencrypted::DownloadAndScanMediaRequest,
25 },
26 public_server_key::PublicServerKeyRequest,
27};
28use matrix_sdk::{
29 BoxFuture, Client, Error, IdParseError,
30 encryption::vodozemac::pk_encryption::Message,
31 locks::Mutex,
32 media::{MediaFetcher, MediaRequestParameters},
33 ruma::events::room::MediaSource,
34};
35#[cfg(feature = "e2e-encryption")]
36use matrix_sdk_crypto::AttachmentDecryptor;
37use matrix_sdk_crypto::olm::Curve25519PublicKey;
38use ruma::{
39 events::room::EncryptedFile,
40 serde::{Base64, base64::Standard},
41};
42use serde::{Deserialize, Serialize};
43use tracing::trace;
44
45#[cfg(feature = "uniffi")]
46uniffi::setup_scaffolding!();
47
48pub use crate::api::scan::MediaScanResponse;
49use crate::api::{
50 DownloadAndScanMediaResponse,
51 scan::{encrypted::EncryptedMediaScanRequest, unencrypted::MediaScanRequest},
52};
53
54mod api;
55
56#[derive(Debug)]
58pub struct ContentScanner {
59 scanner_url: String,
60 public_server_key: Arc<Mutex<Option<String>>>,
61}
62
63impl ContentScanner {
64 pub fn new(scanner_url: impl Into<String>) -> Self {
66 Self { scanner_url: scanner_url.into(), public_server_key: Arc::new(Mutex::new(None)) }
67 }
68
69 pub(crate) async fn fetch_public_server_key(&self, client: &Client) -> Result<String, Error> {
70 let response = client.send(PublicServerKeyRequest::new(self.scanner_url.clone())).await?;
71 Ok(response.public_key)
72 }
73
74 async fn get_or_fetch_public_server_key(&self, client: &Client) -> Option<Curve25519PublicKey> {
75 let public_server_key =
76 if let Some(public_server_key) = (*self.public_server_key.lock()).clone() {
77 trace!("Using cached public server key");
78 Some(public_server_key)
79 } else {
80 trace!("Using cached public server key");
81 let ret = self.fetch_public_server_key(client).await.ok();
82
83 if let Some(public_server_key) = &ret {
84 trace!("Saved new public server key");
85 let mut guard = self.public_server_key.lock();
86 let _ = guard.insert(public_server_key.clone());
87 }
88
89 ret
90 };
91
92 public_server_key.and_then(|key| Curve25519PublicKey::from_base64(&key).ok())
93 }
94
95 pub(crate) async fn get_media(
96 &self,
97 client: &Client,
98 media_source: &MediaSource,
99 ) -> Result<DownloadAndScanMediaResponse, Error> {
100 match &media_source {
101 MediaSource::Encrypted(encrypted) => {
102 let public_server_key = self.get_or_fetch_public_server_key(client).await;
104
105 Ok(client
106 .send(DownloadAndScanEncryptedMediaRequest::new(
107 self.scanner_url.clone(),
108 public_server_key,
109 *encrypted.clone(),
110 ))
111 .await?)
112 }
113 MediaSource::Plain(mxc) => {
114 let (server_name, media_id) =
115 mxc.parts().map_err(|e| Error::Identifier(IdParseError::InvalidMxcUri(e)))?;
116 Ok(client
117 .send(DownloadAndScanMediaRequest::new(
118 &self.scanner_url,
119 server_name.as_str(),
120 media_id,
121 ))
122 .await?)
123 }
124 }
125 }
126
127 pub async fn scan(
130 &self,
131 client: &Client,
132 media_source: &MediaSource,
133 ) -> Result<MediaScanResponse, Error> {
134 match &media_source {
135 MediaSource::Encrypted(encrypted) => {
136 let public_server_key = self.get_or_fetch_public_server_key(client).await;
138
139 Ok(client
140 .send(EncryptedMediaScanRequest::new(
141 self.scanner_url.clone(),
142 public_server_key,
143 *encrypted.clone(),
144 ))
145 .await?)
146 }
147 MediaSource::Plain(mxc) => {
148 let (server_name, media_id) =
149 mxc.parts().map_err(|e| Error::Identifier(IdParseError::InvalidMxcUri(e)))?;
150 Ok(client
151 .send(MediaScanRequest::new(
152 self.scanner_url.clone(),
153 server_name.to_string(),
154 media_id.to_owned(),
155 ))
156 .await?)
157 }
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize)]
163struct EncryptedBody {
164 ciphertext: String,
165 mac: String,
166 ephemeral: String,
167}
168
169impl From<Message> for EncryptedBody {
170 fn from(value: Message) -> Self {
171 Self {
172 ciphertext: Base64::<Standard>::new(value.ciphertext).to_string(),
173 mac: Base64::<Standard>::new(value.mac).to_string(),
174 ephemeral: value.ephemeral_key.to_base64(),
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize)]
180pub(crate) struct EncryptedFileRequest {
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub file: Option<EncryptedFile>,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub encrypted_body: Option<EncryptedBody>,
185}
186
187impl EncryptedFileRequest {
188 pub(crate) fn from_file_info(file_info: EncryptedFile) -> Self {
189 Self { file: Some(file_info), encrypted_body: None }
190 }
191
192 pub(crate) fn from_encrypted_body(encrypted_body: EncryptedBody) -> Self {
193 Self { file: None, encrypted_body: Some(encrypted_body) }
194 }
195}
196
197pub struct ContentScannerMediaFetcher {
199 pub content_scanner: Arc<ContentScanner>,
200}
201
202impl ContentScannerMediaFetcher {
203 pub fn new(scanner_url: impl Into<String>) -> Self {
205 Self { content_scanner: Arc::new(ContentScanner::new(scanner_url.into())) }
206 }
207
208 pub fn with_content_scanner(content_scanner: Arc<ContentScanner>) -> Self {
209 Self { content_scanner }
210 }
211}
212
213impl MediaFetcher for ContentScannerMediaFetcher {
214 fn fetch_media_content<'a>(
215 &'a self,
216 client: &'a Client,
217 request: &'a MediaRequestParameters,
218 ) -> BoxFuture<'a, matrix_sdk::Result<Vec<u8>, Error>> {
219 Box::pin(async move {
220 let content = self.content_scanner.get_media(client, &request.source).await?.content;
221 #[cfg(feature = "e2e-encryption")]
222 let content = {
223 match &request.source {
224 MediaSource::Encrypted(file) => {
225 let content_len = content.len();
226 let mut cursor = std::io::Cursor::new(content);
227 let mut reader =
228 AttachmentDecryptor::new(&mut cursor, file.as_ref().clone().into())?;
229
230 let mut decrypted = Vec::with_capacity(content_len);
233
234 reader.read_to_end(&mut decrypted)?;
235
236 decrypted
237 }
238 MediaSource::Plain(_) => content,
239 }
240 };
241 Ok(content)
242 })
243 }
244}
245
246impl Debug for ContentScannerMediaFetcher {
247 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
248 f.write_str("ContentScannerMediaFetcher")
249 }
250}
251
252#[derive(Debug, Deserialize)]
254pub struct ContentScannerError {
255 pub info: String,
256 pub reason: ErrorReason,
257}
258
259#[allow(non_camel_case_types)]
261#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
262#[derive(Clone, Debug, Deserialize)]
263pub enum ErrorReason {
264 MCS_MALFORMED_JSON,
266 MCS_MEDIA_FAILED_TO_DECRYPT,
268 M_MISSING_TOKEN,
270 M_UNKNOWN_TOKEN,
272 M_NOT_FOUND,
274 MCS_MEDIA_NOT_CLEAN,
276 MCS_MIME_TYPE_FORBIDDEN,
278 MCS_BAD_DECRYPTION,
280 M_UNKNOWN,
282 MCS_MEDIA_REQUEST_FAILED,
284}
285
286#[cfg(test)]
287mod tests {
288 use std::ops::Not;
289 #[cfg(feature = "e2e-encryption")]
290 use std::sync::Arc;
291
292 use assert_matches2::assert_matches;
293 #[cfg(feature = "e2e-encryption")]
294 use matrix_sdk::media::{MediaFormat, MediaRequestParameters};
295 use matrix_sdk::{HttpError, RumaApiError, test_utils::mocks::MatrixMockServer};
296 use matrix_sdk_test::async_test;
297 use ruma::{
298 api::{
299 MatrixVersion,
300 error::{ErrorBody, FromHttpResponseError},
301 },
302 events::room::{
303 EncryptedFile, EncryptedFileHash, EncryptedFileHashes, EncryptedFileInfo, MediaSource,
304 V2EncryptedFileInfo,
305 },
306 exports::{http::StatusCode, serde_json::json},
307 owned_mxc_uri,
308 serde::Base64,
309 };
310 use serde::Deserialize;
311 use wiremock::{
312 Mock, MockServer, ResponseTemplate,
313 matchers::{header_exists, method, path, path_regex},
314 };
315
316 #[cfg(feature = "e2e-encryption")]
317 use crate::ContentScannerMediaFetcher;
318 use crate::{ContentScanner, ContentScannerError, ErrorReason};
319
320 #[async_test]
321 async fn test_fetch_public_key() {
322 let server = MatrixMockServer::new().await;
323 let client =
324 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
325
326 let content_scanner_server = MockServer::start().await;
327 Mock::given(method("GET"))
328 .and(path("/_matrix/media_proxy/unstable/public_key"))
329 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
330 "public_key": "1234567890"
331 })))
332 .mount(&content_scanner_server)
333 .await;
334
335 let content_scanner = ContentScanner::new(content_scanner_server.uri());
336 content_scanner.fetch_public_server_key(&client).await.expect("Load public key");
337 }
338
339 #[async_test]
340 async fn test_get_media() {
341 let server = MatrixMockServer::new().await;
342 let client =
343 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
344
345 let content_scanner_server = MockServer::start().await;
346 Mock::given(method("GET"))
347 .and(path_regex(r"/_matrix/media_proxy/unstable/download/.+/.+"))
348 .and(header_exists("Authorization"))
349 .respond_with(
350 ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]),
351 )
352 .mount(&content_scanner_server)
353 .await;
354
355 let content_scanner = ContentScanner::new(content_scanner_server.uri());
356 let media_source =
357 MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/RhfpOXOzAwzkuqcmbgMwQUrJ"));
358 content_scanner.get_media(&client, &media_source).await.expect("Get media");
359 }
360
361 #[async_test]
362 async fn test_get_media_unsupported() {
363 let server = MatrixMockServer::new().await;
364 let client =
365 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
366
367 let content_scanner_server = MockServer::start().await;
368 Mock::given(method("GET"))
369 .and(path_regex(r"/_matrix/media_proxy/unstable/download/.+/.+"))
370 .and(header_exists("Authorization"))
371 .respond_with(ResponseTemplate::new(403).set_body_json(json!({
372 "reason": "MCS_MIME_TYPE_FORBIDDEN",
373 "info": "File type: application/octet-stream not allowed",
374 })))
375 .mount(&content_scanner_server)
376 .await;
377
378 let content_scanner = ContentScanner::new(content_scanner_server.uri());
379 let media_source =
380 MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/ckTaStcNnFXLzKApkBmgRDoC"));
381 let err =
382 content_scanner.get_media(&client, &media_source).await.expect_err("Get media error");
383 let client_error = err.as_client_api_error().expect("Get client error");
384 assert_eq!(client_error.status_code, StatusCode::FORBIDDEN);
385 assert_eq!(
386 client_error.to_string(),
387 "[403] {\"info\":\"File type: application/octet-stream not allowed\",\"reason\":\"MCS_MIME_TYPE_FORBIDDEN\"}"
388 );
389 }
390
391 #[async_test]
392 async fn test_get_encrypted_media() {
393 let server = MatrixMockServer::new().await;
394 let client =
395 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
396
397 let content_scanner_server = MockServer::start().await;
398 Mock::given(method("POST"))
399 .and(path("/_matrix/media_proxy/unstable/download_encrypted"))
400 .and(header_exists("Authorization"))
401 .respond_with(
402 ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]),
403 )
404 .mount(&content_scanner_server)
405 .await;
406
407 let content_scanner = ContentScanner::new(content_scanner_server.uri());
408 let file_info = EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
409 Base64::parse("9lpOscZyMOZRCF3v867nPPo3WPNMZt9JXMsuYiWRszc".as_bytes()).expect("k"),
410 Base64::parse("czvdfKSjfLEAAAAAAAAAAA".as_bytes()).expect("iv"),
411 ));
412 let mut hashes = EncryptedFileHashes::new();
413 hashes.insert(EncryptedFileHash::Sha256(
414 Base64::parse("SBbJ3hINT2LgwXK8ev82enjnhubUy5UuKGDF3SezAhs".as_bytes()).expect("hash"),
415 ));
416 let media_source = MediaSource::Encrypted(Box::new(EncryptedFile::new(
417 owned_mxc_uri!(
418 "mxc://element.io/b50f38aa8ae820c75992370e4e944a045481e3932057062074730676224"
419 ),
420 file_info,
421 hashes,
422 )));
423 content_scanner.get_media(&client, &media_source).await.expect("Get media");
424 }
425
426 #[async_test]
427 async fn test_get_encrypted_media_unsupported() {
428 let server = MatrixMockServer::new().await;
429 let client =
430 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
431
432 let content_scanner_server = MockServer::start().await;
433
434 Mock::given(method("POST"))
435 .and(path("/_matrix/media_proxy/unstable/download_encrypted"))
436 .and(header_exists("Authorization"))
437 .respond_with(ResponseTemplate::new(403).set_body_json(json!({
438 "reason": "MCS_MIME_TYPE_FORBIDDEN",
439 "info": "File type: application/octet-stream not allowed",
440 })))
441 .mount(&content_scanner_server)
442 .await;
443
444 let content_scanner = ContentScanner::new(content_scanner_server.uri());
445 let file_info = EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
446 Base64::parse("tdHdCI5mc-g29IYfhYx2wkA5o-bILP9-nXY6Np1uSnM".as_bytes()).expect("k"),
447 Base64::parse("IBFdH65KqhoAAAAAAAAAAA".as_bytes()).expect("iv"),
448 ));
449 let mut hashes = EncryptedFileHashes::new();
450 hashes.insert(EncryptedFileHash::Sha256(
451 Base64::parse("HSkkamvMSvF3Q30HInorh0ccPrxjgu+wp1vyUOmov/8".as_bytes()).expect("hash"),
452 ));
453 let media_source = MediaSource::Encrypted(Box::new(EncryptedFile::new(
454 owned_mxc_uri!("mxc://matrix.org/WlfuejQQdpvWiWVpAGwfIKJL"),
455 file_info,
456 hashes,
457 )));
458 let err = content_scanner
459 .get_media(&client, &media_source)
460 .await
461 .expect_err("Invalid type error");
462 let client_error = err.as_client_api_error().expect("Invalid error");
463 assert_eq!(client_error.status_code, StatusCode::FORBIDDEN);
464 assert_eq!(
465 client_error.to_string(),
466 "[403] {\"info\":\"File type: application/octet-stream not allowed\",\"reason\":\"MCS_MIME_TYPE_FORBIDDEN\"}"
467 );
468 }
469
470 #[async_test]
471 async fn test_scan_media() {
472 let server = MatrixMockServer::new().await;
473 let client =
474 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
475
476 let content_scanner_server = MockServer::start().await;
477 Mock::given(method("GET"))
478 .and(path_regex(r"/_matrix/media_proxy/unstable/scan/.+/.+"))
479 .and(header_exists("Authorization"))
480 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
481 "clean": true,
482 "info": "All clear!"
483 })))
484 .mount(&content_scanner_server)
485 .await;
486
487 let content_scanner = ContentScanner::new(content_scanner_server.uri());
488 let media_source =
489 MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/RhfpOXOzAwzkuqcmbgMwQUrJ"));
490 let response = content_scanner.scan(&client, &media_source).await.expect("Get media");
491 assert!(response.clean);
492 }
493
494 #[async_test]
495 async fn test_scan_encrypted_media() {
496 let server = MatrixMockServer::new().await;
497 let client =
498 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
499
500 let content_scanner_server = MockServer::start().await;
501 Mock::given(method("POST"))
502 .and(path("/_matrix/media_proxy/unstable/scan_encrypted"))
503 .and(header_exists("Authorization"))
504 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
505 "clean": true,
506 "info": "All clear!"
507 })))
508 .mount(&content_scanner_server)
509 .await;
510
511 let content_scanner = ContentScanner::new(content_scanner_server.uri());
512 let file_info = EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
513 Base64::parse("9lpOscZyMOZRCF3v867nPPo3WPNMZt9JXMsuYiWRszc".as_bytes()).expect("k"),
514 Base64::parse("czvdfKSjfLEAAAAAAAAAAA".as_bytes()).expect("iv"),
515 ));
516 let mut hashes = EncryptedFileHashes::new();
517 hashes.insert(EncryptedFileHash::Sha256(
518 Base64::parse("SBbJ3hINT2LgwXK8ev82enjnhubUy5UuKGDF3SezAhs".as_bytes()).expect("hash"),
519 ));
520 let media_source = MediaSource::Encrypted(Box::new(EncryptedFile::new(
521 owned_mxc_uri!(
522 "mxc://element.io/b50f38aa8ae820c75992370e4e944a045481e3932057062074730676224"
523 ),
524 file_info,
525 hashes,
526 )));
527 let response = content_scanner.scan(&client, &media_source).await.expect("Get media");
528 assert!(response.clean);
529 }
530
531 #[async_test]
532 async fn test_scan_media_unsupported() {
533 let server = MatrixMockServer::new().await;
534 let client =
535 server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
536
537 let content_scanner_server = MockServer::start().await;
538 Mock::given(method("GET"))
539 .and(path_regex(r"/_matrix/media_proxy/unstable/scan/.+/.+"))
540 .and(header_exists("Authorization"))
541 .respond_with(
542 ResponseTemplate::new(200).set_body_json(json!({
544 "clean": false,
545 "info": "***VIRUS DETECTED***"
546 })),
547 )
548 .mount(&content_scanner_server)
549 .await;
550
551 let content_scanner = ContentScanner::new(content_scanner_server.uri());
552 let media_source =
553 MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/RhfpOXOzAwzkuqcmbgMwQUrJ"));
554 let response = content_scanner.scan(&client, &media_source).await.expect("Get media");
555 assert!(response.clean.not());
556 }
557
558 #[test]
559 fn test_error_mapping() {
560 let error = HttpError::Api(Box::new(FromHttpResponseError::Server(
561 RumaApiError::MatrixError(ruma::api::error::Error::new(
562 StatusCode::FORBIDDEN,
563 ErrorBody::Json(json!({
564 "info": "***VIRUS DETECTED***",
565 "reason": "MCS_MEDIA_NOT_CLEAN"
566 })),
567 )),
568 )));
569 let api_error = error.as_client_api_error().expect("error as api error");
570 assert_eq!(
571 api_error.to_string(),
572 "[403] {\"info\":\"***VIRUS DETECTED***\",\"reason\":\"MCS_MEDIA_NOT_CLEAN\"}"
573 );
574 assert_matches!(&api_error.body, ErrorBody::Json(json_body));
575 let content_scanner_error =
576 ContentScannerError::deserialize(json_body).expect("deserialize");
577 assert_eq!(content_scanner_error.info, "***VIRUS DETECTED***");
578 assert_matches!(content_scanner_error.reason, ErrorReason::MCS_MEDIA_NOT_CLEAN);
579 }
580
581 #[cfg(feature = "e2e-encryption")]
582 #[async_test]
583 async fn test_content_scanner_media_fetcher_decrypts_media() {
584 let server = MatrixMockServer::new().await;
585
586 let media_fetcher = Arc::new(ContentScannerMediaFetcher::new(server.uri()));
587
588 let client = server
589 .client_builder()
590 .on_builder(|builder| builder.media_fetcher(media_fetcher.clone()))
591 .server_versions(vec![MatrixVersion::V1_11])
592 .build()
593 .await;
594
595 let original = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
597 let encrypted: Vec<u8> = vec![0xEA, 0x10, 0x2D, 0x01, 0x53, 0xB7, 0x87, 0xF0, 0x75, 0xED];
599
600 Mock::given(method("POST"))
602 .and(path("/_matrix/media_proxy/unstable/download_encrypted"))
603 .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted.clone()))
604 .expect(1)
605 .mount(server.server())
606 .await;
607
608 let mut hashes = EncryptedFileHashes::new();
609 hashes.insert(EncryptedFileHash::Sha256(
611 Base64::parse("HT/BV9JX7tQgtvLt9NQs914ytE8kp4V6cBGN6SAU/do".as_bytes())
612 .expect("Hash deserialization"),
613 ));
614
615 let request = MediaRequestParameters {
616 source: MediaSource::Encrypted(Box::new(EncryptedFile::new(
617 owned_mxc_uri!("mxc://example.com/1234"),
618 EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
619 Base64::parse("QRsQvqumCgRLrZEMTZIc-DU-08Lak2c1dBQC6u5x7rE".as_bytes())
620 .expect("k"),
621 Base64::parse("uMwbioRbD6EAAAAAAAAAAA".as_bytes()).expect("iv"),
622 )),
623 hashes,
624 ))),
625 format: MediaFormat::File,
626 };
627
628 let result = client
631 .media()
632 .get_media_content(&request, false)
633 .await
634 .expect("Get media content from mock server");
635
636 assert_eq!(original, result);
638 }
639}