diff --git a/src-tauri/src/matrix_crypto/backup.rs b/src-tauri/src/matrix_crypto/backup.rs index 1654570fa..57476a754 100644 --- a/src-tauri/src/matrix_crypto/backup.rs +++ b/src-tauri/src/matrix_crypto/backup.rs @@ -99,6 +99,10 @@ async fn handle(machine: &OlmMachine, method: &str, args: &Value) -> Result Value::Bool(machine.backup_machine().enabled().await), + "backupVersion" => match machine.backup_machine().backup_version().await { + Some(version) => Value::String(version), + None => Value::Null, + }, "verifyBackup" => { let info: RoomKeyBackupInfo = serde_json::from_str(&str_arg(args, method, "backupInfo")?) diff --git a/src-tauri/src/matrix_crypto/cross_signing.rs b/src-tauri/src/matrix_crypto/cross_signing.rs index 39102fa2e..7cedff072 100644 --- a/src-tauri/src/matrix_crypto/cross_signing.rs +++ b/src-tauri/src/matrix_crypto/cross_signing.rs @@ -57,16 +57,21 @@ async fn bootstrap(machine: &OlmMachine, args: &Value) -> Result let upload_keys_request = match requests.upload_keys_req.as_ref() { Some(request) => match request.request() { - AnyOutgoingRequest::KeysUpload(req) => json!({ - "id": request.request_id().to_string(), - "type": KEYS_UPLOAD_REQUEST_TYPE, - "className": "KeysUploadRequest", - "body": json!({ - "device_keys": req.device_keys, + AnyOutgoingRequest::KeysUpload(req) => { + let mut body = json!({ "one_time_keys": req.one_time_keys, "fallback_keys": req.fallback_keys, - }).to_string(), - }), + }); + if let Some(device_keys) = &req.device_keys { + body["device_keys"] = json!(device_keys); + } + json!({ + "id": request.request_id().to_string(), + "type": KEYS_UPLOAD_REQUEST_TYPE, + "className": "KeysUploadRequest", + "body": body.to_string(), + }) + } _ => { return Err( "bootstrapCrossSigning: upload_keys_req was not a /keys/upload request" diff --git a/src-tauri/src/matrix_crypto/devices.rs b/src-tauri/src/matrix_crypto/devices.rs index edfd3f741..bf836581d 100644 --- a/src-tauri/src/matrix_crypto/devices.rs +++ b/src-tauri/src/matrix_crypto/devices.rs @@ -233,7 +233,7 @@ async fn encrypt_to_device_event( .get("content") .ok_or_else(|| format!("{method}: missing argument `content`"))?; let Some(device) = device_for(machine, args, method).await? else { - return Err(format!("{method}: unknown device")); + return Ok(Value::Null); }; let encrypted = device diff --git a/src-tauri/src/matrix_crypto/dispatch.rs b/src-tauri/src/matrix_crypto/dispatch.rs index f6fe5f82b..c1779da0d 100644 --- a/src-tauri/src/matrix_crypto/dispatch.rs +++ b/src-tauri/src/matrix_crypto/dispatch.rs @@ -18,6 +18,9 @@ use matrix_sdk_crypto::{EncryptionSyncChanges, OlmMachine}; use serde::Serialize; use serde_json::{json, Value}; +use matrix_sdk::deserialized_responses::{DeviceLinkProblem, VerificationLevel}; +use matrix_sdk_crypto::MegolmError; + use super::args::{caller_decryption_settings, decryption_settings, room_id, str_arg}; use super::requests::{mark_request_sent, outgoing_requests}; use super::wasm_enums::processed_to_device_event_type; @@ -137,6 +140,55 @@ fn processed_to_device_event_json( Ok(value) } +mod decryption_error_code { + pub const MISSING_ROOM_KEY: u8 = 0; + pub const UNKNOWN_MESSAGE_INDEX: u8 = 1; + pub const MISMATCHED_IDENTITY_KEYS: u8 = 2; + pub const UNKNOWN_SENDER_DEVICE: u8 = 3; + pub const UNSIGNED_SENDER_DEVICE: u8 = 4; + pub const SENDER_IDENTITY_VERIFICATION_VIOLATION: u8 = 5; + pub const UNABLE_TO_DECRYPT: u8 = 6; + pub const MISMATCHED_SENDER: u8 = 7; +} + +fn decryption_error_json(error: &MegolmError) -> Value { + use decryption_error_code as code; + + let (error_code, withheld) = match error { + MegolmError::MissingRoomKey(withheld) => ( + code::MISSING_ROOM_KEY, + withheld.as_ref().map(|code| code.to_string()), + ), + MegolmError::Decryption( + matrix_sdk_crypto::vodozemac::megolm::DecryptionError::UnknownMessageIndex(_, _), + ) => (code::UNKNOWN_MESSAGE_INDEX, None), + MegolmError::MismatchedIdentityKeys(_) => (code::MISMATCHED_IDENTITY_KEYS, None), + MegolmError::SenderIdentityNotTrusted(level) => ( + match level { + VerificationLevel::VerificationViolation => { + code::SENDER_IDENTITY_VERIFICATION_VIOLATION + } + VerificationLevel::UnsignedDevice => code::UNSIGNED_SENDER_DEVICE, + VerificationLevel::MismatchedSender => code::MISMATCHED_SENDER, + VerificationLevel::None(DeviceLinkProblem::MissingDevice) + | VerificationLevel::None(DeviceLinkProblem::InsecureSource) => { + code::UNKNOWN_SENDER_DEVICE + } + _ => code::UNABLE_TO_DECRYPT, + }, + None, + ), + _ => (code::UNABLE_TO_DECRYPT, None), + }; + + json!({ + "className": "DecryptionError", + "code": error_code, + "description": error.to_string(), + "maybeWithheld": withheld, + }) +} + fn verification_request_snapshot( machine: &OlmMachine, event: &ProcessedToDeviceEvent, @@ -280,10 +332,13 @@ pub async fn invoke(machine: &OlmMachine, method: &str, args: Value) -> Result = serde_json::from_str(&event_json) .map_err(|e| format!("decryptRoomEvent: bad event json: {e}"))?; - let decrypted = machine + let decrypted = match machine .decrypt_room_event(&event, &room, &caller_decryption_settings(&args)) .await - .map_err(|e| format!("decryptRoomEvent failed: {e:?}"))?; + { + Ok(decrypted) => decrypted, + Err(error) => return Ok(decryption_error_json(&error)), + }; let info = decrypted.encryption_info; let (sender_curve25519_key, claimed_ed25519_key) = match &info.algorithm_info { @@ -308,8 +363,14 @@ pub async fn invoke(machine: &OlmMachine, method: &str, args: Value) -> Result::new(), })) } diff --git a/src-tauri/src/matrix_crypto/message_flow.rs b/src-tauri/src/matrix_crypto/message_flow.rs new file mode 100644 index 000000000..0304e4aa2 --- /dev/null +++ b/src-tauri/src/matrix_crypto/message_flow.rs @@ -0,0 +1,331 @@ +#![cfg(test)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use matrix_sdk::ruma::api::client::keys::upload_keys; +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::{OneTimeKeyAlgorithm, OwnedUserId, UInt, UserId}; +use matrix_sdk_crypto::types::requests::{AnyIncomingResponse, AnyOutgoingRequest}; +use matrix_sdk_crypto::OlmMachine; +use matrix_sdk_sqlite::SqliteCryptoStore; +use serde_json::{json, Value}; + +use super::dispatch; + +const ROOM: &str = "!room:example.org"; + +struct Peer { + machine: OlmMachine, + user: OwnedUserId, +} + +async fn peer(user: &str, device: &str, tag: &str) -> Peer { + let dir = std::env::temp_dir().join(format!("sable-flow-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let user: OwnedUserId = UserId::parse(user).unwrap(); + let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), Some("pw")) + .await + .unwrap(); + let machine = OlmMachine::with_store(&user, device.into(), Arc::new(store), None) + .await + .unwrap(); + + Peer { machine, user } +} + +async fn call(peer: &Peer, method: &str, args: Value) -> Value { + dispatch::invoke(&peer.machine, method, args) + .await + .unwrap_or_else(|e| panic!("{method}: {e}")) +} + +async fn publish_keys(peer: &Peer) -> (Raw, Value) { + let mut device_keys = None; + let mut one_time_keys = Value::Null; + + for request in peer.machine.outgoing_requests().await.unwrap() { + let AnyOutgoingRequest::KeysUpload(upload) = request.request() else { + continue; + }; + if let Some(keys) = upload.device_keys.clone() { + device_keys = Some(keys); + } + one_time_keys = json!(upload.one_time_keys); + + let mut counts = BTreeMap::new(); + counts.insert( + OneTimeKeyAlgorithm::SignedCurve25519, + UInt::new(upload.one_time_keys.len() as u64).unwrap(), + ); + let response = upload_keys::v3::Response::new(counts); + peer.machine + .mark_request_as_sent( + request.request_id(), + AnyIncomingResponse::KeysUpload(&response), + ) + .await + .unwrap(); + } + + ( + device_keys.expect("peer issued no device keys"), + one_time_keys, + ) +} + +async fn learn_about( + learner: &Peer, + about: &Peer, + device: &str, + keys: &Raw, +) { + call( + learner, + "updateTrackedUsers", + json!({ "users": [about.user.to_string()] }), + ) + .await; + + let request = call( + learner, + "queryKeysForUsers", + json!({ "users": [about.user.to_string()] }), + ) + .await; + + let response = json!({ + "device_keys": { about.user.to_string(): { device: keys } }, + }); + call( + learner, + "markRequestAsSent", + json!({ + "requestId": request["id"], + "requestType": request["type"], + "response": response.to_string(), + }), + ) + .await; +} + +async fn claim_session(claimer: &Peer, peer_user: &str, peer_device: &str, one_time_keys: &Value) { + let request = call( + claimer, + "getMissingSessions", + json!({ "users": [peer_user] }), + ) + .await; + assert!(!request.is_null(), "expected a keys-claim request"); + + let response = json!({ + "one_time_keys": { peer_user: { peer_device: one_time_keys } }, + }); + call( + claimer, + "markRequestAsSent", + json!({ + "requestId": request["id"], + "requestType": request["type"], + "response": response.to_string(), + }), + ) + .await; +} + +fn to_device_events(sender: &UserId, request: &Value) -> Value { + let body: Value = serde_json::from_str(request["body"].as_str().unwrap()).unwrap(); + let event_type = request["event_type"].as_str().unwrap(); + + let mut events = Vec::new(); + for devices in body["messages"].as_object().into_iter().flatten() { + for content in devices.1.as_object().into_iter().flatten() { + events.push(json!({ + "sender": sender.to_string(), + "type": event_type, + "content": content.1, + })); + } + } + Value::Array(events) +} + +async fn drain_to(from: &Peer, to: &Peer) { + let requests = call(from, "outgoingRequests", json!({})).await; + + for request in requests.as_array().unwrap() { + if request["className"] != "ToDeviceRequest" { + continue; + } + let events = to_device_events(&from.user, request); + call( + to, + "receiveSyncChanges", + json!({ "toDeviceEvents": events.to_string() }), + ) + .await; + call( + from, + "markRequestAsSent", + json!({ + "requestId": request["id"], + "requestType": request["type"], + "response": "{}", + }), + ) + .await; + } +} + +fn encryption_settings() -> Value { + json!({ + "algorithm": "m.megolm.v1.aes-sha2", + "historyVisibility": "shared", + "sharingStrategy": "allDevices", + }) +} + +#[tokio::test] +async fn the_encrypt_event_sequence_produces_a_readable_message() { + let alice = peer("@alice:example.org", "ALICEDEV", "alice").await; + let bob = peer("@bob:example.org", "BOBDEV", "bob").await; + + let (alice_keys, _) = publish_keys(&alice).await; + let (bob_keys, bob_otks) = publish_keys(&bob).await; + + learn_about(&alice, &bob, "BOBDEV", &bob_keys).await; + learn_about(&bob, &alice, "ALICEDEV", &alice_keys).await; + + claim_session(&alice, "@bob:example.org", "BOBDEV", &bob_otks).await; + drain_to(&alice, &bob).await; + + let shared = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": ["@bob:example.org"], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + + for request in shared.as_array().unwrap() { + let events = to_device_events(&alice.user, request); + call( + &bob, + "receiveSyncChanges", + json!({ "toDeviceEvents": events.to_string() }), + ) + .await; + call( + &alice, + "markRequestAsSent", + json!({ + "requestId": request["id"], + "requestType": request["type"], + "response": "{}", + }), + ) + .await; + } + drain_to(&alice, &bob).await; + + let encrypted = call( + &alice, + "encryptRoomEvent", + json!({ + "roomId": ROOM, + "eventType": "m.room.message", + "content": json!({ "msgtype": "m.text", "body": "hello over the engine" }).to_string(), + }), + ) + .await; + + let content: Value = serde_json::from_str(encrypted.as_str().unwrap()).unwrap(); + let event = json!({ + "event_id": "$1:example.org", + "type": "m.room.encrypted", + "sender": "@alice:example.org", + "room_id": ROOM, + "origin_server_ts": 0, + "content": content, + }); + + let decrypted = call( + &bob, + "decryptRoomEvent", + json!({ + "event": event.to_string(), + "roomId": ROOM, + "decryptionSettings": { "senderDeviceTrustRequirement": 0 }, + }), + ) + .await; + + let clear: Value = serde_json::from_str(decrypted["event"].as_str().unwrap()).unwrap(); + assert_eq!(clear["content"]["body"], "hello over the engine"); + assert_eq!(decrypted["sender"], "@alice:example.org"); + assert_eq!(decrypted["senderDevice"], "ALICEDEV"); +} + +#[tokio::test] +async fn share_room_key_requests_never_reach_the_outgoing_pump() { + let alice = peer("@alice:example.org", "ALICEDEV", "pump-alice").await; + let bob = peer("@bob:example.org", "BOBDEV", "pump-bob").await; + + let (alice_keys, _) = publish_keys(&alice).await; + let (bob_keys, bob_otks) = publish_keys(&bob).await; + learn_about(&alice, &bob, "BOBDEV", &bob_keys).await; + learn_about(&bob, &alice, "ALICEDEV", &alice_keys).await; + + claim_session(&alice, "@bob:example.org", "BOBDEV", &bob_otks).await; + drain_to(&alice, &bob).await; + + let shared = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": ["@bob:example.org"], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + assert!( + !shared.as_array().unwrap().is_empty(), + "sharing a room key must produce to-device messages" + ); + + let queued = call(&alice, "outgoingRequests", json!({})).await; + let to_device = queued + .as_array() + .unwrap() + .iter() + .filter(|request| request["className"] == "ToDeviceRequest") + .count(); + assert_eq!( + to_device, 0, + "the pump does not carry them; the caller must send what shareRoomKey returned" + ); +} + +#[tokio::test] +async fn share_room_key_accepts_the_settings_the_webview_builds() { + let alice = peer("@alice:example.org", "ALICEDEV", "settings").await; + + let result = dispatch::invoke( + &alice.machine, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": [], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + + assert!(result.is_ok(), "{:?}", result.unwrap_err()); +} diff --git a/src-tauri/src/matrix_crypto/mod.rs b/src-tauri/src/matrix_crypto/mod.rs index 3fd6e1185..e79387150 100644 --- a/src-tauri/src/matrix_crypto/mod.rs +++ b/src-tauri/src/matrix_crypto/mod.rs @@ -9,6 +9,7 @@ pub mod dispatch; pub mod events; #[cfg(target_os = "android")] pub mod jni_push; +pub mod message_flow; pub mod push; pub mod requests; pub mod rooms; @@ -76,6 +77,23 @@ impl CryptoEngineState { .remove(account) .is_some()) } + + pub fn close_account_if(&self, account: &str, machine: &Arc) -> Result<(), String> { + let registered = self + .machines + .lock() + .map_err(|e| e.to_string())? + .get(account) + .cloned(); + + match registered { + Some(current) if Arc::ptr_eq(¤t, machine) => { + self.close_account(account)?; + Ok(()) + } + _ => Ok(()), + } + } } #[derive(Debug, Serialize)] @@ -139,6 +157,8 @@ fn store_dir( Ok(base.join(store_subpath(user_id, device_id))) } +pub(super) static OPEN_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Opens a store and registers its machine, replacing any machine already open for the /// account. Tauri-free so a cold push can open the same store without an `AppHandle`. pub async fn open_machine( @@ -147,6 +167,7 @@ pub async fn open_machine( user_id: &str, device_id: &str, ) -> Result<(Arc, EngineInfo), String> { + let _guard = OPEN_GUARD.lock().await; let user: &matrix_sdk::ruma::UserId = user_id .try_into() .map_err(|e| format!("bad user id: {e}"))?; @@ -203,11 +224,16 @@ pub async fn engine_open( let account = account_key(&user_id, &device_id); let listeners = events::spawn(&app, &machine, account.clone()); - engines() + if let Some(displaced) = engines() .listeners .lock() .map_err(|e| e.to_string())? - .insert(account, listeners); + .insert(account, listeners) + { + for handle in displaced { + handle.abort(); + } + } Ok(info) } diff --git a/src-tauri/src/matrix_crypto/push.rs b/src-tauri/src/matrix_crypto/push.rs index 4e1d68f27..6246c31c9 100644 --- a/src-tauri/src/matrix_crypto/push.rs +++ b/src-tauri/src/matrix_crypto/push.rs @@ -8,15 +8,10 @@ use matrix_sdk::ruma::RoomId; use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; use matrix_sdk_crypto::OlmMachine; use serde_json::Value; -use tokio::sync::Mutex as AsyncMutex; use super::args::decryption_settings; use super::{account_key, engines, open_machine}; -/// Serialises open-if-absent. Two pushes racing here would otherwise build two -/// `OlmMachine`s over one sqlite store, which wedges Olm sessions. -static OPEN_GUARD: AsyncMutex<()> = AsyncMutex::const_new(()); - /// Returns the machine already registered for the account, opening one if the process is /// cold, and reports whether this call is what opened the store. Never evicts a machine /// the webview is using; only the opener may close it again — see [`release_after_push`]. @@ -30,12 +25,6 @@ pub async fn open_machine_for_push( return Ok((machine, false)); } - let _guard = OPEN_GUARD.lock().await; - // Another push may have opened it while we waited for the guard. - if let Ok(machine) = engines().machine(user_id, device_id) { - return Ok((machine, false)); - } - let (machine, _) = open_machine(dir, passphrase, user_id, device_id).await?; Ok((machine, true)) } @@ -83,13 +72,13 @@ fn string_at(value: &Value, path: &[&str]) -> Option { .map(str::to_owned) } -/// Closes the machine this process opened for a push, leaving a webview-owned machine -/// alone. Callers on a cold path should release the store once the notification is shown. -pub fn release_after_push(user_id: &str, device_id: &str, was_cold: bool) -> Result<(), String> { - if was_cold { - engines().close_account(&account_key(user_id, device_id))?; - } - Ok(()) +pub fn release_after_push( + user_id: &str, + device_id: &str, + opened: Option<&Arc>, +) -> Result<(), String> { + let Some(opened) = opened else { return Ok(()) }; + engines().close_account_if(&account_key(user_id, device_id), opened) } /// One-shot headless decrypt: opens the store if the process is cold, decrypts, then @@ -106,9 +95,7 @@ pub async fn decrypt_push( let (machine, was_cold) = open_machine_for_push(dir, passphrase, user_id, device_id).await?; let decrypted = decrypt_push_event(&machine, room_id, event_json).await; - // The registry holds the other reference; drop ours so the close actually frees it. - drop(machine); - release_after_push(user_id, device_id, was_cold)?; + release_after_push(user_id, device_id, was_cold.then_some(&machine))?; decrypted } diff --git a/src-tauri/src/matrix_crypto/verification.rs b/src-tauri/src/matrix_crypto/verification.rs index 4cc29acda..ae58fb586 100644 --- a/src-tauri/src/matrix_crypto/verification.rs +++ b/src-tauri/src/matrix_crypto/verification.rs @@ -449,7 +449,13 @@ pub async fn invoke( Err(e) => Err(e), }, - "verificationRequest.state" => request(machine, args, method).map(|r| request_state(&r)), + "verificationRequest.state" => match flow(args, method) { + Ok((user, flow_id)) => Ok(machine + .get_verification_request(&user, &flow_id) + .map(|request| request_state(&request)) + .unwrap_or(Value::Null)), + Err(e) => Err(e), + }, "verification.state" => { let (user, flow_id) = match flow(args, method) { Ok(flow) => flow, diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index 58371c049..f2c582566 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -93,7 +93,7 @@ type VerificationStartProps = { }; function AutoVerificationStart({ onStart }: VerificationStartProps) { useEffect(() => { - onStart(); + onStart().catch(() => undefined); }, [onStart]); return ( @@ -226,7 +226,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) const handleCancel = useCallback(() => { if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) { - request.cancel(); + request.cancel().catch(() => undefined); } onExit(); }, [request, onExit]); diff --git a/src/app/crypto/engineCrypto/EngineCrypto.ts b/src/app/crypto/engineCrypto/EngineCrypto.ts index b9085321b..6185709c2 100644 --- a/src/app/crypto/engineCrypto/EngineCrypto.ts +++ b/src/app/crypto/engineCrypto/EngineCrypto.ts @@ -11,7 +11,6 @@ import { MatrixEventEvent, MsgType, UserVerificationStatus, - VerificationMethod, } from '$types/matrix-sdk'; import { isVerificationEvent } from 'matrix-js-sdk/lib/rust-crypto/verification'; import { Device, DeviceVerification } from 'matrix-js-sdk/lib/models/device'; @@ -29,10 +28,15 @@ import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'; import { encodeUri } from 'matrix-js-sdk/lib/utils'; import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; import { CryptoEvent, DeviceIsolationModeKind } from 'matrix-js-sdk/lib/crypto-api'; +import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'; +import { DecryptionError } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import type { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api/CryptoEventHandlerMap'; import { createDebugLogger } from '$utils/debugLogger'; import { EngineVerificationRequest } from '../verification/request'; -import { codeFromMethod, type EngineVerificationState } from '../verification/state'; +import { + SUPPORTED_VERIFICATION_METHOD_CODES, + type EngineVerificationState, +} from '../verification/state'; import { engineInvoke, type EngineIdentity } from '../olmMachine/engineInvoke'; import { sendOutgoingRequest, type OutgoingRequest } from './outgoing'; import type { @@ -79,6 +83,50 @@ const engineCryptoLog = createDebugLogger('engine-crypto'); const DECRYPTION_WAIT_MS = 5 * 60 * 1000; +const MAX_OUTGOING_DRAIN_PASSES = 5; + +const RESTORE_CHUNK_SIZE = 200; + +const MAX_BACKUP_UPLOAD_FAILURES = 5; +const MAX_BACKUP_VERSIONS_TO_DELETE = 50; +const BACKUP_RETRY_DELAY_MS = 5000; +const MAX_BACKUP_RETRY_DELAY_MS = 60000; + +const DecryptionErrorCode = { + MissingRoomKey: 0, + UnknownMessageIndex: 1, + UnknownSenderDevice: 3, + UnsignedSenderDevice: 4, + SenderIdentityVerificationViolation: 5, +} as const; + +const WITHHELD_FOR_UNVERIFIED_DEVICE = 'The sender has disabled encrypting to unverified devices.'; + +type EngineDecryptionError = { + className: 'DecryptionError'; + code: number; + description: string; + maybeWithheld?: string | null; +}; + +const isDecryptionError = (value: unknown): value is EngineDecryptionError => + typeof value === 'object' && + value !== null && + 'className' in value && + (value as { className?: string }).className === 'DecryptionError'; + +const ROOM_KEY_BUNDLE_TYPES = new Set(['io.element.msc4268.room_key_bundle', 'm.room_key_bundle']); + +const canonicalJson = (value: unknown): string => { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([a], [b]) => (a < b ? -1 : 1)); + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`; +}; + /** js-sdk keeps this union private to its own rust-crypto module; derived the same way. */ type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent]; @@ -91,15 +139,6 @@ const SECRETS_IN_STORAGE = [ 'm.cross_signing.user_signing', ] as const satisfies readonly SecretStorageKey[]; -const SUPPORTED_VERIFICATION_METHOD_CODES = [ - VerificationMethod.Sas, - VerificationMethod.ScanQrCode, - VerificationMethod.ShowQrCode, - VerificationMethod.Reciprocate, -] - .map(codeFromMethod) - .filter((code): code is number => code !== undefined); - type EngineDevice = { userId: string; deviceId: string; @@ -109,6 +148,8 @@ type EngineDevice = { isCrossSigningTrusted: boolean; isCrossSignedByOwner: boolean; isLocallyTrusted: boolean; + isVerified: boolean; + isBlacklisted: boolean; isDehydrated: boolean; }; @@ -185,6 +226,8 @@ type EngineDecryptedEvent = { senderCurve25519Key?: string | null; senderClaimedEd25519Key?: string | null; forwardingCurve25519KeyChain?: string[]; + forwarder?: string | null; + forwarderDevice?: string | null; }; const isOutgoingRequest = (value: unknown): value is OutgoingRequest => { @@ -235,14 +278,23 @@ type EngineIdentityInfo = { userSigningKey?: unknown; }; +const deviceVerification = (device: EngineDevice): DeviceVerification => { + if (device.isBlacklisted) return DeviceVerification.Blocked; + return device.isVerified ? DeviceVerification.Verified : DeviceVerification.Unverified; +}; + +const ENCRYPTION_ALGORITHMS = ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2']; + const toSdkDevice = (device: EngineDevice): Device => new Device({ userId: device.userId, deviceId: device.deviceId, displayName: device.displayName ?? undefined, - algorithms: [], + algorithms: ENCRYPTION_ALGORITHMS.filter((_, index) => + device.algorithms?.includes(index) + ) as string[], keys: new Map(Object.entries(device.keys)), - verified: device.isLocallyTrusted ? DeviceVerification.Verified : DeviceVerification.Unverified, + verified: deviceVerification(device), signatures: new Map(), dehydrated: device.isDehydrated, }); @@ -270,8 +322,12 @@ export class EngineCrypto #flushing: Promise = Promise.resolve(); + readonly #roomsWithTrackedMembers = new Set(); + #backingUp: Promise = Promise.resolve(); + #checkingKeyBackup: Promise = Promise.resolve(null); + readonly #eventsPendingKey = new Map>(); constructor(mx: MatrixClient, identity: EngineIdentity) { @@ -286,11 +342,8 @@ export class EngineCrypto async #connectKeyBackup(): Promise { try { await this.checkKeyBackupAndEnable(); - const enabled = (await this.getActiveSessionBackupVersion()) !== null; - this.emit(CryptoEvent.KeyBackupStatus, enabled); - if (enabled) this.#scheduleKeyBackup(); - } catch { - // Backup is optional; failing here must not break the session. + } catch (error) { + engineCryptoLog.warn('general', 'Could not connect the key backup', error); } } @@ -298,8 +351,6 @@ export class EngineCrypto this.#backingUp = this.#backingUp.then(() => this.#uploadRoomKeysToBackup().catch((error: unknown) => { engineCryptoLog.error('general', 'Uploading room keys to backup failed', error); - const errcode = (error as { data?: { errcode?: string } }).data?.errcode; - if (errcode) this.emit(CryptoEvent.KeyBackupFailed, errcode); }) ); } @@ -308,20 +359,29 @@ export class EngineCrypto if (this.#stopped) return; if (!(await this.#call('isBackupEnabled'))) return; - for (;;) { + for (let failures = 0; failures < MAX_BACKUP_UPLOAD_FAILURES;) { if (this.#stopped) return; // eslint-disable-next-line no-await-in-loop const request = (await this.#call('backupRoomKeys')) as OutgoingRequest | null; if (!request) break; - // eslint-disable-next-line no-await-in-loop - const response = await sendOutgoingRequest(this.#mx, request); - // eslint-disable-next-line no-await-in-loop - await this.#call('markRequestAsSent', { - requestId: request.id, - requestType: request.type, - response, - }); + try { + // eslint-disable-next-line no-await-in-loop + const response = await sendOutgoingRequest(this.#mx, request); + // eslint-disable-next-line no-await-in-loop + await this.#call('markRequestAsSent', { + requestId: request.id, + requestType: request.type, + response, + }); + failures = 0; + } catch (error) { + failures += 1; + // eslint-disable-next-line no-await-in-loop + if (!(await this.#recoverFromBackupUploadError(error))) return; + // eslint-disable-next-line no-await-in-loop + continue; + } // eslint-disable-next-line no-await-in-loop const counts = (await this.#call('roomKeyCounts')) as { total: number; backedUp: number }; @@ -331,17 +391,39 @@ export class EngineCrypto this.emit(CryptoEvent.KeyBackupSessionsRemaining, 0); } + async #recoverFromBackupUploadError(error: unknown): Promise { + const failure = error as { data?: { errcode?: string; retry_after_ms?: number } }; + const errcode = failure.data?.errcode; + + if (errcode === 'M_WRONG_ROOM_KEYS_VERSION' || errcode === 'M_NOT_FOUND') { + this.emit(CryptoEvent.KeyBackupFailed, errcode); + await this.#disableKeyBackup(); + await this.#connectKeyBackup(); + return false; + } + + if (errcode === 'M_LIMIT_EXCEEDED') { + const wait = failure.data?.retry_after_ms ?? BACKUP_RETRY_DELAY_MS; + await new Promise((resolve) => { + setTimeout(resolve, Math.min(wait, MAX_BACKUP_RETRY_DELAY_MS)); + }); + return true; + } + + if (errcode) this.emit(CryptoEvent.KeyBackupFailed, errcode); + return false; + } + onUserIdentityUpdated(userId: string): void { - this.emit( - CryptoEvent.UserTrustStatusChanged, - userId, - new UserVerificationStatus(false, false, true) - ); + void this.getUserVerificationStatus(userId) + .then((status) => this.emit(CryptoEvent.UserTrustStatusChanged, userId, status)) + .catch(() => undefined); // Our own identity becoming trusted can make a backup we rejected trustworthy. if (userId === this.#identity.userId) void this.#connectKeyBackup(); } onDevicesUpdated(userIds: string[]): void { + this.emit(CryptoEvent.WillUpdateDevices, userIds, false); this.emit(CryptoEvent.DevicesUpdated, userIds, false); } @@ -360,6 +442,7 @@ export class EngineCrypto } #retryEventsPendingKey({ roomId, sessionId }: EngineRoomKeyInfo): void { + if (this.#stopped) return; const pending = this.#eventsPendingKey.get(`${roomId}|${sessionId}`); if (!pending) return; this.#eventsPendingKey.delete(`${roomId}|${sessionId}`); @@ -369,6 +452,21 @@ export class EngineCrypto }); } + #dropEventPendingKey(event: MatrixEvent): void { + const key = this.#pendingKeyFor(event); + if (!key) return; + const pending = this.#eventsPendingKey.get(key); + if (!pending) return; + pending.delete(event); + if (pending.size === 0) this.#eventsPendingKey.delete(key); + } + + #pendingKeyFor(event: MatrixEvent): string | undefined { + const roomId = event.getRoomId(); + const sessionId = (event.getWireContent() as { session_id?: string }).session_id; + return roomId && sessionId ? `${roomId}|${sessionId}` : undefined; + } + #holdEventPendingKey(event: MatrixEvent): void { const roomId = event.getRoomId(); const sessionId = (event.getWireContent() as { session_id?: string }).session_id; @@ -391,7 +489,7 @@ export class EngineCrypto changedDevices: input.deviceLists?.changed ?? [], leftDevices: input.deviceLists?.left ?? [], oneTimeKeysCounts: input.oneTimeKeysCounts ?? {}, - unusedFallbackKeys: input.unusedFallbackKeys ?? null, + ...(input.unusedFallbackKeys ? { unusedFallbackKeys: input.unusedFallbackKeys } : {}), })) as EngineProcessedToDeviceEvent[] | null; void this.#flushOutgoingRequests(); @@ -524,6 +622,14 @@ export class EngineCrypto onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { const roomId = event.getRoomId(); if (!roomId) return; + + if ( + member.membership === KnownMembership.Join || + member.membership === KnownMembership.Invite + ) { + void this.#trackUsers([member.userId]); + } + if ( oldMembership === KnownMembership.Join && member.membership !== KnownMembership.Join && @@ -533,9 +639,19 @@ export class EngineCrypto } } + async #trackUsers(users: string[]): Promise { + if (users.length === 0) return; + try { + await this.#call('updateTrackedUsers', { users }); + } catch (error) { + engineCryptoLog.warn('general', 'Could not track device lists for users', error); + } + } + async #sendTracked(request: unknown): Promise { if (!isOutgoingRequest(request)) return; const response = await sendOutgoingRequest(this.#mx, request); + if (typeof request.id !== 'string') return; await this.#call('markRequestAsSent', { requestId: request.id, requestType: request.type, @@ -572,11 +688,19 @@ export class EngineCrypto /** matrix-sdk-crypto only clears a request once told it was sent, so a failure here * leaves it queued for the next drain rather than losing it. */ async #drainOutgoingRequests(): Promise { - if (this.#stopped) return; + for (let pass = 0; pass < MAX_OUTGOING_DRAIN_PASSES; pass += 1) { + // eslint-disable-next-line no-await-in-loop + if (!(await this.#drainOutgoingRequestsOnce())) return; + } + } + + async #drainOutgoingRequestsOnce(): Promise { + if (this.#stopped) return false; const requests = ((await this.#call('outgoingRequests')) ?? []) as OutgoingRequest[]; + let sent = 0; for (const request of requests) { - if (this.#stopped) return; + if (this.#stopped) return false; try { // Sequential: the engine's queue is ordered and later requests can depend on // earlier ones having landed. @@ -588,11 +712,14 @@ export class EngineCrypto requestType: request.type, response, }); + sent += 1; } catch (error) { // Loud: a request the engine never marks sent is retried on every sync forever. engineCryptoLog.error('general', `Outgoing crypto request ${request.id} failed`, error); } } + + return sent > 0; } async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise { @@ -631,6 +758,10 @@ export class EngineCrypto } } + if (ROOM_KEY_BUNDLE_TYPES.has(message.type) && message.sender) { + void this.#acceptArrivedKeyBundle(message.sender); + } + if (event.type === ProcessedToDeviceEventType.Decrypted && event.encryptionInfo) { received.push({ message, @@ -671,14 +802,21 @@ export class EngineCrypto return; } - await this.#call('setRoomSettings', { - roomId: room.roomId, - settings: { - algorithm: config.algorithm, - sessionRotationPeriodMs: config.rotation_period_ms, - sessionRotationPeriodMessages: config.rotation_period_msgs, - }, - }); + try { + await this.#call('setRoomSettings', { + roomId: room.roomId, + settings: { + algorithm: config.algorithm, + sessionRotationPeriodMs: config.rotation_period_ms, + sessionRotationPeriodMessages: config.rotation_period_msgs, + }, + }); + } catch (error) { + engineCryptoLog.warn('general', 'Could not update room encryption settings', { + roomId: room.roomId, + error, + }); + } } onSyncCompleted(syncState: OnSyncCompletedData): void { @@ -693,12 +831,14 @@ export class EngineCrypto stop(): void { this.#stopped = true; + this.#eventsPendingKey.clear(); + this.#roomsWithTrackedMembers.clear(); } #trustRequirement(): number { return this.#deviceIsolationMode?.kind === DeviceIsolationModeKind.OnlySignedDevicesIsolationMode - ? TrustRequirement.CrossSigned + ? TrustRequirement.CrossSignedOrLegacy : TrustRequirement.Untrusted; } @@ -708,7 +848,9 @@ export class EngineCrypto ) { return 'identityBasedStrategy'; } - if (room.getBlacklistUnverifiedDevices()) return 'onlyTrustedDevices'; + if (room.getBlacklistUnverifiedDevices() ?? this.globalBlacklistUnverifiedDevices) { + return 'onlyTrustedDevices'; + } if (this.#deviceIsolationMode?.errorOnVerifiedUserProblems) { return 'errorOnVerifiedUserProblem'; } @@ -736,13 +878,27 @@ export class EngineCrypto async encryptEvent(event: MatrixEvent, room: Room): Promise { // The megolm session has to reach every device in the room before the event does. const members = await room.getEncryptionTargetMembers(); - await this.#call('getMissingSessions', { users: members.map((member) => member.userId) }); + const users = members.map((member) => member.userId); + + if (!this.#roomsWithTrackedMembers.has(room.roomId)) { + await this.#trackUsers(users); + await this.#flushOutgoingRequests(); + this.#roomsWithTrackedMembers.add(room.roomId); + } + + const claim = (await this.#call('getMissingSessions', { users })) as OutgoingRequest | null; + await this.#sendTracked(claim); await this.#flushOutgoingRequests(); - await this.#call('shareRoomKey', { + + const shared = ((await this.#call('shareRoomKey', { roomId: room.roomId, - users: members.map((m) => m.userId), + users, encryptionSettings: this.#encryptionSettings(room), - }); + })) ?? []) as OutgoingRequest[]; + for (const request of shared) { + // eslint-disable-next-line no-await-in-loop + await this.#sendTracked(request); + } await this.#flushOutgoingRequests(); const encrypted = (await this.#call('encryptRoomEvent', { @@ -751,11 +907,12 @@ export class EngineCrypto content: JSON.stringify(event.getContent()), })) as string; + const own = await this.getOwnDeviceKeys(); event.makeEncrypted( 'm.room.encrypted', JSON.parse(encrypted) as Record, - '', - '' + own.curve25519, + own.ed25519 ); } @@ -763,33 +920,130 @@ export class EngineCrypto const roomId = event.getRoomId(); if (!roomId) throw new Error('Cannot decrypt an event with no room id'); - let decrypted: EngineDecryptedEvent; - try { - decrypted = (await this.#call('decryptRoomEvent', { - event: JSON.stringify({ - event_id: event.getId(), - type: event.getWireType(), - sender: event.getSender(), - room_id: roomId, - origin_server_ts: event.getTs(), - content: event.getWireContent(), - }), - roomId, - decryptionSettings: { senderDeviceTrustRequirement: this.#trustRequirement() }, - })) as EngineDecryptedEvent; - } catch (error) { - this.#holdEventPendingKey(event); - throw error; - } + this.#holdEventPendingKey(event); + + const result = (await this.#call('decryptRoomEvent', { + event: JSON.stringify({ + event_id: event.getId(), + type: event.getWireType(), + sender: event.getSender(), + room_id: roomId, + origin_server_ts: event.getTs(), + content: event.getWireContent(), + }), + roomId, + decryptionSettings: { senderDeviceTrustRequirement: this.#trustRequirement() }, + })) as unknown; + + if (isDecryptionError(result)) await this.#throwDecryptionError(event, result); + const decrypted = result as EngineDecryptedEvent; + + this.#dropEventPendingKey(event); return { clearEvent: JSON.parse(decrypted.event) as EventDecryptionResult['clearEvent'], senderCurve25519Key: decrypted.senderCurve25519Key ?? undefined, claimedEd25519Key: decrypted.senderClaimedEd25519Key ?? undefined, forwardingCurve25519KeyChain: decrypted.forwardingCurve25519KeyChain ?? [], + ...(decrypted.forwarder ? { keyForwardedBy: decrypted.forwarder } : {}), }; } + async #throwDecryptionError(event: MatrixEvent, error: EngineDecryptionError): Promise { + const content = event.getWireContent() as { sender_key?: string; session_id?: string }; + const details: Record = {}; + if (content.sender_key) details.sender_key = content.sender_key; + if (content.session_id) details.session_id = content.session_id; + + const recoverable = + error.code === DecryptionErrorCode.MissingRoomKey || + error.code === DecryptionErrorCode.UnknownMessageIndex; + + if (recoverable) { + const membership = event.getMembershipAtEvent(); + if ( + membership && + membership !== KnownMembership.Join && + membership !== KnownMembership.Invite + ) { + throw new DecryptionError( + DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED, + 'This message was sent when we were not a member of the room.', + details + ); + } + await this.#throwIfHistorical(event, details); + } + + if (error.maybeWithheld) { + throw new DecryptionError( + error.maybeWithheld === WITHHELD_FOR_UNVERIFIED_DEVICE + ? DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE + : DecryptionFailureCode.MEGOLM_KEY_WITHHELD, + error.maybeWithheld, + details + ); + } + + switch (error.code) { + case DecryptionErrorCode.MissingRoomKey: + throw new DecryptionError( + DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, + "The sender's device has not sent us the keys for this message.", + details + ); + case DecryptionErrorCode.UnknownMessageIndex: + throw new DecryptionError( + DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX, + "The sender's device has not sent us the keys for this message at this index.", + details + ); + case DecryptionErrorCode.SenderIdentityVerificationViolation: + this.#dropEventPendingKey(event); + throw new DecryptionError( + DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, + 'The sender identity is unverified, but was previously verified.' + ); + case DecryptionErrorCode.UnknownSenderDevice: + this.#dropEventPendingKey(event); + throw new DecryptionError( + DecryptionFailureCode.UNKNOWN_SENDER_DEVICE, + 'The sender device is not known.' + ); + case DecryptionErrorCode.UnsignedSenderDevice: + this.#dropEventPendingKey(event); + throw new DecryptionError( + DecryptionFailureCode.UNSIGNED_SENDER_DEVICE, + 'The sender identity is not cross-signed.' + ); + default: + throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, error.description, details); + } + } + + async #throwIfHistorical(event: MatrixEvent, details: Record): Promise { + const createdAt = (await this.#call('deviceCreationTimeMs')) as number | null; + if (createdAt === null || event.getTs() > createdAt) return; + + const backupInfo = await this.getKeyBackupInfo().catch(() => null); + if (!backupInfo) { + throw new DecryptionError( + DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP, + 'This message was sent before this device logged in, and there is no key backup on the server.', + details + ); + } + + const usable = (await this.getSessionBackupPrivateKey()) !== null; + throw new DecryptionError( + usable + ? DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP + : DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + 'This message was sent before this device logged in.', + details + ); + } + /** Stateless: needs the backup key, not the crypto store, so it stays in-process. */ async getBackupDecryptor( backupInfo: KeyBackupInfo, @@ -808,17 +1062,27 @@ export class EngineCrypto return { sourceTrusted: false, async decryptSessions(ciphertexts) { - return Object.entries(ciphertexts).map(([sessionId, session]) => { - const decrypted = JSON.parse( - key.decryptV1( - session.session_data.ephemeral, - session.session_data.mac, - session.session_data.ciphertext - ) - ) as IMegolmSessionData; - decrypted.session_id = sessionId; - return decrypted; - }); + const decrypted: IMegolmSessionData[] = []; + for (const [sessionId, session] of Object.entries(ciphertexts)) { + try { + const data = JSON.parse( + key.decryptV1( + session.session_data.ephemeral, + session.session_data.mac, + session.session_data.ciphertext + ) + ) as IMegolmSessionData; + data.session_id = sessionId; + decrypted.push(data); + } catch (error) { + engineCryptoLog.warn( + 'general', + `Could not decrypt backed up session ${sessionId}`, + error + ); + } + } + return decrypted; }, free() { key.free(); @@ -831,23 +1095,44 @@ export class EngineCrypto backupVersion: string, opts?: ImportRoomKeysOpts ): Promise { + await this.#importBackedUpRoomKeys(keys, backupVersion, opts); + } + + async #importBackedUpRoomKeys( + keys: IMegolmSessionData[], + backupVersion: string, + opts?: ImportRoomKeysOpts, + already = 0, + grandTotal = keys.length + ): Promise { const result = (await this.#call('importBackedUpRoomKeys', { keys: JSON.stringify(keys), backupVersion, })) as { importedCount?: number; totalCount?: number } | null; - const total = result?.totalCount ?? keys.length; - const successes = result?.importedCount ?? 0; + const successes = already + (result?.importedCount ?? 0); opts?.progressCallback?.({ stage: ImportRoomKeyStage.LoadKeys, successes, - failures: total - successes, - total, + failures: grandTotal - successes, + total: grandTotal, }); + return result?.importedCount ?? 0; } /** MSC4268. The engine encrypts; we upload; only the mxc URL goes back. */ + async #downloadAllRoomKeys(roomId: string): Promise { + if ((await this.#call('hasDownloadedAllRoomKeys', { roomId })) === true) return; + try { + await this.restoreKeyBackup(); + await this.#call('setHasDownloadedAllRoomKeys', { roomId }); + } catch (error) { + engineCryptoLog.warn('general', 'Could not download room keys before sharing', error); + } + } + async shareRoomHistoryWithUser(roomId: string, userId: string): Promise { + await this.#downloadAllRoomKeys(roomId); const own = await this.getUserVerificationStatus(this.#identity.userId); if (!own.isCrossSigningVerified()) { engineCryptoLog.warn( @@ -867,23 +1152,31 @@ export class EngineCrypto { includeFilename: false } ); - await this.#call('queryKeysForUsers', { users: [userId] }); + await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [userId] })); await this.#flushOutgoingRequests(); - await this.#call('getMissingSessions', { users: [userId] }); + await this.#sendTracked(await this.#call('getMissingSessions', { users: [userId] })); await this.#flushOutgoingRequests(); - await this.#call('shareRoomKeyBundleData', { + const requests = ((await this.#call('shareRoomKeyBundleData', { userId, roomId, url, mediaEncryptionInfo: bundle.mediaEncryptionInfo, sharingStrategy: 'identityBasedStrategy', - }); + })) ?? []) as OutgoingRequest[]; + for (const request of requests) { + // eslint-disable-next-line no-await-in-loop + await this.#sendTracked(request); + } await this.#flushOutgoingRequests(); } /** MSC4268. The engine stores the bundle metadata; we fetch the media it points at. */ async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise { + await this.#trackUsers([inviter]); + await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [inviter] })); + await this.#flushOutgoingRequests(); + const data = (await this.#call('getReceivedRoomKeyBundleData', { roomId, inviterId: inviter, @@ -919,6 +1212,22 @@ export class EngineCrypto return true; } + async #acceptArrivedKeyBundle(inviter: string): Promise { + try { + const pending = ((await this.#call('getAllRoomsPendingKeyBundles')) ?? []) as { + roomId: string; + inviterId: string; + }[]; + + for (const room of pending.filter((entry) => entry.inviterId === inviter)) { + // eslint-disable-next-line no-await-in-loop + await this.maybeAcceptKeyBundle(room.roomId, inviter); + } + } catch (error) { + engineCryptoLog.warn('general', 'Could not accept an arrived room key bundle', error); + } + } + async markRoomAsPendingKeyBundle(roomId: string, inviterId: string): Promise { await this.#call('storeRoomPendingKeyBundle', { roomId, inviterId }); } @@ -950,10 +1259,12 @@ export class EngineCrypto prepareToEncrypt(room: Room): void { void room .getEncryptionTargetMembers() - .then((members) => - this.#call('getMissingSessions', { users: members.map((member) => member.userId) }) - ) - .then(() => this.#flushOutgoingRequests()) + .then(async (members) => { + const users = members.map((member) => member.userId); + await this.#trackUsers(users); + await this.#sendTracked(await this.#call('getMissingSessions', { users })); + await this.#flushOutgoingRequests(); + }) .catch((error: unknown) => engineCryptoLog.warn('general', 'prepareToEncrypt failed', error)); } @@ -990,33 +1301,32 @@ export class EngineCrypto devices: { userId: string; deviceId: string }[], payload: ToDevicePayload ): Promise { - const batch = await Promise.all( - devices.map(async ({ userId, deviceId }) => ({ - userId, - deviceId, - payload: JSON.parse( - (await this.#call('device.encryptToDeviceEvent', { - userId, - deviceId, - eventType, - content: JSON.stringify(payload), - })) as string - ) as ToDevicePayload, - })) + const users = [...new Set(devices.map(({ userId }) => userId))]; + await this.#sendTracked(await this.#call('getMissingSessions', { users })); + await this.#flushOutgoingRequests(); + + const encrypted = await Promise.all( + devices.map(async ({ userId, deviceId }) => { + const content = (await this.#call('device.encryptToDeviceEvent', { + userId, + deviceId, + eventType, + content: payload, + })) as string | null; + if (!content) return null; + return { userId, deviceId, payload: JSON.parse(content) as ToDevicePayload }; + }) ); - return { eventType: EventType.RoomMessageEncrypted, batch }; + return { + eventType: EventType.RoomMessageEncrypted, + batch: encrypted.filter((entry) => entry !== null), + }; } - /** The outgoing-request queue has no interactive-auth path, so a server that challenges - * the signing-key upload will reject it. */ async resetEncryption(authUploadDeviceSigningKeys: UIAuthCallback): Promise { - engineCryptoLog.info('general', 'Resetting encryption', { - interactiveAuthAvailable: typeof authUploadDeviceSigningKeys === 'function', - }); await this.disableKeyStorage(); - await this.#call('bootstrapCrossSigning', { reset: true }); - await this.#flushOutgoingRequests(); + await this.#resetCrossSigning(authUploadDeviceSigningKeys); await this.resetKeyBackup(); } @@ -1047,21 +1357,26 @@ export class EngineCrypto userId: string = this.#identity.userId, downloadUncached = false ): Promise { - if (downloadUncached) await this.#call('queryKeysForUsers', { users: [userId] }); + if (downloadUncached) { + await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [userId] })); + } const identity = (await this.#call('getIdentity', { userId })) as EngineIdentityInfo | null; return identity !== null; } async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise { - if (downloadUncached) await this.#call('queryKeysForUsers', { users: userIds }); + if (downloadUncached) { + await this.#sendTracked(await this.#call('queryKeysForUsers', { users: userIds })); + } const map: DeviceMap = new Map(); await Promise.all( userIds.map(async (userId) => { - const devices = ((await this.#call('getUserDevices', { + const answer = (await this.#call('getUserDevices', { userId, timeoutSecs: null, - })) ?? []) as EngineDevice[]; + })) as { devices?: EngineDevice[] } | null; + const devices = answer?.devices ?? []; map.set(userId, new Map(devices.map((device) => [device.deviceId, toSdkDevice(device)]))); }) @@ -1136,7 +1451,9 @@ export class EngineCrypto } async crossSignDevice(deviceId: string): Promise { - await this.#call('device.verify', { userId: this.#identity.userId, deviceId }); + await this.#sendTracked( + await this.#call('device.verify', { userId: this.#identity.userId, deviceId }) + ); await this.#flushOutgoingRequests(); } @@ -1382,7 +1699,7 @@ export class EngineCrypto getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { return [...this.#verificationRequests.values()].filter( - (request) => request.otherUserId === userId && request.roomId === undefined + (request) => request.otherUserId === userId && request.roomId === undefined && request.pending ); } @@ -1400,11 +1717,14 @@ export class EngineCrypto /** The engine needs the event id of the request we send, so build, send, then register. */ async requestVerificationDM(userId: string, roomId: string): Promise { - const content = (await this.#call('userIdentity.verificationRequestContent', { + const requestContent = (await this.#call('userIdentity.verificationRequestContent', { userId, roomId, methods: SUPPORTED_VERIFICATION_METHOD_CODES, - })) as string; + })) as { outgoingRequest?: { body?: string } } | null; + + const content = requestContent?.outgoingRequest?.body; + if (!content) throw new Error('The engine produced no verification request content'); const { event_id: eventId } = await this.#mx.sendEvent( roomId, @@ -1514,10 +1834,7 @@ export class EngineCrypto } async getActiveSessionBackupVersion(): Promise { - const enabled = (await this.#call('isBackupEnabled')) as boolean; - if (!enabled) return null; - const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; - return keys?.backupVersion ?? null; + return (await this.#call('backupVersion')) as string | null; } /** The engine reports signature trust only; whether our key opens it is separate. */ @@ -1556,22 +1873,68 @@ export class EngineCrypto } } + async #getKeyBackupInfoForVersion(version: string): Promise { + try { + return await this.#mx.http.authedRequest( + Method.Get, + encodeUri('/room_keys/version/$version', { $version: version }), + undefined, + undefined, + { prefix: ClientPrefix.V3 } + ); + } catch (error) { + if ((error as { errcode?: string }).errcode === 'M_NOT_FOUND') return null; + throw error; + } + } + async checkKeyBackupAndEnable(): Promise { + this.#checkingKeyBackup = this.#checkingKeyBackup + .catch(() => null) + .then(() => this.#checkKeyBackupAndEnable()); + return this.#checkingKeyBackup; + } + + async #checkKeyBackupAndEnable(): Promise { const backupInfo = await this.getKeyBackupInfo(); - if (!backupInfo?.version) return null; + const activeVersion = await this.getActiveSessionBackupVersion(); + + if (!backupInfo?.version) { + if (activeVersion !== null) await this.#disableKeyBackup(); + return null; + } const trustInfo = await this.isKeyBackupTrusted(backupInfo); - const authData = backupInfo.auth_data as { public_key?: string } | undefined; - if (trustInfo.trusted && authData?.public_key) { - await this.#call('enableBackupV1', { - publicKeyBase64: authData.public_key, - version: backupInfo.version, - }); + const publicKey = (backupInfo.auth_data as { public_key?: string } | undefined)?.public_key; + + if (!publicKey || (!trustInfo.trusted && !trustInfo.matchesDecryptionKey)) { + if (activeVersion !== null) await this.#disableKeyBackup(); + return { backupInfo, trustInfo }; + } + + if (activeVersion !== backupInfo.version) { + if (activeVersion !== null) await this.#disableKeyBackup(); + await this.#enableKeyBackup(backupInfo.version, publicKey); + } else { + this.#scheduleKeyBackup(); } return { backupInfo, trustInfo }; } + async #enableKeyBackup(version: string, publicKeyBase64: string): Promise { + await this.#call('enableBackupV1', { publicKeyBase64, version }); + this.emit(CryptoEvent.KeyBackupStatus, true); + this.#scheduleKeyBackup(); + } + + async #disableKeyBackup(): Promise { + await this.#call('disableBackup'); + this.emit(CryptoEvent.KeyBackupStatus, false); + } + async resetKeyBackup(): Promise { + await this.#deleteAllKeyBackupVersions(); + const key = await this.createRecoveryKeyFromPassphrase(); const decryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64( encodeBase64(key.privateKey) @@ -1583,13 +1946,17 @@ export class EngineCrypto decryptionKey.free(); } + const authData: Record = { public_key: publicKey }; + const signatures = await this.#signatureFor(authData); + if (signatures) authData.signatures = signatures; + const created = await this.#mx.http.authedRequest<{ version: string }>( Method.Post, '/room_keys/version', undefined, { algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', - auth_data: { public_key: publicKey }, + auth_data: authData, }, { prefix: ClientPrefix.V3 } ); @@ -1600,20 +1967,58 @@ export class EngineCrypto await this.#mx.secretStorage.store('m.megolm_backup.v1', encodeBase64(key.privateKey)); } + async #signatureFor(value: Record): Promise | null> { + const signed = (await this.#call('sign', { message: canonicalJson(value) })) as { + json?: string; + } | null; + if (!signed?.json) return null; + return JSON.parse(signed.json) as Record; + } + async #pushSecretToVerifiedDevices(secretName: string): Promise { - await this.#call('getMissingSessions', { users: [this.#identity.userId] }); + await this.#sendTracked( + await this.#call('getMissingSessions', { users: [this.#identity.userId] }) + ); await this.#flushOutgoingRequests(); await this.#call('pushSecretToVerifiedDevices', { secretName }); await this.#flushOutgoingRequests(); } async disableKeyStorage(): Promise { - const backupInfo = await this.getKeyBackupInfo(); - if (backupInfo?.version) await this.deleteKeyBackupVersion(backupInfo.version); - else await this.#call('disableBackup'); + await this.#deleteAllKeyBackupVersions(); + await this.#disableKeyBackup(); + await this.#deleteSecretStorage(); + } + + async #deleteAllKeyBackupVersions(): Promise { + const seen = new Set(); + + for (let attempt = 0; attempt < MAX_BACKUP_VERSIONS_TO_DELETE; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + const info = await this.getKeyBackupInfo(); + if (!info?.version || seen.has(info.version)) return; + seen.add(info.version); + // eslint-disable-next-line no-await-in-loop + await this.deleteKeyBackupVersion(info.version); + } + } + + async #deleteSecretStorage(): Promise { + const secrets: SecretStorageKey[] = [...SECRETS_IN_STORAGE, 'm.megolm_backup.v1']; + await Promise.all(secrets.map((name) => this.#mx.secretStorage.store(name, null))); + + const defaultKeyId = await this.#mx.secretStorage.getDefaultKeyId(); + if (defaultKeyId) { + await this.#mx.secretStorage.store( + `m.secret_storage.key.${defaultKeyId}` as SecretStorageKey, + null + ); + } + await this.#mx.secretStorage.setDefaultKeyId(null); } async deleteKeyBackupVersion(version: string): Promise { + const active = await this.getActiveSessionBackupVersion(); await this.#mx.http.authedRequest( Method.Delete, encodeUri('/room_keys/version/$version', { $version: version }), @@ -1621,18 +2026,18 @@ export class EngineCrypto undefined, { prefix: ClientPrefix.V3 } ); - await this.#call('disableBackup'); + if (active === version) await this.#disableKeyBackup(); } async restoreKeyBackup(opts?: KeyBackupRestoreOpts): Promise { const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; if (!keys?.decryptionKeyBase64 || !keys.backupVersion) { - throw new Error('No backup decryption key found in the crypto store'); + throw new Error('No decryption key found in crypto store'); } - const backupInfo = await this.getKeyBackupInfo(); - if (backupInfo?.version !== keys.backupVersion) { - throw new Error(`Backup version ${keys.backupVersion} is not the one on the server`); + const backupInfo = await this.#getKeyBackupInfoForVersion(keys.backupVersion); + if (!backupInfo) { + throw new Error(`Backup version ${keys.backupVersion} is not on the server`); } opts?.progressCallback?.({ stage: ImportRoomKeyStage.Fetch }); @@ -1648,28 +2053,31 @@ export class EngineCrypto prefix: ClientPrefix.V3, }); - const sessions: KeyBackupSession[] = []; - const sessionIds: string[] = []; - for (const [roomId, room] of Object.entries(response.rooms ?? {})) { - for (const [sessionId, session] of Object.entries(room.sessions ?? {})) { - sessionIds.push(sessionId); - sessions.push({ ...session, room_id: roomId } as KeyBackupSession); + const rooms = Object.entries(response.rooms ?? {}); + const total = rooms.reduce( + (count, [, room]) => count + Object.keys(room.sessions ?? {}).length, + 0 + ); + + let imported = 0; + for (const [roomId, room] of rooms) { + // eslint-disable-next-line no-await-in-loop + const decrypted = await decryptor.decryptSessions(room.sessions ?? {}); + const withRoom = decrypted.map((session) => ({ ...session, room_id: roomId })); + + for (let start = 0; start < withRoom.length; start += RESTORE_CHUNK_SIZE) { + // eslint-disable-next-line no-await-in-loop + imported += await this.#importBackedUpRoomKeys( + withRoom.slice(start, start + RESTORE_CHUNK_SIZE), + keys.backupVersion, + opts, + imported, + total + ); } } - const ciphertexts: Record = {}; - sessionIds.forEach((sessionId, index) => { - const session = sessions[index]; - if (session) ciphertexts[sessionId] = session; - }); - const decrypted = await decryptor.decryptSessions(ciphertexts); - const withRooms = decrypted.map((session, index) => ({ - ...session, - room_id: (sessions[index] as unknown as { room_id: string }).room_id, - })); - - await this.importBackedUpRoomKeys(withRooms, keys.backupVersion, opts); - return { total: withRooms.length, imported: withRooms.length }; + return { total, imported }; } finally { decryptor.free(); } diff --git a/src/app/crypto/engineCrypto/backupUpload.test.ts b/src/app/crypto/engineCrypto/backupUpload.test.ts index f30978f7c..2700aa499 100644 --- a/src/app/crypto/engineCrypto/backupUpload.test.ts +++ b/src/app/crypto/engineCrypto/backupUpload.test.ts @@ -47,6 +47,7 @@ describe('key backup upload', () => { if (method === 'verifyBackup') return { trusted: true }; if (method === 'getBackupKeys') return { backupVersion: '7', decryptionKeyBase64: null }; if (method === 'isBackupEnabled') return true; + if (method === 'backupVersion') return '7'; if (method === 'backupRoomKeys') { if (pending === 0) return null; pending -= 1; @@ -72,6 +73,7 @@ describe('key backup upload', () => { if (method === 'verifyBackup') return { trusted: false }; if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; if (method === 'isBackupEnabled') return false; + if (method === 'backupVersion') return null; return null; }); @@ -88,6 +90,7 @@ describe('key backup upload', () => { if (method === 'verifyBackup') return { trusted: false }; if (method === 'getBackupKeys') return { backupVersion: '7', decryptionKeyBase64: null }; if (method === 'isBackupEnabled') return true; + if (method === 'backupVersion') return '7'; if (method === 'backupRoomKeys') { if (pending === 0) return null; pending -= 1; @@ -114,6 +117,7 @@ describe('resetKeyBackup', () => { it('gossips the new backup key to our other verified devices', async () => { mockInvoke.mockImplementation(async (_identity, method) => { if (method === 'isBackupEnabled') return false; + if (method === 'backupVersion') return null; return null; }); const store = vi.fn<(name: string, value: string) => Promise>(async () => undefined); diff --git a/src/app/crypto/engineCrypto/decryptionErrors.test.ts b/src/app/crypto/engineCrypto/decryptionErrors.test.ts new file mode 100644 index 000000000..7f3df00cb --- /dev/null +++ b/src/app/crypto/engineCrypto/decryptionErrors.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'; +import { KnownMembership } from '$types/matrix-sdk'; +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const BACKUP_INFO = { + version: '7', + algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', + auth_data: { public_key: 'cHVibGlj' }, +}; + +type EngineState = { + code: number; + maybeWithheld?: string | null; + deviceCreationTimeMs?: number; + decryptionKeyBase64?: string | null; +}; + +const client = (backupInfo: unknown = BACKUP_INFO) => + ({ + http: { + authedRequest: vi.fn<(...args: never[]) => Promise>(async () => { + if (backupInfo === null) throw Object.assign(new Error('nope'), { errcode: 'M_NOT_FOUND' }); + return backupInfo; + }), + }, + }) as unknown as MatrixClient; + +const engine = ({ + code, + maybeWithheld = null, + deviceCreationTimeMs = 0, + decryptionKeyBase64 = null, +}: EngineState) => { + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'decryptRoomEvent') { + return { className: 'DecryptionError', code, description: 'engine says no', maybeWithheld }; + } + if (method === 'deviceCreationTimeMs') return deviceCreationTimeMs; + if (method === 'getBackupKeys') return { backupVersion: '7', decryptionKeyBase64 }; + if (method === 'backupVersion') return null; + return null; + }); +}; + +const eventAt = (ts: number, membership?: string) => + ({ + getRoomId: () => '!room:e.org', + getId: () => '$e', + getWireType: () => 'm.room.encrypted', + getSender: () => '@them:e.org', + getTs: () => ts, + getMembershipAtEvent: () => membership, + getWireContent: () => ({ session_id: 'session-1', sender_key: 'key' }), + }) as unknown as MatrixEvent; + +const decrypt = (event: MatrixEvent, mx = client()) => + new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).decryptEvent(event); + +const codeOf = async (promise: Promise) => { + const error = await promise.then( + () => undefined, + (err: unknown) => err as { code?: string } + ); + return error?.code; +}; + +describe('decryption failures', () => { + beforeEach(() => mockInvoke.mockReset()); + + it('reports a missing room key rather than a bare unknown error', async () => { + engine({ code: 0 }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe( + DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID + ); + }); + + it('reports a ratcheted session', async () => { + engine({ code: 1 }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe( + DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX + ); + }); + + it('reports a withheld key', async () => { + engine({ code: 0, maybeWithheld: 'm.unauthorised' }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe(DecryptionFailureCode.MEGOLM_KEY_WITHHELD); + }); + + it('singles out a key withheld because we are unverified', async () => { + engine({ + code: 0, + maybeWithheld: 'The sender has disabled encrypting to unverified devices.', + }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe( + DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE + ); + }); + + it('reports a message sent while we were not in the room', async () => { + engine({ code: 0 }); + + expect(await codeOf(decrypt(eventAt(100, KnownMembership.Leave)))).toBe( + DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED + ); + }); + + it('reports history with no backup on the server', async () => { + engine({ code: 0, deviceCreationTimeMs: 5000 }); + + expect(await codeOf(decrypt(eventAt(100), client(null)))).toBe( + DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP + ); + }); + + it('reports history this device cannot reach because backup is unconfigured', async () => { + engine({ code: 0, deviceCreationTimeMs: 5000 }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe( + DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED + ); + }); + + it('reports history a working backup should eventually supply', async () => { + engine({ code: 0, deviceCreationTimeMs: 5000, decryptionKeyBase64: 'AAAA' }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe( + DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP + ); + }); + + it('reports an untrusted sender identity', async () => { + engine({ code: 5 }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe( + DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED + ); + }); + + it('reports an unknown and an unsigned sender device', async () => { + engine({ code: 3 }); + expect(await codeOf(decrypt(eventAt(100)))).toBe(DecryptionFailureCode.UNKNOWN_SENDER_DEVICE); + + engine({ code: 4 }); + expect(await codeOf(decrypt(eventAt(100)))).toBe(DecryptionFailureCode.UNSIGNED_SENDER_DEVICE); + }); + + it('falls back to unknown for anything else', async () => { + engine({ code: 6 }); + + expect(await codeOf(decrypt(eventAt(100)))).toBe(DecryptionFailureCode.UNKNOWN_ERROR); + }); +}); diff --git a/src/app/crypto/engineCrypto/encryptEvent.test.ts b/src/app/crypto/engineCrypto/encryptEvent.test.ts index 14d5840cd..47159239c 100644 --- a/src/app/crypto/engineCrypto/encryptEvent.test.ts +++ b/src/app/crypto/engineCrypto/encryptEvent.test.ts @@ -14,9 +14,8 @@ vi.mock('../olmMachine/engineInvoke', () => ({ const mockInvoke = vi.mocked(engineInvoke); -const mx = { - http: { authedRequest: vi.fn<(...args: never[]) => Promise>(async () => null) }, -} as unknown as MatrixClient; +const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => null); +const mx = { http: { authedRequest } } as unknown as MatrixClient; type RoomOptions = { encryption?: Record; @@ -59,8 +58,10 @@ const encrypt = async (room: Room, isolation?: AllDevicesIsolationMode) => { describe('encryptEvent settings', () => { beforeEach(() => { mockInvoke.mockReset(); + authedRequest.mockClear(); mockInvoke.mockImplementation(async (_identity, method) => { if (method === 'encryptRoomEvent') return '{}'; + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; return null; }); }); @@ -128,3 +129,57 @@ describe('encryptEvent settings', () => { }); }); }); + +describe('encryptEvent request delivery', () => { + const claim = { id: 'c1', type: 2, body: '{}' }; + const share = [ + { id: 's1', type: 3, event_type: 'm.room.encrypted', txn_id: 's1', body: '{"messages":{}}' }, + { id: 's2', type: 3, event_type: 'm.room.encrypted', txn_id: 's2', body: '{"messages":{}}' }, + ]; + + beforeEach(() => { + mockInvoke.mockReset(); + authedRequest.mockClear(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'getMissingSessions') return claim; + if (method === 'shareRoomKey') return share; + if (method === 'encryptRoomEvent') return '{}'; + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; + return null; + }); + }); + + it('sends the key-claim request the engine hands back', async () => { + await encrypt(roomStub()); + + expect( + authedRequest.mock.calls.some((call) => call[1] === '/_matrix/client/v3/keys/claim') + ).toBe(true); + }); + + it('sends every room-key to-device message the engine hands back', async () => { + await encrypt(roomStub()); + + const sent = authedRequest.mock.calls.filter((call) => + String(call[1]).startsWith('/_matrix/client/v3/sendToDevice/') + ); + expect(sent).toHaveLength(2); + }); + + it('acknowledges each one so the engine stops reissuing it', async () => { + await encrypt(roomStub()); + + const marked = mockInvoke.mock.calls + .filter(([, method]) => method === 'markRequestAsSent') + .map(([, , args]) => (args as { requestId: string }).requestId); + expect(marked).toEqual(['c1', 's1', 's2']); + }); + + it('shares the key before it encrypts the event', async () => { + await encrypt(roomStub()); + + const order = mockInvoke.mock.calls.map(([, method]) => method); + expect(order.indexOf('shareRoomKey')).toBeLessThan(order.indexOf('encryptRoomEvent')); + expect(order.indexOf('getMissingSessions')).toBeLessThan(order.indexOf('shareRoomKey')); + }); +}); diff --git a/src/app/crypto/engineCrypto/engineShapes.test.ts b/src/app/crypto/engineCrypto/engineShapes.test.ts index 2ea1ba547..d8f88efff 100644 --- a/src/app/crypto/engineCrypto/engineShapes.test.ts +++ b/src/app/crypto/engineCrypto/engineShapes.test.ts @@ -24,6 +24,13 @@ describe('engine payload shapes', () => { }); await expect(crypto().getSessionBackupPrivateKey()).resolves.not.toBeNull(); + }); + + it('asks the engine for the active backup version rather than the stored one', async () => { + mockInvoke.mockImplementation(async (_identity, method) => + method === 'backupVersion' ? '7' : { className: 'BackupKeys', backupVersion: null } + ); + await expect(crypto().getActiveSessionBackupVersion()).resolves.toBe('7'); }); diff --git a/src/app/crypto/engineCrypto/keyBackupConnect.test.ts b/src/app/crypto/engineCrypto/keyBackupConnect.test.ts index db29485fb..9784fe08b 100644 --- a/src/app/crypto/engineCrypto/keyBackupConnect.test.ts +++ b/src/app/crypto/engineCrypto/keyBackupConnect.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; +import { encodeBase64 } from 'matrix-js-sdk/lib/base64'; import type { MatrixClient } from '$types/matrix-sdk'; import { engineInvoke } from '../olmMachine/engineInvoke'; import { EngineCrypto } from './EngineCrypto'; @@ -100,3 +103,103 @@ describe('key backup connection', () => { ); }); }); + +describe('key backup status reporting', () => { + const PRIVATE_KEY_BASE64 = encodeBase64(new Uint8Array(32).fill(9)); + let publicKey: string; + + beforeAll(async () => { + await RustSdkCryptoJs.initAsync(); + const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(PRIVATE_KEY_BASE64); + publicKey = key.megolmV1PublicKey.publicKeyBase64; + key.free(); + }); + + beforeEach(() => mockInvoke.mockReset()); + + const watch = (crypto: EngineCrypto) => { + const status = vi.fn<(enabled: boolean) => void>(); + crypto.on(CryptoEvent.KeyBackupStatus, status); + return status; + }; + + const engineWith = ( + backupKeys: { backupVersion: string | null; decryptionKeyBase64: string | null }, + trusted: boolean + ) => { + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'verifyBackup') return { trusted }; + if (method === 'getBackupKeys') return backupKeys; + if (method === 'backupVersion') return backupKeys.backupVersion; + return null; + }); + }; + + it('announces the backup once it enables it', async () => { + const { mx } = clientSpy(); + engineWith({ backupVersion: null, decryptionKeyBase64: null }, true); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + const status = watch(crypto); + await crypto.checkKeyBackupAndEnable(); + + expect(status).toHaveBeenCalledWith(true); + }); + + it('enables on a matching decryption key without a trusted signature', async () => { + const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => ({ + version: '7', + algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', + auth_data: { public_key: publicKey }, + })); + const mx = { http: { authedRequest } } as unknown as MatrixClient; + engineWith({ backupVersion: null, decryptionKeyBase64: PRIVATE_KEY_BASE64 }, false); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + await crypto.checkKeyBackupAndEnable(); + + const enabled = mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1'); + expect(enabled.length).toBeGreaterThan(0); + expect(enabled[0]?.[2]).toMatchObject({ publicKeyBase64: publicKey, version: '7' }); + }); + + it('switches away from a stale active version', async () => { + const { mx } = clientSpy(); + engineWith({ backupVersion: '6', decryptionKeyBase64: PRIVATE_KEY_BASE64 }, true); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + const status = watch(crypto); + await crypto.checkKeyBackupAndEnable(); + + const order = mockInvoke.mock.calls.map(([, method]) => method); + expect(order.indexOf('disableBackup')).toBeLessThan(order.indexOf('enableBackupV1')); + expect(status).toHaveBeenCalledWith(false); + expect(status).toHaveBeenLastCalledWith(true); + }); + + it('turns the backup off when the server no longer has one', async () => { + const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => { + throw Object.assign(new Error('not found'), { errcode: 'M_NOT_FOUND' }); + }); + const mx = { http: { authedRequest } } as unknown as MatrixClient; + engineWith({ backupVersion: '7', decryptionKeyBase64: PRIVATE_KEY_BASE64 }, true); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + const status = watch(crypto); + expect(await crypto.checkKeyBackupAndEnable()).toBeNull(); + + expect(mockInvoke.mock.calls.some(([, method]) => method === 'disableBackup')).toBe(true); + expect(status).toHaveBeenCalledWith(false); + }); + + it('leaves a still-current backup enabled', async () => { + const { mx } = clientSpy(); + engineWith({ backupVersion: '7', decryptionKeyBase64: PRIVATE_KEY_BASE64 }, true); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + await crypto.checkKeyBackupAndEnable(); + + expect(mockInvoke.mock.calls.some(([, method]) => method === 'disableBackup')).toBe(false); + expect(mockInvoke.mock.calls.some(([, method]) => method === 'enableBackupV1')).toBe(false); + }); +}); diff --git a/src/app/crypto/engineCrypto/outgoing.ts b/src/app/crypto/engineCrypto/outgoing.ts index 71372e201..d09b8ac34 100644 --- a/src/app/crypto/engineCrypto/outgoing.ts +++ b/src/app/crypto/engineCrypto/outgoing.ts @@ -22,6 +22,8 @@ export type OutgoingRequest = { version?: string; }; +const OUTGOING_REQUEST_TIMEOUT_MS = 60000; + const path = { keysUpload: '/_matrix/client/v3/keys/upload', keysQuery: '/_matrix/client/v3/keys/query', @@ -43,7 +45,8 @@ export const sendOutgoingRequest = async ( mx.http.authedRequest(method, url, params, request.body, { prefix: '', json: false, - headers: { 'Content-Type': 'application/json' }, + localTimeoutMs: OUTGOING_REQUEST_TIMEOUT_MS, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); switch (request.type) { diff --git a/src/app/crypto/install.ts b/src/app/crypto/install.ts index 8d7334a83..02d3a39b6 100644 --- a/src/app/crypto/install.ts +++ b/src/app/crypto/install.ts @@ -4,7 +4,7 @@ import { isTauri } from '@tauri-apps/api/core'; import { ClientEvent, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { createDebugLogger } from '$utils/debugLogger'; -import { engineOpen } from '$generated/tauri/commands'; +import { engineClose, engineOpen } from '$generated/tauri/commands'; import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; import { engineInvoke } from './olmMachine/engineInvoke'; import { EngineCrypto } from './engineCrypto/EngineCrypto'; @@ -136,11 +136,14 @@ export const installRustCrypto = async ( stopClientEvents(); stopEventBridge(); stopEngineCrypto(); + engineClose({ userId, deviceId }).catch((error: unknown) => { + cryptoLog.warn('general', 'Could not close the native crypto engine', error); + }); }; - await acceptPendingKeyBundles(engineCrypto, identity); - (mx as unknown as { cryptoBackend?: unknown }).cryptoBackend = engineCrypto; + + void acceptPendingKeyBundles(engineCrypto, identity); cryptoLog.info('general', 'Installed the Rust IPC crypto engine', { userId, deviceId }); return { rustCrypto: engineCrypto }; @@ -162,12 +165,14 @@ const acceptPendingKeyBundles = async ( }[]; for (const { roomId, inviterId, inviteAcceptedAtMillis } of pending) { - if (Date.now() - inviteAcceptedAtMillis <= MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE) { + const expired = Date.now() - inviteAcceptedAtMillis > MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE; + try { // eslint-disable-next-line no-await-in-loop - await crypto.maybeAcceptKeyBundle(roomId, inviterId); - } else { + if (expired) await engineInvoke(identity, 'clearRoomPendingKeyBundle', { roomId }); // eslint-disable-next-line no-await-in-loop - await engineInvoke(identity, 'clearRoomPendingKeyBundle', { roomId }); + else await crypto.maybeAcceptKeyBundle(roomId, inviterId); + } catch (error) { + cryptoLog.warn('general', 'Could not accept a pending room key bundle', { roomId, error }); } } }; diff --git a/src/app/crypto/verification/request.test.ts b/src/app/crypto/verification/request.test.ts index 640b35f47..ccb8ded90 100644 --- a/src/app/crypto/verification/request.test.ts +++ b/src/app/crypto/verification/request.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { VerificationMethod, VerificationPhase } from '$types/matrix-sdk'; import { EngineVerificationRequest } from './request'; -import { EnginePhase, type EngineVerificationState } from './state'; +import { + EnginePhase, + SUPPORTED_VERIFICATION_METHOD_CODES, + type EngineVerificationState, +} from './state'; const state = (patch: Partial = {}): EngineVerificationState => ({ ownUserId: '@me:example.org', @@ -19,14 +23,14 @@ const state = (patch: Partial = {}): EngineVerification timedOut: false, timeRemainingMillis: 600000, theirSupportedMethods: [0], - ourSupportedMethods: [0], + ourSupportedMethods: null, cancelInfo: null, verification: null, ...patch, }); describe('EngineVerificationRequest', () => { - it('accepts through the engine with our supported methods and refreshes', async () => { + it('accepts advertising every method we support, not the empty set the engine reports', async () => { const call = vi.fn<(m: string, a?: Record) => Promise>( async (method) => method === 'verificationRequest.state' ? state({ phase: EnginePhase.Ready }) : null @@ -38,8 +42,9 @@ describe('EngineVerificationRequest', () => { expect(call).toHaveBeenCalledWith('verificationRequest.accept', { userId: '@them:example.org', flowId: '$flow', - methods: [0], + methods: SUPPORTED_VERIFICATION_METHOD_CODES, }); + expect(SUPPORTED_VERIFICATION_METHOD_CODES).toEqual([0, 1, 2, 3]); expect(request.phase).toBe(VerificationPhase.Ready); }); diff --git a/src/app/crypto/verification/request.ts b/src/app/crypto/verification/request.ts index e9935804f..b8a071adb 100644 --- a/src/app/crypto/verification/request.ts +++ b/src/app/crypto/verification/request.ts @@ -15,6 +15,7 @@ import { isPending, methodsFromCodes, otherPartySupportsMethod, + SUPPORTED_VERIFICATION_METHOD_CODES, toVerificationPhase, type EngineVerificationState, } from './state'; @@ -62,15 +63,25 @@ export class EngineVerificationRequest return; } - if (!this.#verifier) { - if (verification.className === 'Sas') { + const wanted = verification.className; + const current = + // eslint-disable-next-line no-nested-ternary + this.#verifier instanceof EngineSasVerifier + ? 'Sas' + : this.#verifier instanceof EngineQrVerifier + ? 'Qr' + : undefined; + + if (current !== wanted) { + if (wanted === 'Sas') { this.#verifier = new EngineSasVerifier( this.#call, this.#flow, verification as SasState, this.#state.otherUserId ); - } else if (verification.className === 'Qr') { + if (current !== undefined) void this.#reaccept(); + } else if (wanted === 'Qr') { this.#verifier = new EngineQrVerifier( this.#call, this.#flow, @@ -88,6 +99,14 @@ export class EngineVerificationRequest } } + async #reaccept(): Promise { + try { + await this.#call('sas.accept', this.#flow); + } catch { + this.emit(VerificationRequestEvent.Change); + } + } + async refresh(): Promise { const next = (await this.#call( 'verificationRequest.state', @@ -199,7 +218,7 @@ export class EngineVerificationRequest try { await this.#call('verificationRequest.accept', { ...this.#flow, - methods: this.#state.ourSupportedMethods ?? undefined, + methods: SUPPORTED_VERIFICATION_METHOD_CODES, }); await this.refresh(); } finally { diff --git a/src/app/crypto/verification/state.ts b/src/app/crypto/verification/state.ts index 4faa8e3df..dadf181d2 100644 --- a/src/app/crypto/verification/state.ts +++ b/src/app/crypto/verification/state.ts @@ -54,6 +54,15 @@ export const methodFromCode = (code: number): string | undefined => METHOD_BY_CO export const codeFromMethod = (method: string): number | undefined => CODE_BY_METHOD[method]; +export const SUPPORTED_VERIFICATION_METHOD_CODES = [ + VerificationMethod.Sas, + VerificationMethod.ScanQrCode, + VerificationMethod.ShowQrCode, + VerificationMethod.Reciprocate, +] + .map(codeFromMethod) + .filter((code): code is number => code !== undefined); + export const methodsFromCodes = (codes: number[] | null | undefined): string[] => (codes ?? []).map(methodFromCode).filter((method): method is string => method !== undefined); diff --git a/src/app/hooks/useKeyBackup.ts b/src/app/hooks/useKeyBackup.ts index b8ab47c67..5b5c5baa7 100644 --- a/src/app/hooks/useKeyBackup.ts +++ b/src/app/hooks/useKeyBackup.ts @@ -22,11 +22,12 @@ export const useKeyBackupStatus = (crypto: CryptoApi): boolean => { const [status, setStatus] = useState(false); useEffect(() => { - crypto.getActiveSessionBackupVersion().then((v) => { - if (alive()) { - setStatus(typeof v === 'string'); - } - }); + crypto + .getActiveSessionBackupVersion() + .then((v) => { + if (alive()) setStatus(typeof v === 'string'); + }) + .catch(() => undefined); }, [crypto, alive]); useKeyBackupStatusChange(setStatus); @@ -153,11 +154,12 @@ export const useKeyBackupInfo = (crypto: CryptoApi): KeyBackupInfo | undefined | const [info, setInfo] = useState(); const fetchInfo = useCallback(() => { - crypto.getKeyBackupInfo().then((i) => { - if (alive()) { - setInfo(i); - } - }); + crypto + .getKeyBackupInfo() + .then((i) => { + if (alive()) setInfo(i); + }) + .catch(() => undefined); }, [crypto, alive]); useEffect(() => { @@ -188,11 +190,12 @@ export const useKeyBackupTrust = ( const [trust, setTrust] = useState(); const fetchTrust = useCallback(() => { - crypto.isKeyBackupTrusted(backupInfo).then((t) => { - if (alive()) { - setTrust(t); - } - }); + crypto + .isKeyBackupTrusted(backupInfo) + .then((t) => { + if (alive()) setTrust(t); + }) + .catch(() => undefined); }, [crypto, alive, backupInfo]); useEffect(() => {