story-kit: enforce cryptographic identity verification for Matrix commands (story 246)
Remove the require_verified_devices config toggle. The bot now always requires encrypted rooms and cross-signing-verified devices before executing any command. Messages from unencrypted rooms or unverified devices are rejected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,9 +77,6 @@ pub struct BotContext {
|
|||||||
/// bot so it can continue a conversation thread without requiring an
|
/// bot so it can continue a conversation thread without requiring an
|
||||||
/// explicit `@mention` on every follow-up.
|
/// explicit `@mention` on every follow-up.
|
||||||
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -192,12 +189,9 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
|
|||||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
history_size: config.history_size,
|
history_size: config.history_size,
|
||||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
require_verified_devices: config.require_verified_devices,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if config.require_verified_devices {
|
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||||
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.
|
// Register event handlers and inject shared context.
|
||||||
client.add_event_handler_context(ctx);
|
client.add_event_handler_context(ctx);
|
||||||
@@ -475,15 +469,25 @@ async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When require_verified_devices is enabled and the room is encrypted,
|
// Reject commands from unencrypted rooms — E2EE is mandatory.
|
||||||
// reject messages from users whose devices have not been verified.
|
if !room.encryption_state().is_encrypted() {
|
||||||
if ctx.require_verified_devices && room.encryption_state().is_encrypted() {
|
slog!(
|
||||||
|
"[matrix-bot] Rejecting message from {} — room {} is not encrypted. \
|
||||||
|
Commands are only accepted from encrypted rooms.",
|
||||||
|
ev.sender,
|
||||||
|
incoming_room_id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always verify that the sender has at least one cross-signing-verified
|
||||||
|
// device. This check is unconditional and cannot be disabled via config.
|
||||||
match check_sender_verified(&client, &ev.sender).await {
|
match check_sender_verified(&client, &ev.sender).await {
|
||||||
Ok(true) => { /* sender has at least one verified device — proceed */ }
|
Ok(true) => { /* sender has at least one verified device — proceed */ }
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] WARNING: Rejecting message from {} — \
|
"[matrix-bot] Rejecting message from {} — no cross-signing-verified \
|
||||||
unverified device(s) in encrypted room {}",
|
device found in encrypted room {}",
|
||||||
ev.sender,
|
ev.sender,
|
||||||
incoming_room_id
|
incoming_room_id
|
||||||
);
|
);
|
||||||
@@ -498,7 +502,6 @@ async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let sender = ev.sender.to_string();
|
let sender = ev.sender.to_string();
|
||||||
let user_message = body;
|
let user_message = body;
|
||||||
@@ -930,7 +933,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bot_context_require_verified_devices_field() {
|
fn bot_context_has_no_require_verified_devices_field() {
|
||||||
|
// Verification is always on — BotContext no longer has a toggle field.
|
||||||
|
// This test verifies the struct can be constructed and cloned without it.
|
||||||
let ctx = BotContext {
|
let ctx = BotContext {
|
||||||
bot_user_id: make_user_id("@bot:example.com"),
|
bot_user_id: make_user_id("@bot:example.com"),
|
||||||
target_room_ids: vec![],
|
target_room_ids: vec![],
|
||||||
@@ -939,15 +944,9 @@ mod tests {
|
|||||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
history_size: 20,
|
history_size: 20,
|
||||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
require_verified_devices: true,
|
|
||||||
};
|
};
|
||||||
assert!(ctx.require_verified_devices);
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
|
let _cloned = ctx.clone();
|
||||||
let ctx_off = BotContext {
|
|
||||||
require_verified_devices: false,
|
|
||||||
..ctx
|
|
||||||
};
|
|
||||||
assert!(!ctx_off.require_verified_devices);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- drain_complete_paragraphs ------------------------------------------
|
// -- drain_complete_paragraphs ------------------------------------------
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ pub struct BotConfig {
|
|||||||
/// dropped. Defaults to 20.
|
/// dropped. Defaults to 20.
|
||||||
#[serde(default = "default_history_size")]
|
#[serde(default = "default_history_size")]
|
||||||
pub history_size: usize,
|
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
|
/// Previously used to select an Anthropic model. Now ignored — the bot
|
||||||
/// uses Claude Code which manages its own model selection. Kept for
|
/// uses Claude Code which manages its own model selection. Kept for
|
||||||
/// backwards compatibility so existing bot.toml files still parse.
|
/// backwards compatibility so existing bot.toml files still parse.
|
||||||
@@ -241,47 +235,6 @@ enabled = true
|
|||||||
assert_eq!(config.history_size, 20);
|
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]
|
#[test]
|
||||||
fn load_respects_custom_history_size() {
|
fn load_respects_custom_history_size() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -302,4 +255,32 @@ history_size = 50
|
|||||||
let config = BotConfig::load(tmp.path()).unwrap();
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
assert_eq!(config.history_size, 50);
|
assert_eq!(config.history_size, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_ignores_legacy_require_verified_devices_key() {
|
||||||
|
// Old bot.toml files that still have `require_verified_devices = true`
|
||||||
|
// must parse successfully — the field is simply ignored now that
|
||||||
|
// verification is always enforced unconditionally.
|
||||||
|
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();
|
||||||
|
// Should still load successfully despite the unknown field.
|
||||||
|
let config = BotConfig::load(tmp.path());
|
||||||
|
assert!(
|
||||||
|
config.is_some(),
|
||||||
|
"bot.toml with legacy require_verified_devices key must still load"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user