Keys for a call the media server cannot read
An encrypted room's call is encrypted between the people in it: the SFU forwards frames it has no key for, and the keys go over Matrix instead. Without that half, moho joined and everybody was connected and nobody could hear anybody. Element's own transport, so a call with both clients in it works: one `io.element.call.encryption_keys` per device, Olm-encrypted, carrying a key and its index. Sent with `encrypt_content_for_devices`, which needed matrix-sdk-crypto's `experimental-send-custom-to-device` - the one API that encrypts an arbitrary event to named devices rather than to a room. Received the same way, from a place the daemon was already looking and throwing away: `receive_sync_changes` hands back the decrypted to-device events, and all of them were being dropped. Only the call keys are lifted out; the rest is still the machine's own business. To every device in the call, verified or not. A call's key is not a secret kept from an unverified phone - the people in the room are the people in the call, and withholding it would mean somebody sitting in a call hearing silence. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+4
-1
@@ -61,7 +61,10 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring",
|
||||
# Matrix backend (Client-Server API over plain reqwest + full E2EE via
|
||||
# matrix-sdk-crypto, the standalone vodozemac-backed Olm/Megolm state
|
||||
# machine matrix-sdk itself uses - see backend/matrix/crypto.rs)
|
||||
matrix-sdk-crypto = "0.18"
|
||||
# `experimental-send-custom-to-device` is what lets a call's media keys be
|
||||
# sent straight to the devices in the call, Olm-encrypted, which is how
|
||||
# Element distributes them - see backend/matrix/crypto.rs.
|
||||
matrix-sdk-crypto = { version = "0.18", features = ["experimental-send-custom-to-device"] }
|
||||
# Pinned to exactly what matrix-sdk-crypto 0.18 resolves to, for the same
|
||||
# reason the ruma crates below are: this is where its own response types live
|
||||
# - the verification state a decrypted message carries among them - and a
|
||||
|
||||
@@ -313,6 +313,93 @@ pub fn read_membership(user_id: &str, content: &Value, now_ms: i64) -> Option<Va
|
||||
}))
|
||||
}
|
||||
|
||||
/// The event Element uses to hand a call's media keys around.
|
||||
pub const EVENT_KEYS: &str = "io.element.call.encryption_keys";
|
||||
|
||||
/// One media key, arriving from somebody else's device.
|
||||
///
|
||||
/// A call on a media server is encrypted end to end by encrypting the media
|
||||
/// itself: the server forwards frames it cannot read, and the keys travel over
|
||||
/// Matrix instead - to the devices in the call, one at a time, Olm-encrypted.
|
||||
/// So this is the other half of a call being private, and without it a client
|
||||
/// is connected to everybody and audible to nobody.
|
||||
pub fn handle_key_event(state: &AppState, account_id: &str, event: &Value) {
|
||||
if event["type"].as_str() != Some(EVENT_KEYS) {
|
||||
return;
|
||||
}
|
||||
let content = &event["content"];
|
||||
let Some(room_id) = content["room_id"].as_str() else { return };
|
||||
let Some(key) = content["keys"]["key"].as_str() else { return };
|
||||
let Some(sender) = event["sender"].as_str() else { return };
|
||||
// Whose device it is. Claimed by the sender rather than proven, which is
|
||||
// what Element does too - the Olm session is what proves the account, and
|
||||
// the device id only says which of their devices to draw it against.
|
||||
let device = content["member"]["claimed_device_id"].as_str().unwrap_or_default();
|
||||
let Some(buffer_id) = state.runtime.matrix_buffer_for_room(account_id, room_id) else { return };
|
||||
state.events.emit(
|
||||
"matrixCallKey",
|
||||
json!({
|
||||
"accountId": account_id,
|
||||
"bufferId": buffer_id,
|
||||
"userId": sender,
|
||||
"deviceId": device,
|
||||
"key": key,
|
||||
"index": content["keys"]["index"].as_i64().unwrap_or(0),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hands this end's own media key to everybody else in the call.
|
||||
///
|
||||
/// One event per device rather than one per person: two devices of the same
|
||||
/// account are two participants on the media server, and a key that reached
|
||||
/// only one of them leaves the other in the call hearing static.
|
||||
pub async fn send_key(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
buffer_id: &str,
|
||||
key: &str,
|
||||
index: i64,
|
||||
) -> anyhow::Result<usize> {
|
||||
let account = state.accounts.get_matrix(account_id).ok_or_else(|| anyhow::anyhow!("account not connected"))?;
|
||||
let room_id = state
|
||||
.runtime
|
||||
.get_matrix_room(buffer_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("no known Matrix room for this buffer"))?;
|
||||
let session = state.runtime.get_matrix_machine(account_id).ok_or_else(|| anyhow::anyhow!("this account has no encryption set up"))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let targets: Vec<(ruma_common::OwnedUserId, String)> = state
|
||||
.runtime
|
||||
.matrix_call_members(account_id, &room_id)
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let user = m["user_id"].as_str()?;
|
||||
let device = m["membership"]["device_id"].as_str()?;
|
||||
// Not this device: it has the key already, being where it was
|
||||
// made.
|
||||
if user == account.user_id && device == account.device_id {
|
||||
return None;
|
||||
}
|
||||
Some((ruma_common::OwnedUserId::try_from(user).ok()?, device.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content = json!({
|
||||
"keys": { "index": index, "key": key },
|
||||
"room_id": room_id,
|
||||
"member": {
|
||||
"claimed_device_id": account.device_id,
|
||||
"id": format!("{}:{}", account.user_id, account.device_id),
|
||||
},
|
||||
"session": { "call_id": "", "application": "m.call", "scope": "m.room" },
|
||||
"sent_ts": now,
|
||||
});
|
||||
session
|
||||
.send_to_device_encrypted(&account.homeserver_url, &account.access_token, EVENT_KEYS, &content, &targets)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Where the media server is, if there is one to use.
|
||||
///
|
||||
/// Three places, in the order that answers the question best:
|
||||
|
||||
@@ -553,6 +553,88 @@ impl CryptoSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends one event straight to named devices, encrypted to each of them.
|
||||
///
|
||||
/// Not a room event: this goes to devices rather than to a room, which is
|
||||
/// what a call's media keys need - they are for the people in the call
|
||||
/// right now, not for anybody who will ever read the room. Element's own
|
||||
/// key transport is exactly this, so matching it is what makes a call
|
||||
/// with moho and Element in it audible to both.
|
||||
///
|
||||
/// Olm to each device individually. A device with no session yet gets one
|
||||
/// claimed first, which is the same dance sharing a room key does.
|
||||
pub async fn send_to_device_encrypted(
|
||||
&self,
|
||||
homeserver_url: &str,
|
||||
access_token: &str,
|
||||
event_type: &str,
|
||||
content: &Value,
|
||||
targets: &[(OwnedUserId, String)],
|
||||
) -> Result<usize> {
|
||||
if targets.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let users: Vec<OwnedUserId> = {
|
||||
let mut users: Vec<OwnedUserId> = targets.iter().map(|(user, _)| user.clone()).collect();
|
||||
users.sort();
|
||||
users.dedup();
|
||||
users
|
||||
};
|
||||
// A concrete `Vec<&UserId>`, not an iterator adaptor, for the reason
|
||||
// ensure_keys_shared spells out above: the adaptor's type defeats
|
||||
// rustc's Send inference once this whole chain is inside a spawn.
|
||||
let members: Vec<&UserId> = users.iter().map(|u| u.as_ref()).collect();
|
||||
self.machine.update_tracked_users(members.clone()).await.context("update_tracked_users")?;
|
||||
|
||||
let _guard = self.outgoing_lock.lock().await;
|
||||
if let Some((request_id, claim_request)) =
|
||||
self.machine.get_missing_sessions(members.iter().copied()).await.context("get_missing_sessions")?
|
||||
{
|
||||
self.send_keys_claim(homeserver_url, access_token, &request_id, &claim_request).await?;
|
||||
}
|
||||
|
||||
// The devices as the machine knows them. One we have never heard of
|
||||
// is skipped rather than failing the send: a call carries on for
|
||||
// everybody else while one person's new phone is still unknown.
|
||||
let mut devices = Vec::new();
|
||||
for (user, device_id) in targets {
|
||||
let id: OwnedDeviceId = device_id.as_str().into();
|
||||
match self.machine.get_device(user, &id, None).await {
|
||||
// Deref: a `Device` is its data plus who is asking, and what
|
||||
// the encryptor wants is the data.
|
||||
Ok(Some(device)) => devices.push((*device).clone()),
|
||||
Ok(None) => tracing::debug!("matrix crypto: no such device {user}/{device_id} for a call key"),
|
||||
Err(e) => tracing::debug!("matrix crypto: looking up {user}/{device_id}: {e}"),
|
||||
}
|
||||
}
|
||||
if devices.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let sent = devices.len();
|
||||
let (requests, withheld) = self
|
||||
.machine
|
||||
.encrypt_content_for_devices(
|
||||
devices,
|
||||
event_type,
|
||||
content,
|
||||
// Every device in the call, verified or not. A call's key is
|
||||
// not a secret kept from unverified devices - the people in
|
||||
// the room are the people in the call, and refusing to send
|
||||
// to somebody's unverified phone would mean they sit in the
|
||||
// call hearing nothing.
|
||||
matrix_sdk_crypto::CollectStrategy::AllDevices,
|
||||
)
|
||||
.await
|
||||
.context("encrypting a call key for its devices")?;
|
||||
for (device, code) in &withheld {
|
||||
tracing::debug!("matrix crypto: call key withheld from {}: {code}", device.device_id());
|
||||
}
|
||||
for req in &requests {
|
||||
self.send_to_device_request(homeserver_url, access_token, &req.txn_id, req).await.context("sending a call key")?;
|
||||
}
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
/// Encrypts an arbitrary event (any type - `m.room.message` for a
|
||||
/// plain send/edit, `m.reaction` for a reaction) for the room, once
|
||||
/// ensure_keys_shared has already run for the same room/members.
|
||||
@@ -670,7 +752,7 @@ fn raw_nested_map<T>(value: &Value) -> BTreeMap<OwnedUserId, BTreeMap<OwnedDevic
|
||||
/// requirement: this MUST happen before the sync's `next_batch` token is
|
||||
/// persisted, or a room key delivered in this batch can be lost on a
|
||||
/// crash between the two (see mod.rs's run_sync).
|
||||
pub async fn receive_sync_changes(session: &CryptoSession, sync_response: &Value) {
|
||||
pub async fn receive_sync_changes(session: &CryptoSession, sync_response: &Value) -> Vec<Value> {
|
||||
let to_device_events = sync_response["to_device"]["events"].as_array().cloned().unwrap_or_default();
|
||||
let to_device_events: Vec<Raw<ruma_events::AnyToDeviceEvent>> =
|
||||
to_device_events.into_iter().filter_map(|e| Raw::from_json_string(e.to_string()).ok()).collect();
|
||||
@@ -688,8 +770,19 @@ pub async fn receive_sync_changes(session: &CryptoSession, sync_response: &Value
|
||||
unused_fallback_keys: unused_fallback_keys.as_deref(),
|
||||
next_batch_token: sync_response["next_batch"].as_str().map(String::from),
|
||||
};
|
||||
if let Err(e) = session.machine.receive_sync_changes(changes, &decryption_settings()).await {
|
||||
tracing::warn!("matrix crypto: receive_sync_changes failed: {e}");
|
||||
match session.machine.receive_sync_changes(changes, &decryption_settings()).await {
|
||||
// The decrypted to-device events, which used to be thrown away.
|
||||
// Almost all of them are the machine's own business - room keys,
|
||||
// verification - but a call's media keys arrive this way too, and
|
||||
// they are the client's.
|
||||
Ok((events, _room_keys)) => events
|
||||
.into_iter()
|
||||
.filter_map(|event| event.to_raw().deserialize_as::<Value>().ok())
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!("matrix crypto: receive_sync_changes failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,13 @@ async fn run_sync(state: &AppState, config: &MatrixAccountConfig, account_id: &s
|
||||
// crypto::receive_sync_changes's doc comment (a room key
|
||||
// delivered in this batch can be lost on a crash between the
|
||||
// two otherwise).
|
||||
crypto::receive_sync_changes(&session, &resp).await;
|
||||
let to_device = crypto::receive_sync_changes(&session, &resp).await;
|
||||
// A call's media keys arrive this way rather than in the room: they
|
||||
// are for the people in the call now, not for anybody who will ever
|
||||
// read it back. See calls::handle_key_event.
|
||||
for event in &to_device {
|
||||
calls::handle_key_event(state, account_id, event);
|
||||
}
|
||||
// Receiving those changes can itself produce new outgoing
|
||||
// requests (e.g. claiming one-time keys to establish a session
|
||||
// with a device that just sent us a room key) - send those too
|
||||
|
||||
@@ -3713,6 +3713,22 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// This end's own media key, handed to the devices in the call.
|
||||
"sendMatrixCallKey" => {
|
||||
let (buffer_id, key) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "key")) {
|
||||
(Some(b), Some(k)) => (b, k),
|
||||
_ => return (None, Some("sendMatrixCallKey requires \"bufferId\" and \"key\"".to_string())),
|
||||
};
|
||||
let index = params.get("index").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
};
|
||||
match backend::matrix::calls::send_key(state, &buffer.account_id, buffer_id, key, index).await {
|
||||
Ok(sent) => (Some(serde_json::json!({ "sentTo": sent })), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Where this account's room calls go, for a homeserver that names no
|
||||
// media server of its own.
|
||||
"setMatrixRtcFocus" => match p_str_opt(params, "accountId") {
|
||||
|
||||
Reference in New Issue
Block a user