Everywhere: Initial commit
This patch adds an initial implementation of a tool to download and (if encrypted) decrypt matrix media based on the decrypted event content.
This commit is contained in:
commit
af2baa5089
7 changed files with 3850 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"homeserver"
|
||||||
|
]
|
||||||
|
}
|
3566
Cargo.lock
generated
Normal file
3566
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "matrix-media-event-decrypt"
|
||||||
|
description = "Decrypt matrix media events"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["networkException <git@nwex.de>"]
|
||||||
|
license = "BSD-2-Clause"
|
||||||
|
repository = "https://git.nwex.de/networkException/matrix-media-event-decrypt"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.86"
|
||||||
|
clap = { version = "4.5.16", features = ["derive", "env"] }
|
||||||
|
env_logger = "0.11.5"
|
||||||
|
log = "0.4.22"
|
||||||
|
# NOTE: We need newer apis for resolving the homeserver's base url
|
||||||
|
matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", rev = "40d447dc69a29f0d805984157565e5c5eeca265e", default-features = false, features = ["native-tls", "e2e-encryption"] }
|
||||||
|
rpassword = "7.3.1"
|
||||||
|
rustyline-async = "0.4.3"
|
||||||
|
serde = { version = "1.0.208", features = ["derive"] }
|
||||||
|
serde_json = "1.0.125"
|
||||||
|
tokio = { version = "1.39.3", features = ["full"] }
|
25
LICENSE
Normal file
25
LICENSE
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2024, networkException <git@nwex.de>
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
8
shell.nix
Normal file
8
shell.nix
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
# building
|
||||||
|
cargo
|
||||||
|
pkg-config
|
||||||
|
openssl
|
||||||
|
];
|
||||||
|
}
|
224
src/main.rs
Normal file
224
src/main.rs
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::process::exit;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use log::{info, debug, error};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use rustyline_async::{Readline, ReadlineEvent};
|
||||||
|
|
||||||
|
use matrix_sdk::{matrix_auth::{MatrixSession, MatrixSessionTokens}, ruma::{api::client::{authenticated_media, media}, device_id, user_id}, Client, SessionMeta};
|
||||||
|
use matrix_sdk::ruma::events::room::{MediaSource, message::{MessageType, RoomMessageEventContent}};
|
||||||
|
|
||||||
|
// NOTE: This has been stolen from the matrix-rust-sdk to patch out checks for authenticated media
|
||||||
|
async fn get_media_content(
|
||||||
|
client: &Client,
|
||||||
|
source: &MediaSource,
|
||||||
|
use_auth: bool
|
||||||
|
) -> matrix_sdk::Result<Vec<u8>> {
|
||||||
|
let content: Vec<u8> = match &source {
|
||||||
|
MediaSource::Encrypted(file) => {
|
||||||
|
let content = if use_auth {
|
||||||
|
let request = authenticated_media::get_content::v1::Request::from_uri(&file.url)?;
|
||||||
|
client.send(request, None).await?.file
|
||||||
|
} else {
|
||||||
|
#[allow(deprecated)]
|
||||||
|
let request = media::get_content::v3::Request::from_url(&file.url)?;
|
||||||
|
client.send(request, None).await?.file
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = {
|
||||||
|
debug!("Decrypting file");
|
||||||
|
|
||||||
|
let content_len = content.len();
|
||||||
|
let mut cursor = std::io::Cursor::new(content);
|
||||||
|
let mut reader = match matrix_sdk::crypto::AttachmentDecryptor::new(
|
||||||
|
&mut cursor,
|
||||||
|
file.as_ref().clone().into(),
|
||||||
|
) {
|
||||||
|
Ok(reader) => reader,
|
||||||
|
Err(error) => {
|
||||||
|
error!("Unable to decrypt: {}", error);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encrypted size should be the same as the decrypted size,
|
||||||
|
// rounded up to a cipher block.
|
||||||
|
let mut decrypted = Vec::with_capacity(content_len);
|
||||||
|
|
||||||
|
reader.read_to_end(&mut decrypted)?;
|
||||||
|
|
||||||
|
decrypted
|
||||||
|
};
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
MediaSource::Plain(uri) => {
|
||||||
|
debug!("File doesn't appear to be encrypted");
|
||||||
|
|
||||||
|
if use_auth {
|
||||||
|
let request = authenticated_media::get_content::v1::Request::from_uri(uri)?;
|
||||||
|
client.send(request, None).await?.file
|
||||||
|
} else {
|
||||||
|
#[allow(deprecated)]
|
||||||
|
let request = media::get_content::v3::Request::from_url(uri)?;
|
||||||
|
client.send(request, None).await?.file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_media(source: MediaSource, body: Option<String>, server: Option<String>) -> Result<()> {
|
||||||
|
let uri = match source.clone() {
|
||||||
|
MediaSource::Plain(uri) => uri,
|
||||||
|
MediaSource::Encrypted(encrypted) => encrypted.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_name = uri.server_name()?;
|
||||||
|
let media_id = uri.media_id()?;
|
||||||
|
|
||||||
|
let client = match server {
|
||||||
|
Some(ref url) => Client::builder().server_name_or_homeserver_url(url),
|
||||||
|
None => Client::builder().server_name(server_name),
|
||||||
|
}.build().await?;
|
||||||
|
|
||||||
|
debug!("Downloading {} from {}", uri, client.homeserver());
|
||||||
|
|
||||||
|
let content = match get_media_content(&client, &source, false).await {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(error) => {
|
||||||
|
let error_is_needs_authentication = match &error {
|
||||||
|
matrix_sdk::Error::Http(http) => match http {
|
||||||
|
matrix_sdk::HttpError::IntoHttp(into_http) => {
|
||||||
|
match into_http {
|
||||||
|
matrix_sdk::ruma::api::error::IntoHttpError::NeedsAuthentication => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => false
|
||||||
|
},
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if error_is_needs_authentication {
|
||||||
|
info!("The server did not allow downloading without authentication, please enter an access token:");
|
||||||
|
|
||||||
|
let token = rpassword::prompt_password("> ")?;
|
||||||
|
|
||||||
|
client.restore_session(MatrixSession {
|
||||||
|
meta: SessionMeta {
|
||||||
|
user_id: user_id!("@unused:example.com").to_owned(),
|
||||||
|
device_id: device_id!("UNUSED").to_owned(),
|
||||||
|
},
|
||||||
|
tokens: MatrixSessionTokens {
|
||||||
|
access_token: token,
|
||||||
|
refresh_token: None,
|
||||||
|
},
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
get_media_content(&client, &source, false).await
|
||||||
|
.map_err(|error| anyhow::Error::msg(error.to_string()))?
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::Error::msg(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = match body {
|
||||||
|
Some(body) => format!("{server_name}-{media_id}-{}", body),
|
||||||
|
None => format!("{server_name}-{media_id}")
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Saving to {file_name}");
|
||||||
|
|
||||||
|
fs::write(file_name, content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct DecryptedEventSource {
|
||||||
|
content: RoomMessageEventContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Arguments {
|
||||||
|
/// Homeserver URL or Name
|
||||||
|
#[clap(long, env)]
|
||||||
|
server: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
env_logger::init_from_env(env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "matrix_media_event_decrypt=debug"));
|
||||||
|
|
||||||
|
let Arguments {
|
||||||
|
server
|
||||||
|
} = Arguments::parse();
|
||||||
|
|
||||||
|
info!("Enter decrypted event source, confirm with Ctrl+D");
|
||||||
|
|
||||||
|
let (mut readline, mut _stdout) = Readline::new("> ".to_owned()).unwrap();
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match readline.readline().await {
|
||||||
|
Ok(ReadlineEvent::Line(line)) => {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// readline.add_history_entry(line.to_owned());
|
||||||
|
lines.push(line.to_owned());
|
||||||
|
},
|
||||||
|
Ok(ReadlineEvent::Eof) => {
|
||||||
|
break
|
||||||
|
},
|
||||||
|
Ok(ReadlineEvent::Interrupted) => {
|
||||||
|
println!("^C");
|
||||||
|
exit(1);
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
error!("Unable to read in decrypted event source: {}", error.to_string());
|
||||||
|
exit(1);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(_stdout);
|
||||||
|
drop(readline);
|
||||||
|
|
||||||
|
let event = match serde_json::from_str::<DecryptedEventSource>(lines.join("\n").as_str()) {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(error) => {
|
||||||
|
error!("Unable to parse event: {}", error);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
|
||||||
|
debug!("Parsed event with {} content", event.content.msgtype.msgtype());
|
||||||
|
|
||||||
|
if let Err(error) = match event.content.msgtype {
|
||||||
|
MessageType::Audio(content) => process_media(content.source, Some(content.body), server).await,
|
||||||
|
MessageType::File(content) => process_media(content.source, Some(content.body), server).await,
|
||||||
|
MessageType::Image(content) => process_media(content.source, Some(content.body), server).await,
|
||||||
|
MessageType::Video(content) => process_media(content.source, Some(content.body), server).await,
|
||||||
|
_ => {
|
||||||
|
error!("Event content is not known to contain media, exiting");
|
||||||
|
exit(1);
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
error!("Unable to process media: {}", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in a new issue