Your profile, a room's threads, and a key file
Three of the Matrix queue, built together because they are all the same kind of gap: the server has been answering these questions all along and nothing here asked. Your profile (#100) is now written as well as read. The homeserver's name and picture, which is what every room shows - a different thing from the local rename beside it in the account panel, and the only one anybody else can see. A room's threads (#102) come from `/threads`, which is the only way to reach a thread whose root has scrolled past - exactly the thread somebody is looking for. Each carries who started it, how many replies, whether this account has said anything in it, and its most recent reply. `include=all` is sent rather than left to the default the spec defines, because Conduit's deserializer treats the parameter as required and answers M_BAD_JSON without it: two of the five rooms tested here could not list their threads at all. Synapse accepts it either way. Room keys (#104) export to and import from Element's own encrypted file, at the same 500,000 PBKDF2 rounds Element writes, so a file from here costs an attacker what a file from there does. It is the copy that does not depend on the homeserver being reachable, next to the backup that does. Measured live on two accounts: profile read and written, threads listed in all five rooms, and a key file written, read back, and refused with the wrong passphrase in the crate's own words. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -49,6 +49,9 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
/// PBKDF2 rounds for a key export, matching what Element writes.
|
||||
const EXPORT_ROUNDS: u32 = 500_000;
|
||||
|
||||
pub fn decryption_settings() -> DecryptionSettings {
|
||||
DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted }
|
||||
}
|
||||
@@ -375,6 +378,47 @@ impl CryptoSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes every room key this session holds into Element's own encrypted
|
||||
/// export format.
|
||||
///
|
||||
/// The point of a file, next to the server-side backup already here, is
|
||||
/// that it does not depend on the homeserver being up or reachable - it
|
||||
/// is how history moves to a client that cannot see the backup, and how
|
||||
/// somebody keeps a copy of their own.
|
||||
///
|
||||
/// The passphrase is the whole of the protection: the file is readable by
|
||||
/// anybody who has it and the words, and by nobody else.
|
||||
pub async fn export_room_keys(&self, passphrase: &str) -> Result<(String, usize)> {
|
||||
if passphrase.is_empty() {
|
||||
anyhow::bail!("an export with no passphrase is a plain copy of your keys - choose one");
|
||||
}
|
||||
let keys = self.machine.store().export_room_keys(|_| true).await.context("reading this session's room keys")?;
|
||||
let count = keys.len();
|
||||
// The same work factor Element writes, so a file from here costs an
|
||||
// attacker what a file from there does.
|
||||
let text = matrix_sdk_crypto::encrypt_room_key_export(&keys, passphrase, EXPORT_ROUNDS)
|
||||
.context("encrypting the export")?;
|
||||
Ok((text, count))
|
||||
}
|
||||
|
||||
/// Reads one back in.
|
||||
///
|
||||
/// Returns how many keys the file held and how many were new, because
|
||||
/// those are different numbers and the second is the one that answers
|
||||
/// "did that do anything".
|
||||
pub async fn import_room_keys(&self, text: &str, passphrase: &str) -> Result<(usize, usize)> {
|
||||
let keys = matrix_sdk_crypto::decrypt_room_key_export(std::io::Cursor::new(text), passphrase)
|
||||
.map_err(|e| anyhow::anyhow!("that file would not open - wrong passphrase, or not a key export ({e})"))?;
|
||||
let total = keys.len();
|
||||
let result = self
|
||||
.machine
|
||||
.store()
|
||||
.import_exported_room_keys(keys, |_, _| {})
|
||||
.await
|
||||
.context("importing the keys")?;
|
||||
Ok((result.imported_count, total))
|
||||
}
|
||||
|
||||
/// Sends a `/keys/claim` request and feeds the response back in.
|
||||
/// Shared by the general outgoing-request dispatch above (KeysClaim
|
||||
/// arrives via `outgoing_requests()` in the ordinary flow) and
|
||||
|
||||
@@ -2363,6 +2363,82 @@ pub async fn create_room(
|
||||
///
|
||||
/// Both are the same shape of call with a different type, and both are
|
||||
/// refused by the server if this account's power level is too low, which is
|
||||
/// The threads a room has, newest activity first.
|
||||
///
|
||||
/// A thread can be opened from its root message and continued, but a root
|
||||
/// that has scrolled past is unreachable without this: the room knows its
|
||||
/// threads and only the server can list them.
|
||||
///
|
||||
/// Each one carries what the panel needs to be worth opening - who started
|
||||
/// it, how many replies it has, whether this account has said anything in it
|
||||
/// - which is exactly what the server sends alongside the root event.
|
||||
pub async fn list_threads(state: &AppState, account_id: &str, buffer_id: &str, limit: u32) -> Result<Value> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known Matrix room for this buffer")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let encoded_room = url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>();
|
||||
// Client v1, not v3: threads arrived after v3 was frozen, the same way
|
||||
// /relations did.
|
||||
//
|
||||
// `include=all` is sent rather than left to the default, which the spec
|
||||
// says is that anyway: Conduit's deserializer treats the parameter as
|
||||
// required and answers M_BAD_JSON without it, so two of the four rooms
|
||||
// tested here could not list their threads at all. Synapse accepts it
|
||||
// either way, so saying it costs nothing and fixes a whole homeserver.
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v1/rooms/{encoded_room}/threads?include=all&limit={}",
|
||||
limit.clamp(1, 100)
|
||||
);
|
||||
let resp = http::get_json(&url, &account.access_token).await.context("listing threads")?;
|
||||
|
||||
let session = state.runtime.get_matrix_machine(account_id);
|
||||
let room = ruma_common::RoomId::parse(&room_id).ok();
|
||||
let members = state.runtime.get_matrix_room_members(account_id, &room_id);
|
||||
let mut threads = Vec::new();
|
||||
for root in resp["chunk"].as_array().into_iter().flatten() {
|
||||
let Some(root_id) = root["event_id"].as_str() else { continue };
|
||||
let root = read_event(&session, room.as_deref(), root).await;
|
||||
let sender = root["sender"].as_str().unwrap_or_default();
|
||||
let relation = &root["unsigned"]["m.relations"]["m.thread"];
|
||||
// The most recent reply, which is what somebody scanning a list of
|
||||
// threads is actually looking at.
|
||||
let latest = read_event(&session, room.as_deref(), &relation["latest_event"]).await;
|
||||
let name_of = |user: &str| {
|
||||
members.get(user).cloned().unwrap_or_else(|| protocol::mxid_localpart(user))
|
||||
};
|
||||
threads.push(serde_json::json!({
|
||||
"rootId": root_id,
|
||||
"from": name_of(sender),
|
||||
"body": root["content"]["body"].as_str().unwrap_or("This message cannot be read here."),
|
||||
"ts": root["origin_server_ts"].as_i64().unwrap_or_default() / 1000,
|
||||
"replies": relation["count"].as_i64().unwrap_or(0),
|
||||
// Whether this account has said anything in it - Element sorts
|
||||
// its own list by this, and it is the difference between "a
|
||||
// thread happened" and "a thread you are in happened".
|
||||
"joined": relation["current_user_participated"].as_bool().unwrap_or(false),
|
||||
"lastFrom": latest["sender"].as_str().map(|u| name_of(u)),
|
||||
"lastBody": latest["content"]["body"].as_str(),
|
||||
"lastTs": latest["origin_server_ts"].as_i64().map(|ms| ms / 1000),
|
||||
}));
|
||||
}
|
||||
Ok(serde_json::json!({ "bufferId": buffer_id, "threads": threads, "next": resp["next_batch"].clone() }))
|
||||
}
|
||||
|
||||
/// An event as text, decrypted where it needs to be and where this session
|
||||
/// can. Shared by the thread list and the pinned list, which both read events
|
||||
/// the sync loop never handed to a buffer.
|
||||
async fn read_event(session: &Option<std::sync::Arc<crypto::CryptoSession>>, room: Option<&ruma_common::RoomId>, event: &Value) -> Value {
|
||||
if event["type"].as_str() != Some("m.room.encrypted") {
|
||||
return event.clone();
|
||||
}
|
||||
if let (Some(session), Some(room)) = (session, room) {
|
||||
if let Ok(plain) = crypto::decrypt_room_event(session, event, room).await {
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
event.clone()
|
||||
}
|
||||
|
||||
/// The messages a room has pinned, as messages rather than as ids.
|
||||
///
|
||||
/// Looked up locally first, because most pins point at something this window
|
||||
@@ -2660,6 +2736,76 @@ fn file_extension(path: &str) -> (String, String) {
|
||||
/// Uploads a local file to the homeserver's media repository and builds
|
||||
/// the corresponding `m.room.message` content (`m.image`/`m.video`/
|
||||
/// `m.audio`/`m.file`) - unencrypted rooms only, see
|
||||
/// Changes what this account is called, for everyone.
|
||||
///
|
||||
/// The homeserver's copy rather than the local rename beside it in the
|
||||
/// account panel: one is how moho refers to you and the other is what every
|
||||
/// room you are in shows. Both exist on purpose, and only this one leaves the
|
||||
/// machine.
|
||||
pub async fn set_own_display_name(state: &AppState, account_id: &str, name: &str) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/profile/{}/displayname",
|
||||
url::form_urlencoded::byte_serialize(account.user_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
http::put_json(&url, &account.access_token, serde_json::json!({ "displayname": name }))
|
||||
.await
|
||||
.context("changing your display name")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes this account's picture, for everyone.
|
||||
///
|
||||
/// Uploaded unencrypted on purpose, unlike an attachment: a profile picture
|
||||
/// is shown to anybody who can see the account at all, including in rooms
|
||||
/// this client has never been in, and there is nobody to share a key with.
|
||||
pub async fn set_own_avatar(state: &AppState, account_id: &str, path: &str) -> Result<String> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let bytes = tokio::fs::read(path).await.context("reading the picture")?;
|
||||
let (filename, ext) = file_extension(path);
|
||||
let (_msgtype, mime) = media_msgtype_and_mime(&ext);
|
||||
let upload_url = format!(
|
||||
"{base}/_matrix/media/v3/upload?filename={}",
|
||||
url::form_urlencoded::byte_serialize(filename.as_bytes()).collect::<String>()
|
||||
);
|
||||
let resp = http_client_post_bytes(&upload_url, &account.access_token, mime, bytes)
|
||||
.await
|
||||
.context("uploading the picture")?;
|
||||
let content_uri = resp["content_uri"].as_str().context("the server took the picture but did not say where")?;
|
||||
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/profile/{}/avatar_url",
|
||||
url::form_urlencoded::byte_serialize(account.user_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
http::put_json(&url, &account.access_token, serde_json::json!({ "avatar_url": content_uri }))
|
||||
.await
|
||||
.context("setting your picture")?;
|
||||
Ok(content_uri.to_string())
|
||||
}
|
||||
|
||||
/// What the homeserver currently says this account is called and looks like.
|
||||
pub async fn own_profile(state: &AppState, account_id: &str) -> Result<Value> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/profile/{}",
|
||||
url::form_urlencoded::byte_serialize(account.user_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
let profile = http::get_json(&url, &account.access_token).await.context("reading your profile")?;
|
||||
let avatar = match profile["avatar_url"].as_str() {
|
||||
Some(mxc) => cached_media_path(&account.homeserver_url, &account.access_token, mxc, "").await,
|
||||
None => None,
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"accountId": account_id,
|
||||
"userId": account.user_id,
|
||||
"displayName": profile["displayname"].as_str().unwrap_or_default(),
|
||||
"avatarUrl": avatar,
|
||||
}))
|
||||
}
|
||||
|
||||
/// upload_encrypted_media_message for the E2EE counterpart.
|
||||
async fn upload_media_message(base: &str, access_token: &str, path: &str, body: &str) -> Result<Value> {
|
||||
let bytes = tokio::fs::read(path).await.context("reading attachment")?;
|
||||
|
||||
@@ -1013,6 +1013,75 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// A copy of this session's room keys, in Element's own encrypted
|
||||
// format - the one that does not depend on the homeserver being up.
|
||||
"exportMatrixKeys" => {
|
||||
let (account_id, path) = match (p_str_opt(params, "accountId"), p_str_opt(params, "path")) {
|
||||
(Some(a), Some(p)) => (a, p),
|
||||
_ => return (None, Some("exportMatrixKeys requires \"accountId\" and \"path\"".to_string())),
|
||||
};
|
||||
let Some(session) = state.runtime.get_matrix_machine(account_id) else {
|
||||
return (None, Some("account is not connected".to_string()));
|
||||
};
|
||||
match session.export_room_keys(p_str(params, "passphrase", "")).await {
|
||||
Ok((text, count)) => match tokio::fs::write(path, text).await {
|
||||
Ok(()) => (Some(serde_json::json!({ "keys": count, "path": path })), None),
|
||||
Err(e) => (None, Some(format!("could not write the file: {e}"))),
|
||||
},
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"importMatrixKeys" => {
|
||||
let (account_id, path) = match (p_str_opt(params, "accountId"), p_str_opt(params, "path")) {
|
||||
(Some(a), Some(p)) => (a, p),
|
||||
_ => return (None, Some("importMatrixKeys requires \"accountId\" and \"path\"".to_string())),
|
||||
};
|
||||
let Some(session) = state.runtime.get_matrix_machine(account_id) else {
|
||||
return (None, Some("account is not connected".to_string()));
|
||||
};
|
||||
let text = match tokio::fs::read_to_string(path).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => return (None, Some(format!("could not read the file: {e}"))),
|
||||
};
|
||||
match session.import_room_keys(&text, p_str(params, "passphrase", "")).await {
|
||||
Ok((imported, total)) => (Some(serde_json::json!({ "imported": imported, "total": total })), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// What the homeserver says this account is called and looks like -
|
||||
// which is a different question from the local rename beside it in
|
||||
// the account panel, and the only one anybody else can see.
|
||||
"matrixOwnProfile" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("matrixOwnProfile requires \"accountId\"".to_string())),
|
||||
Some(account_id) => match backend::matrix::own_profile(state, account_id).await {
|
||||
Ok(profile) => (Some(profile), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
},
|
||||
|
||||
"setMatrixProfileName" => {
|
||||
let Some(account_id) = p_str_opt(params, "accountId") else {
|
||||
return (None, Some("setMatrixProfileName requires \"accountId\"".to_string()));
|
||||
};
|
||||
match backend::matrix::set_own_display_name(state, account_id, p_str(params, "name", "")).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"setMatrixProfileAvatar" => {
|
||||
let (account_id, path) = match (p_str_opt(params, "accountId"), p_str_opt(params, "path")) {
|
||||
(Some(a), Some(p)) => (a, p),
|
||||
_ => return (None, Some("setMatrixProfileAvatar requires \"accountId\" and \"path\"".to_string())),
|
||||
};
|
||||
match backend::matrix::set_own_avatar(state, account_id, path).await {
|
||||
Ok(url) => (Some(serde_json::json!({ "avatarUrl": url })), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Who this account has asked never to hear from. The account's own
|
||||
// list, so it agrees with Element and travels to every client.
|
||||
"listMatrixIgnored" => match p_str_opt(params, "accountId") {
|
||||
@@ -1036,6 +1105,25 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Which conversations a room has going on beside the main one.
|
||||
//
|
||||
// From the server rather than from scrollback: a thread whose root
|
||||
// has scrolled past is unreachable otherwise, which is exactly the
|
||||
// thread somebody is looking for.
|
||||
"listMatrixThreads" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("listMatrixThreads requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
let limit = p_i64(params, "limit", 25).clamp(1, 100) as u32;
|
||||
match backend::matrix::list_threads(state, &buffer.account_id, buffer_id, limit).await {
|
||||
Ok(answer) => (Some(answer), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// What a room has told everyone to read first.
|
||||
"listMatrixPinned" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
|
||||
Reference in New Issue
Block a user