From 08e23e383053d76c590665151cd5b6af0bfea2e6 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 26 Feb 2026 10:41:29 +0000 Subject: [PATCH] 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 --- Cargo.lock | 353 +++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + server/src/main.rs | 4 + server/src/matrix/bot.rs | 232 +++++++++++++++++++++++- server/src/matrix/config.rs | 47 +++++ 5 files changed, 629 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f57acb..b759039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accessory" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e416a3ab45838bac2ab2d81b1088d738d7b2d2c5272a54d39366565a29bd80" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "adler2" version = "2.0.1" @@ -283,6 +295,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -299,6 +320,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "0.5.6" @@ -575,6 +602,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -690,6 +726,20 @@ dependencies = [ "regex", ] +[[package]] +name = "delegate-display" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9926686c832494164c33a36bf65118f4bd6e704000b58c94681bf62e9ad67a74" +dependencies = [ + "impartial-ord", + "itoa", + "macroific", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "der" version = "0.7.10" @@ -709,13 +759,44 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", ] [[package]] @@ -893,6 +974,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy_constructor" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a27643a5d05f3a22f5afd6e0d0e6e354f92d37907006f97b84b9cb79082198" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1179,6 +1272,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "growable-bloom-filter" version = "2.1.1" @@ -1623,6 +1729,17 @@ dependencies = [ "bitmaps", ] +[[package]] +name = "impartial-ord" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -1908,6 +2025,54 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macroific" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.116", +] + +[[package]] +name = "macroific_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.116", +] + +[[package]] +name = "macroific_macro" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "maplit" version = "1.0.2" @@ -1989,6 +2154,7 @@ dependencies = [ "language-tags", "matrix-sdk-base", "matrix-sdk-common", + "matrix-sdk-indexeddb", "matrix-sdk-sqlite", "mime", "mime2ext", @@ -2029,6 +2195,7 @@ dependencies = [ "futures-util", "growable-bloom-filter", "matrix-sdk-common", + "matrix-sdk-crypto", "matrix-sdk-store-encryption", "once_cell", "regex", @@ -2061,9 +2228,83 @@ dependencies = [ "tracing", "tracing-subscriber", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "matrix-sdk-crypto" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "304fc576810a9618bb831c4ad6403c758ec424f677668a49a196e3cde4b8f99f" +dependencies = [ + "aes", + "aquamarine", + "as_variant", + "async-trait", + "bs58", + "byteorder", + "cfg-if", + "ctr", + "eyeball", + "futures-core", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "js_option", + "matrix-sdk-common", + "pbkdf2", + "rand 0.8.5", + "rmp-serde", + "ruma", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "ulid", + "url", + "vodozemac", + "zeroize", +] + +[[package]] +name = "matrix-sdk-indexeddb" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6096084cc8d339c03e269ca25534d0f1e88d0097c35a215eb8c311797ec3e9" +dependencies = [ + "async-trait", + "base64", + "futures-util", + "getrandom 0.2.17", + "gloo-utils", + "hkdf", + "js-sys", + "matrix-sdk-base", + "matrix-sdk-crypto", + "matrix-sdk-store-encryption", + "matrix_indexed_db_futures", + "rmp-serde", + "ruma", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "wasm-bindgen", + "web-sys", + "zeroize", +] + [[package]] name = "matrix-sdk-sqlite" version = "0.16.0" @@ -2076,6 +2317,7 @@ dependencies = [ "deadpool-sync", "itertools 0.14.0", "matrix-sdk-base", + "matrix-sdk-crypto", "matrix-sdk-store-encryption", "num_cpus", "rmp-serde", @@ -2100,6 +2342,7 @@ dependencies = [ "base64", "blake3", "chacha20poly1305", + "getrandom 0.2.17", "hmac", "pbkdf2", "rand 0.8.5", @@ -2111,6 +2354,45 @@ dependencies = [ "zeroize", ] +[[package]] +name = "matrix_indexed_db_futures" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "derive_more 2.1.1", + "fancy_constructor", + "futures-core", + "js-sys", + "matrix_indexed_db_futures_macros_internal", + "sealed", + "serde", + "serde-wasm-bindgen", + "smallvec", + "thiserror 2.0.18", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_evt_listener", + "web-sys", + "web-time", +] + +[[package]] +name = "matrix_indexed_db_futures_macros_internal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2596,7 +2878,7 @@ checksum = "1ccbcc395bf4dd03df1da32da351b6b6732e4074ce27ddec315650e52a2be44c" dependencies = [ "base64", "bytes 1.11.1", - "derive_more", + "derive_more 2.1.1", "futures-util", "indexmap", "itertools 0.14.0", @@ -3175,6 +3457,7 @@ dependencies = [ "getrandom 0.2.17", "http", "indexmap", + "js-sys", "js_int", "konst", "percent-encoding", @@ -3482,6 +3765,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -3521,6 +3815,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -3724,6 +4029,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3993,10 +4301,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -4005,6 +4315,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4368,6 +4688,16 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "web-time", +] + [[package]] name = "uncased" version = "0.9.10" @@ -4682,6 +5012,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_evt_listener" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963" +dependencies = [ + "accessory", + "derivative", + "derive_more 1.0.0", + "fancy_constructor", + "futures-core", + "js-sys", + "smallvec", + "tokio", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -4711,6 +5059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index a5aa13b..d99dab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ filetime = "0.2" matrix-sdk = { version = "0.16.0", default-features = false, features = [ "native-tls", "sqlite", + "e2e-encryption", ] } pulldown-cmark = { version = "0.13.1", default-features = false, features = [ "html", diff --git a/server/src/main.rs b/server/src/main.rs index b390946..74816d5 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,7 @@ +// matrix-sdk-crypto's deeply nested types require a higher recursion limit +// when the `e2e-encryption` feature is enabled. +#![recursion_limit = "256"] + mod agent_log; mod agents; mod config; diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 1dba292..37cd7a9 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -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>>, + /// 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 = 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 { + 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) { +async fn on_room_message( + ev: OriginalSyncRoomMessageEvent, + room: Room, + client: Client, + Ctx(ctx): Ctx, +) { 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::(); } + #[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] diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index 80a97ca..2fa64cc 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -35,6 +35,12 @@ pub struct BotConfig { /// dropped. Defaults to 20. #[serde(default = "default_history_size")] pub history_size: usize, + /// When `true`, the bot rejects messages from users whose devices have not + /// been verified via cross-signing in encrypted rooms. When `false` + /// (default), messages are accepted regardless of device verification + /// status, preserving existing plaintext-room behaviour. + #[serde(default)] + pub require_verified_devices: bool, /// Previously used to select an Anthropic model. Now ignored — the bot /// uses Claude Code which manages its own model selection. Kept for /// backwards compatibility so existing bot.toml files still parse. @@ -235,6 +241,47 @@ enabled = true assert_eq!(config.history_size, 20); } + #[test] + fn load_defaults_require_verified_devices_to_false() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert!(!config.require_verified_devices); + } + + #[test] + fn load_respects_require_verified_devices_true() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +require_verified_devices = true +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert!(config.require_verified_devices); + } + #[test] fn load_respects_custom_history_size() { let tmp = tempfile::tempdir().unwrap();