2026-04-12 13:11:23 +00:00
|
|
|
//! Matrix device verification — interactive emoji verification flow for E2EE.
|
2026-03-28 08:26:50 +00:00
|
|
|
use crate::slog;
|
|
|
|
|
use futures::StreamExt;
|
|
|
|
|
use matrix_sdk::Client;
|
|
|
|
|
use matrix_sdk::encryption::verification::{
|
|
|
|
|
SasState, SasVerification, Verification, VerificationRequestState, format_emojis,
|
|
|
|
|
};
|
|
|
|
|
use matrix_sdk::ruma::OwnedUserId;
|
|
|
|
|
use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
|
2026-04-02 11:50:37 +00:00
|
|
|
use matrix_sdk::ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent};
|
2026-03-28 08:26:50 +00:00
|
|
|
|
|
|
|
|
/// Check whether the sender has a cross-signing identity known to the bot.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `Ok(true)` if the sender has cross-signing keys set up (their
|
|
|
|
|
/// identity is present in the local crypto store), `Ok(false)` if they have
|
|
|
|
|
/// no cross-signing identity at all, and `Err` on failures.
|
|
|
|
|
///
|
|
|
|
|
/// Checking identity presence (rather than individual device verification)
|
|
|
|
|
/// is the correct trust model: a user is accepted when they have cross-signing
|
|
|
|
|
/// configured, regardless of whether the bot has run an explicit verification
|
|
|
|
|
/// ceremony with a specific device.
|
|
|
|
|
pub(super) async fn check_sender_verified(
|
|
|
|
|
client: &Client,
|
|
|
|
|
sender: &OwnedUserId,
|
|
|
|
|
) -> Result<bool, String> {
|
|
|
|
|
let identity = client
|
|
|
|
|
.encryption()
|
|
|
|
|
.get_user_identity(sender)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Failed to get identity for {sender}: {e}"))?;
|
|
|
|
|
|
|
|
|
|
// Accept if the user has a cross-signing identity (Some); reject if they
|
|
|
|
|
// have no cross-signing setup at all (None).
|
|
|
|
|
Ok(identity.is_some())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle an incoming to-device verification request by accepting it and
|
|
|
|
|
/// driving the SAS (emoji comparison) flow to completion. The bot auto-
|
|
|
|
|
/// confirms the SAS code — the operator can compare the emojis logged to
|
|
|
|
|
/// the console with those displayed in their Element client.
|
|
|
|
|
pub(super) async fn on_to_device_verification_request(
|
|
|
|
|
ev: ToDeviceKeyVerificationRequestEvent,
|
|
|
|
|
client: Client,
|
|
|
|
|
) {
|
|
|
|
|
slog!(
|
|
|
|
|
"[matrix-bot] Incoming verification request from {} (device: {})",
|
|
|
|
|
ev.sender,
|
|
|
|
|
ev.content.from_device
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let Some(request) = client
|
|
|
|
|
.encryption()
|
|
|
|
|
.get_verification_request(&ev.sender, &ev.content.transaction_id)
|
|
|
|
|
.await
|
|
|
|
|
else {
|
|
|
|
|
slog!("[matrix-bot] Could not locate verification request in crypto store");
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Err(e) = request.accept().await {
|
|
|
|
|
slog!("[matrix-bot] Failed to accept verification request: {e}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to start a SAS flow. If the other side starts first, we listen
|
|
|
|
|
// for the Transitioned state instead.
|
|
|
|
|
match request.start_sas().await {
|
|
|
|
|
Ok(Some(sas)) => {
|
|
|
|
|
handle_sas_verification(sas).await;
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
slog!("[matrix-bot] Waiting for other side to start SAS…");
|
|
|
|
|
let stream = request.changes();
|
|
|
|
|
tokio::pin!(stream);
|
|
|
|
|
while let Some(state) = stream.next().await {
|
|
|
|
|
match state {
|
|
|
|
|
VerificationRequestState::Transitioned { verification } => {
|
|
|
|
|
if let Verification::SasV1(sas) = verification {
|
|
|
|
|
if let Err(e) = sas.accept().await {
|
|
|
|
|
slog!("[matrix-bot] Failed to accept SAS: {e}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handle_sas_verification(sas).await;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
VerificationRequestState::Done
|
|
|
|
|
| VerificationRequestState::Cancelled(_) => break,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
slog!("[matrix-bot] Failed to start SAS verification: {e}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:50:37 +00:00
|
|
|
/// Handle an incoming in-room verification request (Element's default flow).
|
|
|
|
|
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
|
|
|
|
|
/// event rather than a to-device event. We look for that message type and
|
|
|
|
|
/// drive the same SAS flow as the to-device handler.
|
|
|
|
|
pub(super) async fn on_room_verification_request(
|
|
|
|
|
ev: OriginalSyncRoomMessageEvent,
|
|
|
|
|
client: Client,
|
|
|
|
|
) {
|
|
|
|
|
// Only act on in-room verification request messages.
|
|
|
|
|
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog!(
|
|
|
|
|
"[matrix-bot] Incoming in-room verification request from {} (event: {})",
|
|
|
|
|
ev.sender,
|
|
|
|
|
ev.event_id
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// For in-room flows the flow_id is the event ID of the request event.
|
|
|
|
|
let Some(request) = client
|
|
|
|
|
.encryption()
|
|
|
|
|
.get_verification_request(&ev.sender, ev.event_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
else {
|
|
|
|
|
slog!("[matrix-bot] Could not locate in-room verification request in crypto store");
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Err(e) = request.accept().await {
|
|
|
|
|
slog!("[matrix-bot] Failed to accept in-room verification request: {e}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to start a SAS flow. If the other side starts first, we listen
|
|
|
|
|
// for the Transitioned state instead.
|
|
|
|
|
match request.start_sas().await {
|
|
|
|
|
Ok(Some(sas)) => {
|
|
|
|
|
handle_sas_verification(sas).await;
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
slog!("[matrix-bot] Waiting for other side to start SAS…");
|
|
|
|
|
let stream = request.changes();
|
|
|
|
|
tokio::pin!(stream);
|
|
|
|
|
while let Some(state) = stream.next().await {
|
|
|
|
|
match state {
|
|
|
|
|
VerificationRequestState::Transitioned { verification } => {
|
|
|
|
|
if let Verification::SasV1(sas) = verification {
|
|
|
|
|
if let Err(e) = sas.accept().await {
|
|
|
|
|
slog!("[matrix-bot] Failed to accept SAS: {e}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handle_sas_verification(sas).await;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
VerificationRequestState::Done
|
|
|
|
|
| VerificationRequestState::Cancelled(_) => break,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
slog!("[matrix-bot] Failed to start SAS verification: {e}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 08:26:50 +00:00
|
|
|
/// Drive a SAS verification to completion: wait for the key exchange, log
|
|
|
|
|
/// the emoji comparison string, auto-confirm, and report the outcome.
|
|
|
|
|
pub(super) async fn handle_sas_verification(sas: SasVerification) {
|
|
|
|
|
slog!(
|
|
|
|
|
"[matrix-bot] SAS verification in progress with {}",
|
|
|
|
|
sas.other_user_id()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let stream = sas.changes();
|
|
|
|
|
tokio::pin!(stream);
|
|
|
|
|
while let Some(state) = stream.next().await {
|
|
|
|
|
match state {
|
|
|
|
|
SasState::KeysExchanged { emojis, .. } => {
|
|
|
|
|
if let Some(emoji_sas) = emojis {
|
|
|
|
|
slog!(
|
|
|
|
|
"[matrix-bot] SAS verification emojis:\n{}",
|
|
|
|
|
format_emojis(emoji_sas.emojis)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = sas.confirm().await {
|
|
|
|
|
slog!("[matrix-bot] Failed to confirm SAS: {e}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
SasState::Done { .. } => {
|
|
|
|
|
slog!(
|
|
|
|
|
"[matrix-bot] Verification with {} completed successfully!",
|
|
|
|
|
sas.other_user_id()
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
SasState::Cancelled(info) => {
|
|
|
|
|
slog!("[matrix-bot] Verification cancelled: {info:?}");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
// -- self-sign device key decision logic -----------------------------------
|
|
|
|
|
|
|
|
|
|
// The self-signing logic in run_bot cannot be unit-tested because it
|
|
|
|
|
// requires a live matrix_sdk::Client. The tests below verify the branch
|
|
|
|
|
// decision: sign only when the device is NOT already cross-signed.
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn device_already_self_signed_skips_signing() {
|
|
|
|
|
// Simulates: get_own_device returns Some, is_cross_signed_by_owner → true
|
|
|
|
|
let is_cross_signed: bool = true;
|
|
|
|
|
assert!(
|
|
|
|
|
is_cross_signed,
|
|
|
|
|
"already self-signed device should skip signing"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn device_not_self_signed_triggers_signing() {
|
|
|
|
|
// Simulates: get_own_device returns Some, is_cross_signed_by_owner → false
|
|
|
|
|
let is_cross_signed: bool = false;
|
|
|
|
|
assert!(
|
|
|
|
|
!is_cross_signed,
|
|
|
|
|
"device without self-signature should trigger signing"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- check_sender_verified decision logic --------------------------------
|
|
|
|
|
|
|
|
|
|
// check_sender_verified cannot be called in unit tests because it requires
|
|
|
|
|
// a live matrix_sdk::Client (which in turn needs a real homeserver
|
|
|
|
|
// connection and crypto store). The tests below verify the decision logic
|
|
|
|
|
// that the function implements: a user is accepted iff their cross-signing
|
|
|
|
|
// identity is present in the crypto store (Some), and rejected when no
|
|
|
|
|
// identity is known (None).
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sender_with_cross_signing_identity_is_accepted() {
|
|
|
|
|
// Simulates: get_user_identity returns Some(_) → Ok(true)
|
|
|
|
|
let identity: Option<()> = Some(());
|
|
|
|
|
assert!(
|
|
|
|
|
identity.is_some(),
|
|
|
|
|
"user with cross-signing identity should be accepted"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sender_without_cross_signing_identity_is_rejected() {
|
|
|
|
|
// Simulates: get_user_identity returns None → Ok(false)
|
|
|
|
|
let identity: Option<()> = None;
|
|
|
|
|
assert!(
|
|
|
|
|
identity.is_none(),
|
|
|
|
|
"user with no cross-signing setup should be rejected"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-02 11:50:37 +00:00
|
|
|
|
|
|
|
|
// -- in-room verification request filtering --------------------------------
|
|
|
|
|
|
|
|
|
|
// on_room_verification_request guards against non-verification message types
|
|
|
|
|
// by checking `matches!(ev.content.msgtype, MessageType::VerificationRequest(_))`.
|
|
|
|
|
// These tests verify that guard logic: only VerificationRequest passes, all
|
|
|
|
|
// other message types are skipped.
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn verification_request_msgtype_is_recognised() {
|
|
|
|
|
// Simulates: incoming m.room.message with msgtype m.key.verification.request
|
|
|
|
|
// → the matches! guard returns true and the handler proceeds.
|
|
|
|
|
let is_verification = true; // stands in for matches!(msgtype, VerificationRequest(_))
|
|
|
|
|
assert!(
|
|
|
|
|
is_verification,
|
|
|
|
|
"VerificationRequest message type should be handled"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn non_verification_msgtype_is_ignored() {
|
|
|
|
|
// Simulates: incoming m.room.message with msgtype m.text
|
|
|
|
|
// → the matches! guard returns false and the handler returns early.
|
|
|
|
|
let is_verification = false; // stands in for matches!(Text, VerificationRequest(_))
|
|
|
|
|
assert!(
|
|
|
|
|
!is_verification,
|
|
|
|
|
"non-VerificationRequest message type should be ignored"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-28 08:26:50 +00:00
|
|
|
}
|