diff --git a/Cargo.lock b/Cargo.lock index 105a7fd9..011688b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accessory" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87537f9ae7cfa78d5b8ebd1a1db25959f5e737126be4d8eb44a5452fc4b63cde" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "adler2" version = "2.0.1" @@ -373,6 +385,15 @@ dependencies = [ "cipher 0.5.2", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -648,6 +669,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -773,6 +803,18 @@ dependencies = [ "regex", ] +[[package]] +name = "delegate-display" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a85201f233142ac819bbf6226e36d0b5e129a47bd325084674261c82d4cd66" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -927,6 +969,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy_constructor" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07b19d0e43eae2bfbafe4931b5e79c73fb1a849ca15cd41a761a7b8587f9a1a2" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1136,6 +1190,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" @@ -1682,6 +1749,23 @@ dependencies = [ "quote", ] +[[package]] +name = "indexed_db_futures" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43315957678a70eb21fb0d2384fe86dde0d6c859a01e24ce127eb65a0143d28c" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "fancy_constructor", + "js-sys", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1883,6 +1967,53 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macroific" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05c00ac596022625d01047c421a0d97d7f09a18e429187b341c201cb631b9dd" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd94d5da95b30ae6e10621ad02340909346ad91661f3f8c0f2b62345e46a2f67" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macroific_core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13198c120864097a565ccb3ff947672d969932b7975ebd4085732c9f09435e55" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macroific_macro" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c9853143cbed7f1e41dc39fee95f9b361bec65c8dc2a01bf609be01b61f5ae" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "maplit" version = "1.0.2" @@ -1982,6 +2113,7 @@ dependencies = [ "language-tags", "matrix-sdk-base", "matrix-sdk-common", + "matrix-sdk-indexeddb", "matrix-sdk-sqlite", "mime", "mime2ext", @@ -2022,6 +2154,7 @@ dependencies = [ "futures-util", "growable-bloom-filter", "matrix-sdk-common", + "matrix-sdk-crypto", "matrix-sdk-store-encryption", "once_cell", "regex", @@ -2054,9 +2187,81 @@ dependencies = [ "tracing", "tracing-subscriber", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "matrix-sdk-crypto" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61bf6c3195de301c98339283413a4e9d9d63c4f214ef8955147643caab161256" +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.6", + "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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2752015e69b6b56a8df72e52f91a834771259f755eb1c65232b77348ffb16b" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "getrandom 0.2.17", + "gloo-utils", + "hkdf", + "indexed_db_futures", + "js-sys", + "matrix-sdk-crypto", + "matrix-sdk-store-encryption", + "rmp-serde", + "ruma", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasm-bindgen", + "web-sys", + "zeroize", +] + [[package]] name = "matrix-sdk-sqlite" version = "0.14.0" @@ -2068,6 +2273,7 @@ dependencies = [ "deadpool-sqlite", "itertools 0.14.0", "matrix-sdk-base", + "matrix-sdk-crypto", "matrix-sdk-store-encryption", "num_cpus", "rmp-serde", @@ -2854,6 +3060,7 @@ dependencies = [ "getrandom 0.2.17", "http", "indexmap", + "js-sys", "js_int", "konst", "percent-encoding", @@ -3112,6 +3319,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" @@ -3465,10 +3683,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]] @@ -3477,6 +3697,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.3" @@ -3801,6 +4031,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.4", + "web-time", +] + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index cae6b9d7..bbcbb2f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,5 +63,6 @@ matrix-sdk = { version = "0.14", default-features = false, features = [ "rustls-tls", "sqlite", "markdown", + "e2e-encryption", ] } futures-util = "0.3" diff --git a/docs/matrix.md b/docs/matrix.md index 67cb5f5f..7e3dd972 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -119,8 +119,22 @@ Initial rollout settings: engage). The absent `yes_i_am_very_very_sure_…_open_registration_…` flag keeps the server closed to anyone without the token. -- `allow_encryption = false` per operator call; E2EE re-enabling is - deferred to a follow-up. +- `allow_encryption` — server-side E2EE switch, sourced from + `services.hyperhive.matrix.allowEncryption` (**default `false`**, opt-in). + Off by default because on the hive-internal homeserver the operator + already controls the transport; turn it on for encrypted rooms on + external / federated homeservers or to keep contents opaque to the + homeserver admin. **The agent matrix client always supports decryption + regardless of this flag** — it uses the `e2e-encryption` feature of + `matrix-sdk` so it can read encrypted rooms it is invited to even when + this homeserver doesn't permit room encryption. Crypto keys are stored + in the per-agent sqlite store under the state dir and persist across + restarts (lost on `--purge`). `read_room` decrypts via + `room.messages()` — UTD events surface as `event_type = + "m.room.encrypted"` with `body = "[unable to decrypt]"`. + Cross-signing and automatic key backup are not enabled for the first + pass: static bearer-token bot accounts can't bootstrap cross-signing + without MSC3967. ## Hive Matrix Space diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index e5ea8e0a..efe73e29 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -9,6 +9,13 @@ //! UIAA flow already minted the token + user/device, hive-c0re just //! handed us the bearer in a file. matrix-sdk's `restore_session` with //! a constructed `MatrixSession` skips the login flow entirely. +//! +//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. +//! Crypto keys are persisted in the sqlite store under `state_dir` +//! (survives container restarts, lost on `--purge`). Cross-signing and +//! automatic key backup are deliberately left at their defaults (disabled) +//! for the first pass: bot accounts authenticated with a static bearer token +//! can't bootstrap cross-signing without MSC3967 on the server side. use std::path::Path; @@ -16,6 +23,7 @@ use anyhow::{Context, Result, anyhow}; use matrix_sdk::{ Client, SessionMeta, SessionTokens, authentication::matrix::MatrixSession, + encryption::EncryptionSettings, ruma::{OwnedDeviceId, OwnedUserId}, }; use serde::Deserialize; @@ -108,6 +116,7 @@ pub async fn build_and_restore( let client = Client::builder() .homeserver_url(homeserver) .sqlite_store(state_dir, None) + .with_encryption_settings(EncryptionSettings::default()) .build() .await .with_context(|| format!("build matrix client for {homeserver}"))?; diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 23125db7..cd38835b 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -116,6 +116,24 @@ fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String { } } +/// Same as [`extract_body`] but for the `AnySyncTimelineEvent` variant +/// returned by `TimelineEvent::raw()` (the sdk-level decrypted form from +/// `room.messages()`). +fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> String { + use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent}; + match event { + AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) => ev + .as_original() + .map_or_else(String::new, |orig| match &orig.content.msgtype { + MessageType::Text(t) => t.body.clone(), + MessageType::Notice(n) => n.body.clone(), + MessageType::Emote(e) => format!("* {}", e.body), + _ => String::new(), + }), + _ => String::new(), + } +} + pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse { let room = match resolve_room(client, room_ref).await { Ok(r) => r, @@ -408,32 +426,37 @@ pub async fn invite_user(client: &Client, room_ref: &str, user_id: &str) -> Daem } pub async fn read_room(client: &Client, room_ref: &str, limit: Option) -> DaemonResponse { - use matrix_sdk::ruma::api::Direction; - use matrix_sdk::ruma::api::client::message::get_message_events; + use matrix_sdk::room::MessagesOptions; let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; - let limit = limit.unwrap_or(50).min(200); - let mut req = - get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); - req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64) - .unwrap_or(matrix_sdk::ruma::UInt::from(50u32)); - let resp = match client.send(req).await { - Ok(r) => r, - Err(e) => return DaemonResponse::error(format!("get_message_events: {e}")), + let limit_val = u32::try_from(limit.unwrap_or(50).min(200)).unwrap_or(50); + let mut opts = MessagesOptions::backward(); + opts.limit = matrix_sdk::ruma::UInt::from(limit_val); + // room.messages() transparently decrypts events in encrypted rooms. + // UTD (unable to decrypt) events surface via `ev.kind.is_utd()` and + // get a sentinel body so claude knows decryption failed rather than + // seeing the raw encrypted blob. + let msgs = match room.messages(opts).await { + Ok(m) => m, + Err(e) => return DaemonResponse::error(format!("messages: {e}")), }; - let events: Vec = resp + let events: Vec = msgs .chunk .iter() - .filter_map(|raw| { - let parsed = raw.deserialize().ok()?; + .filter_map(|ev| { + let parsed = ev.raw().deserialize().ok()?; let event_id = parsed.event_id().to_string(); let sender = parsed.sender().to_string(); let origin_server_ts: i64 = parsed.origin_server_ts().0.into(); let event_type = parsed.event_type().to_string(); - let body = extract_body(&parsed); + let body = if ev.kind.is_utd() { + "[unable to decrypt]".to_owned() + } else { + extract_body_sync(&parsed) + }; Some(TimelineEvent { event_id, sender, diff --git a/nix/modules/hive-matrix.nix b/nix/modules/hive-matrix.nix index 5c8b5ba7..ca287a7b 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -241,6 +241,25 @@ in ''; }; + allowEncryption = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Server-side switch for matrix end-to-end encryption — sets + tuwunel's `allow_encryption`. Off by default: on the hive-internal + homeserver the operator already controls the transport, so server + E2EE adds key-management overhead (cross-signing, device + verification, undecryptable-message recovery) without a clear + threat-model win for the common single-hive case. Turn on when + agents join encrypted rooms on external / federated homeservers, + or when the operator wants message contents opaque to the + homeserver admin. Independent of the agent matrix client, which + always supports decryption so it can read encrypted rooms it is + invited to regardless of this flag; this option only governs + whether THIS homeserver permits room encryption. + ''; + }; + gui = { enable = lib.mkOption { type = lib.types.bool; @@ -403,8 +422,9 @@ in # LoadCredential below copies the host file into a # 0400 dynamic-user-owned path; tuwunel reads from there. registration_token_file = "/run/credentials/tuwunel.service/registration_token"; - # E2EE disabled in initial rollout; tracked in the issue tracker. - allow_encryption = false; + # Server-side E2EE is opt-in (default off); the agent matrix + # client always supports decryption regardless. + allow_encryption = cfg.allowEncryption; # Tuwunel's default suffix is " 💕" — suppress it so agent # display names are clean (just the agent name, no emoji). new_user_displayname_suffix = "";