Files
huskies/server/src/chat/transport/matrix/bot/verification.rs
T

198 lines
7.1 KiB
Rust

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;
/// 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}");
}
}
}
/// 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"
);
}
}