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:
Generated
+1
@@ -3731,6 +3731,7 @@ dependencies = [
|
||||
"image",
|
||||
"irc",
|
||||
"js_int",
|
||||
"matrix-sdk-common",
|
||||
"matrix-sdk-crypto",
|
||||
"matrix-sdk-sqlite",
|
||||
"qrcode",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user