Vouch for a person, and mark what cannot be vouched for

The two halves #94 was still missing.

Starting a verification with somebody else is its own gesture and its own
transport: the request goes into a direct message with them - made if there
is not one, since asking somebody to verify is asking them something - and
the room message that carries it is what the flow is named after. Refused
early where it cannot work, in words that say which case it is: your own
account is a different gesture, and somebody with no cross-signing identity
has nothing to be vouched for.

And the trust the machine works out while decrypting is no longer thrown
away. A message from a device this account has never verified now carries a
mark beside the sender's name. Only where there was something to check - an
encrypted message from somebody else - and only when the answer is no:
marking the doubtful ones is what makes the mark mean anything, and a tick
on every ordinary message would say nothing while taking the room.

matrix-sdk-common is now a direct dependency, pinned exactly, because that
is where those response types live.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 21:01:24 -04:00
co-authored by Claude Opus 5
parent e591b2f540
commit e0748ea21d
6 changed files with 181 additions and 7 deletions
Generated
+1
View File
@@ -3731,6 +3731,7 @@ dependencies = [
"image",
"irc",
"js_int",
"matrix-sdk-common",
"matrix-sdk-crypto",
"matrix-sdk-sqlite",
"qrcode",
+5
View File
@@ -62,6 +62,11 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring",
# 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"
# 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
# version skew here is a type mismatch rather than a missing feature.
matrix-sdk-common = "=0.18.0"
matrix-sdk-sqlite = { version = "0.18", default-features = false, features = ["crypto-store", "bundled"] }
# Pinned exactly to whatever matrix-sdk-crypto 0.18.0 itself resolves to
# (confirmed via `cargo tree -p matrix-sdk-crypto`) - OlmMachine's public
+21
View File
@@ -718,6 +718,27 @@ fn parse_otk_counts(value: &Value) -> BTreeMap<OneTimeKeyAlgorithm, UInt> {
/// `type`/`event_id`/`sender`/`origin_server_ts`/`content` - the machine
/// needs the whole envelope, not just `content`). Returns the decrypted
/// event's own JSON on success.
/// The same, plus whether the device that sent it has been verified.
///
/// The machine works this out as part of decrypting and it was being thrown
/// away: a message can only be attributed to a device the moment it is
/// decrypted, and asking again later is asking a different question.
pub async fn decrypt_room_event_with_trust(
session: &CryptoSession,
event: &Value,
room_id: &RoomId,
) -> Result<(Value, bool)> {
let raw: Raw<matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent> =
Raw::from_json_string(event.to_string()).context("re-serializing event for decryption")?;
let decrypted = session.machine.decrypt_room_event(&raw, room_id, &decryption_settings()).await.context("decrypt_room_event")?;
let verified = matches!(
decrypted.encryption_info.verification_state,
matrix_sdk_common::deserialized_responses::VerificationState::Verified
);
let value: Value = serde_json::from_str(decrypted.event.json().get()).context("parsing decrypted event JSON")?;
Ok((value, verified))
}
pub async fn decrypt_room_event(session: &CryptoSession, event: &Value, room_id: &RoomId) -> Result<Value> {
// matrix-sdk-crypto uses its own lightweight `Event<C>` wrapper here
// (types::events::room::encrypted::EncryptedEvent), not a ruma_events
+78 -7
View File
@@ -735,9 +735,14 @@ async fn handle_timeline_event(
// reaction can be encrypted too, not just messages) - so the
// effective type/content used below come from the decrypted envelope
// when there is one, the outer event otherwise.
// Whether the sender's device has been verified, which is only a question
// an encrypted message can answer: in a plain room there is no device
// claim to judge, so there is nothing to say rather than something bad.
let mut sender_verified: Option<bool> = None;
let (effective_type, mut content, undecryptable): (String, Value, bool) = if outer_type == protocol::EVENT_ROOM_ENCRYPTED {
match decrypt_event(session, event, room_id).await {
Ok(decrypted) => {
Ok((decrypted, verified)) => {
sender_verified = Some(verified);
let t = decrypted["type"].as_str().unwrap_or(protocol::EVENT_ROOM_MESSAGE).to_string();
(t, decrypted["content"].clone(), false)
}
@@ -943,7 +948,23 @@ async fn handle_timeline_event(
// than rendered here: it is somebody else's markup and the frontend
// is where the sanitiser lives.
protocol::formatted_body(&content).map(str::to_string),
None,
// A mark beside the name where the message came from a device this
// account has never verified. Only where there was something to
// check - an encrypted message from somebody else - and only when the
// answer is no: Element marks the doubtful ones rather than ticking
// every ordinary message, and a badge on all of them would say
// nothing while taking room from the ones that matter.
match sender_verified {
Some(false) if sender != own_user_id => Some(model::SenderStyle {
color: None,
badges: vec![crate::backend::kick::api::Badge {
kind: "unverified".to_string(),
text: "Sent from a device you have not verified".to_string(),
count: None,
}],
}),
_ => None,
},
);
}
@@ -962,9 +983,10 @@ fn handle_redaction(state: &AppState, buffer_id: &str, target_event: &str) {
state.runtime.delete_message(state, buffer_id, target_event);
}
async fn decrypt_event(session: &crypto::CryptoSession, event: &Value, room_id: &str) -> Result<Value> {
/// The plaintext, and whether the device that sent it has been verified.
async fn decrypt_event(session: &crypto::CryptoSession, event: &Value, room_id: &str) -> Result<(Value, bool)> {
let room_id = ruma_common::RoomId::parse(room_id).context("invalid room id")?;
crypto::decrypt_room_event(session, event, &room_id).await
crypto::decrypt_room_event_with_trust(session, event, &room_id).await
}
/// Turns a media event's `content` into an Attachment, carrying across the
@@ -1760,7 +1782,9 @@ async fn store_thread_event(
let decrypted;
let event = if protocol::event_type(event) == protocol::EVENT_ROOM_ENCRYPTED {
match session {
Some(session) => match decrypt_event(session, event, room_id).await.ok() {
// The trust half is for the timeline, where a message is drawn
// beside a name; here only the plaintext matters.
Some(session) => match decrypt_event(session, event, room_id).await.ok().map(|(plain, _)| plain) {
Some(plain) => {
decrypted = plain;
&decrypted
@@ -1834,7 +1858,7 @@ pub async fn backfill(state: &AppState, account_id: &str, buffer_id: &str, limit
let decrypted;
let event = if protocol::event_type(event) == "m.room.encrypted" {
let plain = match session.as_ref() {
Some(session) => decrypt_event(session, event, &room_id).await.ok(),
Some(session) => decrypt_event(session, event, &room_id).await.ok().map(|(plain, _)| plain),
None => None,
};
match plain {
@@ -3065,7 +3089,54 @@ pub async fn edit_message(state: &AppState, account_id: &str, buffer_id: &str, a
Ok(())
}
/// Answers a poll.
/// Sends one message-shaped event into a room and says what id it got.
///
/// Shared by the things that are messages without being chat - so far the
/// request that opens a verification with somebody. Encrypted where the room
/// is, for the same reason everything else is: a room that hides what is said
/// in it should not make an exception for this.
///
/// The event id is the return value because these events are referred to
/// afterwards - a verification is identified by the id of the message that
/// asked for it.
pub async fn send_room_event(
state: &AppState,
account_id: &str,
buffer_id: &str,
room_id: &str,
content: Value,
) -> Result<String> {
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let base = account.homeserver_url.trim_end_matches('/');
let access_token = account.access_token.clone();
let (event_type, body_json) = if state.runtime.is_matrix_room_encrypted(buffer_id) {
let session = state.runtime.get_matrix_machine(account_id).context("crypto session not ready yet")?;
let member_ids = joined_member_ids(base, &access_token, room_id).await?;
let room_id_ruma = ruma_common::RoomId::parse(room_id).context("invalid room id")?;
let encrypted = session
.share_and_encrypt_content(&account.homeserver_url, &access_token, &room_id_ruma, member_ids, protocol::EVENT_ROOM_MESSAGE, content)
.await
.context("encrypting the message")?;
(protocol::EVENT_ROOM_ENCRYPTED.to_string(), encrypted)
} else {
(protocol::EVENT_ROOM_MESSAGE.to_string(), content)
};
let txn_id = model::next_message_id();
let url = format!(
"{base}/_matrix/client/v3/rooms/{}/send/{event_type}/{}",
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>(),
url::form_urlencoded::byte_serialize(txn_id.as_bytes()).collect::<String>(),
);
let resp = http::put_json(&url, &access_token, body_json).await.context("sending the message")?;
resp["event_id"]
.as_str()
.map(|id| id.to_string())
.context("the server took the message but did not say what id it got")
}
/// Answers a poll./// Answers a poll.
///
/// The stable spelling is sent, which is what a current Element writes;
/// everything on the way in is read either way, because a room with older
+62
View File
@@ -195,6 +195,68 @@ pub async fn start_verification(
Ok(verification_id)
}
/// Starts verifying another person.
///
/// Not the same gesture as verifying one of your own sessions, and not the
/// same transport: there is no device to address it to until the two of you
/// have agreed which devices you are talking about, so the request goes into
/// the room you share as an ordinary message. Element does the same, which is
/// why one sent from there arrives as a direct message invitation rather than
/// as anything that looks like verification.
///
/// The direct message is made if there is not one already - asking somebody
/// to verify is asking them something, and a client that could only do it in
/// rooms that happened to exist would refuse most of the time.
pub async fn start_user_verification(state: &AppState, account_id: &str, user_id: &str) -> Result<String> {
let (config, session) = account_session(state, account_id).await?;
for old_id in state.runtime.matrix_verification_ids_for_account(account_id) {
let _ = cancel(state, account_id, &old_id).await;
}
let whose = UserId::parse(user_id).context("that is not a Matrix address")?;
if whose.as_str() == config.user_id {
bail!("that is this account - verify one of its sessions instead");
}
let identity = session
.machine
.get_identity(&whose, None)
.await
.context("looking them up")?
.and_then(|identity| identity.other())
.context("they have no cross-signing identity to verify against")?;
// The room to carry it, made if there is not one.
let buffer_id = super::open_dm(state, account_id, user_id, user_id).await.context("opening a direct message")?;
let room_id = state
.runtime
.get_matrix_room(&buffer_id)
.context("that direct message has no room yet - try again in a moment")?;
let room = ruma_common::RoomId::parse(&room_id).context("invalid room id")?;
// An ordinary room message, encrypted where the room is: a request to
// verify is a message like any other until the other end answers it.
let content = serde_json::to_value(identity.verification_request_content(None))
.context("building the verification request")?;
let event_id = super::send_room_event(state, account_id, &buffer_id, &room_id, content).await?;
let event = ruma_common::EventId::parse(&event_id).context("the server returned an event id this client cannot read")?;
let request = identity.request_verification(&room, &event, None);
let flow_id = request.flow_id().as_str().to_string();
let v = ActiveVerification {
account_id: account_id.to_string(),
flow_id: flow_id.clone(),
request,
sas: None,
emoji_emitted: false,
done: false,
};
state.events.emit("matrixVerificationStatus", status_event(&v, "requested"));
state.runtime.insert_matrix_verification(&flow_id, v);
state.runtime.note_matrix_verification_peer(account_id, user_id);
Ok(flow_id)
}
/// Accepts or declines an incoming verification request (one the *other*
/// session started - see tick's incoming-request scan below).
pub async fn respond_to_request(state: &AppState, account_id: &str, verification_id: &str, accept: bool) -> Result<()> {
+14
View File
@@ -2645,6 +2645,20 @@ pub async fn dispatch(
}
},
// Verifying another person, which is a different gesture from
// verifying one of your own sessions: it travels through a direct
// message with them, made if there is not one.
"startMatrixUserVerification" => {
let (account_id, user_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "userId")) {
(Some(a), Some(u)) => (a, u),
_ => return (None, Some("startMatrixUserVerification requires \"accountId\" and \"userId\"".to_string())),
};
match backend::matrix::verification::start_user_verification(state, account_id, user_id).await {
Ok(verification_id) => (Some(serde_json::json!({ "verificationId": verification_id })), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"startMatrixVerification" => {
let (account_id, device_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "deviceId")) {
(Some(a), Some(d)) => (a, d),