Verify another person, not just another session

`tick` asked `get_verification_requests(own_user_id)` - the machine is asked
one user at a time, so a request from anybody else was never even looked
for. And it would not have arrived anyway: verifying another person happens
in the room the two of you share, because until you have agreed which
devices you are talking about there is no device to address it to. None of
those events came anywhere near the crypto machine.

Room verification events now go to it, decrypted content and all where the
room is encrypted, and their senders are remembered as the only list of who
to ask about. The outgoing half was skipped just as deliberately - the
dispatch carried a comment saying this backend never produces a room
message - and now sends one.

Every verification also says who is on the other end and whether that is
you. For one of your own sessions the device is the interesting half; for
somebody else it is the whole point, and a dialog that cannot say whose
emoji these are is one nobody should press yes on.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 20:07:59 -04:00
co-authored by Claude Opus 5
parent 8d4855cfa6
commit e552bbc5e2
4 changed files with 115 additions and 5 deletions
+43 -3
View File
@@ -39,6 +39,9 @@ use matrix_sdk_crypto::{
CollectStrategy, DecryptionSettings, EncryptionSyncChanges, OlmMachine, TrustRequirement,
};
use matrix_sdk_sqlite::SqliteCryptoStore;
// For `event_type()` on a verification step's content - the wire needs the
// name of the event, and only the trait knows it.
use ruma_events::MessageLikeEventContent;
use ruma_client_api::keys::{claim_keys, get_keys, upload_keys, upload_signatures};
use ruma_client_api::sync::sync_events::DeviceLists;
use ruma_client_api::to_device::send_event_to_device;
@@ -258,14 +261,51 @@ impl CryptoSession {
let response = upload_signatures::v3::Response::new();
self.machine.mark_request_as_sent(request.request_id(), &response).await.context("mark_request_as_sent(SignatureUpload)")?;
}
// A room message: only in-room verification produces one, which
// this backend does not do - it verifies over to-device events.
// Skipped defensively rather than erroring.
// A verification step sent as a room event rather than to-device.
//
// This is what verifying *another person* looks like: Element and
// every other client carry a cross-user verification in the room
// the two of you share, because there is no device to address it
// to until you have agreed which devices you are talking about.
AnyOutgoingRequest::RoomMessage(req) => {
let event_type = req.content.event_type().to_string();
let url = format!(
"{base}/_matrix/client/v3/rooms/{}/send/{event_type}/{}",
url::form_urlencoded::byte_serialize(req.room_id.as_str().as_bytes()).collect::<String>(),
url::form_urlencoded::byte_serialize(req.txn_id.as_str().as_bytes()).collect::<String>(),
);
let body = serde_json::to_value(&req.content).context("serialising a verification step")?;
let resp = http::put_json(&url, access_token, body).await.context("sending a verification step")?;
let response = ruma_client_api::message::send_message_event::v3::Response::new(
ruma_common::OwnedEventId::try_from(resp["event_id"].as_str().unwrap_or("$unknown"))
.unwrap_or_else(|_| ruma_common::OwnedEventId::try_from("$unknown").expect("static event id")),
);
self.machine.mark_request_as_sent(request.request_id(), &response).await.context("mark_request_as_sent(RoomMessage)")?;
}
_ => {}
}
Ok(())
}
/// Hands the machine a verification step that arrived as a room event.
///
/// In-room verification is how one person verifies another: the events
/// travel through the room the two of them share rather than to-device,
/// so nothing in the ordinary to-device path ever sees them. The room id
/// is put back on the event first - a sync timeline event does not carry
/// the room it came from, and the machine needs it to know which flow
/// this belongs to.
pub async fn receive_room_verification(&self, event: &Value, room_id: &str) -> Result<()> {
let mut event = event.clone();
if let Some(object) = event.as_object_mut() {
object.insert("room_id".to_string(), Value::from(room_id));
}
let parsed: ruma_events::AnyMessageLikeEvent =
serde_json::from_value(event).context("reading a verification event")?;
self.machine.receive_verification_event(&parsed).await.context("receive_verification_event")?;
Ok(())
}
/// Publishes signatures over somebody's keys.
///
/// This is what a verification is *for*: having agreed that a device is
+27
View File
@@ -704,6 +704,8 @@ async fn handle_timeline_event(
&& outer_type != protocol::EVENT_STICKER
// A poll is three kinds of event and none of them is a message.
&& !polls::is_poll_event(outer_type)
// And a verification with another person travels through the room.
&& !outer_type.starts_with("m.key.verification.")
{
// Membership/name changes were already folded into naming in
// process_sync_response; anything else (typing, receipts, other
@@ -762,6 +764,31 @@ async fn handle_timeline_event(
return;
}
// Verifying another person: the whole flow travels through the room the
// two of you share, because until you have agreed which devices you are
// talking about there is no device to address it to. None of it is chat,
// so it goes to the machine and stops here.
if effective_type.starts_with("m.key.verification.")
|| content["msgtype"].as_str() == Some("m.key.verification.request")
{
if sender != own_user_id {
state.runtime.note_matrix_verification_peer(account_id, sender);
}
let mut event = event.clone();
// The decrypted content where there was any, so an encrypted room's
// verification reads the same as a plain one's.
if outer_type == protocol::EVENT_ROOM_ENCRYPTED && !undecryptable {
if let Some(object) = event.as_object_mut() {
object.insert("type".to_string(), Value::from(effective_type.clone()));
object.insert("content".to_string(), content.clone());
}
}
if let Err(e) = session.receive_room_verification(&event, room_id).await {
tracing::debug!("matrix[{account_id}]: verification event {event_id}: {e:#}");
}
return;
}
// A poll: the question, a vote, or the end of the counting. Handled after
// decryption like everything else, because a poll in an encrypted room
// arrives as ciphertext the same way a message does.
+25 -2
View File
@@ -141,6 +141,13 @@ fn status_event(v: &ActiveVerification, state: &str) -> serde_json::Value {
"accountId": v.account_id,
"verificationId": v.flow_id,
"state": state,
// Who is on the other end. For one of your own sessions this is you
// and the device is the interesting half; for somebody else it is
// the whole point, and a dialog that cannot say whose emoji these
// are is a dialog nobody should press yes on.
"otherUser": v.request.other_user().as_str(),
"otherDevice": v.request.other_device_id().map(|d| d.to_string()),
"isSelf": v.request.other_user() == v.request.own_user_id(),
})
}
@@ -290,7 +297,17 @@ pub async fn cancel(state: &AppState, account_id: &str, verification_id: &str) -
/// the flow finishing/being cancelled from the other end.
pub async fn tick(state: &AppState, account_id: &str, session: &CryptoSession, own_user_id: &UserId, homeserver_url: &str, access_token: &str) {
let known = state.runtime.matrix_known_flow_ids(account_id);
for request in session.machine.get_verification_requests(own_user_id) {
// Our own other sessions, and anybody who has sent verification traffic
// into a room we share. The machine is asked one user at a time, so
// asking only about ourselves - which is what this did - meant a request
// from another person was never even looked for.
let mut whose: Vec<ruma_common::OwnedUserId> = vec![own_user_id.to_owned()];
for peer in state.runtime.matrix_verification_peers(account_id) {
if let Ok(user_id) = UserId::parse(&peer) {
whose.push(user_id);
}
}
for request in whose.iter().flat_map(|user| session.machine.get_verification_requests(user)) {
if request.we_started() {
continue;
}
@@ -309,7 +326,13 @@ pub async fn tick(state: &AppState, account_id: &str, session: &CryptoSession, o
};
state.events.emit(
"matrixVerificationIncoming",
serde_json::json!({ "accountId": account_id, "verificationId": flow_id, "fromDevice": other_device }),
serde_json::json!({
"accountId": account_id,
"verificationId": flow_id,
"fromDevice": other_device,
"fromUser": v.request.other_user().as_str(),
"isSelf": v.request.other_user() == v.request.own_user_id(),
}),
);
state.runtime.insert_matrix_verification(&flow_id, v);
}
+20
View File
@@ -565,6 +565,12 @@ pub struct Runtime {
/// blocking somebody on one machine and hearing from them on the next is
/// not blocking them.
matrix_ignored: Mutex<HashMap<String, HashSet<String>>>,
/// Whose verification traffic this account has seen, by account.
///
/// Verifying another person happens in the room you share, and the
/// machine is asked about requests one user at a time - so the senders
/// of the events that arrive are the only list of who to ask about.
matrix_verification_peers: Mutex<HashMap<String, HashSet<String>>>,
/// The event ids a room has pinned, by account and room.
///
/// The ids only: what those events *say* lives in scrollback or on the
@@ -794,6 +800,7 @@ impl Runtime {
matrix_room_avatars: Mutex::new(HashMap::new()),
matrix_power_levels: Mutex::new(HashMap::new()),
matrix_pinned: Mutex::new(HashMap::new()),
matrix_verification_peers: Mutex::new(HashMap::new()),
matrix_ignored: Mutex::new(HashMap::new()),
matrix_room_members: Mutex::new(HashMap::new()),
matrix_read_receipts: Mutex::new(HashMap::new()),
@@ -2248,6 +2255,19 @@ impl Runtime {
self.matrix_ignored.lock().unwrap().get(account_id).is_some_and(|list| list.contains(user_id))
}
pub fn note_matrix_verification_peer(&self, account_id: &str, user_id: &str) {
self.matrix_verification_peers
.lock()
.unwrap()
.entry(account_id.to_string())
.or_default()
.insert(user_id.to_string());
}
pub fn matrix_verification_peers(&self, account_id: &str) -> HashSet<String> {
self.matrix_verification_peers.lock().unwrap().get(account_id).cloned().unwrap_or_default()
}
pub fn set_matrix_pinned(&self, account_id: &str, room_id: &str, events: Vec<String>) {
self.matrix_pinned.lock().unwrap().insert((account_id.to_string(), room_id.to_string()), events);
}