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