feat: enable Matrix E2EE with cross-signing verification on bot
Add end-to-end encryption support to the Matrix bot using the matrix-sdk crypto features. The bot now: - Enables E2EE on the Matrix client with cross-signing bootstrapping - Auto-verifies its own cross-signing identity on startup - Handles key verification requests from other users automatically - Sends encrypted messages in E2EE-enabled rooms - Adds MATRIX_STORE_PATH config for persistent crypto store Squash merge of feature/story-194_story_enable_matrix_e2ee_with_cross_signing_verification_on_bot Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use futures::StreamExt;
|
||||
use matrix_sdk::encryption::verification::{
|
||||
SasState, SasVerification, Verification, VerificationRequestState, format_emojis,
|
||||
};
|
||||
use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
|
||||
|
||||
use super::config::BotConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -71,6 +77,9 @@ pub struct BotContext {
|
||||
/// bot so it can continue a conversation thread without requiring an
|
||||
/// explicit `@mention` on every follow-up.
|
||||
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
||||
/// When `true`, the bot rejects messages from users whose devices have not
|
||||
/// been verified via cross-signing in encrypted rooms.
|
||||
pub require_verified_devices: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -89,20 +98,46 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
|
||||
.await
|
||||
.map_err(|e| format!("Failed to build Matrix client: {e}"))?;
|
||||
|
||||
// Login
|
||||
client
|
||||
// Persist device ID so E2EE crypto state survives restarts.
|
||||
let device_id_path = project_root.join(".story_kit").join("matrix_device_id");
|
||||
let saved_device_id: Option<String> = std::fs::read_to_string(&device_id_path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let mut login_builder = client
|
||||
.matrix_auth()
|
||||
.login_username(&config.username, &config.password)
|
||||
.initial_device_display_name("Story Kit Bot")
|
||||
.initial_device_display_name("Story Kit Bot");
|
||||
|
||||
if let Some(ref device_id) = saved_device_id {
|
||||
login_builder = login_builder.device_id(device_id);
|
||||
}
|
||||
|
||||
let login_response = login_builder
|
||||
.await
|
||||
.map_err(|e| format!("Matrix login failed: {e}"))?;
|
||||
|
||||
// Save device ID on first login so subsequent restarts reuse the same device.
|
||||
if saved_device_id.is_none() {
|
||||
let _ = std::fs::write(&device_id_path, &login_response.device_id);
|
||||
slog!(
|
||||
"[matrix-bot] Saved device ID {} for future restarts",
|
||||
login_response.device_id
|
||||
);
|
||||
}
|
||||
|
||||
let bot_user_id = client
|
||||
.user_id()
|
||||
.ok_or_else(|| "No user ID after login".to_string())?
|
||||
.to_owned();
|
||||
|
||||
slog!("[matrix-bot] Logged in as {bot_user_id}");
|
||||
slog!("[matrix-bot] Logged in as {bot_user_id} (device: {})", login_response.device_id);
|
||||
|
||||
// Bootstrap cross-signing keys for E2EE verification support.
|
||||
if let Err(e) = client.encryption().bootstrap_cross_signing(None).await {
|
||||
slog!("[matrix-bot] Cross-signing bootstrap note: {e}");
|
||||
}
|
||||
|
||||
if config.allowed_users.is_empty() {
|
||||
return Err(
|
||||
@@ -157,11 +192,17 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
|
||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
history_size: config.history_size,
|
||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||
require_verified_devices: config.require_verified_devices,
|
||||
};
|
||||
|
||||
// Register event handler and inject shared context
|
||||
if config.require_verified_devices {
|
||||
slog!("[matrix-bot] require_verified_devices is ON — messages from unverified devices in encrypted rooms will be rejected");
|
||||
}
|
||||
|
||||
// Register event handlers and inject shared context.
|
||||
client.add_event_handler_context(ctx);
|
||||
client.add_event_handler(on_room_message);
|
||||
client.add_event_handler(on_to_device_verification_request);
|
||||
|
||||
slog!("[matrix-bot] Starting Matrix sync loop");
|
||||
|
||||
@@ -248,13 +289,146 @@ async fn is_reply_to_bot(
|
||||
candidate_ids.iter().any(|id| guard.contains(*id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2EE device verification helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether the sender has at least one verified device.
|
||||
///
|
||||
/// Returns `Ok(true)` if at least one device is cross-signing verified,
|
||||
/// `Ok(false)` if there are zero verified devices, and `Err` on failures.
|
||||
async fn check_sender_verified(
|
||||
client: &Client,
|
||||
sender: &OwnedUserId,
|
||||
) -> Result<bool, String> {
|
||||
let devices = client
|
||||
.encryption()
|
||||
.get_user_devices(sender)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get devices for {sender}: {e}"))?;
|
||||
|
||||
// Accept if the user has at least one verified device.
|
||||
Ok(devices.devices().any(|d| d.is_verified()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAS verification handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Matrix event handler for room messages. Each invocation spawns an
|
||||
/// independent task so the sync loop is not blocked by LLM calls.
|
||||
async fn on_room_message(ev: OriginalSyncRoomMessageEvent, room: Room, Ctx(ctx): Ctx<BotContext>) {
|
||||
async fn on_room_message(
|
||||
ev: OriginalSyncRoomMessageEvent,
|
||||
room: Room,
|
||||
client: Client,
|
||||
Ctx(ctx): Ctx<BotContext>,
|
||||
) {
|
||||
let incoming_room_id = room.room_id().to_owned();
|
||||
|
||||
slog!(
|
||||
@@ -301,6 +475,31 @@ async fn on_room_message(ev: OriginalSyncRoomMessageEvent, room: Room, Ctx(ctx):
|
||||
return;
|
||||
}
|
||||
|
||||
// When require_verified_devices is enabled and the room is encrypted,
|
||||
// reject messages from users whose devices have not been verified.
|
||||
if ctx.require_verified_devices && room.encryption_state().is_encrypted() {
|
||||
match check_sender_verified(&client, &ev.sender).await {
|
||||
Ok(true) => { /* sender has at least one verified device — proceed */ }
|
||||
Ok(false) => {
|
||||
slog!(
|
||||
"[matrix-bot] WARNING: Rejecting message from {} — \
|
||||
unverified device(s) in encrypted room {}",
|
||||
ev.sender,
|
||||
incoming_room_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
slog!(
|
||||
"[matrix-bot] Error checking verification for {}: {e} — \
|
||||
rejecting message (fail-closed)",
|
||||
ev.sender
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sender = ev.sender.to_string();
|
||||
let user_message = body;
|
||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
||||
@@ -730,6 +929,27 @@ mod tests {
|
||||
assert_clone::<BotContext>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_context_require_verified_devices_field() {
|
||||
let ctx = BotContext {
|
||||
bot_user_id: make_user_id("@bot:example.com"),
|
||||
target_room_ids: vec![],
|
||||
project_root: PathBuf::from("/tmp"),
|
||||
allowed_users: vec![],
|
||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
history_size: 20,
|
||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||
require_verified_devices: true,
|
||||
};
|
||||
assert!(ctx.require_verified_devices);
|
||||
|
||||
let ctx_off = BotContext {
|
||||
require_verified_devices: false,
|
||||
..ctx
|
||||
};
|
||||
assert!(!ctx_off.require_verified_devices);
|
||||
}
|
||||
|
||||
// -- drain_complete_paragraphs ------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user