From 656e1a852380677aba1b2f158b76d286d01b87f5 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 9 Sep 2026 17:55:58 +0200 Subject: [PATCH] fix: restore Matrix SDK crypto Signed-off-by: Erwan Leboucher --- src-tauri/src/lib.rs | 2 +- src-tauri/src/matrix_crypto/jni_push.rs | 50 - src-tauri/src/matrix_crypto/mod.rs | 49 +- src-tauri/src/matrix_crypto/push.rs | 278 -- src/app/crypto/engineCrypto/EngineCrypto.ts | 2499 ----------------- .../crypto/engineCrypto/backupImport.test.ts | 75 - .../engineCrypto/backupInfoCache.test.ts | 62 - .../engineCrypto/backupKeyFrom4S.test.ts | 52 - .../crypto/engineCrypto/backupUpload.test.ts | 169 -- .../bootstrapCrossSigning.test.ts | 230 -- .../bootstrapSecretStorage.test.ts | 58 - .../crypto/engineCrypto/claimChain.test.ts | 78 - .../crypto/engineCrypto/coalescedRunner.ts | 35 - src/app/crypto/engineCrypto/coverage.test.ts | 42 - .../engineCrypto/crossSigningKeys.test.ts | 72 - .../engineCrypto/decryptionErrors.test.ts | 224 -- .../crypto/engineCrypto/encryptEvent.test.ts | 185 -- .../engineCrypto/encryptionOrder.test.ts | 98 - .../crypto/engineCrypto/engineShapes.test.ts | 151 - src/app/crypto/engineCrypto/eventBridge.ts | 57 - .../incomingVerificationRequest.test.ts | 349 --- .../engineCrypto/keyBackupConnect.test.ts | 205 -- .../engineCrypto/keyQueryOrdering.test.ts | 99 - .../engineCrypto/membershipSendRace.test.ts | 255 -- src/app/crypto/engineCrypto/outgoing.test.ts | 96 - src/app/crypto/engineCrypto/outgoing.ts | 92 - .../engineCrypto/outgoingCoalesce.test.ts | 94 - .../engineCrypto/outgoingDispatch.test.ts | 221 -- .../crypto/engineCrypto/outgoingRetry.test.ts | 102 - .../engineCrypto/pendingRoomKeys.test.ts | 95 - .../perSessionBackupDownload.test.ts | 130 - .../engineCrypto/perSessionBackupDownload.ts | 131 - .../engineCrypto/prepareRoomKey.test.ts | 137 - .../crypto/engineCrypto/secretInbox.test.ts | 107 - .../engineCrypto/secretStorageAccess.ts | 15 - .../engineCrypto/sendConcurrency.test.ts | 188 -- .../crypto/engineCrypto/sendLatency.test.ts | 160 -- src/app/crypto/engineCrypto/shape.test.ts | 54 - src/app/crypto/install.test.ts | 124 +- src/app/crypto/install.ts | 206 +- src/app/crypto/olmMachine/engineInvoke.ts | 20 - src/app/crypto/pushDecrypt.test.ts | 129 - src/app/crypto/pushDecrypt.ts | 80 - src/app/crypto/verification/qr.test.ts | 111 - src/app/crypto/verification/request.test.ts | 139 - src/app/crypto/verification/request.ts | 308 -- src/app/crypto/verification/state.test.ts | 164 -- src/app/crypto/verification/state.ts | 123 - src/app/crypto/verification/verifier.test.ts | 203 -- src/app/crypto/verification/verifier.ts | 268 -- src/app/crypto/verificationInternals.test.ts | 34 - .../UnifiedPushNotifications.test.ts | 25 +- .../notifications/UnifiedPushNotifications.ts | 51 +- src/app/generated/tauri/commands.ts | 8 +- src/app/generated/tauri/types.ts | 22 +- src/app/pages/client/ClientRoot.tsx | 41 +- src/app/pages/client/LegacyKeyExport.tsx | 12 +- src/client/initMatrix.sdk.test.ts | 81 + src/client/initMatrix.ts | 23 +- 59 files changed, 318 insertions(+), 8850 deletions(-) delete mode 100644 src-tauri/src/matrix_crypto/jni_push.rs delete mode 100644 src-tauri/src/matrix_crypto/push.rs delete mode 100644 src/app/crypto/engineCrypto/EngineCrypto.ts delete mode 100644 src/app/crypto/engineCrypto/backupImport.test.ts delete mode 100644 src/app/crypto/engineCrypto/backupInfoCache.test.ts delete mode 100644 src/app/crypto/engineCrypto/backupKeyFrom4S.test.ts delete mode 100644 src/app/crypto/engineCrypto/backupUpload.test.ts delete mode 100644 src/app/crypto/engineCrypto/bootstrapCrossSigning.test.ts delete mode 100644 src/app/crypto/engineCrypto/bootstrapSecretStorage.test.ts delete mode 100644 src/app/crypto/engineCrypto/claimChain.test.ts delete mode 100644 src/app/crypto/engineCrypto/coalescedRunner.ts delete mode 100644 src/app/crypto/engineCrypto/coverage.test.ts delete mode 100644 src/app/crypto/engineCrypto/crossSigningKeys.test.ts delete mode 100644 src/app/crypto/engineCrypto/decryptionErrors.test.ts delete mode 100644 src/app/crypto/engineCrypto/encryptEvent.test.ts delete mode 100644 src/app/crypto/engineCrypto/encryptionOrder.test.ts delete mode 100644 src/app/crypto/engineCrypto/engineShapes.test.ts delete mode 100644 src/app/crypto/engineCrypto/eventBridge.ts delete mode 100644 src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts delete mode 100644 src/app/crypto/engineCrypto/keyBackupConnect.test.ts delete mode 100644 src/app/crypto/engineCrypto/keyQueryOrdering.test.ts delete mode 100644 src/app/crypto/engineCrypto/membershipSendRace.test.ts delete mode 100644 src/app/crypto/engineCrypto/outgoing.test.ts delete mode 100644 src/app/crypto/engineCrypto/outgoing.ts delete mode 100644 src/app/crypto/engineCrypto/outgoingCoalesce.test.ts delete mode 100644 src/app/crypto/engineCrypto/outgoingDispatch.test.ts delete mode 100644 src/app/crypto/engineCrypto/outgoingRetry.test.ts delete mode 100644 src/app/crypto/engineCrypto/pendingRoomKeys.test.ts delete mode 100644 src/app/crypto/engineCrypto/perSessionBackupDownload.test.ts delete mode 100644 src/app/crypto/engineCrypto/perSessionBackupDownload.ts delete mode 100644 src/app/crypto/engineCrypto/prepareRoomKey.test.ts delete mode 100644 src/app/crypto/engineCrypto/secretInbox.test.ts delete mode 100644 src/app/crypto/engineCrypto/secretStorageAccess.ts delete mode 100644 src/app/crypto/engineCrypto/sendConcurrency.test.ts delete mode 100644 src/app/crypto/engineCrypto/sendLatency.test.ts delete mode 100644 src/app/crypto/engineCrypto/shape.test.ts delete mode 100644 src/app/crypto/olmMachine/engineInvoke.ts delete mode 100644 src/app/crypto/pushDecrypt.test.ts delete mode 100644 src/app/crypto/pushDecrypt.ts delete mode 100644 src/app/crypto/verification/qr.test.ts delete mode 100644 src/app/crypto/verification/request.test.ts delete mode 100644 src/app/crypto/verification/request.ts delete mode 100644 src/app/crypto/verification/state.test.ts delete mode 100644 src/app/crypto/verification/state.ts delete mode 100644 src/app/crypto/verification/verifier.test.ts delete mode 100644 src/app/crypto/verification/verifier.ts delete mode 100644 src/app/crypto/verificationInternals.test.ts create mode 100644 src/client/initMatrix.sdk.test.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcea61857b..47e0669944 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -471,7 +471,7 @@ pub fn run() { #[cfg(feature = "matrix-crypto")] matrix_crypto::engine_wipe, #[cfg(feature = "matrix-crypto")] - matrix_crypto::push::engine_decrypt_push, + matrix_crypto::engine_store_exists, share_inbox::share_inbox_drain, share_inbox::share_inbox_read, share_inbox::share_inbox_clear, diff --git a/src-tauri/src/matrix_crypto/jni_push.rs b/src-tauri/src/matrix_crypto/jni_push.rs deleted file mode 100644 index abf1887bda..0000000000 --- a/src-tauri/src/matrix_crypto/jni_push.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Decrypting a push payload with no webview. -//! -//! A cold push starts the process for the delivery service alone, so there is no Tauri -//! command to invoke; the notifications plugin calls this symbol directly. - -use jni::objects::{JClass, JString}; -use jni::{Env, EnvUnowned}; - -use super::push::decrypt_push; - -/// The clear event as JSON, or an empty string when it cannot be decrypted. The symbol -/// name must match `PushPayloadDecryptor.nativeDecryptPush`, package included. -#[unsafe(no_mangle)] -pub extern "system" fn Java_app_tauri_notification_PushPayloadDecryptor_nativeDecryptPush< - 'frame, ->( - mut unowned_env: EnvUnowned<'frame>, - _class: JClass<'frame>, - store_dir: JString<'frame>, - user_id: JString<'frame>, - device_id: JString<'frame>, - room_id: JString<'frame>, - event_json: JString<'frame>, -) -> JString<'frame> { - let outcome = unowned_env.with_env(|env: &mut Env<'frame>| -> Result<_, jni::errors::Error> { - let dir = store_dir.to_string(); - let user = user_id.to_string(); - let device = device_id.to_string(); - let room = room_id.to_string(); - let event = event_json.to_string(); - - // The delivery service has no runtime of its own. - let clear = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .ok() - .and_then(|runtime| { - let store = std::path::Path::new(&dir).join(super::store_subpath(&user, &device)); - runtime - .block_on(decrypt_push(&store, None, &user, &device, &room, &event)) - .ok() - }) - .map(|decrypted| decrypted.clear_event) - .unwrap_or_default(); - - JString::from_str(env, clear) - }); - - outcome.resolve::() -} diff --git a/src-tauri/src/matrix_crypto/mod.rs b/src-tauri/src/matrix_crypto/mod.rs index 9f7c1b52df..277d86125c 100644 --- a/src-tauri/src/matrix_crypto/mod.rs +++ b/src-tauri/src/matrix_crypto/mod.rs @@ -7,10 +7,7 @@ pub mod cross_signing; pub mod devices; 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; pub mod verification; @@ -152,6 +149,10 @@ pub fn store_subpath(user_id: &str, device_id: &str) -> PathBuf { PathBuf::from("matrix-crypto").join(account) } +fn store_db_path(dir: &Path) -> PathBuf { + dir.join("matrix-sdk-crypto.sqlite3") +} + /// Per-account store directory. Resolved here rather than passed in so the /// webview never has to know an absolute path, and so the native notification /// handler can derive the same location independently. @@ -170,6 +171,23 @@ fn store_dir( Ok(base.join(store_subpath(user_id, device_id))) } +async fn store_exists_at(dir: &Path) -> Result { + match tokio::fs::metadata(store_db_path(dir)).await { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("checking crypto store failed: {error}")), + } +} + +#[tauri::command] +pub async fn engine_store_exists( + app: tauri::AppHandle, + user_id: String, + device_id: String, +) -> Result { + store_exists_at(&store_dir(&app, &user_id, &device_id)?).await +} + 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 @@ -199,7 +217,7 @@ pub(super) async fn open_machine_locked( tokio::fs::create_dir_all(dir) .await .map_err(|e| e.to_string())?; - let db_path = dir.join("matrix-sdk-crypto.sqlite3"); + let db_path = store_db_path(dir); let account = account_key(user_id, device_id); engines().close_account(&account)?; @@ -329,6 +347,29 @@ mod tests { ); } + #[tokio::test] + async fn store_exists_does_not_create_a_missing_store() { + let dir = + std::env::temp_dir().join(format!("sable-store-exists-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + assert!(!store_exists_at(&dir).await.unwrap()); + assert!(!dir.exists()); + } + + #[tokio::test] + async fn store_exists_only_accepts_the_crypto_database_file() { + let dir = + std::env::temp_dir().join(format!("sable-store-exists-present-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(store_db_path(&dir), b"placeholder").unwrap(); + + assert!(store_exists_at(&dir).await.unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn outgoing_room_key_requests_stay_disabled() { let user: &matrix_sdk::ruma::UserId = "@gossip:example.org".try_into().unwrap(); diff --git a/src-tauri/src/matrix_crypto/push.rs b/src-tauri/src/matrix_crypto/push.rs deleted file mode 100644 index aac0c79560..0000000000 --- a/src-tauri/src/matrix_crypto/push.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Decryption for push notifications, reachable without a webview or an `AppHandle`. - -use std::path::Path; -use std::sync::Arc; - -use matrix_sdk::ruma::serde::Raw; -use matrix_sdk::ruma::RoomId; -use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; -use matrix_sdk_crypto::OlmMachine; -use serde_json::Value; - -use super::args::decryption_settings; -use super::{account_key, engines}; - -/// 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`]. -pub async fn open_machine_for_push( - dir: &Path, - passphrase: Option<&str>, - user_id: &str, - device_id: &str, -) -> Result<(Arc, bool), String> { - if let Ok(machine) = engines().machine(user_id, device_id) { - return Ok((machine, false)); - } - - let _guard = super::OPEN_GUARD.lock().await; - - if let Ok(machine) = engines().machine(user_id, device_id) { - return Ok((machine, false)); - } - - let (machine, _) = super::open_machine_locked(dir, passphrase, user_id, device_id).await?; - Ok((machine, true)) -} - -/// The decrypted event plus the fields a notification needs to render. -#[derive(Debug, serde::Serialize)] -pub struct DecryptedPush { - pub event_type: Option, - pub sender: Option, - pub body: Option, - /// JSON text: the TypeScript generator has no mapping for `serde_json::Value`. - pub clear_event: String, -} - -/// Decrypts one encrypted room event fetched for a push. -pub async fn decrypt_push_event( - machine: &OlmMachine, - room_id: &str, - event_json: &str, -) -> Result { - let room = RoomId::parse(room_id).map_err(|e| format!("bad room id `{room_id}`: {e}"))?; - let event: Raw = - serde_json::from_str(event_json).map_err(|e| format!("bad event json: {e}"))?; - - let decrypted = machine - .decrypt_room_event(&event, &room, &decryption_settings()) - .await - .map_err(|e| format!("decrypting push event failed: {e:?}"))?; - - let clear_event: Value = serde_json::from_str(decrypted.event.json().get()) - .map_err(|e| format!("bad clear event json: {e}"))?; - - Ok(DecryptedPush { - event_type: string_at(&clear_event, &["type"]), - sender: string_at(&clear_event, &["sender"]), - body: string_at(&clear_event, &["content", "body"]), - clear_event: clear_event.to_string(), - }) -} - -fn string_at(value: &Value, path: &[&str]) -> Option { - path.iter() - .try_fold(value, |current, key| current.get(key))? - .as_str() - .map(str::to_owned) -} - -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 -/// releases whatever it opened. This is the entry point native push code uses when -/// there is no webview and no `AppHandle` to route through. -pub async fn decrypt_push( - dir: &Path, - passphrase: Option<&str>, - user_id: &str, - device_id: &str, - room_id: &str, - event_json: &str, -) -> Result { - 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; - - release_after_push(user_id, device_id, was_cold.then_some(&machine))?; - - decrypted -} - -/// Decrypts a push payload for the webview. The cold path cannot come through here: -/// with no webview alive there is nothing to invoke a command. -#[tauri::command] -pub async fn engine_decrypt_push( - app: tauri::AppHandle, - user_id: String, - device_id: String, - room_id: String, - event_json: String, - passphrase: Option, -) -> Result { - let dir = super::store_dir(&app, &user_id, &device_id)?; - decrypt_push( - &dir, - passphrase.as_deref(), - &user_id, - &device_id, - &room_id, - &event_json, - ) - .await -} - -#[cfg(test)] -mod tests { - use matrix_sdk_sqlite::SqliteCryptoStore; - use serde_json::json; - - use super::super::open_machine; - use super::*; - - fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("sable-push-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[tokio::test] - async fn reuses_an_already_open_machine() { - let dir = temp_dir("reuse"); - let (opened, _) = open_machine(&dir, None, "@push:example.org", "PUSHDEVICE") - .await - .unwrap(); - - let (reused, was_cold) = - open_machine_for_push(&dir, None, "@push:example.org", "PUSHDEVICE") - .await - .unwrap(); - assert!(!was_cold, "an already-open account must not report as cold"); - - assert!( - Arc::ptr_eq(&opened, &reused), - "push must not build a second OlmMachine over the same store" - ); - - engines() - .close_account(&account_key("@push:example.org", "PUSHDEVICE")) - .unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn opens_a_machine_when_the_process_is_cold() { - let dir = temp_dir("cold"); - let (machine, was_cold) = - open_machine_for_push(&dir, None, "@cold:example.org", "COLDDEVICE") - .await - .unwrap(); - - assert_eq!(machine.user_id().as_str(), "@cold:example.org"); - assert!( - was_cold, - "a cold process must report that it opened the store" - ); - - engines() - .close_account(&account_key("@cold:example.org", "COLDDEVICE")) - .unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn a_cold_one_shot_decrypt_releases_the_store_it_opened() { - let dir = temp_dir("oneshot-cold"); - let event = json!({ "not": "an encrypted event" }).to_string(); - - let result = decrypt_push( - &dir, - None, - "@oneshot:example.org", - "ONESHOTDEVICE", - "!room:example.org", - &event, - ) - .await; - - assert!(result.is_err(), "a bogus event must not decrypt"); - assert!( - engines() - .machine("@oneshot:example.org", "ONESHOTDEVICE") - .is_err(), - "a cold decrypt must not leave the crypto store open" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn a_warm_one_shot_decrypt_leaves_the_webviews_machine_open() { - let dir = temp_dir("oneshot-warm"); - let (_machine, _) = open_machine(&dir, None, "@warm:example.org", "WARMDEVICE") - .await - .unwrap(); - - let event = json!({ "not": "an encrypted event" }).to_string(); - let _ = decrypt_push( - &dir, - None, - "@warm:example.org", - "WARMDEVICE", - "!room:example.org", - &event, - ) - .await; - - assert!( - engines().machine("@warm:example.org", "WARMDEVICE").is_ok(), - "a push must never close a machine the webview owns" - ); - - engines() - .close_account(&account_key("@warm:example.org", "WARMDEVICE")) - .unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn reports_an_undecryptable_event_rather_than_panicking() { - let dir = temp_dir("undecryptable"); - let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), None) - .await - .unwrap(); - let user: &matrix_sdk::ruma::UserId = "@bad:example.org".try_into().unwrap(); - let machine = OlmMachine::with_store(user, "BADDEVICE".into(), Arc::new(store), None) - .await - .unwrap(); - - let event = json!({ - "type": "m.room.encrypted", - "event_id": "$bogus:example.org", - "sender": "@someone:example.org", - "origin_server_ts": 0, - "room_id": "!room:example.org", - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "AAAAAAAA", - "sender_key": "AAAA", - "session_id": "AAAA", - "device_id": "X", - }, - }) - .to_string(); - - let result = decrypt_push_event(&machine, "!room:example.org", &event).await; - assert!(result.is_err(), "bogus megolm event must not decrypt"); - - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/src/app/crypto/engineCrypto/EngineCrypto.ts b/src/app/crypto/engineCrypto/EngineCrypto.ts deleted file mode 100644 index 674a7f71c1..0000000000 --- a/src/app/crypto/engineCrypto/EngineCrypto.ts +++ /dev/null @@ -1,2499 +0,0 @@ -import { - CrossSigningKey, - DeviceVerificationStatus, - deriveRecoveryKeyFromPassphrase, - EventShieldColour, - EventShieldReason, - encodeRecoveryKey, - EventType, - ImportRoomKeyStage, - KnownMembership, - MatrixEventEvent, - MsgType, - UserVerificationStatus, -} from '$types/matrix-sdk'; -import { isVerificationEvent } from 'matrix-js-sdk/lib/rust-crypto/verification'; -import { Device, DeviceVerification } from 'matrix-js-sdk/lib/models/device'; -import { getHttpUriForMxc } from 'matrix-js-sdk/lib/content-repo'; -import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; -import { decodeBase64, encodeBase64 } from 'matrix-js-sdk/lib/base64'; -import { secureRandomString } from 'matrix-js-sdk/lib/randomstring'; -import type { KeyBackupSession } from 'matrix-js-sdk/lib/crypto-api/keybackup'; -import { - SECRET_STORAGE_ALGORITHM_V1_AES, - type SecretStorageKey, -} from 'matrix-js-sdk/lib/secret-storage'; -import { ClientPrefix, Method } from 'matrix-js-sdk/lib/http-api'; -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, - DecryptionKeyDoesNotMatchError, -} from 'matrix-js-sdk/lib/crypto-api'; -import { DecryptionError } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; -import { secretStorageCanAccessSecrets } from './secretStorageAccess'; -import { PerSessionBackupDownloader } from './perSessionBackupDownload'; -import type { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api/CryptoEventHandlerMap'; -import { createDebugLogger } from '$utils/debugLogger'; -import { traceVerification, warnToDevice, warnVerification } from '$utils/verificationTrace'; -import { EngineVerificationRequest } from '../verification/request'; -import { - EnginePhase, - SUPPORTED_VERIFICATION_METHOD_CODES, - type EngineVerificationState, -} from '../verification/state'; -import { engineInvoke, type EngineIdentity } from '../olmMachine/engineInvoke'; -import { RequestType, sendOutgoingRequest, type OutgoingRequest } from './outgoing'; -import { createCoalescedRunner } from './coalescedRunner'; -import type { - BackupDecryptor, - CryptoBackend, - EventDecryptionResult, - OnSyncCompletedData, -} from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; -import type { DeviceMap } from 'matrix-js-sdk/lib/models/device'; -import type { - IDeviceLists, - IToDeviceEvent, - ReceivedToDeviceMessage, -} from 'matrix-js-sdk/lib/sync-accumulator'; -import type { IMegolmSessionData } from 'matrix-js-sdk/lib/@types/crypto'; -import type { ToDeviceBatch, ToDevicePayload } from 'matrix-js-sdk/lib/models/ToDeviceMessage'; -import type { AuthDict, UIAuthCallback } from 'matrix-js-sdk/lib/interactive-auth'; -import type { - BackupTrustInfo, - BootstrapCrossSigningOpts, - CreateSecretStorageOpts, - CrossSigningKeys, - CrossSigningKeyInfo, - CrossSigningStatus, - DeviceIsolationMode, - EventEncryptionInfo, - GeneratedSecretStorageKey, - ImportRoomKeysOpts, - KeyBackupCheck, - KeyBackupInfo, - KeyBackupRestoreOpts, - KeyBackupRestoreResult, - MatrixClient, - MatrixEvent, - OwnDeviceKeys, - Room, - RoomMember, - SecretStorageStatus, - StartDehydrationOpts, - VerificationRequest, -} from '$types/matrix-sdk'; - -const engineCryptoLog = createDebugLogger('engine-crypto'); - -const DECRYPTION_WAIT_MS = 5 * 60 * 1000; - -const MAX_OUTGOING_DRAIN_PASSES = 5; - -const VERIFICATION_SWEEP_INTERVAL_MS = 5000; - -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]; - -/** Matches matrix-js-sdk's own derivation cost so keys stay interchangeable. */ -const RECOVERY_KEY_DERIVATION_ITERATIONS = 500000; - -const SECRETS_IN_STORAGE = [ - 'm.cross_signing.master', - 'm.cross_signing.self_signing', - 'm.cross_signing.user_signing', -] as const satisfies readonly SecretStorageKey[]; - -type EngineDevice = { - userId: string; - deviceId: string; - displayName?: string | null; - algorithms: number[]; - keys: Record; - isCrossSigningTrusted: boolean; - isCrossSignedByOwner: boolean; - isLocallyTrusted: boolean; - isVerified: boolean; - isBlacklisted: boolean; - isDehydrated: boolean; -}; - -const TrustRequirement = { - Untrusted: 0, - CrossSignedOrLegacy: 1, - CrossSigned: 2, -} as const; - -/** wasm's ProcessedToDeviceEventType, which the engine emits as bare numbers. */ -const ProcessedToDeviceEventType = { - Decrypted: 0, - UnableToDecrypt: 1, - PlainText: 2, - Invalid: 3, -} as const; - -type EngineProcessedToDeviceEvent = { - type: number; - rawEvent: string; - encryptionInfo?: { - sender: string; - senderDevice?: string; - senderCurve25519Key: string; - isSenderVerified: boolean; - }; -}; - -type EngineShieldState = { color: number; code?: number | null }; - -type EngineEncryptionInfo = { - shieldStateLax?: EngineShieldState; - shieldStateStrict?: EngineShieldState; -}; - -// Engine colour codes, per shield_state_json in matrix_crypto/rooms.rs. -const SHIELD_COLOUR: Record = { - 0: EventShieldColour.RED, - 1: EventShieldColour.GREY, - 2: EventShieldColour.NONE, -}; - -// Engine ShieldStateCode ordinals, per shield_state_json in matrix_crypto/rooms.rs. -const SHIELD_REASON: Record = { - 0: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - 1: EventShieldReason.UNKNOWN_DEVICE, - 2: EventShieldReason.UNSIGNED_DEVICE, - 3: EventShieldReason.UNVERIFIED_IDENTITY, - 4: EventShieldReason.VERIFICATION_VIOLATION, - 5: EventShieldReason.MISMATCHED_SENDER, -}; - -export const toEventEncryptionInfo = ( - info: EngineEncryptionInfo | null -): EventEncryptionInfo | null => { - if (!info) return null; - - // js-sdk reads the lax state; strict is only used behind its own setting. - const state = info.shieldStateLax; - if (!state) return null; - - const code = state.code; - return { - shieldColour: SHIELD_COLOUR[state.color] ?? EventShieldColour.RED, - shieldReason: - code === undefined || code === null - ? null - : (SHIELD_REASON[code] ?? EventShieldReason.UNKNOWN), - }; -}; - -type EngineDecryptedEvent = { - event: string; - senderCurve25519Key?: string | null; - senderClaimedEd25519Key?: string | null; - forwardingCurve25519KeyChain?: string[]; - forwarder?: string | null; - forwarderDevice?: string | null; -}; - -const countRecipients = (body: string): number => { - try { - const messages = (JSON.parse(body) as { messages?: Record> }) - .messages; - if (!messages) return 0; - return Object.values(messages).reduce( - (total, devices) => total + Object.keys(devices).length, - 0 - ); - } catch { - return -1; - } -}; - -const isOutgoingRequest = (value: unknown): value is OutgoingRequest => { - if (!value || typeof value !== 'object') return false; - const candidate = value as Partial; - return typeof candidate.type === 'number' && typeof candidate.body === 'string'; -}; - -type EngineRoomKeyInfo = { - roomId: string; - sessionId: string; -}; - -type EngineRoomKeyBundle = { - encryptedData: string; - mediaEncryptionInfo: string; -}; - -type EngineBootstrapRequests = { - uploadKeysRequest?: OutgoingRequest | null; - uploadSigningKeysRequest?: { body: string } | null; - uploadSignaturesRequest?: OutgoingRequest | null; -}; - -type EngineBackupKeys = { - backupVersion?: string | null; - /** Base64 text, not raw bytes; the engine never hands the key over as an object. */ - decryptionKeyBase64?: string | null; -}; - -/** The engine serialises each cross-signing key as JSON text. */ -const parseCrossSigningKey = (raw: unknown): CrossSigningKeyInfo | undefined => { - if (typeof raw !== 'string') return undefined; - try { - return JSON.parse(raw) as CrossSigningKeyInfo; - } catch { - return undefined; - } -}; - -type EngineIdentityInfo = { - userId: string; - isVerified: boolean; - wasPreviouslyVerified: boolean; - identityNeedsUserApproval?: boolean; - masterKey?: unknown; - selfSigningKey?: unknown; - 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: ENCRYPTION_ALGORITHMS.filter((_, index) => - device.algorithms?.includes(index) - ) as string[], - keys: new Map(Object.entries(device.keys)), - verified: deviceVerification(device), - signatures: new Map(), - dehydrated: device.isDehydrated, - }); - -export class EngineCrypto - extends TypedEventEmitter - implements CryptoBackend -{ - globalBlacklistUnverifiedDevices = false; - - globalErrorOnUnknownDevices = false; - - readonly #mx: MatrixClient; - - readonly #identity: EngineIdentity; - - #trustCrossSignedDevices = true; - - #stopped = false; - - #lastVerificationSweep = 0; - - #deviceIsolationMode: DeviceIsolationMode | undefined; - - /** Live requests, keyed by flow id, so the synchronous CryptoApi getters can answer. */ - readonly #verificationRequests = new Map(); - - readonly #outgoingFlush = createCoalescedRunner( - () => - this.#drainOutgoingRequests().catch((error: unknown) => { - engineCryptoLog.error('general', 'Draining outgoing crypto requests failed', error); - }), - () => this.#stopped - ); - - readonly #roomsWithTrackedMembers = new Set(); - - #claimChain: Promise = Promise.resolve(); - - #keyQueryChain: Promise = Promise.resolve(); - - readonly #encryptionChains = new Map>(); - - readonly #roomKeyInvalidations = new Map>(); - - readonly #backupUpload = createCoalescedRunner( - () => - this.#uploadRoomKeysToBackup().catch((error: unknown) => { - engineCryptoLog.error('general', 'Uploading room keys to backup failed', error); - }), - () => this.#stopped - ); - - #keyBackupCheck: Promise | undefined; - - #serverBackupInfo: KeyBackupInfo | null | undefined; - - #deviceCreationTimeMs: number | null | undefined; - - readonly #eventsPendingKey = new Map>(); - - readonly #backupDownloader: PerSessionBackupDownloader; - - constructor(mx: MatrixClient, identity: EngineIdentity) { - super(); - this.#mx = mx; - this.#identity = identity; - this.#backupDownloader = new PerSessionBackupDownloader({ - mx, - getBackupVersion: () => this.getActiveSessionBackupVersion().catch(() => null), - importSession: (roomId, session) => this.#importBackedUpSession(roomId, session), - now: () => Date.now(), - }); - // Nothing else drives the backup connection. - void this.#connectKeyBackup(); - } - - /** The engine reports a backup version only once `enableBackupV1` has run. */ - async #connectKeyBackup(): Promise { - try { - await this.checkKeyBackupAndEnable(); - } catch (error) { - engineCryptoLog.warn('general', 'Could not connect the key backup', error); - } - } - - #scheduleKeyBackup(): void { - void this.#backupUpload.schedule(); - } - - async #uploadRoomKeysToBackup(): Promise { - if (this.#stopped) return; - if (!(await this.#call('isBackupEnabled'))) return; - - 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) { - this.emit(CryptoEvent.KeyBackupSessionsRemaining, 0); - return; - } - - 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 }; - this.emit(CryptoEvent.KeyBackupSessionsRemaining, counts.total - counts.backedUp); - } - } - - 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 { - 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); - } - - onKeysChanged(): void { - this.emit(CryptoEvent.KeysChanged, {}); - this.#scheduleKeyBackup(); - } - - onRoomKeysUpdated(keys: EngineRoomKeyInfo[]): void { - this.onKeysChanged(); - keys.forEach((key) => this.#retryEventsPendingKey(key)); - } - - onRoomKeysWithheld(sessions: EngineRoomKeyInfo[]): void { - sessions.forEach((session) => this.#retryEventsPendingKey(session)); - } - - #retryEventsPendingKey({ roomId, sessionId }: EngineRoomKeyInfo): void { - if (this.#stopped) return; - const pending = this.#eventsPendingKey.get(`${roomId}|${sessionId}`); - if (!pending) return; - this.#eventsPendingKey.delete(`${roomId}|${sessionId}`); - - pending.forEach((event) => { - event.attemptDecryption(this, { isRetry: true }).catch(() => undefined); - }); - } - - #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; - if (!roomId || !sessionId) return; - - const key = `${roomId}|${sessionId}`; - const pending = this.#eventsPendingKey.get(key) ?? new Set(); - pending.add(event); - this.#eventsPendingKey.set(key, pending); - - this.#backupDownloader.request({ roomId, sessionId }); - } - - async #importBackedUpSession(roomId: string, session: KeyBackupSession): Promise { - const backupInfo = await this.getKeyBackupInfo().catch(() => null); - if (!backupInfo?.version) return false; - - const stored = await this.#call('getBackupKeys'); - const privateKey = (stored as { decryptionKeyBase64?: string } | null)?.decryptionKeyBase64; - if (!privateKey) return false; - - const decryptor = await this.getBackupDecryptor(backupInfo, decodeBase64(privateKey)).catch( - () => null - ); - if (!decryptor) return false; - - try { - const decrypted = await decryptor.decryptSessions({ session }); - if (decrypted.length === 0) return false; - - const withRoom = decrypted.map((entry) => ({ ...entry, room_id: roomId })); - const result = await this.#importBackedUpRoomKeys(withRoom, backupInfo.version); - return result.imported > 0; - } finally { - decryptor.free(); - } - } - - async #receiveSyncChanges(input: { - toDeviceEvents?: IToDeviceEvent[]; - deviceLists?: IDeviceLists; - oneTimeKeysCounts?: Record; - unusedFallbackKeys?: string[]; - }): Promise { - const processed = (await this.#call('receiveSyncChanges', { - toDeviceEvents: JSON.stringify(input.toDeviceEvents ?? []), - changedDevices: input.deviceLists?.changed ?? [], - leftDevices: input.deviceLists?.left ?? [], - oneTimeKeysCounts: input.oneTimeKeysCounts ?? {}, - ...(input.unusedFallbackKeys ? { unusedFallbackKeys: input.unusedFallbackKeys } : {}), - })) as EngineProcessedToDeviceEvent[] | null; - - void this.#flushOutgoingRequests(); - return processed ?? []; - } - - #call(method: string, args: Record = {}): Promise { - return engineInvoke(this.#identity, method, args); - } - - /** - * Verification actions return their outgoing request instead of queueing it, so the - * generic drain never sees it. Unsent, the flow stalls with no error. - */ - readonly #engineCall = async ( - method: string, - args: Record = {} - ): Promise => { - const result = await this.#call(method, args); - - // `sas.confirm` answers with several requests (the MAC plus a signature upload), and - // `startSas` answers with [state, request]. Sending only a lone object drops both. - if (Array.isArray(result)) { - for (const item of result) { - if (!isOutgoingRequest(item)) continue; - // Ordered: the peer rejects a MAC that arrives before the accept. - // eslint-disable-next-line no-await-in-loop - await sendOutgoingRequest(this.#mx, item); - } - return result; - } - - if (isOutgoingRequest(result)) { - await sendOutgoingRequest(this.#mx, result); - return null; - } - return result; - }; - - async #startVerification( - method: string, - args: Record - ): Promise { - const started = (await this.#call(method, args)) as { - request: EngineVerificationState; - outgoingRequest?: unknown; - }; - if (isOutgoingRequest(started.outgoingRequest)) { - const recipients = countRecipients(started.outgoingRequest.body); - traceVerification('Sending a verification request', { - method, - flowId: started.request.flowId, - recipientDevices: recipients, - }); - if (recipients === 0) { - warnVerification('The verification request reaches no device', { - method, - flowId: started.request.flowId, - }); - } - try { - await sendOutgoingRequest(this.#mx, started.outgoingRequest); - } catch (error) { - warnVerification('The verification request could not be sent', { - method, - flowId: started.request.flowId, - reason: error instanceof Error ? error.message : String(error), - }); - throw error; - } - } else { - warnVerification('The engine returned no verification request to send', { - method, - flowId: started.request.flowId, - }); - } - await this.#flushOutgoingRequests(); - - const request = new EngineVerificationRequest(this.#engineCall, started.request); - this.#verificationRequests.set(started.request.flowId, request); - return request; - } - - async onLiveEventFromSync(event: MatrixEvent): Promise { - if (event.isState() || event.getUnsigned().transaction_id) return; - - const handle = async (candidate: MatrixEvent): Promise => { - if (isVerificationEvent(candidate)) await this.onKeyVerificationEvent(candidate); - }; - - if (event.isDecryptionFailure() || event.isEncrypted()) { - let timeoutId: ReturnType; - const onDecrypted = (decrypted: MatrixEvent, error?: Error) => { - if (error) return; - clearTimeout(timeoutId); - event.off(MatrixEventEvent.Decrypted, onDecrypted); - void handle(decrypted); - }; - timeoutId = setTimeout(() => { - event.off(MatrixEventEvent.Decrypted, onDecrypted); - }, DECRYPTION_WAIT_MS); - event.on(MatrixEventEvent.Decrypted, onDecrypted); - return; - } - - await handle(event); - } - - async onKeyVerificationEvent(event: MatrixEvent): Promise { - const roomId = event.getRoomId(); - const senderId = event.getSender(); - const eventId = event.getId(); - if (!roomId || !senderId || !eventId) return; - - const content = event.getContent(); - const isRequest = - event.getType() === EventType.RoomMessage && - content.msgtype === MsgType.KeyVerificationRequest; - - if (isRequest) { - await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [senderId] })); - } - - await this.#call('receiveVerificationEvent', { - roomId, - event: JSON.stringify({ - event_id: eventId, - type: event.getType(), - sender: senderId, - state_key: event.getStateKey(), - content, - origin_server_ts: event.getTs(), - }), - }); - - if (isRequest) { - await this.onIncomingKeyVerificationRequest(senderId, eventId); - } else { - const flowId = (content['m.relates_to'] as { event_id?: string } | undefined)?.event_id; - if (flowId) await this.#verificationRequests.get(flowId)?.refresh(); - } - - await this.#flushOutgoingRequests(); - } - - onRoomStateEvent(event: MatrixEvent): void { - if (event.getType() !== EventType.RoomMember) return; - if ( - event.getStateKey() !== this.#identity.userId && - event.getContent().membership !== KnownMembership.Join - ) { - void this.forceDiscardSession(event.getRoomId() ?? '').catch((error: unknown) => - engineCryptoLog.warn('general', 'Could not invalidate room session', error) - ); - } - } - - 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 && - member.userId === this.#identity.userId - ) { - void this.#call('clearRoomPendingKeyBundle', { roomId }); - } - } - - 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 send = async () => { - const response = await sendOutgoingRequest(this.#mx, request); - if (typeof request.id !== 'string') return; - await this.#call('markRequestAsSent', { - requestId: request.id, - requestType: request.type, - response, - }); - }; - if (request.type !== RequestType.KeysQuery) return send(); - - const next = this.#keyQueryChain.catch(() => undefined).then(send); - this.#keyQueryChain = next; - await next; - } - - async onIncomingKeyVerificationRequest(sender: string, transactionId: string): Promise { - const state = (await this.#call('getVerificationRequest', { - userId: sender, - flowId: transactionId, - })) as EngineVerificationState | null; - if (!state) return false; - - const existing = this.#verificationRequests.get(transactionId); - if (existing) { - existing.apply(state); - return true; - } - const request = new EngineVerificationRequest(this.#engineCall, state); - this.#verificationRequests.set(transactionId, request); - if (state.phase === EnginePhase.Done || state.phase === EnginePhase.Cancelled) return true; - traceVerification('Surfacing an incoming verification request', { - sender, - transactionId, - isSelfVerification: state.isSelfVerification, - phase: state.phase, - }); - this.emit(CryptoEvent.VerificationRequestReceived, request); - return true; - } - - async #retryVerificationRequestWithKeys( - sender: string, - transactionId: string, - event: IToDeviceEvent - ): Promise { - await this.#trackUsers([sender]); - await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [sender] })); - await this.#flushOutgoingRequests(); - await this.#receiveSyncChanges({ toDeviceEvents: [event] }); - if (!(await this.onIncomingKeyVerificationRequest(sender, transactionId))) { - const sentAt = (event.content as { timestamp?: number } | undefined)?.timestamp; - warnVerification('The engine kept ignoring a verification request', { - sender, - transactionId, - clockSkewSeconds: - typeof sentAt === 'number' ? Math.round((Date.now() - sentAt) / 1000) : null, - }); - } - } - - #flushOutgoingRequests(): Promise { - return this.#outgoingFlush.schedule(); - } - - /** 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 { - 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 false; - try { - // Sequential: the engine's queue is ordered and later requests can depend on - // earlier ones having landed. - // eslint-disable-next-line no-await-in-loop - await this.#sendTracked(request); - 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 { - const processed = await this.#receiveSyncChanges({ toDeviceEvents: events }); - const received: ReceivedToDeviceMessage[] = []; - - const messages = processed.flatMap((event) => { - try { - return [[event, JSON.parse(event.rawEvent) as IToDeviceEvent] as const]; - } catch (error) { - engineCryptoLog.warn('general', 'Dropping an unparseable to-device event', error); - return []; - } - }); - - if ( - messages.some( - ([, message]) => - typeof message.type === 'string' && message.type.startsWith('m.key.verification.') - ) - ) { - await this.#flushOutgoingRequests(); - } - - for (const [event, message] of messages) { - if (typeof message.type === 'string' && message.type.startsWith('m.key.verification.')) { - const transactionId = (message.content as { transaction_id?: string })?.transaction_id; - if (transactionId && message.sender) { - traceVerification('Received a verification to-device event', { - type: message.type, - sender: message.sender, - transactionId, - }); - if (message.type === EventType.KeyVerificationRequest) { - // eslint-disable-next-line no-await-in-loop - const handled = await this.onIncomingKeyVerificationRequest( - message.sender, - transactionId - ); - if (!handled) { - // eslint-disable-next-line no-await-in-loop - await this.#retryVerificationRequestWithKeys(message.sender, transactionId, message); - } - } else if (message.type === EventType.KeyVerificationDone) { - // Rust removes completed requests while consuming the event, so no state snapshot - // exists to refresh. Keep the JS request alive long enough to expose Done. - this.#verificationRequests.get(transactionId)?.markDone(); - // eslint-disable-next-line no-await-in-loop - await this.#queryOwnKeys(); - } else { - // Without this the verifier never learns the SAS digits arrived. - // eslint-disable-next-line no-await-in-loop - await this.#verificationRequests.get(transactionId)?.refresh(); - } - } - } - - 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, - encryptionInfo: { - sender: event.encryptionInfo.sender, - senderDevice: event.encryptionInfo.senderDevice, - senderCurve25519KeyBase64: event.encryptionInfo.senderCurve25519Key, - senderVerified: event.encryptionInfo.isSenderVerified, - }, - }); - } else if (event.type === ProcessedToDeviceEventType.PlainText) { - received.push({ message, encryptionInfo: null }); - } else { - // Dropped like js-sdk's backend does, but an unreadable one carries no type, so a - // verification request lost here is invisible everywhere else. - warnToDevice('Dropped a to-device event the engine could not read', { - sender: message.sender ?? 'unknown', - type: message.type ?? 'unknown', - processed: event.type, - }); - } - } - - return received; - } - - async processKeyCounts( - oneTimeKeysCounts?: Record, - unusedFallbackKeys?: string[] - ): Promise { - await this.#receiveSyncChanges({ oneTimeKeysCounts, unusedFallbackKeys }); - } - - async processDeviceLists(deviceLists: IDeviceLists): Promise { - await this.#receiveSyncChanges({ deviceLists }); - } - - async onCryptoEvent(room: Room, event: MatrixEvent): Promise { - const config = event.getContent(); - if (config.algorithm !== 'm.megolm.v1.aes-sha2') { - engineCryptoLog.warn('general', 'Ignoring encryption event with invalid algorithm', { - roomId: room.roomId, - algorithm: config.algorithm, - }); - return; - } - - 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 { - // Working through a backlog: the next sync follows immediately, so batch the drain. - if (syncState.catchingUp) return; - void this.#flushOutgoingRequests(); - void this.#surfacePendingVerificationRequests(); - } - - async #surfacePendingVerificationRequests(): Promise { - const now = Date.now(); - if (now - this.#lastVerificationSweep < VERIFICATION_SWEEP_INTERVAL_MS) return; - this.#lastVerificationSweep = now; - - let states: EngineVerificationState[]; - try { - states = ((await this.#call('getVerificationRequests', { - userId: this.#identity.userId, - })) ?? []) as EngineVerificationState[]; - } catch (error) { - warnVerification('Could not list pending verification requests', { - reason: error instanceof Error ? error.message : String(error), - }); - return; - } - - for (const state of states) { - if (this.#verificationRequests.has(state.flowId)) continue; - if (state.phase === EnginePhase.Done || state.phase === EnginePhase.Cancelled) continue; - - const request = new EngineVerificationRequest(this.#engineCall, state); - this.#verificationRequests.set(state.flowId, request); - warnVerification('Recovered a verification request the sync path missed', { - flowId: state.flowId, - otherUserId: state.otherUserId, - phase: state.phase, - }); - this.emit(CryptoEvent.VerificationRequestReceived, request); - } - } - - async markAllTrackedUsersAsDirty(): Promise { - await this.#call('markAllTrackedUsersAsDirty'); - } - - stop(): void { - this.#stopped = true; - this.#outgoingFlush.cancel(); - this.#backupUpload.cancel(); - this.#eventsPendingKey.clear(); - this.#roomsWithTrackedMembers.clear(); - this.#encryptionChains.clear(); - this.#roomKeyInvalidations.clear(); - this.#claimChain = Promise.resolve(); - this.#backupDownloader.stop(); - } - - #trustRequirement(): number { - return this.#deviceIsolationMode?.kind === - DeviceIsolationModeKind.OnlySignedDevicesIsolationMode - ? TrustRequirement.CrossSignedOrLegacy - : TrustRequirement.Untrusted; - } - - #sharingStrategy(room: Room): string { - if ( - this.#deviceIsolationMode?.kind === DeviceIsolationModeKind.OnlySignedDevicesIsolationMode - ) { - return 'identityBasedStrategy'; - } - if (room.getBlacklistUnverifiedDevices() ?? this.globalBlacklistUnverifiedDevices) { - return 'onlyTrustedDevices'; - } - if (this.#deviceIsolationMode?.errorOnVerifiedUserProblems) { - return 'errorOnVerifiedUserProblem'; - } - return 'allDevices'; - } - - #encryptionSettings(room: Room): Record { - const config = - room.currentState.getStateEvents(EventType.RoomEncryption, '')?.getContent() ?? {}; - - const settings: Record = { - algorithm: 'm.megolm.v1.aes-sha2', - historyVisibility: room.getHistoryVisibility(), - sharingStrategy: this.#sharingStrategy(room), - }; - if (typeof config.rotation_period_ms === 'number') { - settings.rotationPeriod = config.rotation_period_ms * 1000; - } - if (typeof config.rotation_period_msgs === 'number') { - settings.rotationPeriodMessages = config.rotation_period_msgs; - } - return settings; - } - - #serializeForRoom(roomId: string, run: () => Promise): Promise { - const next = (this.#encryptionChains.get(roomId) ?? Promise.resolve()) - .catch(() => undefined) - .then(run); - this.#encryptionChains.set(roomId, next); - return next; - } - - async #ensureSessionsForUsers(users: string[]): Promise { - const next = this.#claimChain - .catch(() => undefined) - .then(async () => { - const claim = (await this.#call('getMissingSessions', { users })) as OutgoingRequest | null; - await this.#sendTracked(claim); - }); - this.#claimChain = next; - await next; - } - - async encryptEvent(event: MatrixEvent, room: Room): Promise { - return this.#serializeForRoom(room.roomId, () => this.#encryptEventInner(event, room)); - } - - async #prepareRoomForEncryption(room: Room): Promise { - const members = await room.getEncryptionTargetMembers(); - const users = members.map((member) => member.userId); - - if (!this.#roomsWithTrackedMembers.has(room.roomId)) { - await this.#trackUsers(users); - await this.#sendTracked(await this.#call('queryKeysForUsers', { users })); - this.#roomsWithTrackedMembers.add(room.roomId); - } - - void this.#flushOutgoingRequests(); - - await this.#ensureSessionsForUsers(users); - - const shared = ((await this.#call('shareRoomKey', { - roomId: room.roomId, - users, - encryptionSettings: this.#encryptionSettings(room), - })) ?? []) as OutgoingRequest[]; - for (const request of shared) { - // eslint-disable-next-line no-await-in-loop - await this.#sendTracked(request); - } - } - - async #encryptEventInner(event: MatrixEvent, room: Room): Promise { - while (true) { - const invalidation = this.#roomKeyInvalidations.get(room.roomId); - if (invalidation) await invalidation; - - await this.#prepareRoomForEncryption(room); - if (this.#roomKeyInvalidations.get(room.roomId) !== invalidation) continue; - - const encrypted = (await this.#call('encryptRoomEvent', { - roomId: room.roomId, - eventType: event.getType(), - content: JSON.stringify(event.getContent()), - })) as string; - - const own = await this.getOwnDeviceKeys(); - if (this.#roomKeyInvalidations.get(room.roomId) !== invalidation) continue; - - event.makeEncrypted( - 'm.room.encrypted', - JSON.parse(encrypted) as Record, - own.curve25519, - own.ed25519 - ); - return; - } - } - - async decryptEvent(event: MatrixEvent): Promise { - const roomId = event.getRoomId(); - if (!roomId) throw new Error('Cannot decrypt an event with no room id'); - - 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 #deviceCreationTime(): Promise { - if (this.#deviceCreationTimeMs === undefined) { - this.#deviceCreationTimeMs = (await this.#call('deviceCreationTimeMs')) as number | null; - } - return this.#deviceCreationTimeMs; - } - - async #hasSessionBackupKey(backupInfo: KeyBackupInfo): Promise { - const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; - return ( - !!keys?.decryptionKeyBase64 && - keys.backupVersion === backupInfo.version && - EngineCrypto.#keyMatchesBackup(keys.decryptionKeyBase64, backupInfo) - ); - } - - async #throwIfHistorical(event: MatrixEvent, details: Record): Promise { - const createdAt = await this.#deviceCreationTime(); - 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.#hasSessionBackupKey(backupInfo); - 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, - privKey: Uint8Array - ): Promise { - if (backupInfo.algorithm !== 'm.megolm_backup.v1.curve25519-aes-sha2') { - throw new Error(`Unsupported key backup algorithm ${backupInfo.algorithm}`); - } - - const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey)); - const authData = backupInfo.auth_data as { public_key?: string } | undefined; - if (authData?.public_key !== key.megolmV1PublicKey.publicKeyBase64) { - throw new Error('The backup key does not match this backup version'); - } - - return { - sourceTrusted: false, - async decryptSessions(ciphertexts) { - 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(); - }, - }; - } - - async importBackedUpRoomKeys( - keys: IMegolmSessionData[], - backupVersion: string, - opts?: ImportRoomKeysOpts - ): Promise { - await this.#importBackedUpRoomKeys(keys, backupVersion, opts); - } - - async #importBackedUpRoomKeys( - keys: IMegolmSessionData[], - backupVersion: string, - opts?: ImportRoomKeysOpts, - already = 0, - grandTotal = keys.length, - alreadyFailed = 0 - ): Promise<{ imported: number; processed: number; failures: number }> { - const result = (await this.#call('importBackedUpRoomKeys', { - keys: JSON.stringify(keys), - backupVersion, - })) as { importedCount?: number; totalCount?: number; skippedCount?: number } | null; - - const processed = already + (result?.totalCount ?? 0); - const failures = alreadyFailed + (result?.skippedCount ?? 0); - opts?.progressCallback?.({ - stage: ImportRoomKeyStage.LoadKeys, - successes: processed, - failures, - total: grandTotal, - }); - return { - imported: result?.importedCount ?? 0, - processed: result?.totalCount ?? 0, - failures: result?.skippedCount ?? 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( - 'general', - 'Not sharing message history: this device is not verified by our own identity' - ); - return; - } - - const bundle = (await this.#call('buildRoomKeyBundle', { - roomId, - })) as EngineRoomKeyBundle | null; - if (!bundle) return; - - const { content_uri: url } = await this.#mx.uploadContent( - new Blob([decodeBase64(bundle.encryptedData) as BlobPart]), - { includeFilename: false } - ); - - await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [userId] })); - await this.#flushOutgoingRequests(); - await this.#sendTracked(await this.#call('getMissingSessions', { users: [userId] })); - await this.#flushOutgoingRequests(); - - 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, - })) as { url?: string } | null; - if (!data?.url) return false; - - const httpUrl = new URL( - getHttpUriForMxc( - this.#mx.baseUrl, - data.url, - undefined, - undefined, - undefined, - false, - true, - true - ) - ); - const blob = await this.#mx.http.authedRequest( - Method.Get, - httpUrl.pathname + httpUrl.search, - {}, - undefined, - { rawResponseBody: true, prefix: '' } - ); - - await this.#call('receiveRoomKeyBundle', { - roomId, - inviterId: inviter, - bundle: encodeBase64(new Uint8Array(await blob.arrayBuffer())), - }); - await this.#call('clearRoomPendingKeyBundle', { roomId }); - 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 }); - } - - setDeviceIsolationMode(isolationMode: DeviceIsolationMode): void { - this.#deviceIsolationMode = isolationMode; - } - - getVersion(): string { - return 'Rust SDK (Sable engine over IPC)'; - } - - async getOwnDeviceKeys(): Promise { - const keys = (await this.#call('identityKeys')) as { ed25519: string; curve25519: string }; - return { ed25519: keys.ed25519, curve25519: keys.curve25519 }; - } - - async isEncryptionEnabledInRoom(roomId: string): Promise { - return this.#mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false; - } - - async isStateEncryptionEnabledInRoom(roomId: string): Promise { - const settings = (await this.#call('getRoomSettings', { roomId })) as { - encryptStateEvents?: boolean; - } | null; - return settings?.encryptStateEvents ?? false; - } - - prepareToEncrypt(room: Room): void { - void this.#serializeForRoom(room.roomId, async () => { - const invalidation = this.#roomKeyInvalidations.get(room.roomId); - if (invalidation) await invalidation; - await this.#prepareRoomForEncryption(room); - }).catch((error: unknown) => engineCryptoLog.warn('general', 'prepareToEncrypt failed', error)); - } - - async forceDiscardSession(roomId: string): Promise { - const invalidation = (this.#roomKeyInvalidations.get(roomId) ?? Promise.resolve()) - .catch(() => undefined) - .then(() => this.#call('invalidateGroupSession', { roomId })); - this.#roomKeyInvalidations.set(roomId, invalidation); - await invalidation; - } - - async getEncryptionInfoForEvent(event: MatrixEvent): Promise { - if (!event.getClearContent() || event.isDecryptionFailure()) return null; - if (event.status !== null) { - return { shieldColour: EventShieldColour.NONE, shieldReason: null }; - } - - const roomId = event.getRoomId(); - if (!roomId) return null; - - const info = (await this.#call('getRoomEventEncryptionInfo', { - 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, - })) as EngineEncryptionInfo | null; - - return toEventEncryptionInfo(info); - } - - async encryptToDeviceMessages( - eventType: string, - devices: { userId: string; deviceId: string }[], - payload: ToDevicePayload - ): Promise { - 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: encrypted.filter((entry) => entry !== null), - }; - } - - async resetEncryption(authUploadDeviceSigningKeys: UIAuthCallback): Promise { - await this.disableKeyStorage(); - await this.#resetCrossSigning(authUploadDeviceSigningKeys); - await this.resetKeyBackup(); - } - - async exportRoomKeys(): Promise { - return JSON.parse(await this.exportRoomKeysAsJson()) as IMegolmSessionData[]; - } - - async exportRoomKeysAsJson(): Promise { - // Already JSON text; stringifying again would double-encode the export. - return (await this.#call('exportRoomKeys')) as string; - } - - async importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise { - await this.#call('importExportedRoomKeys', { keys: JSON.stringify(keys) }); - opts?.progressCallback?.({ - stage: ImportRoomKeyStage.LoadKeys, - successes: keys.length, - failures: 0, - total: keys.length, - }); - } - - async importRoomKeysAsJson(keys: string, opts?: ImportRoomKeysOpts): Promise { - await this.importRoomKeys(JSON.parse(keys) as IMegolmSessionData[], opts); - } - - async userHasCrossSigningKeys( - userId: string = this.#identity.userId, - downloadUncached = false - ): Promise { - if (downloadUncached || userId === this.#identity.userId) { - 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.#sendTracked(await this.#call('queryKeysForUsers', { users: userIds })); - } - - const map: DeviceMap = new Map(); - await Promise.all( - userIds.map(async (userId) => { - const answer = (await this.#call('getUserDevices', { - userId, - timeoutSecs: null, - })) as { devices?: EngineDevice[] } | null; - const devices = answer?.devices ?? []; - - map.set(userId, new Map(devices.map((device) => [device.deviceId, toSdkDevice(device)]))); - }) - ); - return map; - } - - setTrustCrossSignedDevices(val: boolean): void { - this.#trustCrossSignedDevices = val; - } - - getTrustCrossSignedDevices(): boolean { - return this.#trustCrossSignedDevices; - } - - async getUserVerificationStatus(userId: string): Promise { - const identity = (await this.#call('getIdentity', { userId })) as EngineIdentityInfo | null; - if (!identity) return new UserVerificationStatus(false, false, false); - - return new UserVerificationStatus( - identity.isVerified, - identity.wasPreviouslyVerified, - true, - identity.identityNeedsUserApproval ?? false - ); - } - - async pinCurrentUserIdentity(userId: string): Promise { - await this.#call('userIdentity.pin', { userId }); - } - - async withdrawVerificationRequirement(userId: string): Promise { - await this.#call('userIdentity.withdrawVerification', { userId }); - } - - async getUserCrossSigningKeys(userId: string): Promise | null> { - const identity = (await this.#call('getIdentity', { userId })) as EngineIdentityInfo | null; - if (!identity) return null; - - return { - [CrossSigningKey.Master]: parseCrossSigningKey(identity.masterKey), - [CrossSigningKey.SelfSigning]: parseCrossSigningKey(identity.selfSigningKey), - [CrossSigningKey.UserSigning]: parseCrossSigningKey(identity.userSigningKey), - }; - } - - async getDeviceVerificationStatus( - userId: string, - deviceId: string - ): Promise { - const device = (await this.#call('getDevice', { - userId, - deviceId, - timeoutSecs: null, - })) as EngineDevice | null; - if (!device) return null; - - return new DeviceVerificationStatus({ - signedByOwner: device.isCrossSignedByOwner, - crossSigningVerified: device.isCrossSigningTrusted, - localVerified: device.isLocallyTrusted, - trustCrossSignedDevices: this.#trustCrossSignedDevices, - }); - } - - async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise { - await this.#call('device.setLocalTrust', { - userId, - deviceId, - trustState: verified ? RustSdkCryptoJs.LocalTrust.Verified : RustSdkCryptoJs.LocalTrust.Unset, - }); - } - - async #queryOwnKeys(): Promise { - await this.#sendTracked( - await this.#call('queryKeysForUsers', { users: [this.#identity.userId] }) - ); - await this.#flushOutgoingRequests(); - } - - async #signOwnDeviceIfNeeded(): Promise { - const status = await this.getDeviceVerificationStatus( - this.#identity.userId, - this.#identity.deviceId - ); - if (status?.crossSigningVerified) return; - - const request = (await this.#call('device.verify', { - userId: this.#identity.userId, - deviceId: this.#identity.deviceId, - })) as OutgoingRequest | null; - if (!request) return; - await sendOutgoingRequest(this.#mx, request); - await this.#queryOwnKeys(); - } - - async crossSignDevice(deviceId: string): Promise { - await this.#sendTracked( - await this.#call('device.verify', { userId: this.#identity.userId, deviceId }) - ); - await this.#flushOutgoingRequests(); - await this.#queryOwnKeys(); - } - - async isCrossSigningReady(): Promise { - const status = await this.getCrossSigningStatus(); - const cached = status.privateKeysCachedLocally; - const cachedLocally = cached.masterKey && cached.selfSigningKey && cached.userSigningKey; - return status.publicKeysOnDevice && (cachedLocally || status.privateKeysInSecretStorage); - } - - async getCrossSigningKeyId( - type: CrossSigningKey = CrossSigningKey.Master - ): Promise { - const keys = await this.getUserCrossSigningKeys(this.#identity.userId); - const first = Object.values(keys?.[type]?.keys ?? {})[0]; - return first ?? null; - } - - async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise { - if (opts.setupNewCrossSigning) { - await this.#resetCrossSigning(opts.authUploadDeviceSigningKeys); - return; - } - - const status = (await this.#call('crossSigningStatus')) as { - hasMaster: boolean; - hasSelfSigning: boolean; - hasUserSigning: boolean; - }; - const stored = await this.#crossSigningKeysInStorage(); - - if (status.hasMaster && status.hasSelfSigning && status.hasUserSigning) { - if (!stored && (await this.#mx.secretStorage.hasKey())) { - await this.#exportCrossSigningKeysToStorage(); - } - await this.#signOwnDeviceIfNeeded(); - return; - } - - if (!stored) { - await this.#resetCrossSigning(opts.authUploadDeviceSigningKeys); - return; - } - - await this.#importCrossSigningKeys(stored); - } - - async #crossSigningKeysInStorage(): Promise | null> { - const [masterKey, selfSigningKey, userSigningKey] = await Promise.all([ - this.#mx.secretStorage.get('m.cross_signing.master'), - this.#mx.secretStorage.get('m.cross_signing.self_signing'), - this.#mx.secretStorage.get('m.cross_signing.user_signing'), - ]); - - if (!masterKey || !selfSigningKey || !userSigningKey) return null; - return { - master_key: masterKey, - self_signing_key: selfSigningKey, - user_signing_key: userSigningKey, - }; - } - - async #importCrossSigningKeys(keys: Record): Promise { - await this.#queryOwnKeys(); - - const status = (await this.#call('importCrossSigningKeys', keys)) as { - hasMaster: boolean; - hasSelfSigning: boolean; - hasUserSigning: boolean; - }; - if (!status.hasMaster || !status.hasSelfSigning || !status.hasUserSigning) { - throw new Error('The cross-signing keys in secret storage could not be imported'); - } - - const request = (await this.#call('device.verify', { - userId: this.#identity.userId, - deviceId: this.#identity.deviceId, - })) as OutgoingRequest | null; - if (request) { - await sendOutgoingRequest(this.#mx, request); - await this.#queryOwnKeys(); - } - } - - async #exportCrossSigningKeysToStorage(): Promise { - const exported = (await this.#call('exportCrossSigningKeys')) as Record< - string, - string | undefined - > | null; - if (!exported) return; - - const entries: [SecretStorageKey, string | undefined][] = [ - ['m.cross_signing.master', exported.masterKey], - ['m.cross_signing.self_signing', exported.self_signing_key ?? exported.selfSigningKey], - ['m.cross_signing.user_signing', exported.user_signing_key ?? exported.userSigningKey], - ]; - await Promise.all( - entries - .filter(([, value]) => Boolean(value)) - .map(([name, value]) => this.#mx.secretStorage.store(name, value as string)) - ); - } - - async #resetCrossSigning(uiaCallback?: UIAuthCallback): Promise { - const requests = (await this.#call('bootstrapCrossSigning', { - reset: true, - })) as EngineBootstrapRequests | null; - - if (await this.#mx.secretStorage.hasKey()) await this.#exportCrossSigningKeysToStorage(); - - const uploadKeys = requests?.uploadKeysRequest; - if (uploadKeys) { - const response = await sendOutgoingRequest(this.#mx, uploadKeys); - await this.#call('markRequestAsSent', { - requestId: uploadKeys.id, - requestType: uploadKeys.type, - response, - }); - } - - await this.#uploadDeviceSigningKeys(requests?.uploadSigningKeysRequest, uiaCallback); - - const signatures = requests?.uploadSignaturesRequest; - if (signatures) await sendOutgoingRequest(this.#mx, signatures); - - await this.#flushOutgoingRequests(); - } - - async #uploadDeviceSigningKeys( - request: { body: string } | null | undefined, - uiaCallback?: UIAuthCallback - ): Promise { - if (!request) return; - - const body = JSON.parse(request.body) as Record; - const send = (auth: AuthDict | null) => - this.#mx.http.authedRequest( - Method.Post, - '/keys/device_signing/upload', - undefined, - auth ? { ...body, auth } : body, - { prefix: ClientPrefix.V3 } - ); - - if (uiaCallback) await uiaCallback(send); - else await send(null); - } - - async isSecretStorageReady(): Promise { - return (await this.getSecretStorageStatus()).ready; - } - - async getSecretStorageStatus(): Promise { - const defaultKeyId = await this.#mx.secretStorage.getDefaultKeyId(); - if (!defaultKeyId) { - return { ready: false, defaultKeyId: null, secretStorageKeyValidityMap: {} }; - } - - const names: SecretStorageKey[] = [...SECRETS_IN_STORAGE]; - if (await this.getActiveSessionBackupVersion()) names.push('m.megolm_backup.v1'); - - const entries = await Promise.all( - names.map( - async (name) => - [name, await secretStorageCanAccessSecrets(this.#mx.secretStorage, [name])] as const - ) - ); - const secretStorageKeyValidityMap = Object.fromEntries(entries); - - return { - ready: entries.every(([, stored]) => stored), - defaultKeyId, - secretStorageKeyValidityMap, - }; - } - - async bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise { - const existingKeyId = await this.#mx.secretStorage.getDefaultKeyId(); - const needsKey = opts.setupNewSecretStorage || !existingKeyId; - - if (needsKey) { - if (!opts.createSecretStorageKey) { - throw new Error('bootstrapSecretStorage needs createSecretStorageKey to make a new key'); - } - const key = await opts.createSecretStorageKey(); - const { keyId, keyInfo } = await this.#mx.secretStorage.addKey( - SECRET_STORAGE_ALGORITHM_V1_AES, - { ...key.keyInfo, key: key.privateKey } - ); - await this.#mx.secretStorage.setDefaultKeyId(keyId); - this.#mx.cryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, key.privateKey); - engineCryptoLog.info('general', 'Created a new secret storage key', { - keyId, - algorithm: keyInfo.algorithm, - }); - } - - await this.#exportCrossSigningKeysToStorage(); - - if (opts.setupNewKeyBackup) await this.resetKeyBackup(); - } - - async getCrossSigningStatus(): Promise { - const status = (await this.#call('crossSigningStatus')) as { - hasMaster: boolean; - hasSelfSigning: boolean; - hasUserSigning: boolean; - }; - const inStorage = await secretStorageCanAccessSecrets(this.#mx.secretStorage, [ - ...SECRETS_IN_STORAGE, - ]); - - return { - publicKeysOnDevice: status.hasMaster && status.hasSelfSigning && status.hasUserSigning, - privateKeysInSecretStorage: inStorage, - privateKeysCachedLocally: { - masterKey: status.hasMaster, - selfSigningKey: status.hasSelfSigning, - userSigningKey: status.hasUserSigning, - }, - }; - } - - async createRecoveryKeyFromPassphrase(password?: string): Promise { - if (!password) { - const key = new Uint8Array(32); - globalThis.crypto.getRandomValues(key); - return { privateKey: key, encodedPrivateKey: encodeRecoveryKey(key) }; - } - - const salt = secureRandomString(32); - const privateKey = await deriveRecoveryKeyFromPassphrase( - password, - salt, - RECOVERY_KEY_DERIVATION_ITERATIONS - ); - - return { - keyInfo: { - passphrase: { - algorithm: 'm.pbkdf2', - iterations: RECOVERY_KEY_DERIVATION_ITERATIONS, - salt, - }, - }, - privateKey, - encodedPrivateKey: encodeRecoveryKey(privateKey), - }; - } - - getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { - return [...this.#verificationRequests.values()].filter( - (request) => request.otherUserId === userId && request.roomId === undefined && request.pending - ); - } - - findVerificationRequestDMInProgress( - roomId: string, - userId?: string - ): VerificationRequest | undefined { - return [...this.#verificationRequests.values()].find( - (request) => - request.roomId === roomId && - request.pending && - (userId === undefined || request.otherUserId === userId) - ); - } - - /** The engine needs the event id of the request we send, so build, send, then register. */ - async requestVerificationDM(userId: string, roomId: string): Promise { - const requestContent = (await this.#call('userIdentity.verificationRequestContent', { - userId, - roomId, - methods: SUPPORTED_VERIFICATION_METHOD_CODES, - })) 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, - EventType.RoomMessage, - JSON.parse(content) as RoomMessageEventContent - ); - - const started = (await this.#call('userIdentity.requestVerificationDm', { - userId, - roomId, - requestEventId: eventId, - methods: SUPPORTED_VERIFICATION_METHOD_CODES, - })) as { request: EngineVerificationState; outgoingRequest?: unknown }; - - if (isOutgoingRequest(started.outgoingRequest)) { - await sendOutgoingRequest(this.#mx, started.outgoingRequest); - } - await this.#flushOutgoingRequests(); - - const request = new EngineVerificationRequest(this.#engineCall, started.request); - this.#verificationRequests.set(started.request.flowId, request); - return request; - } - - async #cancelStaleRequests(userId: string): Promise { - let states: EngineVerificationState[] = []; - try { - states = ((await this.#call('getVerificationRequests', { userId })) ?? - []) as EngineVerificationState[]; - } catch (error) { - warnVerification('Could not list stale verification requests', { - reason: error instanceof Error ? error.message : String(error), - }); - } - - const stale = new Map(); - for (const state of states) { - if (state.phase === EnginePhase.Done || state.phase === EnginePhase.Cancelled) continue; - stale.set( - state.flowId, - this.#verificationRequests.get(state.flowId) ?? - new EngineVerificationRequest(this.#engineCall, state) - ); - } - for (const request of this.#verificationRequests.values()) { - if (request.otherUserId === userId && request.pending && request.transactionId) { - stale.set(request.transactionId, request); - } - } - - for (const [flowId, request] of stale) { - traceVerification('Cancelling a stale verification request', { flowId }); - try { - // eslint-disable-next-line no-await-in-loop - await request.cancel(); - this.#verificationRequests.delete(flowId); - } catch (error) { - warnVerification('Could not cancel a stale verification request', { - flowId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - await this.#flushOutgoingRequests(); - } - - async requestOwnUserVerification(): Promise { - await this.#cancelStaleRequests(this.#identity.userId); - return this.#startVerification('userIdentity.requestVerification', { - userId: this.#identity.userId, - methods: SUPPORTED_VERIFICATION_METHOD_CODES, - }); - } - - async requestDeviceVerification(userId: string, deviceId: string): Promise { - await this.#cancelStaleRequests(userId); - await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [userId] })); - await this.#flushOutgoingRequests(); - return this.#startVerification('device.requestVerification', { - userId, - deviceId, - methods: SUPPORTED_VERIFICATION_METHOD_CODES, - }); - } - - async getSessionBackupPrivateKey(): Promise { - const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; - if (!keys?.decryptionKeyBase64) return null; - return decodeBase64(keys.decryptionKeyBase64); - } - - async storeSessionBackupPrivateKey(key: Uint8Array, version: string): Promise { - await this.#call('saveBackupDecryptionKey', { decryptionKey: encodeBase64(key), version }); - this.emit(CryptoEvent.KeyBackupDecryptionKeyCached, version); - } - - async requestMissingSecretsIfNeeded(): Promise { - const requested = (await this.#call('requestMissingSecretsIfNeeded')) === true; - if (requested) await this.#flushOutgoingRequests(); - return requested; - } - - async checkSecrets(name: string): Promise { - const values = ((await this.#call('getSecretsFromInbox', { secretName: name })) ?? - []) as string[]; - - for (const value of values) { - // eslint-disable-next-line no-await-in-loop - if (await this.#handleSecretReceived(name, value)) break; - } - - await this.#call('deleteSecretsFromInbox', { secretName: name }); - } - - async #handleSecretReceived(name: string, value: string): Promise { - if (name !== 'm.megolm_backup.v1') return false; - - const backupInfo = await this.#requestKeyBackupVersion(); - if (!backupInfo?.version) { - engineCryptoLog.warn('general', 'Received a backup key with no server-side backup'); - return false; - } - - const publicKey = (backupInfo.auth_data as { public_key?: string } | undefined)?.public_key; - let matches = false; - try { - const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(value); - try { - matches = key.megolmV1PublicKey.publicKeyBase64 === publicKey; - } finally { - key.free(); - } - } catch (error) { - engineCryptoLog.warn('general', 'Received an invalid backup decryption key', error); - return false; - } - - if (!matches) { - engineCryptoLog.warn( - 'general', - `Received a backup key for another backup than version ${backupInfo.version}` - ); - return false; - } - - await this.storeSessionBackupPrivateKey(decodeBase64(value), backupInfo.version); - await this.#connectKeyBackup(); - return true; - } - - async loadSessionBackupPrivateKeyFromSecretStorage(): Promise { - const encoded = await this.#mx.secretStorage.get('m.megolm_backup.v1'); - if (!encoded) throw new Error('No session backup key in secret storage'); - - const backupInfo = await this.#requestKeyBackupVersion(); - if (!backupInfo?.version) throw new Error('No key backup version to attach the key to'); - - if (!EngineCrypto.#keyMatchesBackup(encoded, backupInfo)) { - throw new DecryptionKeyDoesNotMatchError( - 'loadSessionBackupPrivateKeyFromSecretStorage: decryption key does not match backup info' - ); - } - - await this.storeSessionBackupPrivateKey(decodeBase64(encoded), backupInfo.version); - } - - static #keyMatchesBackup(encoded: string, backupInfo: KeyBackupInfo): boolean { - const publicKey = (backupInfo.auth_data as { public_key?: string } | undefined)?.public_key; - try { - const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encoded); - try { - return key.megolmV1PublicKey.publicKeyBase64 === publicKey; - } finally { - key.free(); - } - } catch { - return false; - } - } - - async getActiveSessionBackupVersion(): Promise { - return (await this.#call('backupVersion')) as string | null; - } - - /** The engine reports signature trust only; whether our key opens it is separate. */ - async isKeyBackupTrusted(info: KeyBackupInfo): Promise { - const verification = (await this.#call('verifyBackup', { - backupInfo: JSON.stringify(info), - })) as { trusted?: boolean } | null; - - const stored = await this.getSessionBackupPrivateKey(); - const publicKey = (info.auth_data as { public_key?: string } | undefined)?.public_key; - let matchesDecryptionKey = false; - if (stored && publicKey) { - const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(stored)); - try { - matchesDecryptionKey = key.megolmV1PublicKey.publicKeyBase64 === publicKey; - } finally { - key.free(); - } - } - - return { trusted: verification?.trusted ?? false, matchesDecryptionKey }; - } - - async getKeyBackupInfo(): Promise { - if (this.#serverBackupInfo !== undefined) return this.#serverBackupInfo; - return this.#requestKeyBackupVersion(); - } - - async #requestKeyBackupVersion(): Promise { - try { - this.#serverBackupInfo = await this.#mx.http.authedRequest( - Method.Get, - '/room_keys/version', - undefined, - undefined, - { prefix: ClientPrefix.V3 } - ); - } catch (error) { - if ((error as { errcode?: string }).errcode !== 'M_NOT_FOUND') throw error; - this.#serverBackupInfo = null; - } - return this.#serverBackupInfo; - } - - 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.#keyBackupCheck ??= this.#checkKeyBackupAndEnable().finally(() => { - this.#keyBackupCheck = undefined; - }); - return this.#keyBackupCheck; - } - - async #checkKeyBackupAndEnable(): Promise { - const backupInfo = await this.#requestKeyBackupVersion(); - const activeVersion = await this.getActiveSessionBackupVersion(); - - if (!backupInfo?.version) { - if (activeVersion !== null) await this.#disableKeyBackup(); - return null; - } - - const trustInfo = await this.isKeyBackupTrusted(backupInfo); - 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) - ); - let publicKey: string; - try { - publicKey = decryptionKey.megolmV1PublicKey.publicKeyBase64; - } finally { - 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: authData, - }, - { prefix: ClientPrefix.V3 } - ); - - this.#serverBackupInfo = undefined; - await this.#call('enableBackupV1', { publicKeyBase64: publicKey, version: created.version }); - await this.storeSessionBackupPrivateKey(key.privateKey, created.version); - await this.#pushSecretToVerifiedDevices('m.megolm_backup.v1'); - if (await this.#secretStorageHasAesKey()) { - await this.#mx.secretStorage.store('m.megolm_backup.v1', encodeBase64(key.privateKey)); - } - } - - async #secretStorageHasAesKey(): Promise { - const stored = await this.#mx.secretStorage.getKey(); - if (!stored) return false; - const [, keyInfo] = stored; - return keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES; - } - - 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.#sendTracked( - await this.#call('getMissingSessions', { users: [this.#identity.userId] }) - ); - await this.#flushOutgoingRequests(); - await this.#call('pushSecretToVerifiedDevices', { secretName }); - await this.#flushOutgoingRequests(); - } - - async disableKeyStorage(): Promise { - 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.#requestKeyBackupVersion(); - 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 }), - undefined, - undefined, - { prefix: ClientPrefix.V3 } - ); - this.#serverBackupInfo = undefined; - 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 decryption key found in crypto store'); - } - - 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 }); - - const decryptor = await this.getBackupDecryptor( - backupInfo, - decodeBase64(keys.decryptionKeyBase64) - ); - try { - const response = await this.#mx.http.authedRequest<{ - rooms: Record }>; - }>(Method.Get, '/room_keys/keys', { version: keys.backupVersion }, undefined, { - prefix: ClientPrefix.V3, - }); - - const rooms = Object.entries(response.rooms ?? {}); - const total = rooms.reduce( - (count, [, room]) => count + Object.keys(room.sessions ?? {}).length, - 0 - ); - - let imported = 0; - let processed = 0; - let failures = 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 - const chunk = await this.#importBackedUpRoomKeys( - withRoom.slice(start, start + RESTORE_CHUNK_SIZE), - keys.backupVersion, - opts, - processed, - total, - failures - ); - imported += chunk.imported; - processed += chunk.processed; - failures += chunk.failures; - } - } - - return { total, imported }; - } finally { - decryptor.free(); - } - } - - async restoreKeyBackupWithPassphrase( - passphrase: string, - opts?: KeyBackupRestoreOpts - ): Promise { - const backupInfo = await this.#requestKeyBackupVersion(); - const passphraseInfo = backupInfo?.auth_data?.private_key_salt - ? backupInfo.auth_data - : undefined; - if (!passphraseInfo?.private_key_salt || !passphraseInfo.private_key_iterations) { - throw new Error('This backup was not created from a passphrase'); - } - - const privateKey = await deriveRecoveryKeyFromPassphrase( - passphrase, - passphraseInfo.private_key_salt, - passphraseInfo.private_key_iterations - ); - if (backupInfo?.version) - await this.storeSessionBackupPrivateKey(privateKey, backupInfo.version); - return this.restoreKeyBackup(opts); - } - - async isDehydrationSupported(): Promise { - return false; - } - - async startDehydration(opts?: StartDehydrationOpts | boolean): Promise { - // A dehydrated device is a second server-side device; Sable keeps one per session. - throw new Error( - `Device dehydration is not supported by the Sable crypto engine (opts: ${JSON.stringify(opts) ?? 'none'})` - ); - } -} diff --git a/src/app/crypto/engineCrypto/backupImport.test.ts b/src/app/crypto/engineCrypto/backupImport.test.ts deleted file mode 100644 index f1726b15d1..0000000000 --- a/src/app/crypto/engineCrypto/backupImport.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ImportRoomKeyStage, type MatrixClient } from '$types/matrix-sdk'; -import type { IMegolmSessionData } from 'matrix-js-sdk/lib/@types/crypto'; -import { engineInvoke } from '../olmMachine/engineInvoke'; -import { EngineCrypto } from './EngineCrypto'; - -vi.mock('../olmMachine/engineInvoke', () => ({ - engineInvoke: vi.fn<(...args: never[]) => Promise>(async () => ({ - importedCount: 0, - totalCount: 0, - })), -})); - -const mockInvoke = vi.mocked(engineInvoke); - -const crypto = () => - new EngineCrypto({} as MatrixClient, { userId: '@me:example.org', deviceId: 'DEVICE' }); - -const session = (id: string) => ({ session_id: id }) as IMegolmSessionData; - -describe('importBackedUpRoomKeys', () => { - beforeEach(() => mockInvoke.mockClear()); - - it('passes the backup version through alongside the keys', async () => { - await crypto().importBackedUpRoomKeys([session('a'), session('b')], '7'); - - const [, method, args] = mockInvoke.mock.calls[0] as [ - unknown, - string, - { backupVersion: string; keys: string }, - ]; - expect(method).toBe('importBackedUpRoomKeys'); - expect(args.backupVersion).toBe('7'); - expect(JSON.parse(args.keys)).toHaveLength(2); - }); - - it('counts keys the engine already held as processed, not as failures', async () => { - mockInvoke.mockResolvedValueOnce({ importedCount: 0, totalCount: 2, skippedCount: 0 }); - const progressCallback = vi.fn<(stage: unknown) => void>(); - - await crypto().importBackedUpRoomKeys([session('a'), session('b')], '7', { progressCallback }); - - expect(progressCallback).toHaveBeenCalledWith({ - stage: ImportRoomKeyStage.LoadKeys, - successes: 2, - failures: 0, - total: 2, - }); - }); - - it('reports only the keys the engine could not read as failures', async () => { - mockInvoke.mockResolvedValueOnce({ importedCount: 1, totalCount: 1, skippedCount: 1 }); - const progressCallback = vi.fn<(stage: unknown) => void>(); - - await crypto().importBackedUpRoomKeys([session('a'), session('b')], '7', { progressCallback }); - - expect(progressCallback).toHaveBeenCalledWith({ - stage: ImportRoomKeyStage.LoadKeys, - successes: 1, - failures: 1, - total: 2, - }); - }); - - it('reports nothing processed when the engine reports nothing', async () => { - mockInvoke.mockResolvedValueOnce(null); - const progressCallback = vi.fn<(stage: unknown) => void>(); - - await crypto().importBackedUpRoomKeys([session('a')], '7', { progressCallback }); - - expect(progressCallback).toHaveBeenCalledWith( - expect.objectContaining({ successes: 0, failures: 0, total: 1 }) - ); - }); -}); diff --git a/src/app/crypto/engineCrypto/backupInfoCache.test.ts b/src/app/crypto/engineCrypto/backupInfoCache.test.ts deleted file mode 100644 index 473093fab8..0000000000 --- a/src/app/crypto/engineCrypto/backupInfoCache.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient } 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 notFound = Object.assign(new Error('no backup'), { errcode: 'M_NOT_FOUND' }); - -const setup = () => { - const versionGets: unknown[] = []; - const authedRequest = vi.fn<(...args: never[]) => Promise>( - async (_method: unknown, url: unknown) => { - if (url === '/room_keys/version') { - versionGets.push(url); - throw notFound; - } - return {}; - } - ); - const mx = { - http: { authedRequest }, - secretStorage: { isStored: async () => null }, - } as unknown as MatrixClient; - return { mx, versionGets }; -}; - -describe('key backup info caching', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('serves repeated getKeyBackupInfo calls from one request', async () => { - const { mx, versionGets } = setup(); - mockInvoke.mockImplementation(async () => null); - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - - await vi.waitFor(() => expect(versionGets.length).toBe(1)); - - await crypto.getKeyBackupInfo(); - await crypto.getKeyBackupInfo(); - await crypto.getKeyBackupInfo(); - - expect(versionGets).toHaveLength(1); - }); - - it('re-reads the version after a backup is deleted', async () => { - const { mx, versionGets } = setup(); - mockInvoke.mockImplementation(async () => null); - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await vi.waitFor(() => expect(versionGets.length).toBe(1)); - - await crypto.getKeyBackupInfo(); - expect(versionGets).toHaveLength(1); - - await crypto.deleteKeyBackupVersion('1'); - await crypto.getKeyBackupInfo(); - expect(versionGets).toHaveLength(2); - }); -}); diff --git a/src/app/crypto/engineCrypto/backupKeyFrom4S.test.ts b/src/app/crypto/engineCrypto/backupKeyFrom4S.test.ts deleted file mode 100644 index 1eea6d7ff0..0000000000 --- a/src/app/crypto/engineCrypto/backupKeyFrom4S.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; -import { DecryptionKeyDoesNotMatchError } from 'matrix-js-sdk/lib/crypto-api'; -import type { MatrixClient } 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 clientWith = (encoded: string, publicKey: string) => - ({ - secretStorage: { get: async () => encoded }, - http: { - authedRequest: vi.fn<(...args: never[]) => Promise>(async () => ({ - version: '3', - algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', - auth_data: { public_key: publicKey }, - })), - }, - }) as unknown as MatrixClient; - -describe('loadSessionBackupPrivateKeyFromSecretStorage', () => { - beforeAll(() => RustSdkCryptoJs.initAsync()); - beforeEach(() => mockInvoke.mockReset().mockResolvedValue(null)); - - it('rejects a stored key that does not match the server backup', async () => { - const stored = RustSdkCryptoJs.BackupDecryptionKey.createRandomKey(); - const other = RustSdkCryptoJs.BackupDecryptionKey.createRandomKey(); - const mx = clientWith(stored.toBase64(), other.megolmV1PublicKey.publicKeyBase64); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - - await expect(crypto.loadSessionBackupPrivateKeyFromSecretStorage()).rejects.toBeInstanceOf( - DecryptionKeyDoesNotMatchError - ); - expect(mockInvoke.mock.calls.some(([, m]) => m === 'saveBackupDecryptionKey')).toBe(false); - }); - - it('accepts a stored key that matches the server backup', async () => { - const stored = RustSdkCryptoJs.BackupDecryptionKey.createRandomKey(); - const mx = clientWith(stored.toBase64(), stored.megolmV1PublicKey.publicKeyBase64); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - - await expect(crypto.loadSessionBackupPrivateKeyFromSecretStorage()).resolves.toBeUndefined(); - expect(mockInvoke.mock.calls.some(([, m]) => m === 'saveBackupDecryptionKey')).toBe(true); - }); -}); diff --git a/src/app/crypto/engineCrypto/backupUpload.test.ts b/src/app/crypto/engineCrypto/backupUpload.test.ts deleted file mode 100644 index 684fed377e..0000000000 --- a/src/app/crypto/engineCrypto/backupUpload.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 { SECRET_STORAGE_ALGORITHM_V1_AES } from 'matrix-js-sdk/lib/secret-storage'; -import type { MatrixClient } 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' }, -}; - -const clientStub = () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => BACKUP_INFO); - return { http: { authedRequest } } as unknown as MatrixClient; -}; - -const tick = () => - new Promise((resolve) => { - setTimeout(resolve, 0); - }); - -const settle = async () => { - for (let i = 0; i < 10; i += 1) { - // eslint-disable-next-line no-await-in-loop - await tick(); - } -}; - -const invoked = (method: string) => mockInvoke.mock.calls.filter(([, name]) => name === method); - -describe('key backup upload', () => { - beforeEach(() => mockInvoke.mockReset()); - - const backupRequest = { id: 'r1', type: 6, version: '7', body: '{"rooms":{}}' }; - - it('uploads pending room keys once the backup is connected', async () => { - let pending = 1; - mockInvoke.mockImplementation(async (_identity, method) => { - 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; - return backupRequest; - } - if (method === 'roomKeyCounts') return { total: 4, backedUp: 3 }; - return null; - }); - - const crypto = new EngineCrypto(clientStub(), { userId: '@me:e.org', deviceId: 'D' }); - const remaining = vi.fn<(count: number) => void>(); - crypto.on(CryptoEvent.KeyBackupSessionsRemaining, remaining); - await settle(); - - expect(invoked('backupRoomKeys').length).toBeGreaterThan(0); - expect(invoked('markRequestAsSent')[0]?.[2]).toMatchObject({ requestId: 'r1', requestType: 6 }); - expect(remaining).toHaveBeenCalledWith(1); - expect(remaining).toHaveBeenLastCalledWith(0); - }); - - it('does not upload when the engine has no backup enabled', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - 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; - }); - - const crypto = new EngineCrypto(clientStub(), { userId: '@me:e.org', deviceId: 'D' }); - crypto.onKeysChanged(); - await settle(); - - expect(invoked('backupRoomKeys')).toHaveLength(0); - }); - - it('uploads newly received room keys', async () => { - let pending = 2; - mockInvoke.mockImplementation(async (_identity, method) => { - 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; - return backupRequest; - } - if (method === 'roomKeyCounts') return { total: 2, backedUp: 2 }; - return null; - }); - - const crypto = new EngineCrypto(clientStub(), { userId: '@me:e.org', deviceId: 'D' }); - await settle(); - crypto.onKeysChanged(); - await settle(); - - expect(invoked('backupRoomKeys').length).toBeGreaterThan(1); - }); -}); - -function clientWithSecretStorage(key: unknown) { - const store = vi.fn<(name: string, value: string) => Promise>(async () => undefined); - const getKey = vi.fn<() => Promise>(async () => key); - const mx = { - http: { - authedRequest: vi.fn<(...args: never[]) => Promise>(async () => ({ - version: '8', - })), - }, - secretStorage: { store, getKey }, - } as unknown as MatrixClient; - return { mx, store }; -} - -describe('resetKeyBackup', () => { - beforeAll(() => RustSdkCryptoJs.initAsync()); - - beforeEach(() => mockInvoke.mockReset()); - - 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 { mx, store } = clientWithSecretStorage([ - 'key-id', - { algorithm: SECRET_STORAGE_ALGORITHM_V1_AES }, - ]); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).resetKeyBackup(); - - const order = mockInvoke.mock.calls.map(([, method]) => method); - expect(order.indexOf('saveBackupDecryptionKey')).toBeLessThan( - order.indexOf('pushSecretToVerifiedDevices') - ); - expect(invoked('pushSecretToVerifiedDevices')[0]?.[2]).toMatchObject({ - secretName: 'm.megolm_backup.v1', - }); - expect(invoked('getMissingSessions')[0]?.[2]).toMatchObject({ users: ['@me:e.org'] }); - expect(store).toHaveBeenCalledWith('m.megolm_backup.v1', expect.any(String)); - }); - - it('does not write the backup key to secret storage when 4S is not set up', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'isBackupEnabled') return false; - if (method === 'backupVersion') return null; - return null; - }); - const { mx, store } = clientWithSecretStorage(null); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).resetKeyBackup(); - - expect(invoked('pushSecretToVerifiedDevices')).toHaveLength(1); - expect(store).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/crypto/engineCrypto/bootstrapCrossSigning.test.ts b/src/app/crypto/engineCrypto/bootstrapCrossSigning.test.ts deleted file mode 100644 index 10ca86dab1..0000000000 --- a/src/app/crypto/engineCrypto/bootstrapCrossSigning.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Method } from 'matrix-js-sdk/lib/http-api'; -import type { MatrixClient } 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 BOOTSTRAP_REQUESTS = { - uploadKeysRequest: { - id: 'u1', - type: 0, - className: 'KeysUploadRequest', - body: '{"device_keys":{}}', - }, - uploadSigningKeysRequest: { - className: 'UploadSigningKeysRequest', - id: null, - body: '{"master_key":{"keys":{}}}', - }, - uploadSignaturesRequest: { type: 4, className: 'SignatureUploadRequest', body: '{}' }, -}; - -const STORED_KEYS = { - 'm.cross_signing.master': 'msk', - 'm.cross_signing.self_signing': 'ssk', - 'm.cross_signing.user_signing': 'usk', -}; - -type ClientOptions = { - storage?: Record; - hasKey?: boolean; -}; - -const clientStub = ({ storage = {}, hasKey = true }: ClientOptions = {}) => { - const store = vi.fn<(name: string, value: string) => Promise>(async () => undefined); - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => ({})); - const mx = { - http: { authedRequest }, - secretStorage: { - get: async (name: string) => storage[name] ?? null, - hasKey: async () => hasKey, - store, - }, - } as unknown as MatrixClient; - return { mx, authedRequest, store }; -}; - -const requestsTo = (authedRequest: ReturnType['authedRequest'], url: string) => - authedRequest.mock.calls.filter((call) => call[1] === url); - -const invoked = (method: string) => mockInvoke.mock.calls.filter(([, name]) => name === method); - -const engine = (overrides: (method: string, args?: Record) => unknown) => { - mockInvoke.mockImplementation(async (_identity, method, args) => { - const result = overrides(method as string, args as Record); - return result === undefined ? null : result; - }); -}; - -const noKeys = { hasMaster: false, hasSelfSigning: false, hasUserSigning: false }; -const allKeys = { hasMaster: true, hasSelfSigning: true, hasUserSigning: true }; - -describe('bootstrapCrossSigning', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('imports the keys from secret storage instead of resetting', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return noKeys; - if (method === 'importCrossSigningKeys') return allKeys; - if (method === 'device.verify') return { id: null, type: 4, body: '{}' }; - return undefined; - }); - const { mx, authedRequest } = clientStub({ storage: STORED_KEYS }); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}); - - expect(invoked('bootstrapCrossSigning')).toHaveLength(0); - expect(invoked('importCrossSigningKeys')[0]?.[2]).toMatchObject({ - master_key: 'msk', - self_signing_key: 'ssk', - user_signing_key: 'usk', - }); - expect(requestsTo(authedRequest, '/_matrix/client/v3/keys/signatures/upload')).toHaveLength(1); - }); - - it('refreshes the published keys on both sides of a secret storage import', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return noKeys; - if (method === 'queryKeysForUsers') - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - if (method === 'importCrossSigningKeys') return allKeys; - if (method === 'device.verify') return { id: null, type: 4, body: '{}' }; - return undefined; - }); - const { mx, authedRequest } = clientStub({ storage: STORED_KEYS }); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}); - - const methods = mockInvoke.mock.calls.map(([, name]) => name); - expect(methods.indexOf('queryKeysForUsers')).toBeLessThan( - methods.indexOf('importCrossSigningKeys') - ); - expect(methods.lastIndexOf('queryKeysForUsers')).toBeGreaterThan( - methods.indexOf('device.verify') - ); - expect(requestsTo(authedRequest, '/_matrix/client/v3/keys/query')).toHaveLength(2); - }); - - it('refreshes the published keys after blindly cross-signing a device', async () => { - engine((method) => { - if (method === 'queryKeysForUsers') - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - if (method === 'device.verify') return { id: null, type: 4, body: '{}' }; - return undefined; - }); - const { mx, authedRequest } = clientStub(); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).crossSignDevice('OTHER'); - - expect(requestsTo(authedRequest, '/_matrix/client/v3/keys/signatures/upload')).toHaveLength(1); - expect(requestsTo(authedRequest, '/_matrix/client/v3/keys/query')).toHaveLength(1); - }); - - it('refuses a secret storage import the engine did not actually apply', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return noKeys; - if (method === 'importCrossSigningKeys') return { ...allKeys, hasUserSigning: false }; - return undefined; - }); - const { mx } = clientStub({ storage: STORED_KEYS }); - - await expect( - new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}) - ).rejects.toThrow('could not be imported'); - }); - - it('creates and publishes an identity when there is none anywhere', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return noKeys; - if (method === 'bootstrapCrossSigning') return BOOTSTRAP_REQUESTS; - if (method === 'exportCrossSigningKeys') return { masterKey: 'msk' }; - return undefined; - }); - const { mx, authedRequest } = clientStub(); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}); - - expect(invoked('bootstrapCrossSigning')[0]?.[2]).toMatchObject({ reset: true }); - expect(requestsTo(authedRequest, '/_matrix/client/v3/keys/upload')).toHaveLength(1); - expect(requestsTo(authedRequest, '/keys/device_signing/upload')).toHaveLength(1); - expect(requestsTo(authedRequest, '/_matrix/client/v3/keys/signatures/upload')).toHaveLength(1); - }); - - it('marks only the queued device-key upload as sent', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return noKeys; - if (method === 'bootstrapCrossSigning') return BOOTSTRAP_REQUESTS; - return undefined; - }); - const { mx } = clientStub(); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}); - - const marked = invoked('markRequestAsSent'); - expect(marked).toHaveLength(1); - expect(marked[0]?.[2]).toMatchObject({ requestId: 'u1', requestType: 0 }); - }); - - it('routes the signing key upload through the interactive-auth callback', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return noKeys; - if (method === 'bootstrapCrossSigning') return BOOTSTRAP_REQUESTS; - return undefined; - }); - const { mx, authedRequest } = clientStub(); - const authUploadDeviceSigningKeys = vi.fn< - (makeRequest: (auth: Record | null) => Promise) => Promise - >(async (makeRequest) => { - await makeRequest({ type: 'm.login.password' }); - }); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({ - setupNewCrossSigning: true, - authUploadDeviceSigningKeys, - }); - - expect(authUploadDeviceSigningKeys).toHaveBeenCalledTimes(1); - const call = requestsTo(authedRequest, '/keys/device_signing/upload')[0]; - expect(call?.[0]).toBe(Method.Post); - expect(call?.[3]).toMatchObject({ - master_key: { keys: {} }, - auth: { type: 'm.login.password' }, - }); - }); - - it('backs local keys up to secret storage rather than touching the identity', async () => { - engine((method) => { - if (method === 'crossSigningStatus') return allKeys; - if (method === 'exportCrossSigningKeys') { - return { masterKey: 'msk', self_signing_key: 'ssk', user_signing_key: 'usk' }; - } - return undefined; - }); - const { mx, store } = clientStub(); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}); - - expect(invoked('bootstrapCrossSigning')).toHaveLength(0); - expect(store.mock.calls.map(([name]) => name)).toEqual([ - 'm.cross_signing.master', - 'm.cross_signing.self_signing', - 'm.cross_signing.user_signing', - ]); - }); - - it('does nothing when the keys are already local and already stored', async () => { - engine((method) => (method === 'crossSigningStatus' ? allKeys : undefined)); - const { mx, store } = clientStub({ storage: STORED_KEYS }); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapCrossSigning({}); - - expect(invoked('bootstrapCrossSigning')).toHaveLength(0); - expect(store).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/crypto/engineCrypto/bootstrapSecretStorage.test.ts b/src/app/crypto/engineCrypto/bootstrapSecretStorage.test.ts deleted file mode 100644 index 3e4e0d8100..0000000000 --- a/src/app/crypto/engineCrypto/bootstrapSecretStorage.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient } 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 PRIVATE_KEY = new Uint8Array([1, 2, 3]); - -const clientStub = () => { - const cacheSecretStorageKey = vi.fn<(keyId: string, keyInfo: unknown, key: Uint8Array) => void>(); - const store = vi.fn<(name: string, value: string) => Promise>(async () => undefined); - const mx = { - http: { authedRequest: vi.fn<(...args: never[]) => Promise>(async () => '{}') }, - cryptoCallbacks: { cacheSecretStorageKey }, - secretStorage: { - getDefaultKeyId: async () => null, - addKey: async () => ({ - keyId: 'KEYID', - keyInfo: { algorithm: 'm.secret_storage.v1.aes-hmac-sha2' }, - }), - setDefaultKeyId: async () => undefined, - hasKey: async () => true, - get: async () => null, - store, - }, - } as unknown as MatrixClient; - return { mx, cacheSecretStorageKey, store }; -}; - -describe('bootstrapSecretStorage', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('caches the key it just created so storing secrets can find it', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'exportCrossSigningKeys') return { masterKey: 'msk' }; - return null; - }); - const { mx, cacheSecretStorageKey } = clientStub(); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).bootstrapSecretStorage({ - createSecretStorageKey: async () => ({ - privateKey: PRIVATE_KEY, - encodedPrivateKey: 'encoded', - }), - }); - - expect(cacheSecretStorageKey).toHaveBeenCalledWith( - 'KEYID', - { algorithm: 'm.secret_storage.v1.aes-hmac-sha2' }, - PRIVATE_KEY - ); - }); -}); diff --git a/src/app/crypto/engineCrypto/claimChain.test.ts b/src/app/crypto/engineCrypto/claimChain.test.ts deleted file mode 100644 index f73bc4a0bf..0000000000 --- a/src/app/crypto/engineCrypto/claimChain.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } 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 room = (roomId: string) => - ({ - roomId, - getEncryptionTargetMembers: async () => [{ userId: '@them:e.org' }], - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, - }) as unknown as Room; - -const event = () => - ({ - getType: () => 'm.room.message', - getContent: () => ({}), - makeEncrypted: vi.fn<() => void>(), - getTxnId: () => 't', - }) as unknown as MatrixEvent; - -const client = () => - ({ - http: { authedRequest: vi.fn<(...args: never[]) => Promise>(async () => '{}') }, - }) as unknown as MatrixClient; - -describe('key claim serialisation', () => { - it('never claims keys for two rooms at the same time', async () => { - const marks: string[] = []; - let inFlight = 0; - - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'getMissingSessions') { - inFlight += 1; - marks.push(`enter${inFlight}`); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - inFlight -= 1; - marks.push('exit'); - return null; - } - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'e', curve25519: 'c' }; - return null; - }); - - const crypto = new EngineCrypto(client(), { userId: '@me:e.org', deviceId: 'D' }); - - await Promise.all([ - crypto.encryptEvent(event(), room('!a:e.org')), - crypto.encryptEvent(event(), room('!b:e.org')), - ]); - - expect(marks).toEqual(['enter1', 'exit', 'enter1', 'exit']); - }); - - it('claims keys for every encryption target member', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'e', curve25519: 'c' }; - return null; - }); - - const crypto = new EngineCrypto(client(), { userId: '@me:e.org', deviceId: 'D' }); - await crypto.encryptEvent(event(), room('!a:e.org')); - - const args = mockInvoke.mock.calls.find(([, method]) => method === 'getMissingSessions')?.[2]; - expect(args).toEqual({ users: ['@them:e.org'] }); - }); -}); diff --git a/src/app/crypto/engineCrypto/coalescedRunner.ts b/src/app/crypto/engineCrypto/coalescedRunner.ts deleted file mode 100644 index bf90b56d88..0000000000 --- a/src/app/crypto/engineCrypto/coalescedRunner.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** Runs `task` serially, collapsing requests that arrive mid-run into one further run. */ -export const createCoalescedRunner = (task: () => Promise, stopped = () => false) => { - let next: PromiseWithResolvers | undefined; - let running = false; - - const loop = async (): Promise => { - running = true; - try { - while (!stopped() && next) { - const pending = next; - next = undefined; - // eslint-disable-next-line no-await-in-loop - await task().catch(() => undefined); - pending.resolve(); - } - } finally { - running = false; - next?.resolve(); - next = undefined; - } - }; - - return { - schedule: (): Promise => { - next ??= Promise.withResolvers(); - const done = next.promise; - if (!running) void loop(); - return done; - }, - cancel: (): void => { - next?.resolve(); - next = undefined; - }, - }; -}; diff --git a/src/app/crypto/engineCrypto/coverage.test.ts b/src/app/crypto/engineCrypto/coverage.test.ts deleted file mode 100644 index efb35d3bda..0000000000 --- a/src/app/crypto/engineCrypto/coverage.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { describe, expect, it } from 'vitest'; -import type { MatrixClient } from '$types/matrix-sdk'; -import { EngineCrypto } from './EngineCrypto'; - -const members = (): string[] => - Object.getOwnPropertyNames(EngineCrypto.prototype).filter((name) => name !== 'constructor'); - -describe('EngineCrypto surface', () => { - // Names one member per interface so the class cannot be hollowed out to satisfy tsc. - it.each([ - ['SyncCryptoCallbacks', 'preprocessToDeviceMessages'], - ['SyncCryptoCallbacks', 'onSyncCompleted'], - ['CryptoBackend', 'decryptEvent'], - ['CryptoBackend', 'encryptEvent'], - ['CryptoBackend', 'getBackupDecryptor'], - ['CryptoApi verification', 'requestDeviceVerification'], - ['CryptoApi trust', 'getDeviceVerificationStatus'], - ['CryptoApi backup', 'restoreKeyBackup'], - ['CryptoApi cross-signing', 'bootstrapCrossSigning'], - ['CryptoApi secret storage', 'bootstrapSecretStorage'], - ])('implements %s.%s', (_group, member) => { - expect(members()).toContain(member); - }); - - // js-sdk re-emits CryptoEvents off the backend; without this, prompts never arrive. - it('is an event emitter so js-sdk can re-emit crypto events', () => { - const crypto = new EngineCrypto({} as MatrixClient, { - userId: '@me:example.org', - deviceId: 'D', - }); - - expect(typeof crypto.on).toBe('function'); - expect(typeof crypto.emit).toBe('function'); - }); - - it('is the backend install.ts wires into the client', () => { - const install = readFileSync('src/app/crypto/install.ts', 'utf8'); - - expect(install).toMatch(/\bEngineCrypto\b/); - }); -}); diff --git a/src/app/crypto/engineCrypto/crossSigningKeys.test.ts b/src/app/crypto/engineCrypto/crossSigningKeys.test.ts deleted file mode 100644 index 68a8a2b386..0000000000 --- a/src/app/crypto/engineCrypto/crossSigningKeys.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient } 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 invoked = (method: string) => mockInvoke.mock.calls.filter(([, called]) => called === method); - -const crypto = () => - new EngineCrypto( - { - http: { authedRequest: vi.fn<(...args: never[]) => Promise>(async () => '{}') }, - } as unknown as MatrixClient, - { - userId: '@me:e.org', - deviceId: 'D', - } - ); - -describe('userHasCrossSigningKeys', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('refreshes keys/query before answering for our own user', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') return { id: 'q1', type: 1, body: '{}' }; - if (method === 'getIdentity') return { isVerified: false }; - return null; - }); - - await expect(crypto().userHasCrossSigningKeys()).resolves.toBe(true); - - const order = mockInvoke.mock.calls.map(([, method]) => method); - expect(order.indexOf('queryKeysForUsers')).toBeLessThan(order.indexOf('getIdentity')); - expect(invoked('queryKeysForUsers')[0]?.[2]).toMatchObject({ users: ['@me:e.org'] }); - }); - - it('reports no identity for our own user only after a successful refresh', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') return null; - if (method === 'getIdentity') return null; - return null; - }); - - await expect(crypto().userHasCrossSigningKeys()).resolves.toBe(false); - expect(invoked('queryKeysForUsers')).toHaveLength(1); - }); - - it('does not query for another user unless asked to', async () => { - mockInvoke.mockImplementation(async (_identity, method) => - method === 'getIdentity' ? null : null - ); - - await expect(crypto().userHasCrossSigningKeys('@them:e.org')).resolves.toBe(false); - expect(invoked('queryKeysForUsers')).toHaveLength(0); - }); - - it('queries for another user when downloadUncached is set', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') return { id: 'q1', type: 1, body: '{}' }; - if (method === 'getIdentity') return { isVerified: false }; - return null; - }); - - await expect(crypto().userHasCrossSigningKeys('@them:e.org', true)).resolves.toBe(true); - expect(invoked('queryKeysForUsers')[0]?.[2]).toMatchObject({ users: ['@them:e.org'] }); - }); -}); diff --git a/src/app/crypto/engineCrypto/decryptionErrors.test.ts b/src/app/crypto/engineCrypto/decryptionErrors.test.ts deleted file mode 100644 index ab5b996c2a..0000000000 --- a/src/app/crypto/engineCrypto/decryptionErrors.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; -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); - -let backupDecryptionKeyBase64: string; -let publicKey: string; - -const backupInfo = () => ({ - version: '7', - algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', - auth_data: { public_key: publicKey }, -}); - -type EngineState = { - code: number; - maybeWithheld?: string | null; - deviceCreationTimeMs?: number; - decryptionKeyBase64?: string | null; - backupVersion?: string | null; -}; - -const client = (serverBackupInfo: unknown = backupInfo()) => - ({ - http: { - authedRequest: vi.fn<(...args: never[]) => Promise>(async () => { - if (serverBackupInfo === null) - throw Object.assign(new Error('nope'), { errcode: 'M_NOT_FOUND' }); - return serverBackupInfo; - }), - }, - }) as unknown as MatrixClient; - -const engine = ({ - code, - maybeWithheld = null, - deviceCreationTimeMs = 0, - decryptionKeyBase64 = null, - backupVersion = '7', -}: 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, 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(async () => { - await RustSdkCryptoJs.initAsync(); - const key = RustSdkCryptoJs.BackupDecryptionKey.createRandomKey(); - backupDecryptionKeyBase64 = key.toBase64(); - publicKey = key.megolmV1PublicKey.publicKeyBase64; - key.free(); - }); - - 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('does not promise history recovery for a key without a backup version', async () => { - engine({ - code: 0, - deviceCreationTimeMs: 5000, - decryptionKeyBase64: 'AAAA', - backupVersion: null, - }); - - expect(await codeOf(decrypt(eventAt(100)))).toBe( - DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED - ); - }); - - it('does not promise history recovery for a key from another backup version', async () => { - engine({ - code: 0, - deviceCreationTimeMs: 5000, - decryptionKeyBase64: 'AAAA', - backupVersion: '6', - }); - - expect(await codeOf(decrypt(eventAt(100)))).toBe( - DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED - ); - }); - - it('does not promise history recovery for a key that cannot open the backup', async () => { - const other = RustSdkCryptoJs.BackupDecryptionKey.createRandomKey(); - const mx = client({ - ...backupInfo(), - auth_data: { public_key: other.megolmV1PublicKey.publicKeyBase64 }, - }); - other.free(); - engine({ code: 0, deviceCreationTimeMs: 5000, decryptionKeyBase64: backupDecryptionKeyBase64 }); - - expect(await codeOf(decrypt(eventAt(100), mx))).toBe( - DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED - ); - }); - - it('reports history a working backup should eventually supply', async () => { - engine({ - code: 0, - deviceCreationTimeMs: 5000, - decryptionKeyBase64: backupDecryptionKeyBase64, - }); - - 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 deleted file mode 100644 index 47159239c5..0000000000 --- a/src/app/crypto/engineCrypto/encryptEvent.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { HistoryVisibility } from 'matrix-js-sdk/lib/@types/partials'; -import { - AllDevicesIsolationMode, - OnlySignedDevicesIsolationMode, -} from 'matrix-js-sdk/lib/crypto-api'; -import type { MatrixClient, MatrixEvent, Room } 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 authedRequest = vi.fn<(...args: never[]) => Promise>(async () => null); -const mx = { http: { authedRequest } } as unknown as MatrixClient; - -type RoomOptions = { - encryption?: Record; - historyVisibility?: HistoryVisibility; - blacklistUnverified?: boolean | null; -}; - -const roomStub = ({ - encryption, - historyVisibility = HistoryVisibility.Shared, - blacklistUnverified = false, -}: RoomOptions = {}) => - ({ - roomId: '!room:e.org', - getEncryptionTargetMembers: async () => [{ userId: '@a:e.org' }], - getHistoryVisibility: () => historyVisibility, - getBlacklistUnverifiedDevices: () => blacklistUnverified, - currentState: { - getStateEvents: () => (encryption ? { getContent: () => encryption } : null), - }, - }) as unknown as Room; - -const event = { - getType: () => 'm.room.message', - getContent: () => ({ body: 'hi' }), - makeEncrypted: vi.fn<(...args: never[]) => void>(), -} as unknown as MatrixEvent; - -const shareArgs = () => - mockInvoke.mock.calls.find(([, method]) => method === 'shareRoomKey')?.[2] as - | Record - | undefined; - -const encrypt = async (room: Room, isolation?: AllDevicesIsolationMode) => { - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - if (isolation) crypto.setDeviceIsolationMode(isolation); - await crypto.encryptEvent(event, room); -}; - -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; - }); - }); - - it('sends the settings the engine requires', async () => { - await encrypt(roomStub()); - - expect(shareArgs()?.encryptionSettings).toMatchObject({ - algorithm: 'm.megolm.v1.aes-sha2', - historyVisibility: HistoryVisibility.Shared, - sharingStrategy: 'allDevices', - }); - }); - - it('converts the room rotation period from milliseconds to microseconds', async () => { - await encrypt( - roomStub({ encryption: { rotation_period_ms: 604800000, rotation_period_msgs: 100 } }) - ); - - expect(shareArgs()?.encryptionSettings).toMatchObject({ - rotationPeriod: 604800000000, - rotationPeriodMessages: 100, - }); - }); - - it('omits a rotation the room does not configure', async () => { - await encrypt(roomStub()); - - const settings = shareArgs()?.encryptionSettings as Record; - expect(settings.rotationPeriod).toBeUndefined(); - expect(settings.rotationPeriodMessages).toBeUndefined(); - }); - - it('carries the room history visibility', async () => { - await encrypt(roomStub({ historyVisibility: HistoryVisibility.Invited })); - - expect(shareArgs()?.encryptionSettings).toMatchObject({ - historyVisibility: HistoryVisibility.Invited, - }); - }); - - it('restricts sharing when the room blacklists unverified devices', async () => { - await encrypt(roomStub({ blacklistUnverified: true })); - - expect(shareArgs()?.encryptionSettings).toMatchObject({ - sharingStrategy: 'onlyTrustedDevices', - }); - }); - - it('uses the identity strategy for signed-devices-only isolation', async () => { - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - crypto.setDeviceIsolationMode(new OnlySignedDevicesIsolationMode()); - await crypto.encryptEvent(event, roomStub()); - - expect(shareArgs()?.encryptionSettings).toMatchObject({ - sharingStrategy: 'identityBasedStrategy', - }); - }); - - it('errors on verified-user problems when the isolation mode asks for it', async () => { - await encrypt(roomStub(), new AllDevicesIsolationMode(true)); - - expect(shareArgs()?.encryptionSettings).toMatchObject({ - sharingStrategy: 'errorOnVerifiedUserProblem', - }); - }); -}); - -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/encryptionOrder.test.ts b/src/app/crypto/engineCrypto/encryptionOrder.test.ts deleted file mode 100644 index 1a87d32868..0000000000 --- a/src/app/crypto/engineCrypto/encryptionOrder.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } 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 room = (roomId: string, onMembers?: () => void) => - ({ - roomId, - getEncryptionTargetMembers: async () => { - onMembers?.(); - return [{ userId: '@me:e.org' }]; - }, - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, - }) as unknown as Room; - -const event = () => - ({ - getType: () => 'm.room.message', - getContent: () => ({}), - makeEncrypted: vi.fn<() => void>(), - getTxnId: () => 't', - }) as unknown as MatrixEvent; - -describe('encryptEvent ordering', () => { - it('does not look up members for the next event until the previous one is encrypted', async () => { - let lookups = 0; - let release: (() => void) | undefined; - const held = new Promise((resolve) => { - release = resolve; - }); - - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'encryptRoomEvent') { - await held; - return '{}'; - } - if (method === 'identityKeys') return { ed25519: 'e', curve25519: 'c' }; - return null; - }); - - const mx = { http: { authedRequest: vi.fn<() => void>() } } as unknown as MatrixClient; - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const target = room('!r:e.org', () => { - lookups += 1; - }); - - const both = Promise.all([ - crypto.encryptEvent(event(), target), - crypto.encryptEvent(event(), target), - ]); - - await Promise.resolve(); - await Promise.resolve(); - expect(lookups).toBe(1); - - release?.(); - await both; - expect(lookups).toBe(2); - }); - - it('never runs two encryptions for a room at the same time', async () => { - const marks: string[] = []; - let inFlight = 0; - - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'encryptRoomEvent') { - inFlight += 1; - marks.push(`enter${inFlight}`); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - inFlight -= 1; - marks.push('exit'); - return '{}'; - } - if (method === 'identityKeys') return { ed25519: 'e', curve25519: 'c' }; - return null; - }); - - const mx = { http: { authedRequest: vi.fn<() => void>() } } as unknown as MatrixClient; - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - - await Promise.all([ - crypto.encryptEvent(event(), room('!r:e.org')), - crypto.encryptEvent(event(), room('!r:e.org')), - ]); - - expect(marks).toEqual(['enter1', 'exit', 'enter1', 'exit']); - }); -}); diff --git a/src/app/crypto/engineCrypto/engineShapes.test.ts b/src/app/crypto/engineCrypto/engineShapes.test.ts deleted file mode 100644 index 484a714d6b..0000000000 --- a/src/app/crypto/engineCrypto/engineShapes.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CrossSigningKey, type MatrixClient } 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 DEFAULT_KEY_ID = 'default-key'; - -const crypto = (stored: string[] = [], keyId: string = DEFAULT_KEY_ID) => - new EngineCrypto( - { - secretStorage: { - getDefaultKeyId: async () => DEFAULT_KEY_ID, - isStored: async (name: string) => (stored.includes(name) ? { [keyId]: {} } : null), - }, - } as unknown as MatrixClient, - { userId: '@me:example.org', deviceId: 'DEVICE' } - ); - -const CROSS_SIGNING_SECRETS = [ - 'm.cross_signing.master', - 'm.cross_signing.self_signing', - 'm.cross_signing.user_signing', -]; - -/** Mirrors the exact JSON the Rust side emits; a rename there breaks these loudly. */ -describe('engine payload shapes', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('reads the backup key from decryptionKeyBase64, not decryptionKey', async () => { - mockInvoke.mockResolvedValue({ - className: 'BackupKeys', - backupVersion: '7', - decryptionKeyBase64: 'AAAA', - }); - - 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'); - }); - - it('returns null when the engine holds no backup key', async () => { - mockInvoke.mockResolvedValue({ className: 'BackupKeys', backupVersion: null }); - - await expect(crypto().getSessionBackupPrivateKey()).resolves.toBeNull(); - }); - - // exportRoomKeys hands back JSON text, so it must be parsed, and the JSON variant - // must not stringify it a second time. - it('parses the room key export and does not re-encode the JSON variant', async () => { - const exported = JSON.stringify([{ session_id: 'a' }, { session_id: 'b' }]); - mockInvoke.mockResolvedValue(exported); - - await expect(crypto().exportRoomKeys()).resolves.toHaveLength(2); - await expect(crypto().exportRoomKeysAsJson()).resolves.toBe(exported); - }); - - it('keys cross-signing keys by the SDK enum and parses each JSON blob', async () => { - mockInvoke.mockResolvedValue({ - userId: '@me:example.org', - isVerified: true, - wasPreviouslyVerified: true, - masterKey: JSON.stringify({ keys: { 'ed25519:AAA': 'AAA' }, usage: ['master'] }), - selfSigningKey: JSON.stringify({ keys: { 'ed25519:BBB': 'BBB' }, usage: ['self_signing'] }), - userSigningKey: JSON.stringify({ keys: { 'ed25519:CCC': 'CCC' }, usage: ['user_signing'] }), - }); - - const keys = await crypto().getUserCrossSigningKeys('@me:example.org'); - - expect(Object.keys(keys ?? {})).toEqual([ - CrossSigningKey.Master, - CrossSigningKey.SelfSigning, - CrossSigningKey.UserSigning, - ]); - expect(keys?.[CrossSigningKey.Master]?.keys).toEqual({ 'ed25519:AAA': 'AAA' }); - }); - - it('resolves a cross-signing key id through that mapping', async () => { - mockInvoke.mockResolvedValue({ - userId: '@me:example.org', - isVerified: true, - wasPreviouslyVerified: true, - masterKey: JSON.stringify({ keys: { 'ed25519:AAA': 'AAA' }, usage: ['master'] }), - }); - - await expect(crypto().getCrossSigningKeyId()).resolves.toBe('AAA'); - }); - - it('reads own device keys from identityKeys', async () => { - mockInvoke.mockResolvedValue({ ed25519: 'ed', curve25519: 'curve' }); - - await expect(crypto().getOwnDeviceKeys()).resolves.toEqual({ - ed25519: 'ed', - curve25519: 'curve', - }); - }); - - it('reads cross-signing status from the hasX flags', async () => { - mockInvoke.mockResolvedValue({ - hasMaster: true, - hasSelfSigning: true, - hasUserSigning: false, - }); - - const status = await crypto().getCrossSigningStatus(); - expect(status.publicKeysOnDevice).toBe(false); - expect(status.privateKeysCachedLocally).toEqual({ - masterKey: true, - selfSigningKey: true, - userSigningKey: false, - }); - }); - - it('reports cross-signing keys held in secret storage', async () => { - mockInvoke.mockResolvedValue({ - hasMaster: false, - hasSelfSigning: false, - hasUserSigning: false, - }); - - await expect(crypto(CROSS_SIGNING_SECRETS).getCrossSigningStatus()).resolves.toMatchObject({ - privateKeysInSecretStorage: true, - }); - await expect( - crypto(CROSS_SIGNING_SECRETS.slice(0, 2)).getCrossSigningStatus() - ).resolves.toMatchObject({ privateKeysInSecretStorage: false }); - }); - - it('does not count secrets stored under a key that is no longer the default', async () => { - mockInvoke.mockResolvedValue({ - hasMaster: false, - hasSelfSigning: false, - hasUserSigning: false, - }); - - await expect( - crypto(CROSS_SIGNING_SECRETS, 'rotated-away-key').getCrossSigningStatus() - ).resolves.toMatchObject({ privateKeysInSecretStorage: false }); - }); -}); diff --git a/src/app/crypto/engineCrypto/eventBridge.ts b/src/app/crypto/engineCrypto/eventBridge.ts deleted file mode 100644 index 1edacfb459..0000000000 --- a/src/app/crypto/engineCrypto/eventBridge.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { createDebugLogger } from '$utils/debugLogger'; -import type { EngineIdentity } from '../olmMachine/engineInvoke'; -import type { EngineCrypto } from './EngineCrypto'; - -const eventBridgeLog = createDebugLogger('crypto'); - -const ROOM_KEYS_RECEIVED = 'matrix-crypto://room-keys-received'; -const ROOM_KEYS_WITHHELD = 'matrix-crypto://room-keys-withheld'; -const IDENTITIES_UPDATED = 'matrix-crypto://identities-updated'; -const SECRET_RECEIVED = 'matrix-crypto://secret-received'; - -type Envelope = { account: string; payload: T }; - -type RoomKeyInfo = { roomId: string; sessionId: string }; - -/** Must be torn down with the client, or a re-login leaks a listener. */ -export const startCryptoEventBridge = async ( - crypto: EngineCrypto, - identity: EngineIdentity -): Promise => { - const account = `${identity.userId}|${identity.deviceId}`; - const forAccount = - (handle: (payload: T) => void) => - ({ payload: envelope }: { payload: Envelope }) => { - if (envelope.account !== account) return; - handle(envelope.payload); - }; - - const unlisten = await Promise.all([ - listen>( - ROOM_KEYS_RECEIVED, - forAccount((keys) => crypto.onRoomKeysUpdated(keys)) - ), - listen>( - ROOM_KEYS_WITHHELD, - forAccount((sessions) => crypto.onRoomKeysWithheld(sessions)) - ), - listen>( - IDENTITIES_UPDATED, - forAccount<{ identities: string[]; devices: string[] }>(({ identities, devices }) => { - identities.forEach((userId) => crypto.onUserIdentityUpdated(userId)); - if (devices.length > 0) crypto.onDevicesUpdated(devices); - }) - ), - listen>( - SECRET_RECEIVED, - forAccount<{ name: string }>(({ name }) => { - crypto.checkSecrets(name).catch((error: unknown) => { - eventBridgeLog.warn('general', `Failed to handle gossiped secret ${name}`, error); - }); - }) - ), - ]); - - return () => unlisten.forEach((stop) => stop()); -}; diff --git a/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts b/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts deleted file mode 100644 index a7d3b82713..0000000000 --- a/src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CryptoEvent, EventType, type MatrixClient } from '$types/matrix-sdk'; -import { engineInvoke } from '../olmMachine/engineInvoke'; -import { traceVerification, warnToDevice, warnVerification } from '$utils/verificationTrace'; -import { EngineCrypto } from './EngineCrypto'; - -vi.mock('$utils/verificationTrace', () => ({ - traceVerification: vi.fn<(message: string, data?: unknown) => void>(), - warnToDevice: vi.fn<(message: string, data?: unknown) => void>(), - warnVerification: vi.fn<(message: string, data?: unknown) => void>(), -})); - -vi.mock('../olmMachine/engineInvoke', () => ({ - engineInvoke: vi.fn<(...args: never[]) => Promise>(), -})); - -const mockInvoke = vi.mocked(engineInvoke); - -const clientSpy = () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => '{}'); - return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; -}; - -const REQUEST_EVENT = { - type: EventType.KeyVerificationRequest, - sender: '@me:e.org', - content: { transaction_id: '$f', from_device: 'OTHER' }, -}; - -const requestState = { - flowId: '$f', - otherUserId: '@me:e.org', - phase: 1, - isSelfVerification: true, -}; - -describe('unreadable to-device events', () => { - beforeEach(() => { - mockInvoke.mockReset(); - vi.mocked(warnToDevice).mockClear(); - }); - - it('reports one the engine could not decrypt instead of dropping it silently', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'receiveSyncChanges') { - return [ - { - type: 1, - rawEvent: JSON.stringify({ type: 'm.room.encrypted', sender: '@me:e.org' }), - }, - ]; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]); - - expect(warnToDevice).toHaveBeenCalledWith( - 'Dropped a to-device event the engine could not read', - expect.objectContaining({ sender: '@me:e.org', type: 'm.room.encrypted' }) - ); - }); -}); - -describe('sending a verification request', () => { - beforeEach(() => { - mockInvoke.mockReset(); - vi.mocked(traceVerification).mockClear(); - vi.mocked(warnVerification).mockClear(); - }); - - it('reports when the request reaches no device', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') { - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - } - if (method === 'device.requestVerification') { - return { - request: requestState, - outgoingRequest: { - id: 'txn', - type: 3, - body: JSON.stringify({ messages: {} }), - event_type: 'm.key.verification.request', - txn_id: 'txn', - }, - }; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.requestDeviceVerification('@me:e.org', 'OTHER'); - - expect(warnVerification).toHaveBeenCalledWith( - 'The verification request reaches no device', - expect.objectContaining({ flowId: '$f' }) - ); - }); - - it('stays quiet when the request has a recipient', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') { - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - } - if (method === 'device.requestVerification') { - return { - request: requestState, - outgoingRequest: { - id: 'txn', - type: 3, - body: JSON.stringify({ messages: { '@me:e.org': { OTHER: {} } } }), - event_type: 'm.key.verification.request', - txn_id: 'txn', - }, - }; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.requestDeviceVerification('@me:e.org', 'OTHER'); - - expect(warnVerification).not.toHaveBeenCalled(); - expect(traceVerification).toHaveBeenCalledWith( - 'Sending a verification request', - expect.objectContaining({ recipientDevices: 1 }) - ); - }); -}); - -describe('stale verification requests', () => { - beforeEach(() => { - mockInvoke.mockReset(); - vi.mocked(traceVerification).mockClear(); - }); - - it('cancels an existing pending flow before starting a new one', async () => { - const { mx } = clientSpy(); - const invoked: string[] = []; - mockInvoke.mockImplementation(async (_identity, method) => { - invoked.push(method as string); - if (method === 'receiveSyncChanges') { - return [{ type: 3, rawEvent: JSON.stringify(REQUEST_EVENT) }]; - } - if (method === 'getVerificationRequest') return requestState; - if (method === 'queryKeysForUsers') { - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - } - if (method === 'device.requestVerification') { - return { request: { ...requestState, flowId: '$new' }, outgoingRequest: null }; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]); - await crypto.requestDeviceVerification('@me:e.org', 'OTHER'); - - expect(invoked).toContain('verificationRequest.cancel'); - expect(invoked.indexOf('verificationRequest.cancel')).toBeLessThan( - invoked.indexOf('device.requestVerification') - ); - }); -}); - -describe('pending verification request sweep', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('surfaces a request the engine holds but the sync path never delivered', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'getVerificationRequests') return [requestState]; - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const received = vi.fn<(request: unknown) => void>(); - crypto.on(CryptoEvent.VerificationRequestReceived, received); - - crypto.onSyncCompleted({} as never); - await vi.waitFor(() => expect(received).toHaveBeenCalledOnce()); - }); - - it('does not surface the same request twice', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'receiveSyncChanges') { - return [{ type: 3, rawEvent: JSON.stringify(REQUEST_EVENT) }]; - } - if (method === 'getVerificationRequest') return requestState; - if (method === 'getVerificationRequests') return [requestState]; - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const received = vi.fn<(request: unknown) => void>(); - crypto.on(CryptoEvent.VerificationRequestReceived, received); - - await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]); - crypto.onSyncCompleted({} as never); - await vi.waitFor(() => expect(received).toHaveBeenCalledOnce()); - }); -}); - -describe('outgoing device verification request', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('refreshes the target user keys before asking the engine for the device', async () => { - const { mx } = clientSpy(); - const invoked: string[] = []; - - mockInvoke.mockImplementation(async (_identity, method) => { - invoked.push(method as string); - if (method === 'queryKeysForUsers') { - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - } - if (method === 'device.requestVerification') { - return { request: { ...requestState, phase: 0 }, outgoingRequest: null }; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.requestDeviceVerification('@me:e.org', 'OTHER'); - - expect(invoked.indexOf('queryKeysForUsers')).toBeGreaterThanOrEqual(0); - expect(invoked.indexOf('queryKeysForUsers')).toBeLessThan( - invoked.indexOf('device.requestVerification') - ); - }); -}); - -describe('incoming verification request', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('fetches the sender keys and replays the event when the device is unknown', async () => { - const { mx } = clientSpy(); - let senderKnown = false; - const invoked: string[] = []; - - mockInvoke.mockImplementation(async (_identity, method) => { - invoked.push(method as string); - if (method === 'receiveSyncChanges') { - return [{ type: 3, rawEvent: JSON.stringify(REQUEST_EVENT) }]; - } - if (method === 'queryKeysForUsers') { - senderKnown = true; - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - } - if (method === 'getVerificationRequest') return senderKnown ? requestState : null; - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const received = vi.fn<(request: unknown) => void>(); - crypto.on(CryptoEvent.VerificationRequestReceived, received); - - await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]); - - expect(invoked.filter((m) => m === 'queryKeysForUsers')).toHaveLength(1); - expect(invoked.filter((m) => m === 'receiveSyncChanges')).toHaveLength(2); - expect(received).toHaveBeenCalledOnce(); - }); - - it('does not replay when the engine already knows the request', async () => { - const { mx } = clientSpy(); - const invoked: string[] = []; - - mockInvoke.mockImplementation(async (_identity, method) => { - invoked.push(method as string); - if (method === 'receiveSyncChanges') { - return [{ type: 3, rawEvent: JSON.stringify(REQUEST_EVENT) }]; - } - if (method === 'getVerificationRequest') return requestState; - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const received = vi.fn<(request: unknown) => void>(); - crypto.on(CryptoEvent.VerificationRequestReceived, received); - - await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]); - - expect(invoked).not.toContain('queryKeysForUsers'); - expect(invoked.filter((m) => m === 'receiveSyncChanges')).toHaveLength(1); - expect(received).toHaveBeenCalledOnce(); - }); -}); - -describe('stale verification requests the JS map never saw', () => { - beforeEach(() => { - mockInvoke.mockReset(); - vi.mocked(traceVerification).mockClear(); - }); - - it('cancels a pending flow the engine reports on its own', async () => { - const { mx } = clientSpy(); - const cancelled: string[] = []; - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'getVerificationRequests') { - return [{ ...requestState, flowId: '$ghost' }]; - } - if (method === 'verificationRequest.cancel') { - cancelled.push((args as { flowId: string }).flowId); - return null; - } - if (method === 'queryKeysForUsers') { - return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' }; - } - if (method === 'device.requestVerification') { - return { request: { ...requestState, flowId: '$new' }, outgoingRequest: null }; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.requestDeviceVerification('@me:e.org', 'OTHER'); - - expect(cancelled).toEqual(['$ghost']); - }); -}); - -describe('already terminal incoming request', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('does not raise a modal for a flow the engine already cancelled', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'receiveSyncChanges') { - return [{ type: 3, rawEvent: JSON.stringify(REQUEST_EVENT) }]; - } - if (method === 'getVerificationRequest') return { ...requestState, phase: 5 }; - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const received = vi.fn<(request: unknown) => void>(); - crypto.on(CryptoEvent.VerificationRequestReceived, received); - - await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]); - - expect(received).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/crypto/engineCrypto/keyBackupConnect.test.ts b/src/app/crypto/engineCrypto/keyBackupConnect.test.ts deleted file mode 100644 index 9784fe08b2..0000000000 --- a/src/app/crypto/engineCrypto/keyBackupConnect.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -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'; - -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' }, -}; - -const clientSpy = () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => BACKUP_INFO); - return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; -}; - -const settle = () => - new Promise((resolve) => { - setTimeout(resolve, 0); - }); - -/** - * Nothing else calls checkKeyBackupAndEnable, so without this the engine never runs - * enableBackupV1 and the backup reads as disconnected on a fully verified device. - */ -describe('key backup connection', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('enables a trusted backup on construction', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'verifyBackup') return { trusted: true }; - if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - expect(crypto).toBeDefined(); - await settle(); - - const enabled = mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1'); - expect(enabled).toHaveLength(1); - expect(enabled[0]?.[2]).toMatchObject({ publicKeyBase64: 'cHVibGlj', version: '7' }); - }); - - it('leaves an untrusted backup alone', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'verifyBackup') return { trusted: false }; - if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - expect(crypto).toBeDefined(); - await settle(); - - expect(mockInvoke.mock.calls.some(([, method]) => method === 'enableBackupV1')).toBe(false); - }); - - it('retries once our own identity becomes trusted', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'verifyBackup') return { trusted: true }; - if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await settle(); - crypto.onUserIdentityUpdated('@me:e.org'); - await settle(); - - expect(mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1')).toHaveLength( - 2 - ); - }); - - it('ignores another user becoming trusted', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'verifyBackup') return { trusted: true }; - if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await settle(); - crypto.onUserIdentityUpdated('@them:e.org'); - await settle(); - - expect(mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1')).toHaveLength( - 1 - ); - }); -}); - -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/keyQueryOrdering.test.ts b/src/app/crypto/engineCrypto/keyQueryOrdering.test.ts deleted file mode 100644 index cba4ceecd6..0000000000 --- a/src/app/crypto/engineCrypto/keyQueryOrdering.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } 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 identity = { userId: '@me:e.org', deviceId: 'D' }; - -const room = { - roomId: '!room:e.org', - getEncryptionTargetMembers: async () => [{ userId: '@a:e.org' }], - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, -} as unknown as Room; - -const event = () => - ({ - getType: () => 'm.room.message', - getContent: () => ({ body: 'hello' }), - makeEncrypted: vi.fn<() => void>(), - }) as unknown as MatrixEvent; - -describe('keys/query ordering', () => { - afterEach(() => mockInvoke.mockReset()); - - it('waits for a background query HTTP response and acknowledgement before a first-room query', async () => { - const http = Promise.withResolvers(); - const acknowledgement = Promise.withResolvers(); - let backgroundAcknowledged = false; - let queries = 0; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (url !== '/_matrix/client/v3/keys/query') return '{}'; - queries += 1; - if (queries === 1) return http.promise; - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'outgoingRequests') - return backgroundAcknowledged ? [] : [{ id: 'background', type: 1, body: '{}' }]; - if (method === 'markRequestAsSent') { - if ((args as { requestId: string }).requestId === 'background') { - await acknowledgement.promise; - backgroundAcknowledged = true; - } - return null; - } - if (method === 'queryKeysForUsers') return { id: 'room', type: 1, body: '{}' }; - if (method === 'getMissingSessions') return null; - if (method === 'shareRoomKey') return []; - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - const crypto = new EngineCrypto( - { http: { authedRequest } } as unknown as MatrixClient, - identity - ); - - crypto.onSyncCompleted({}); - const keyQueries = () => - authedRequest.mock.calls.filter(([, url]) => url === '/_matrix/client/v3/keys/query'); - await vi.waitFor(() => expect(keyQueries()).toHaveLength(1)); - - const send = crypto.encryptEvent(event(), room); - try { - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'queryKeysForUsers', - expect.anything() - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(keyQueries()).toHaveLength(1); - - http.resolve('{}'); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'markRequestAsSent', - expect.anything() - ) - ); - expect(keyQueries()).toHaveLength(1); - - acknowledgement.resolve(); - await vi.waitFor(() => expect(keyQueries()).toHaveLength(2)); - } finally { - http.resolve('{}'); - acknowledgement.resolve(); - await send; - } - }); -}); diff --git a/src/app/crypto/engineCrypto/membershipSendRace.test.ts b/src/app/crypto/engineCrypto/membershipSendRace.test.ts deleted file mode 100644 index b056bece72..0000000000 --- a/src/app/crypto/engineCrypto/membershipSendRace.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { EventType, KnownMembership } from '$types/matrix-sdk'; -import type { MatrixClient, MatrixEvent, Room } 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 deferred = () => Promise.withResolvers(); - -const memberEvent = () => - ({ - getType: () => EventType.RoomMember, - getStateKey: () => '@departed:e.org', - getContent: () => ({ membership: KnownMembership.Leave }), - getRoomId: () => '!room:e.org', - }) as unknown as MatrixEvent; - -const encryptedEvent = () => - ({ - getType: () => 'm.room.message', - getContent: () => ({ body: 'hello' }), - makeEncrypted: vi.fn<(...args: never[]) => void>(), - }) as unknown as MatrixEvent; - -const setup = () => { - let members = [{ userId: '@departed:e.org' }]; - const room = { - roomId: '!room:e.org', - getEncryptionTargetMembers: () => Promise.resolve(members), - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, - } as unknown as Room; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => '{}'); - const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { - userId: '@me:e.org', - deviceId: 'D', - }); - return { - crypto, - room, - authedRequest, - replaceMembers: (next: string) => { - members = [{ userId: next }]; - }, - }; -}; - -describe('membership changes racing room sends', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('reprepares with the current recipients after a leave during room-key delivery', async () => { - const shareDelivery = deferred(); - const invalidated = deferred(); - const { crypto, room, authedRequest, replaceMembers } = setup(); - let shares = 0; - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'shareRoomKey') { - shares += 1; - return [ - { - id: `share-${shares}`, - type: 3, - event_type: 'm.room.encrypted', - txn_id: 't', - body: '{}', - }, - ]; - } - if (method === 'invalidateGroupSession') return invalidated.promise; - if (method === 'encryptRoomEvent') return JSON.stringify({ session_id: `session-${shares}` }); - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - authedRequest.mockImplementation(async (_method, url) => - String(url).includes('/sendToDevice/') ? shareDelivery.promise : '{}' - ); - - const event = encryptedEvent(); - const send = crypto.encryptEvent(event, room); - await vi.waitFor(() => - expect( - authedRequest.mock.calls.some(([, url]) => String(url).includes('/sendToDevice/')) - ).toBe(true) - ); - replaceMembers('@joined:e.org'); - crypto.onRoomStateEvent(memberEvent()); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'invalidateGroupSession', - expect.anything() - ) - ); - shareDelivery.resolve('{}'); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(event.makeEncrypted).not.toHaveBeenCalled(); - expect(mockInvoke.mock.calls.filter(([, method]) => method === 'shareRoomKey')).toHaveLength(1); - invalidated.resolve(); - await send; - - const users = mockInvoke.mock.calls - .filter(([, method]) => method === 'shareRoomKey') - .map(([, , args]) => (args as { users: string[] }).users); - expect(users).toEqual([['@departed:e.org'], ['@joined:e.org']]); - expect(event.makeEncrypted).toHaveBeenCalledTimes(1); - }); - - it('discards ciphertext produced while invalidation is pending', async () => { - const oldCiphertext = deferred(); - const invalidated = deferred(); - const { crypto, room } = setup(); - let encryptions = 0; - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'invalidateGroupSession') return invalidated.promise; - if (method === 'encryptRoomEvent') { - encryptions += 1; - return encryptions === 1 ? oldCiphertext.promise : JSON.stringify({ session_id: 'new' }); - } - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const event = encryptedEvent(); - const send = crypto.encryptEvent(event, room); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'encryptRoomEvent', - expect.anything() - ) - ); - crypto.onRoomStateEvent(memberEvent()); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'invalidateGroupSession', - expect.anything() - ) - ); - oldCiphertext.resolve(JSON.stringify({ session_id: 'old' })); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(event.makeEncrypted).not.toHaveBeenCalled(); - expect(encryptions).toBe(1); - invalidated.resolve(); - await send; - - expect(encryptions).toBe(2); - expect(event.makeEncrypted).toHaveBeenCalledTimes(1); - expect(event.makeEncrypted).toHaveBeenCalledWith( - 'm.room.encrypted', - { session_id: 'new' }, - 'curve', - 'ed' - ); - }); - - it('discards ciphertext when membership changes while fetching identity keys', async () => { - const oldKeys = deferred<{ ed25519: string; curve25519: string }>(); - const invalidated = deferred(); - const { crypto, room } = setup(); - let encryptions = 0; - let keyRequests = 0; - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'invalidateGroupSession') return invalidated.promise; - if (method === 'encryptRoomEvent') { - encryptions += 1; - return JSON.stringify({ session_id: encryptions === 1 ? 'old' : 'new' }); - } - if (method === 'identityKeys') { - keyRequests += 1; - return keyRequests === 1 ? oldKeys.promise : { ed25519: 'ed', curve25519: 'curve' }; - } - return null; - }); - - const event = encryptedEvent(); - const send = crypto.encryptEvent(event, room); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith(expect.anything(), 'identityKeys', expect.anything()) - ); - crypto.onRoomStateEvent(memberEvent()); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'invalidateGroupSession', - expect.anything() - ) - ); - oldKeys.resolve({ ed25519: 'old-ed', curve25519: 'old-curve' }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(event.makeEncrypted).not.toHaveBeenCalled(); - expect(encryptions).toBe(1); - invalidated.resolve(); - await send; - - expect(event.makeEncrypted).toHaveBeenCalledWith( - 'm.room.encrypted', - { session_id: 'new' }, - 'curve', - 'ed' - ); - expect(event.makeEncrypted).toHaveBeenCalledTimes(1); - }); - - it('propagates a failed invalidation', async () => { - const { crypto, room } = setup(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'invalidateGroupSession') throw new Error('invalidation failed'); - return null; - }); - - await expect(crypto.forceDiscardSession('!room:e.org')).rejects.toThrow('invalidation failed'); - const event = encryptedEvent(); - await expect(crypto.encryptEvent(event, room)).rejects.toThrow('invalidation failed'); - expect(event.makeEncrypted).not.toHaveBeenCalled(); - }); - - it('serializes consecutive invalidations and waits for the latest one before sending', async () => { - const first = deferred(); - const second = deferred(); - const { crypto, room } = setup(); - let invalidations = 0; - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'invalidateGroupSession') { - invalidations += 1; - return invalidations === 1 ? first.promise : second.promise; - } - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const firstDiscard = crypto.forceDiscardSession(room.roomId); - const secondDiscard = crypto.forceDiscardSession(room.roomId); - await vi.waitFor(() => expect(invalidations).toBe(1)); - first.resolve(); - await vi.waitFor(() => expect(invalidations).toBe(2)); - - const event = encryptedEvent(); - const send = crypto.encryptEvent(event, room); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') - ).toHaveLength(0); - second.resolve(); - await Promise.all([firstDiscard, secondDiscard, send]); - expect(event.makeEncrypted).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/crypto/engineCrypto/outgoing.test.ts b/src/app/crypto/engineCrypto/outgoing.test.ts deleted file mode 100644 index fd723802fb..0000000000 --- a/src/app/crypto/engineCrypto/outgoing.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { MatrixClient } from '$types/matrix-sdk'; -import { RequestType, sendOutgoingRequest } from './outgoing'; - -const clientReturning = (body: string) => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => body); - return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; -}; - -describe('sendOutgoingRequest', () => { - /** - * `json: false` makes js-sdk return res.text(), so the response is already JSON text. - * Stringifying it again produced a double-encoded body that the engine could not parse, - * so it never cleared its queue and re-sent every request on every sync. - */ - it('returns the response body unchanged rather than re-encoding it', async () => { - const { mx } = clientReturning('{"one_time_key_counts":{"signed_curve25519":50}}'); - - const response = await sendOutgoingRequest(mx, { - id: 'req-1', - type: RequestType.KeysUpload, - body: '{"device_keys":{}}', - }); - - expect(response).toBe('{"one_time_key_counts":{"signed_curve25519":50}}'); - expect(JSON.parse(response)).toHaveProperty('one_time_key_counts'); - }); - - it('forwards the pre-signed body verbatim and does not let js-sdk re-serialise it', async () => { - const { mx, authedRequest } = clientReturning('{}'); - const body = '{"b":1,"a":2}'; - - await sendOutgoingRequest(mx, { id: 'r', type: RequestType.KeysQuery, body }); - - const [, , , sentBody, opts] = authedRequest.mock.calls[0] as unknown as [ - unknown, - unknown, - unknown, - string, - { json: boolean }, - ]; - expect(sentBody).toBe(body); - expect(opts.json).toBe(false); - }); - - it('routes each request type to its own endpoint', async () => { - const cases = [ - [RequestType.KeysUpload, '/_matrix/client/v3/keys/upload'], - [RequestType.KeysQuery, '/_matrix/client/v3/keys/query'], - [RequestType.KeysClaim, '/_matrix/client/v3/keys/claim'], - [RequestType.SignatureUpload, '/_matrix/client/v3/keys/signatures/upload'], - ] as const; - - await Promise.all( - cases.map(async ([type, expected]) => { - const { mx, authedRequest } = clientReturning('{}'); - await sendOutgoingRequest(mx, { id: 'r', type, body: '{}' }); - expect(authedRequest.mock.calls[0]?.[1]).toBe(expected); - }) - ); - }); - - it('puts to-device and room-message requests on their transaction-scoped paths', async () => { - const { mx, authedRequest } = clientReturning('{}'); - await sendOutgoingRequest(mx, { - id: 'r', - type: RequestType.ToDevice, - body: '{}', - event_type: 'm.key.verification.start', - txn_id: 'txn1', - }); - expect(authedRequest.mock.calls[0]?.[1]).toBe( - '/_matrix/client/v3/sendToDevice/m.key.verification.start/txn1' - ); - - const room = clientReturning('{}'); - await sendOutgoingRequest(room.mx, { - id: 'r', - type: RequestType.RoomMessage, - body: '{}', - room_id: '!r:e.org', - event_type: 'm.room.message', - txn_id: 'txn2', - }); - expect(room.authedRequest.mock.calls[0]?.[1]).toBe( - '/_matrix/client/v3/rooms/!r%3Ae.org/send/m.room.message/txn2' - ); - }); - - it('rejects a request type it cannot route instead of silently dropping it', async () => { - const { mx } = clientReturning('{}'); - await expect(sendOutgoingRequest(mx, { id: 'r', type: 99, body: '{}' })).rejects.toThrow( - 'Unknown outgoing request type 99' - ); - }); -}); diff --git a/src/app/crypto/engineCrypto/outgoing.ts b/src/app/crypto/engineCrypto/outgoing.ts deleted file mode 100644 index 9ef7431c36..0000000000 --- a/src/app/crypto/engineCrypto/outgoing.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Method } from 'matrix-js-sdk/lib/http-api'; -import { calculateRetryBackoff } from 'matrix-js-sdk/lib/http-api/utils'; -import { sleep } from 'matrix-js-sdk/lib/utils'; -import type { MatrixClient } from '$types/matrix-sdk'; - -/** Numeric codes the engine tags outgoing requests with; see wasm_enums.rs. */ -export const RequestType = { - KeysUpload: 0, - KeysQuery: 1, - KeysClaim: 2, - ToDevice: 3, - SignatureUpload: 4, - RoomMessage: 5, - KeysBackup: 6, -} as const; - -export type OutgoingRequest = { - id: string; - type: number; - body: string; - event_type?: string; - txn_id?: string; - room_id?: string; - version?: string; -}; - -const OUTGOING_REQUEST_TIMEOUT_MS = 60000; - -const path = { - keysUpload: '/_matrix/client/v3/keys/upload', - keysQuery: '/_matrix/client/v3/keys/query', - keysClaim: '/_matrix/client/v3/keys/claim', - signatures: '/_matrix/client/v3/keys/signatures/upload', - keysBackup: '/_matrix/client/v3/room_keys/keys', -} as const; - -/** - * `body` goes verbatim: these bodies are signed and re-serialising reorders keys. - * `json: false` also makes js-sdk return `res.text()`, so the response is already the - * JSON string `markRequestAsSent` wants — encoding it again wedges the engine's queue. - */ -export const sendOutgoingRequest = async ( - mx: MatrixClient, - request: OutgoingRequest -): Promise => { - const send = async (method: Method, url: string, params: Record = {}) => { - for (let attempts = 0; ;) { - try { - // eslint-disable-next-line no-await-in-loop - return await mx.http.authedRequest(method, url, params, request.body, { - prefix: '', - json: false, - localTimeoutMs: OUTGOING_REQUEST_TIMEOUT_MS, - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - }); - } catch (error) { - attempts += 1; - const backoff = calculateRetryBackoff(error, attempts, true); - if (backoff < 0) throw error; - // eslint-disable-next-line no-await-in-loop - await sleep(backoff); - } - } - }; - - switch (request.type) { - case RequestType.KeysUpload: - return send(Method.Post, path.keysUpload); - case RequestType.KeysQuery: - return send(Method.Post, path.keysQuery); - case RequestType.KeysClaim: - return send(Method.Post, path.keysClaim); - case RequestType.SignatureUpload: - return send(Method.Post, path.signatures); - case RequestType.KeysBackup: - return send(Method.Put, path.keysBackup, { version: request.version ?? '' }); - case RequestType.ToDevice: { - const url = - `/_matrix/client/v3/sendToDevice/${encodeURIComponent(request.event_type ?? '')}` + - `/${encodeURIComponent(request.txn_id ?? '')}`; - return send(Method.Put, url); - } - case RequestType.RoomMessage: { - const url = - `/_matrix/client/v3/rooms/${encodeURIComponent(request.room_id ?? '')}/send` + - `/${encodeURIComponent(request.event_type ?? '')}/${encodeURIComponent(request.txn_id ?? '')}`; - return send(Method.Put, url); - } - default: - throw new Error(`Unknown outgoing request type ${request.type}`); - } -}; diff --git a/src/app/crypto/engineCrypto/outgoingCoalesce.test.ts b/src/app/crypto/engineCrypto/outgoingCoalesce.test.ts deleted file mode 100644 index dcc514ac33..0000000000 --- a/src/app/crypto/engineCrypto/outgoingCoalesce.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient } 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 identity = { userId: '@me:e.org', deviceId: 'D' }; - -const settle = () => - new Promise((resolve) => { - setTimeout(resolve, 0); - }); - -describe('coalesced crypto work', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('collapses flushes that arrive while a drain is running into one extra drain', async () => { - const gate = Promise.withResolvers(); - let drains = 0; - mockInvoke.mockImplementation(async (_identity, method) => { - if (method !== 'outgoingRequests') return null; - drains += 1; - await gate.promise; - return []; - }); - - const mx = { - http: { authedRequest: vi.fn<() => Promise>() }, - } as unknown as MatrixClient; - const crypto = new EngineCrypto(mx, identity); - for (let i = 0; i < 5; i += 1) crypto.onSyncCompleted({}); - - await Promise.resolve(); - expect(drains).toBe(1); - - gate.resolve(); - await vi.waitFor(() => expect(drains).toBe(2)); - - await settle(); - expect(drains).toBe(2); - }); - - it('shares one in-flight key backup check between concurrent callers', async () => { - const gate = Promise.withResolvers(); - let versionGets = 0; - const authedRequest = vi.fn<(...args: never[]) => Promise>( - async (_method: unknown, url: unknown) => { - if (url === '/room_keys/version') { - versionGets += 1; - await gate.promise; - } - return {}; - } - ); - mockInvoke.mockImplementation(async () => null); - - const crypto = new EngineCrypto( - { http: { authedRequest } } as unknown as MatrixClient, - identity - ); - const checks = Array.from({ length: 5 }, () => crypto.checkKeyBackupAndEnable()); - - await Promise.resolve(); - gate.resolve(); - await Promise.all(checks); - await settle(); - - expect(versionGets).toBe(1); - }); - - it('drops an unparseable to-device event instead of failing the batch', async () => { - mockInvoke.mockImplementation(async (_identity, method) => { - if (method !== 'receiveSyncChanges') return null; - return [ - { type: 2, rawEvent: 'not json' }, - { type: 2, rawEvent: JSON.stringify({ type: 'm.room_key', sender: '@a:e.org' }) }, - ]; - }); - - const mx = { - http: { authedRequest: vi.fn<() => Promise>() }, - } as unknown as MatrixClient; - const crypto = new EngineCrypto(mx, identity); - - const received = await crypto.preprocessToDeviceMessages([]); - expect(received).toHaveLength(1); - expect(received[0]?.message.type).toBe('m.room_key'); - }); -}); diff --git a/src/app/crypto/engineCrypto/outgoingDispatch.test.ts b/src/app/crypto/engineCrypto/outgoingDispatch.test.ts deleted file mode 100644 index 72ab0e1219..0000000000 --- a/src/app/crypto/engineCrypto/outgoingDispatch.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { VerificationPhase, EventType, type MatrixClient } 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 clientSpy = () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => '{}'); - return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; -}; - -/** Unsent, the peer never receives the ready/accept and nothing reports an error. */ -describe('verification outgoing requests', () => { - beforeEach(() => mockInvoke.mockReset()); - - it('sends the request that starting a device verification returns', async () => { - const { mx, authedRequest } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'device.requestVerification') { - return { - request: { flowId: '$f', otherUserId: '@them:e.org', phase: 1 }, - outgoingRequest: { - id: 'txn', - type: 3, - body: '{"messages":{}}', - event_type: 'm.key.verification.request', - txn_id: 'txn', - }, - }; - } - return []; - }); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).requestDeviceVerification( - '@them:e.org', - 'THEIRS' - ); - - const sent = authedRequest.mock.calls.map((call) => call[1] as unknown as string); - expect(sent).toContain('/_matrix/client/v3/sendToDevice/m.key.verification.request/txn'); - }); - - /** - * sas.confirm answers with the MAC plus a signature upload. Sending only one leaves the - * peer waiting after both sides pressed "they match", with no error either side. - */ - it('sends every request when the engine answers with several', async () => { - const { mx, authedRequest } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'device.requestVerification') { - return { - request: { - flowId: '$f', - otherUserId: '@them:e.org', - phase: 3, - verification: { className: 'Sas', decimals: [1, 2, 3] }, - }, - }; - } - if (method === 'sas.confirm') { - return [ - { id: 'a', type: 3, body: '{}', event_type: 'm.key.verification.mac', txn_id: 'a' }, - { id: null, type: 4, body: '{}' }, - ]; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const request = await crypto.requestDeviceVerification('@them:e.org', 'THEIRS'); - await request.verifier?.getShowSasCallbacks()?.confirm(); - - const sent = authedRequest.mock.calls.map((call) => call[1] as unknown as string); - expect(sent).toContain('/_matrix/client/v3/sendToDevice/m.key.verification.mac/a'); - expect(sent).toContain('/_matrix/client/v3/keys/signatures/upload'); - }); - - it('does not mistake a state snapshot for an outgoing request', async () => { - const { mx, authedRequest } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'device.requestVerification') { - return { request: { flowId: '$f', otherUserId: '@them:e.org', phase: 1 } }; - } - return []; - }); - - await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).requestDeviceVerification( - '@them:e.org', - 'THEIRS' - ); - - // The constructor probes the key backup; only verification traffic matters here. - const verificationCalls = authedRequest.mock.calls.filter( - ([, url]) => !String(url).startsWith('/room_keys/') - ); - expect(verificationCalls).toEqual([]); - }); - - it('completes a request when the engine consumes the final done event', async () => { - const { mx } = clientSpy(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'device.requestVerification') { - return { - request: { - flowId: '$f', - otherUserId: '@them:e.org', - phase: 3, - verification: { className: 'Sas' }, - }, - }; - } - if (method === 'receiveSyncChanges') { - return [ - { - type: 3, - rawEvent: JSON.stringify({ - type: EventType.KeyVerificationDone, - sender: '@them:e.org', - content: { transaction_id: '$f' }, - }), - }, - ]; - } - if (method === 'verificationRequest.state') { - throw new Error('verificationRequest.state: no verification request'); - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const request = await crypto.requestDeviceVerification('@them:e.org', 'THEIRS'); - - await expect( - crypto.preprocessToDeviceMessages([ - { - type: EventType.KeyVerificationDone, - sender: '@them:e.org', - content: { transaction_id: '$f' }, - } as never, - ]) - ).resolves.toEqual([]); - expect(request.phase).toBe(VerificationPhase.Done); - }); - - it('drains the outgoing queue before re-reading the SAS snapshot', async () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>( - async () => - new Promise((resolve) => { - setTimeout(() => resolve('{}'), 5); - }) - ); - const mx = { http: { authedRequest } } as unknown as MatrixClient; - let peerKeyReceived = false; - let ourKeyAcked = false; - - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'device.requestVerification') { - return { - request: { - flowId: '$f', - otherUserId: '@them:e.org', - phase: 3, - verification: { className: 'Sas' }, - }, - }; - } - if (method === 'receiveSyncChanges') { - peerKeyReceived = true; - return [ - { - type: 3, - rawEvent: JSON.stringify({ - type: 'm.key.verification.key', - sender: '@them:e.org', - content: { transaction_id: '$f' }, - }), - }, - ]; - } - if (method === 'outgoingRequests') { - return peerKeyReceived - ? [{ id: 'k', type: 3, body: '{}', event_type: 'm.key.verification.key', txn_id: 'k' }] - : []; - } - if (method === 'markRequestAsSent') { - ourKeyAcked = true; - return null; - } - if (method === 'verificationRequest.state') { - return { - flowId: '$f', - otherUserId: '@them:e.org', - phase: 3, - verification: { - className: 'Sas', - emoji: ourKeyAcked ? [{ symbol: '🌏', description: 'Globe' }] : null, - }, - }; - } - return []; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const request = await crypto.requestDeviceVerification('@them:e.org', 'THEIRS'); - - await crypto.preprocessToDeviceMessages([ - { - type: 'm.key.verification.key', - sender: '@them:e.org', - content: { transaction_id: '$f' }, - } as never, - ]); - - expect(request.verifier?.getShowSasCallbacks()?.sas.emoji).toEqual([['🌏', 'Globe']]); - }); -}); diff --git a/src/app/crypto/engineCrypto/outgoingRetry.test.ts b/src/app/crypto/engineCrypto/outgoingRetry.test.ts deleted file mode 100644 index 615c06efcf..0000000000 --- a/src/app/crypto/engineCrypto/outgoingRetry.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ConnectionError, MatrixError, Method } from 'matrix-js-sdk/lib/http-api'; -import { sleep } from 'matrix-js-sdk/lib/utils'; -import type * as MatrixUtilsNs from 'matrix-js-sdk/lib/utils'; - -type MatrixUtils = typeof MatrixUtilsNs; -import type { MatrixClient } from '$types/matrix-sdk'; -import { RequestType, sendOutgoingRequest } from './outgoing'; - -vi.mock('matrix-js-sdk/lib/utils', async (importOriginal) => ({ - ...(await importOriginal()), - sleep: vi.fn<(ms: number) => Promise>(async () => undefined), -})); - -const rateLimited = (retryAfterMs = 10) => - new MatrixError( - { errcode: 'M_LIMIT_EXCEEDED', error: 'Too many requests', retry_after_ms: retryAfterMs }, - 429 - ); - -const clientWith = (authedRequest: unknown) => - ({ http: { authedRequest } }) as unknown as MatrixClient; - -const request = { id: 'r1', type: RequestType.KeysClaim, body: '{}' }; - -describe('sendOutgoingRequest', () => { - beforeEach(() => vi.clearAllMocks()); - - it('retries a rate-limited request instead of failing the caller', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValueOnce(rateLimited()) - .mockResolvedValueOnce('{}'); - const mx = { http: { authedRequest } } as unknown as MatrixClient; - - await expect(sendOutgoingRequest(mx, request)).resolves.toBe('{}'); - expect(authedRequest).toHaveBeenCalledTimes(2); - expect(authedRequest.mock.calls[0]?.[0]).toBe(Method.Post); - }); - - it('waits as long as the server asks after a rate limit', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValueOnce(rateLimited(5000)) - .mockResolvedValueOnce('{}'); - - await sendOutgoingRequest(clientWith(authedRequest), request); - - expect(vi.mocked(sleep)).toHaveBeenCalledWith(5000); - }); - - it('gives up after five attempts on a persistent server error', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValue(new MatrixError({ errcode: 'M_UNKNOWN', error: 'boom' }, 500)); - - await expect(sendOutgoingRequest(clientWith(authedRequest), request)).rejects.toThrow('boom'); - expect(authedRequest).toHaveBeenCalledTimes(5); - }); - - it('does not retry a request the server says is too large', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValue(new MatrixError({ errcode: 'M_TOO_LARGE', error: 'too big' }, 502)); - - await expect(sendOutgoingRequest(clientWith(authedRequest), request)).rejects.toThrow( - 'too big' - ); - expect(authedRequest).toHaveBeenCalledTimes(1); - }); - - it('does not retry a request we aborted ourselves', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' })); - - await expect(sendOutgoingRequest(clientWith(authedRequest), request)).rejects.toThrow( - 'aborted' - ); - expect(authedRequest).toHaveBeenCalledTimes(1); - }); - - it('retries after a connection error', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValueOnce(new ConnectionError('Failed to fetch')) - .mockResolvedValueOnce('{}'); - - await expect(sendOutgoingRequest(clientWith(authedRequest), request)).resolves.toBe('{}'); - expect(authedRequest).toHaveBeenCalledTimes(2); - }); - - it('rethrows an error that is not worth retrying', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValue(Object.assign(new Error('nope'), { httpStatus: 400, data: {} })); - const mx = { http: { authedRequest } } as unknown as MatrixClient; - - await expect(sendOutgoingRequest(mx, request)).rejects.toThrow('nope'); - expect(authedRequest).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/crypto/engineCrypto/pendingRoomKeys.test.ts b/src/app/crypto/engineCrypto/pendingRoomKeys.test.ts deleted file mode 100644 index 5627cfedbe..0000000000 --- a/src/app/crypto/engineCrypto/pendingRoomKeys.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -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 mx = { - http: { authedRequest: vi.fn<(...args: never[]) => Promise>(async () => null) }, -} as unknown as MatrixClient; - -const utdEvent = (sessionId: string, roomId = '!room:e.org') => { - const attemptDecryption = vi.fn<(...args: never[]) => Promise>(async () => undefined); - const event = { - getRoomId: () => roomId, - getId: () => '$e', - getWireType: () => 'm.room.encrypted', - getSender: () => '@them:e.org', - getTs: () => 0, - getWireContent: () => ({ session_id: sessionId }), - attemptDecryption, - } as unknown as MatrixEvent; - return { event, attemptDecryption }; -}; - -const crypto = () => new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - -describe('events pending a room key', () => { - beforeEach(() => { - mockInvoke.mockReset(); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'decryptRoomEvent') throw new Error('decryptRoomEvent failed: MissingRoomKey'); - return null; - }); - }); - - it('retries an undecryptable event once its key arrives', async () => { - const { event, attemptDecryption } = utdEvent('session-1'); - const engine = crypto(); - - await expect(engine.decryptEvent(event)).rejects.toThrow('MissingRoomKey'); - engine.onRoomKeysUpdated([{ roomId: '!room:e.org', sessionId: 'session-1' }]); - - expect(attemptDecryption).toHaveBeenCalledWith(engine, { isRetry: true }); - }); - - it('retries when the key is reported withheld', async () => { - const { event, attemptDecryption } = utdEvent('session-1'); - const engine = crypto(); - - await expect(engine.decryptEvent(event)).rejects.toThrow('MissingRoomKey'); - engine.onRoomKeysWithheld([{ roomId: '!room:e.org', sessionId: 'session-1' }]); - - expect(attemptDecryption).toHaveBeenCalledTimes(1); - }); - - it('leaves events waiting on another session alone', async () => { - const { event, attemptDecryption } = utdEvent('session-1'); - const engine = crypto(); - - await expect(engine.decryptEvent(event)).rejects.toThrow('MissingRoomKey'); - engine.onRoomKeysUpdated([{ roomId: '!room:e.org', sessionId: 'session-2' }]); - engine.onRoomKeysUpdated([{ roomId: '!other:e.org', sessionId: 'session-1' }]); - - expect(attemptDecryption).not.toHaveBeenCalled(); - }); - - it('retries every event stuck on the same session', async () => { - const first = utdEvent('session-1'); - const second = utdEvent('session-1'); - const engine = crypto(); - - await expect(engine.decryptEvent(first.event)).rejects.toThrow('MissingRoomKey'); - await expect(engine.decryptEvent(second.event)).rejects.toThrow('MissingRoomKey'); - engine.onRoomKeysUpdated([{ roomId: '!room:e.org', sessionId: 'session-1' }]); - - expect(first.attemptDecryption).toHaveBeenCalledTimes(1); - expect(second.attemptDecryption).toHaveBeenCalledTimes(1); - }); - - it('drops the events it has retried', async () => { - const { event, attemptDecryption } = utdEvent('session-1'); - const engine = crypto(); - - await expect(engine.decryptEvent(event)).rejects.toThrow('MissingRoomKey'); - engine.onRoomKeysUpdated([{ roomId: '!room:e.org', sessionId: 'session-1' }]); - engine.onRoomKeysUpdated([{ roomId: '!room:e.org', sessionId: 'session-1' }]); - - expect(attemptDecryption).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/crypto/engineCrypto/perSessionBackupDownload.test.ts b/src/app/crypto/engineCrypto/perSessionBackupDownload.test.ts deleted file mode 100644 index 3ab00c6ad9..0000000000 --- a/src/app/crypto/engineCrypto/perSessionBackupDownload.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { KeyBackupSession } from 'matrix-js-sdk/lib/crypto-api/keybackup'; -import type { MatrixClient } from '$types/matrix-sdk'; -import { BACKOFF_TIME_MS, PerSessionBackupDownloader } from './perSessionBackupDownload'; - -const settle = async () => { - for (let i = 0; i < 20; i += 1) { - // eslint-disable-next-line no-await-in-loop - await Promise.resolve(); - } -}; - -const rateLimited = (retryAfterMs: number) => - Object.assign(new Error('slow down'), { - httpStatus: 429, - data: { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: retryAfterMs }, - }); - -describe('PerSessionBackupDownloader', () => { - let clock = 0; - let backupVersion: string | null = '7'; - - beforeEach(() => { - clock = 0; - backupVersion = '7'; - }); - - const make = ( - authedRequest: ReturnType Promise>>, - importSession = vi.fn<(roomId: string, session: KeyBackupSession) => Promise>( - async () => true - ) - ) => { - const downloader = new PerSessionBackupDownloader({ - mx: { http: { authedRequest } } as unknown as MatrixClient, - getBackupVersion: async () => backupVersion, - importSession, - now: () => clock, - }); - return { downloader, importSession }; - }; - - it('fetches the one missing session and imports it', async () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => ({ - session_data: {}, - })); - const { downloader, importSession } = make(authedRequest); - - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - - expect(authedRequest).toHaveBeenCalledTimes(1); - expect(authedRequest.mock.calls[0]?.[1]).toBe('/room_keys/keys/!r%3Ae.org/S1'); - expect(authedRequest.mock.calls[0]?.[2]).toEqual({ version: '7' }); - expect(importSession).toHaveBeenCalledTimes(1); - }); - - it('does not query the backup when no active version is known', async () => { - backupVersion = null; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => ({ - session_data: {}, - })); - const { downloader, importSession } = make(authedRequest); - - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - - expect(authedRequest).not.toHaveBeenCalled(); - expect(importSession).not.toHaveBeenCalled(); - }); - - it('does not hammer the backup for a session it is already fetching', async () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => ({ - session_data: {}, - })); - const { downloader } = make(authedRequest); - - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - downloader.request({ roomId: '!r:e.org', sessionId: 'S2' }); - await settle(); - - expect(authedRequest).toHaveBeenCalledTimes(2); - }); - - it('does not re-request a session the backup does not have until the backoff expires', async () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => { - throw Object.assign(new Error('nope'), { httpStatus: 404, data: {} }); - }); - const { downloader } = make(authedRequest); - - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - expect(authedRequest).toHaveBeenCalledTimes(1); - - clock += BACKOFF_TIME_MS + 1; - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - expect(authedRequest).toHaveBeenCalledTimes(2); - }); - - it('stops fetching once told to stop', async () => { - const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => ({ - session_data: {}, - })); - const { downloader } = make(authedRequest); - - downloader.stop(); - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - - expect(authedRequest).not.toHaveBeenCalled(); - }); - - it('re-queues a rate-limited session instead of dropping it', async () => { - const authedRequest = vi - .fn<(...args: never[]) => Promise>() - .mockRejectedValueOnce(rateLimited(0)) - .mockResolvedValue({ session_data: {} }); - const { downloader, importSession } = make(authedRequest); - - downloader.request({ roomId: '!r:e.org', sessionId: 'S1' }); - await settle(); - - expect(authedRequest.mock.calls.length).toBeGreaterThanOrEqual(2); - expect(importSession).toHaveBeenCalled(); - }); -}); diff --git a/src/app/crypto/engineCrypto/perSessionBackupDownload.ts b/src/app/crypto/engineCrypto/perSessionBackupDownload.ts deleted file mode 100644 index da92c962bd..0000000000 --- a/src/app/crypto/engineCrypto/perSessionBackupDownload.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { ClientPrefix, Method } from 'matrix-js-sdk/lib/http-api'; -import { encodeUri } from 'matrix-js-sdk/lib/utils'; -import type { KeyBackupSession } from 'matrix-js-sdk/lib/crypto-api/keybackup'; -import type { MatrixClient } from '$types/matrix-sdk'; - -export const BACKOFF_TIME_MS = 5000; - -export type SessionRef = { roomId: string; sessionId: string }; - -export type BackupDownloadHost = { - mx: MatrixClient; - getBackupVersion: () => Promise; - importSession: (roomId: string, session: KeyBackupSession) => Promise; - now: () => number; -}; - -export class PerSessionBackupDownloader { - readonly #host: BackupDownloadHost; - - readonly #queue: SessionRef[] = []; - - readonly #queued = new Set(); - - readonly #missingUntil = new Map(); - - #running = false; - - #stopped = false; - - #pausedUntil = 0; - - constructor(host: BackupDownloadHost) { - this.#host = host; - } - - stop(): void { - this.#stopped = true; - this.#queue.length = 0; - this.#queued.clear(); - } - - resume(): void { - this.#missingUntil.clear(); - this.#pausedUntil = 0; - } - - request(ref: SessionRef): void { - if (this.#stopped) return; - - const key = `${ref.roomId}|${ref.sessionId}`; - if (this.#queued.has(key)) return; - - const retryAt = this.#missingUntil.get(key); - if (retryAt !== undefined && this.#host.now() < retryAt) return; - - this.#queued.add(key); - this.#queue.push(ref); - void this.#drain(); - } - - async #drain(): Promise { - if (this.#running) return; - this.#running = true; - - try { - while (!this.#stopped) { - const ref = this.#queue.shift(); - if (!ref) break; - - const wait = this.#pausedUntil - this.#host.now(); - if (wait > 0) { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, wait); - }); - } - if (this.#stopped) break; - - // eslint-disable-next-line no-await-in-loop - await this.#fetchOne(ref); - this.#queued.delete(`${ref.roomId}|${ref.sessionId}`); - } - } finally { - this.#running = false; - } - } - - async #fetchOne(ref: SessionRef): Promise { - const key = `${ref.roomId}|${ref.sessionId}`; - const path = encodeUri('/room_keys/keys/$roomId/$sessionId', { - $roomId: ref.roomId, - $sessionId: ref.sessionId, - }); - - const version = await this.#host.getBackupVersion(); - if (!version) { - this.#missingUntil.set(key, this.#host.now() + BACKOFF_TIME_MS); - return; - } - - try { - const session = await this.#host.mx.http.authedRequest( - Method.Get, - path, - { version }, - undefined, - { prefix: ClientPrefix.V3 } - ); - const imported = await this.#host.importSession(ref.roomId, session); - if (!imported) this.#missingUntil.set(key, this.#host.now() + BACKOFF_TIME_MS); - } catch (error) { - const failure = error as { - httpStatus?: number; - data?: { errcode?: string; retry_after_ms?: number }; - }; - - if (failure.data?.errcode === 'M_LIMIT_EXCEEDED') { - const after = failure.data.retry_after_ms ?? BACKOFF_TIME_MS; - this.#pausedUntil = this.#host.now() + after; - this.#requeue(ref); - return; - } - - this.#missingUntil.set(key, this.#host.now() + BACKOFF_TIME_MS); - } - } - - #requeue(ref: SessionRef): void { - this.#queue.push(ref); - } -} diff --git a/src/app/crypto/engineCrypto/prepareRoomKey.test.ts b/src/app/crypto/engineCrypto/prepareRoomKey.test.ts deleted file mode 100644 index a1b8c088cb..0000000000 --- a/src/app/crypto/engineCrypto/prepareRoomKey.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } 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 room = { - roomId: '!room:e.org', - getEncryptionTargetMembers: async () => [{ userId: '@a:e.org' }], - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, -} as unknown as Room; - -const event = () => - ({ - getType: () => 'm.room.message', - getContent: () => ({ body: 'hello' }), - makeEncrypted: vi.fn<() => void>(), - }) as unknown as MatrixEvent; - -describe('room-key preparation', () => { - afterEach(() => { - vi.useRealTimers(); - mockInvoke.mockReset(); - }); - - it('shares room keys during preparation instead of delaying the send', async () => { - vi.useFakeTimers(); - let shareAcknowledged = false; - const share = { - id: 'share', - type: 3, - event_type: 'm.room.encrypted', - txn_id: 'share', - body: '{}', - }; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (String(url).startsWith('/_matrix/client/v3/sendToDevice/')) { - return new Promise((resolve) => setTimeout(() => resolve('{}'), 20_000)); - } - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; - if (method === 'shareRoomKey') return shareAcknowledged ? [] : [share]; - if (method === 'markRequestAsSent' && (args as { requestId: string }).requestId === 'share') { - shareAcknowledged = true; - } - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { - userId: '@me:e.org', - deviceId: 'D', - }); - crypto.prepareToEncrypt(room); - await vi.runAllTimersAsync(); - - const started = Date.now(); - const send = crypto.encryptEvent(event(), room).then(() => Date.now() - started); - await vi.runAllTimersAsync(); - - expect(await send).toBe(0); - expect( - authedRequest.mock.calls.filter(([, url]) => String(url).includes('/sendToDevice/')) - ).toHaveLength(1); - expect(mockInvoke.mock.calls.filter(([, method]) => method === 'shareRoomKey')).toHaveLength(2); - }); - - it('keeps a concurrent send behind room-key sharing and its acknowledgement', async () => { - vi.useFakeTimers(); - const shareResponse = Promise.withResolvers(); - const shareAcknowledged = Promise.withResolvers(); - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (String(url).startsWith('/_matrix/client/v3/sendToDevice/')) return shareResponse.promise; - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; - if (method === 'shareRoomKey') - return [ - { id: 'share', type: 3, event_type: 'm.room.encrypted', txn_id: 'share', body: '{}' }, - ]; - if (method === 'markRequestAsSent' && (args as { requestId: string }).requestId === 'share') - await shareAcknowledged.promise; - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { - userId: '@me:e.org', - deviceId: 'D', - }); - crypto.prepareToEncrypt(room); - const send = crypto.encryptEvent(event(), room); - try { - await vi.waitFor(() => - expect(authedRequest).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('/sendToDevice/'), - expect.anything(), - expect.anything(), - expect.anything() - ) - ); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') - ).toHaveLength(0); - shareResponse.resolve('{}'); - await vi.waitFor(() => - expect(mockInvoke).toHaveBeenCalledWith( - expect.anything(), - 'markRequestAsSent', - expect.objectContaining({ requestId: 'share' }) - ) - ); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') - ).toHaveLength(0); - shareAcknowledged.resolve(); - await send; - } finally { - shareResponse.resolve('{}'); - shareAcknowledged.resolve(); - await send; - } - }); -}); diff --git a/src/app/crypto/engineCrypto/secretInbox.test.ts b/src/app/crypto/engineCrypto/secretInbox.test.ts deleted file mode 100644 index e2ac27afbe..0000000000 --- a/src/app/crypto/engineCrypto/secretInbox.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -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'; - -vi.mock('../olmMachine/engineInvoke', () => ({ - engineInvoke: vi.fn<(...args: never[]) => Promise>(), -})); - -const mockInvoke = vi.mocked(engineInvoke); - -const PRIVATE_KEY_BASE64 = encodeBase64(new Uint8Array(32).fill(7)); - -let publicKey: string; - -const clientFor = (backupInfo: unknown) => - ({ - http: { authedRequest: vi.fn<(...args: never[]) => Promise>(async () => backupInfo) }, - }) as unknown as MatrixClient; - -const invoked = (method: string) => mockInvoke.mock.calls.filter(([, name]) => name === method); - -describe('secret inbox', () => { - beforeAll(async () => { - await RustSdkCryptoJs.initAsync(); - const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(PRIVATE_KEY_BASE64); - publicKey = key.megolmV1PublicKey.publicKeyBase64; - key.free(); - }); - - beforeEach(() => mockInvoke.mockReset()); - - const backupInfo = { version: '7', algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2' }; - - it('saves a gossiped backup key that matches the server backup', async () => { - const mx = clientFor({ ...backupInfo, auth_data: { public_key: publicKey } }); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'getSecretsFromInbox') return [PRIVATE_KEY_BASE64]; - if (method === 'verifyBackup') return { trusted: true }; - if (method === 'getBackupKeys') - return { backupVersion: '7', decryptionKeyBase64: PRIVATE_KEY_BASE64 }; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - const cached = vi.fn<(version: string) => void>(); - crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, cached); - - await crypto.checkSecrets('m.megolm_backup.v1'); - - expect(invoked('saveBackupDecryptionKey')[0]?.[2]).toMatchObject({ - decryptionKey: PRIVATE_KEY_BASE64, - version: '7', - }); - expect(cached).toHaveBeenCalledWith('7'); - expect(invoked('deleteSecretsFromInbox')).toHaveLength(1); - }); - - it('drops a key for a backup other than the one on the server', async () => { - const mx = clientFor({ ...backupInfo, auth_data: { public_key: 'c29tZW9uZS1lbHNl' } }); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'getSecretsFromInbox') return [PRIVATE_KEY_BASE64]; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.checkSecrets('m.megolm_backup.v1'); - - expect(invoked('saveBackupDecryptionKey')).toHaveLength(0); - expect(invoked('deleteSecretsFromInbox')).toHaveLength(1); - }); - - it('survives an unusable value and keeps reading the rest', async () => { - const mx = clientFor({ ...backupInfo, auth_data: { public_key: publicKey } }); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'getSecretsFromInbox') return ['not-a-key', PRIVATE_KEY_BASE64]; - if (method === 'verifyBackup') return { trusted: true }; - if (method === 'getBackupKeys') - return { backupVersion: '7', decryptionKeyBase64: PRIVATE_KEY_BASE64 }; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.checkSecrets('m.megolm_backup.v1'); - - expect(invoked('saveBackupDecryptionKey')).toHaveLength(1); - }); - - it('ignores secrets the engine handles itself', async () => { - const mx = clientFor({ ...backupInfo, auth_data: { public_key: publicKey } }); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'getSecretsFromInbox') return ['whatever']; - return null; - }); - - const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); - await crypto.checkSecrets('m.cross_signing.master'); - - expect(invoked('saveBackupDecryptionKey')).toHaveLength(0); - expect(invoked('deleteSecretsFromInbox')[0]?.[2]).toMatchObject({ - secretName: 'm.cross_signing.master', - }); - }); -}); diff --git a/src/app/crypto/engineCrypto/secretStorageAccess.ts b/src/app/crypto/engineCrypto/secretStorageAccess.ts deleted file mode 100644 index 9ce028581c..0000000000 --- a/src/app/crypto/engineCrypto/secretStorageAccess.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SecretStorageKey, ServerSideSecretStorage } from 'matrix-js-sdk/lib/secret-storage'; - -export const secretStorageCanAccessSecrets = async ( - secretStorage: ServerSideSecretStorage, - secretNames: SecretStorageKey[] -): Promise => { - const defaultKeyId = await secretStorage.getDefaultKeyId(); - if (!defaultKeyId) return false; - - const stored = await Promise.all( - secretNames.map(async (name) => (await secretStorage.isStored(name)) ?? {}) - ); - - return stored.every((record) => defaultKeyId in record); -}; diff --git a/src/app/crypto/engineCrypto/sendConcurrency.test.ts b/src/app/crypto/engineCrypto/sendConcurrency.test.ts deleted file mode 100644 index 7d165d6719..0000000000 --- a/src/app/crypto/engineCrypto/sendConcurrency.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } 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 room = (roomId: string) => - ({ - roomId, - getEncryptionTargetMembers: async () => [{ userId: `@${roomId.slice(1, 2)}:e.org` }], - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, - }) as unknown as Room; - -const event = (body: string) => - ({ - getType: () => 'm.room.message', - getContent: () => ({ body }), - makeEncrypted: vi.fn<() => void>(), - }) as unknown as MatrixEvent; - -const cryptoWith = (authedRequest: ReturnType) => - new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { - userId: '@me:e.org', - deviceId: 'D', - }); - -describe('encrypted send concurrency', () => { - afterEach(() => mockInvoke.mockReset()); - - it('holds same-room sends behind preparation response and acknowledgement', async () => { - const queryResponse = Promise.withResolvers(); - const queryAcknowledged = Promise.withResolvers(); - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (url === '/_matrix/client/v3/keys/query') return queryResponse.promise; - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; - if (method === 'markRequestAsSent' && (args as { requestId: string }).requestId === 'query') { - await queryAcknowledged.promise; - } - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const crypto = cryptoWith(authedRequest); - const target = room('!room:e.org'); - crypto.prepareToEncrypt(target); - const sends = [ - crypto.encryptEvent(event('first'), target), - crypto.encryptEvent(event('second'), target), - ]; - - try { - await vi.waitFor(() => expect(authedRequest).toHaveBeenCalledOnce()); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'getMissingSessions') - ).toHaveLength(0); - - queryResponse.resolve('{}'); - await vi.waitFor(() => - expect(mockInvoke.mock.calls).toContainEqual([ - expect.anything(), - 'markRequestAsSent', - expect.objectContaining({ requestId: 'query' }), - ]) - ); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'getMissingSessions') - ).toHaveLength(0); - - queryAcknowledged.resolve(); - await Promise.all(sends); - - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'queryKeysForUsers') - ).toHaveLength(1); - expect( - mockInvoke.mock.calls - .filter(([, method]) => method === 'encryptRoomEvent') - .map(([, , args]) => JSON.parse((args as { content: string }).content).body) - ).toEqual(['first', 'second']); - } finally { - queryResponse.resolve('{}'); - queryAcknowledged.resolve(); - await Promise.allSettled(sends); - } - }); - - it('keeps claims for separate rooms serialized until their response is acknowledged', async () => { - const firstClaimResponse = Promise.withResolvers(); - const firstClaimAcknowledged = Promise.withResolvers(); - let claimsSent = 0; - let heldClaimId: string | undefined; - let queries = 0; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (url === '/_matrix/client/v3/keys/claim' && claimsSent++ === 0) { - return firstClaimResponse.promise; - } - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'queryKeysForUsers') return { id: `query-${queries++}`, type: 1, body: '{}' }; - if (method === 'getMissingSessions') { - const user = (args as { users: string[] }).users[0]; - return { id: `claim-${user}`, type: 2, body: '{}' }; - } - if (method === 'markRequestAsSent') { - const requestId = (args as { requestId: string }).requestId; - if (requestId.startsWith('claim-') && !heldClaimId) { - heldClaimId = requestId; - await firstClaimAcknowledged.promise; - } - } - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const crypto = cryptoWith(authedRequest); - const sends = [ - crypto.encryptEvent(event('a'), room('!a:e.org')), - crypto.encryptEvent(event('b'), room('!b:e.org')), - ]; - - try { - await vi.waitFor(() => expect(claimsSent).toBe(1)); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'getMissingSessions') - ).toHaveLength(1); - - firstClaimResponse.resolve('{}'); - await vi.waitFor(() => expect(heldClaimId).toBeDefined()); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'getMissingSessions') - ).toHaveLength(1); - - firstClaimAcknowledged.resolve(); - await Promise.all(sends); - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'getMissingSessions') - ).toHaveLength(2); - } finally { - firstClaimResponse.resolve('{}'); - firstClaimAcknowledged.resolve(); - await Promise.allSettled(sends); - } - }); - - it('lets a queued send retry after preparation query failure', async () => { - const failedQuery = Promise.withResolvers(); - let queries = 0; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (url === '/_matrix/client/v3/keys/query' && queries++ === 0) return failedQuery.promise; - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const crypto = cryptoWith(authedRequest); - const target = room('!room:e.org'); - crypto.prepareToEncrypt(target); - let send: Promise | undefined; - try { - await vi.waitFor(() => expect(queries).toBe(1)); - send = crypto.encryptEvent(event('after failure'), target); - failedQuery.reject(new Error('query failed')); - - await expect(send).resolves.toBeUndefined(); - expect(queries).toBe(2); - } finally { - failedQuery.reject(new Error('query failed')); - await send?.catch(() => undefined); - } - }); -}); diff --git a/src/app/crypto/engineCrypto/sendLatency.test.ts b/src/app/crypto/engineCrypto/sendLatency.test.ts deleted file mode 100644 index 1a7fba424a..0000000000 --- a/src/app/crypto/engineCrypto/sendLatency.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } 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 room = { - roomId: '!room:e.org', - getEncryptionTargetMembers: async () => [{ userId: '@a:e.org' }], - getHistoryVisibility: () => 'shared', - getBlacklistUnverifiedDevices: () => false, - currentState: { getStateEvents: () => null }, -} as unknown as Room; - -const event = () => - ({ - getType: () => 'm.room.message', - getContent: () => ({ body: 'hello' }), - makeEncrypted: vi.fn<() => void>(), - }) as unknown as MatrixEvent; - -describe('first encrypted send latency', () => { - afterEach(() => mockInvoke.mockReset()); - - it.each([false, true])( - 'does not wait for an unrelated outgoing drain (%s preparation)', - async (prepareFirst) => { - const signatureUpload = Promise.withResolvers(); - let signatureAcknowledged = false; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (url === '/_matrix/client/v3/keys/signatures/upload') return signatureUpload.promise; - return '{}'; - }); - - mockInvoke.mockImplementation(async (_identity, method, args) => { - if (method === 'outgoingRequests') - return signatureAcknowledged ? [] : [{ id: 'signature', type: 4, body: '{}' }]; - if (method === 'markRequestAsSent') { - if ((args as { requestId?: string } | undefined)?.requestId === 'signature') { - signatureAcknowledged = true; - } - return null; - } - if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; - if (method === 'getMissingSessions') return { id: 'claim', type: 2, body: '{}' }; - if (method === 'shareRoomKey') { - return [ - { - id: 'share', - type: 3, - event_type: 'm.room.encrypted', - txn_id: 'share', - body: '{}', - }, - ]; - } - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - - const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { - userId: '@me:e.org', - deviceId: 'D', - }); - crypto.onSyncCompleted({}); - await vi.waitFor(() => - expect(authedRequest).toHaveBeenCalledWith( - expect.anything(), - '/_matrix/client/v3/keys/signatures/upload', - expect.anything(), - expect.anything(), - expect.anything() - ) - ); - - let send: Promise | undefined; - try { - if (prepareFirst) crypto.prepareToEncrypt(room); - const encrypted = event(); - send = crypto.encryptEvent(encrypted, room); - await vi.waitFor(() => expect(encrypted.makeEncrypted).toHaveBeenCalledOnce(), { - timeout: 500, - }); - await send; - - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'queryKeysForUsers') - ).toHaveLength(1); - - const methods = mockInvoke.mock.calls.map(([, method]) => method); - const marked = (requestId: string) => - mockInvoke.mock.calls.findIndex( - ([, method, args]) => - method === 'markRequestAsSent' && - (args as { requestId: string }).requestId === requestId - ); - expect(marked('query')).toBeGreaterThanOrEqual(0); - expect(marked('query')).toBeLessThan(methods.indexOf('getMissingSessions')); - expect(marked('claim')).toBeGreaterThanOrEqual(0); - expect(marked('claim')).toBeLessThan(methods.indexOf('shareRoomKey')); - expect(marked('share')).toBeGreaterThanOrEqual(0); - expect(marked('share')).toBeLessThan(methods.indexOf('encryptRoomEvent')); - } finally { - signatureUpload.resolve('{}'); - await send; - } - } - ); - - it('retries the initial query when preparation could not send it', async () => { - let failFirstQuery = true; - const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { - if (url === '/_matrix/client/v3/keys/query' && failFirstQuery) { - failFirstQuery = false; - throw new Error('query failed'); - } - return '{}'; - }); - mockInvoke.mockImplementation(async (_identity, method) => { - if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; - if (method === 'encryptRoomEvent') return '{}'; - if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; - return null; - }); - const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { - userId: '@me:e.org', - deviceId: 'D', - }); - - crypto.prepareToEncrypt(room); - await vi.waitFor(() => - expect(authedRequest).toHaveBeenCalledWith( - expect.anything(), - '/_matrix/client/v3/keys/query', - expect.anything(), - expect.anything(), - expect.anything() - ) - ); - await vi.waitFor(() => - expect( - mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') - ).toHaveLength(0) - ); - - const encrypted = event(); - await crypto.encryptEvent(encrypted, room); - - expect(encrypted.makeEncrypted).toHaveBeenCalledOnce(); - expect( - authedRequest.mock.calls.filter(([, url]) => url === '/_matrix/client/v3/keys/query') - ).toHaveLength(2); - }); -}); diff --git a/src/app/crypto/engineCrypto/shape.test.ts b/src/app/crypto/engineCrypto/shape.test.ts deleted file mode 100644 index e8f30de958..0000000000 --- a/src/app/crypto/engineCrypto/shape.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { EventShieldColour, EventShieldReason } from '$types/matrix-sdk'; -import { toEventEncryptionInfo } from './EngineCrypto'; - -/** Engine payloads are cast from JSON, so only these pin the numeric encodings. */ -describe('toEventEncryptionInfo', () => { - it('maps engine colours onto the SDK enum', () => { - expect(toEventEncryptionInfo({ shieldStateLax: { color: 0 } })?.shieldColour).toBe( - EventShieldColour.RED - ); - expect(toEventEncryptionInfo({ shieldStateLax: { color: 1 } })?.shieldColour).toBe( - EventShieldColour.GREY - ); - expect(toEventEncryptionInfo({ shieldStateLax: { color: 2 } })?.shieldColour).toBe( - EventShieldColour.NONE - ); - }); - - it('maps every engine shield code onto the SDK reason', () => { - const expected: [number, EventShieldReason][] = [ - [0, EventShieldReason.AUTHENTICITY_NOT_GUARANTEED], - [1, EventShieldReason.UNKNOWN_DEVICE], - [2, EventShieldReason.UNSIGNED_DEVICE], - [3, EventShieldReason.UNVERIFIED_IDENTITY], - [4, EventShieldReason.VERIFICATION_VIOLATION], - [5, EventShieldReason.MISMATCHED_SENDER], - ]; - - expected.forEach(([code, reason]) => { - expect(toEventEncryptionInfo({ shieldStateLax: { color: 0, code } })?.shieldReason).toBe( - reason - ); - }); - }); - - it('has no reason when the engine sends none', () => { - expect(toEventEncryptionInfo({ shieldStateLax: { color: 2, code: null } })?.shieldReason).toBe( - null - ); - expect(toEventEncryptionInfo({ shieldStateLax: { color: 2 } })?.shieldReason).toBe(null); - }); - - // Fail safe: an unrecognised code must not silently read as "no warning". - it('falls back to a warning rather than clearing the shield', () => { - const unknown = toEventEncryptionInfo({ shieldStateLax: { color: 99, code: 99 } }); - expect(unknown?.shieldColour).toBe(EventShieldColour.RED); - expect(unknown?.shieldReason).toBe(EventShieldReason.UNKNOWN); - }); - - it('returns null when the engine has no info', () => { - expect(toEventEncryptionInfo(null)).toBeNull(); - expect(toEventEncryptionInfo({})).toBeNull(); - }); -}); diff --git a/src/app/crypto/install.test.ts b/src/app/crypto/install.test.ts index d72182da13..156224b16e 100644 --- a/src/app/crypto/install.test.ts +++ b/src/app/crypto/install.test.ts @@ -1,76 +1,114 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { isTauri } from '@tauri-apps/api/core'; -import { EventEmitter } from 'events'; -import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; -import { LegacyWasmCryptoStoreError, reEmitCryptoEvents, rustEngineEnabled } from './install'; +import { + engineClose, + engineInvoke, + engineOpen, + engineStoreExists, +} from '$generated/tauri/commands'; +import { + ensureSdkCryptoCanStart, + isNativeCryptoStoreError, + NativeCryptoStoreError, +} from './install'; vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn<() => boolean>() })); - vi.mock('$generated/tauri/commands', () => ({ - engineOpen: vi.fn<(...args: never[]) => unknown>(), + engineClose: vi.fn(), + engineInvoke: vi.fn(), + engineOpen: vi.fn(), + engineStoreExists: vi.fn(), })); const mockIsTauri = vi.mocked(isTauri); +const mockEngineClose = vi.mocked(engineClose); +const mockEngineInvoke = vi.mocked(engineInvoke); +const mockEngineOpen = vi.mocked(engineOpen); +const mockEngineStoreExists = vi.mocked(engineStoreExists); -describe('rustEngineEnabled', () => { +describe('ensureSdkCryptoCanStart', () => { beforeEach(() => { - mockIsTauri.mockReset(); + vi.resetAllMocks(); }); - it('keeps WASM crypto for non-Tauri clients', async () => { + it('starts SDK crypto outside Tauri without inspecting a native store', async () => { mockIsTauri.mockReturnValue(false); - await expect(rustEngineEnabled('sync@alice:example.org')).resolves.toBe(false); + await expect(ensureSdkCryptoCanStart('@alice:example.org', 'ALICE')).resolves.toBeUndefined(); + + expect(mockEngineStoreExists).not.toHaveBeenCalled(); }); - it('enables the native engine when no legacy crypto store exists', async () => { + it('starts SDK crypto when no native store exists', async () => { mockIsTauri.mockReturnValue(true); - const databases = vi.fn<() => Promise>().mockResolvedValue([]); - vi.stubGlobal('indexedDB', { databases }); + mockEngineStoreExists.mockResolvedValue(false); - await expect(rustEngineEnabled('sync@alice:example.org')).resolves.toBe(true); - expect(databases).toHaveBeenCalledOnce(); + await expect(ensureSdkCryptoCanStart('@alice:example.org', 'ALICE')).resolves.toBeUndefined(); }); - it('requires re-authentication instead of retaining a legacy WASM engine', async () => { + it('blocks SDK initialization when a native store exists', async () => { mockIsTauri.mockReturnValue(true); - vi.stubGlobal('indexedDB', { - databases: vi - .fn<() => Promise>() - .mockResolvedValue([{ name: 'sync@alice:example.org::matrix-sdk-crypto' }]), - }); + mockEngineStoreExists.mockResolvedValue(true); - await expect(rustEngineEnabled('sync@alice:example.org')).rejects.toBeInstanceOf( - LegacyWasmCryptoStoreError + await expect(ensureSdkCryptoCanStart('@alice:example.org', 'ALICE')).rejects.toBeInstanceOf( + NativeCryptoStoreError ); }); +}); - it('requires re-authentication when the legacy store cannot be inspected safely', async () => { - mockIsTauri.mockReturnValue(true); - vi.stubGlobal('indexedDB', {}); +describe('NativeCryptoStoreError', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); - await expect(rustEngineEnabled('sync@alice:example.org')).rejects.toBeInstanceOf( - LegacyWasmCryptoStoreError - ); + it('exports the unwrapped room-key JSON and closes the native store', async () => { + mockEngineStoreExists.mockResolvedValue(true); + mockEngineOpen.mockResolvedValue({} as never); + mockEngineInvoke.mockResolvedValue(JSON.stringify('[{"session_id":"session"}]')); + mockEngineClose.mockResolvedValue(true); + const error = new NativeCryptoStoreError('@alice:example.org', 'ALICE'); + + await expect(error.exportRoomKeys()).resolves.toBe('[{"session_id":"session"}]'); + + expect(mockEngineInvoke).toHaveBeenCalledWith({ + userId: '@alice:example.org', + deviceId: 'ALICE', + method: 'exportRoomKeys', + argsJson: '{}', + }); + expect(mockEngineClose).toHaveBeenCalledWith({ + userId: '@alice:example.org', + deviceId: 'ALICE', + }); }); -}); -describe('reEmitCryptoEvents', () => { - it('forwards SDK crypto events to MatrixClient and detaches them on stop', () => { - const mx = new EventEmitter(); - const rustCrypto = new EventEmitter(); - const listener = vi.fn<(request: unknown) => void>(); - mx.on(CryptoEvent.VerificationRequestReceived, listener); + it('does not create a native store after it has disappeared', async () => { + mockEngineStoreExists.mockResolvedValue(false); + const error = new NativeCryptoStoreError('@alice:example.org', 'ALICE'); - const stop = reEmitCryptoEvents(mx as never, rustCrypto as never); - const request = { transactionId: 'verification-request' }; - rustCrypto.emit(CryptoEvent.VerificationRequestReceived, request); + await expect(error.exportRoomKeys()).rejects.toThrow('no longer available'); + + expect(mockEngineOpen).not.toHaveBeenCalled(); + }); - expect(listener).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledWith(request, rustCrypto); + it('closes the native store when export fails', async () => { + mockEngineStoreExists.mockResolvedValue(true); + mockEngineOpen.mockResolvedValue({} as never); + mockEngineInvoke.mockRejectedValue(new Error('export failed')); + mockEngineClose.mockResolvedValue(true); + const error = new NativeCryptoStoreError('@alice:example.org', 'ALICE'); + + await expect(error.exportRoomKeys()).rejects.toThrow('export failed'); + + expect(mockEngineClose).toHaveBeenCalledWith({ + userId: '@alice:example.org', + deviceId: 'ALICE', + }); + }); - stop(); - rustCrypto.emit(CryptoEvent.VerificationRequestReceived, request); - expect(listener).toHaveBeenCalledOnce(); + it('identifies the recovery error', () => { + expect( + isNativeCryptoStoreError(new NativeCryptoStoreError('@alice:example.org', 'ALICE')) + ).toBe(true); }); }); diff --git a/src/app/crypto/install.ts b/src/app/crypto/install.ts index c90f29015c..18709af9fc 100644 --- a/src/app/crypto/install.ts +++ b/src/app/crypto/install.ts @@ -1,182 +1,44 @@ -import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; -import { ReEmitter } from 'matrix-js-sdk/lib/ReEmitter'; 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 { 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'; -import { startCryptoEventBridge } from './engineCrypto/eventBridge'; +import { + engineClose, + engineInvoke, + engineOpen, + engineStoreExists, +} from '$generated/tauri/commands'; -const cryptoLog = createDebugLogger('rust-crypto-install'); +export class NativeCryptoStoreError extends Error { + readonly exportRoomKeys: () => Promise; -const wasmCryptoStoreExists = async (cryptoDatabasePrefix: string): Promise => { - const name = `${cryptoDatabasePrefix}::matrix-sdk-crypto`; - // Cannot look without creating, so assume legacy rather than seize the device id. - if (!indexedDB.databases) return true; - const databases = await indexedDB.databases(); - return databases.some((database) => database.name === name); -}; - -export class LegacyWasmCryptoStoreError extends Error { - client?: MatrixClient; - - constructor() { + constructor(userId: string, deviceId: string) { super( - 'Encrypted chat has been upgraded to the native crypto engine. Sign out and sign in again to continue. Local encrypted-message keys from this installation will need to be restored from backup.' + 'This installation has encrypted-message keys that need to be exported before signing in again.' ); - this.name = 'LegacyWasmCryptoStoreError'; + this.name = 'NativeCryptoStoreError'; + this.exportRoomKeys = async () => { + if (!(await engineStoreExists({ userId, deviceId }))) { + throw new Error('The encrypted-message keys are no longer available on this installation.'); + } + await engineOpen({ dir: null, passphrase: null, userId, deviceId }); + try { + const raw = await engineInvoke({ + userId, + deviceId, + method: 'exportRoomKeys', + argsJson: '{}', + }); + return JSON.parse(raw) as string; + } finally { + await engineClose({ userId, deviceId }); + } + }; } } -export const isLegacyWasmCryptoStoreError = (error: unknown): error is LegacyWasmCryptoStoreError => - error instanceof LegacyWasmCryptoStoreError; - -export const rustEngineEnabled = async (cryptoDatabasePrefix: string): Promise => { - if (!isTauri()) return false; - if (await wasmCryptoStoreExists(cryptoDatabasePrefix)) { - cryptoLog.warn('general', 'Legacy WASM crypto store requires re-authentication'); - throw new LegacyWasmCryptoStoreError(); - } - return true; -}; - -type InstallResult = { - rustCrypto: EngineCrypto; -}; - -const MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE = 24 * 60 * 60 * 1000; - -const REEMITTED_CRYPTO_EVENTS = [ - CryptoEvent.VerificationRequestReceived, - CryptoEvent.UserTrustStatusChanged, - CryptoEvent.KeyBackupStatus, - CryptoEvent.KeyBackupSessionsRemaining, - CryptoEvent.KeyBackupFailed, - CryptoEvent.KeyBackupDecryptionKeyCached, - CryptoEvent.KeysChanged, - CryptoEvent.DevicesUpdated, - CryptoEvent.WillUpdateDevices, - CryptoEvent.DehydratedDeviceCreated, - CryptoEvent.DehydratedDeviceUploaded, - CryptoEvent.RehydrationStarted, - CryptoEvent.RehydrationProgress, - CryptoEvent.RehydrationCompleted, - CryptoEvent.RehydrationError, - CryptoEvent.DehydrationKeyCached, - CryptoEvent.DehydratedDeviceRotationError, -]; - -export const reEmitCryptoEvents = (mx: MatrixClient, crypto: EngineCrypto): (() => void) => { - const reEmitter = new ReEmitter(mx); - reEmitter.reEmit(crypto, REEMITTED_CRYPTO_EVENTS); - return () => reEmitter.stopReEmitting(crypto, REEMITTED_CRYPTO_EVENTS); -}; - -export const wireCryptoClientEvents = (mx: MatrixClient, crypto: EngineCrypto): (() => void) => { - const onLiveEvent = (event: MatrixEvent) => { - void crypto.onLiveEventFromSync(event); - }; - const onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string) => { - crypto.onRoomMembership(event, member, oldMembership); - }; - const onStateEvent = (event: MatrixEvent) => { - crypto.onRoomStateEvent(event); - }; - - mx.on(ClientEvent.Event, onLiveEvent); - mx.on(RoomMemberEvent.Membership, onMembership); - mx.on(RoomStateEvent.Events, onStateEvent); - - return () => { - mx.removeListener(ClientEvent.Event, onLiveEvent); - mx.removeListener(RoomMemberEvent.Membership, onMembership); - mx.removeListener(RoomStateEvent.Events, onStateEvent); - }; -}; +export const isNativeCryptoStoreError = (error: unknown): error is NativeCryptoStoreError => + error instanceof NativeCryptoStoreError; -export const installRustCrypto = async ( - mx: MatrixClient, - options: { storeDir?: string; passphrase?: string } = {} -): Promise => { - // getBackupDecryptor uses the wasm primitive in-process, so the module must be ready. - await RustSdkCryptoJs.initAsync(); - - const userId = mx.getUserId(); - const deviceId = mx.getDeviceId(); - if (!userId || !deviceId) { - throw new Error('Cannot install the Rust crypto engine before the session has an identity'); - } - - await engineOpen({ - dir: options.storeDir ?? null, - passphrase: options.passphrase ?? null, - userId, - deviceId, - }); - - const identity = { userId, deviceId }; - const engineCrypto = new EngineCrypto(mx, identity); - - // `MatrixClient.initRustCrypto` normally wires these events to the client. The native - // engine is installed independently, so reproduce that SDK initialization step here. - const stopReEmittingCryptoEvents = reEmitCryptoEvents(mx, engineCrypto); - const stopClientEvents = wireCryptoClientEvents(mx, engineCrypto); - const stopEventBridge = await startCryptoEventBridge(engineCrypto, identity); - - engineCrypto.checkSecrets('m.megolm_backup.v1').catch((error: unknown) => { - cryptoLog.warn('general', 'Failed to read the gossiped backup key', error); - }); - - engineCrypto.requestMissingSecretsIfNeeded().catch((error: unknown) => { - cryptoLog.warn('general', 'Failed to ask our other devices for missing secrets', error); - }); - - const stopEngineCrypto = engineCrypto.stop.bind(engineCrypto); - engineCrypto.stop = () => { - stopReEmittingCryptoEvents(); - stopClientEvents(); - stopEventBridge(); - stopEngineCrypto(); - engineClose({ userId, deviceId }).catch((error: unknown) => { - cryptoLog.warn('general', 'Could not close the native crypto engine', error); - }); - }; - - (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 }; -}; - -/** - * MSC4268: invites accepted before the key bundle arrived are marked pending by the - * engine. Import what is still in the window and forget the rest, or the list grows - * forever. - */ -const acceptPendingKeyBundles = async ( - crypto: EngineCrypto, - identity: { userId: string; deviceId: string } -): Promise => { - const pending = ((await engineInvoke(identity, 'getAllRoomsPendingKeyBundles')) ?? []) as { - roomId: string; - inviterId: string; - inviteAcceptedAtMillis: number; - }[]; - - for (const { roomId, inviterId, inviteAcceptedAtMillis } of pending) { - const expired = Date.now() - inviteAcceptedAtMillis > MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE; - try { - // eslint-disable-next-line no-await-in-loop - if (expired) await engineInvoke(identity, 'clearRoomPendingKeyBundle', { roomId }); - // eslint-disable-next-line no-await-in-loop - else await crypto.maybeAcceptKeyBundle(roomId, inviterId); - } catch (error) { - cryptoLog.warn('general', 'Could not accept a pending room key bundle', { roomId, error }); - } - } +export const ensureSdkCryptoCanStart = async (userId: string, deviceId: string): Promise => { + if (!isTauri()) return; + if (await engineStoreExists({ userId, deviceId })) + throw new NativeCryptoStoreError(userId, deviceId); }; diff --git a/src/app/crypto/olmMachine/engineInvoke.ts b/src/app/crypto/olmMachine/engineInvoke.ts deleted file mode 100644 index bcc0addd75..0000000000 --- a/src/app/crypto/olmMachine/engineInvoke.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { engineInvoke as invokeEngineCommand } from '$generated/tauri/commands'; - -export type EngineIdentity = { - userId: string; - deviceId: string; -}; - -export const engineInvoke = async ( - identity: EngineIdentity, - method: string, - args: Record = {} -): Promise => { - const raw = await invokeEngineCommand({ - userId: identity.userId, - deviceId: identity.deviceId, - method, - argsJson: JSON.stringify(args), - }); - return JSON.parse(raw) as unknown; -}; diff --git a/src/app/crypto/pushDecrypt.test.ts b/src/app/crypto/pushDecrypt.test.ts deleted file mode 100644 index a36c766933..0000000000 --- a/src/app/crypto/pushDecrypt.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -type DecryptPushParams = { - userId: string; - deviceId: string; - roomId: string; - eventJson: string; - passphrase: string | null; -}; - -const engineDecryptPush = vi.fn<(params: DecryptPushParams) => Promise>(); -const isTauri = vi.fn<() => boolean>(() => true); - -vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => isTauri() })); -vi.mock('$generated/tauri/commands', () => ({ - engineDecryptPush: (params: DecryptPushParams) => engineDecryptPush(params), -})); - -const { decryptPushEventNatively } = await import('./pushDecrypt'); - -const event = { - roomId: '!room:example.org', - eventId: '$event:example.org', - sender: '@sender:example.org', - content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AAAA' }, -}; - -describe('decryptPushEventNatively', () => { - afterEach(() => vi.useRealTimers()); - beforeEach(() => { - vi.clearAllMocks(); - isTauri.mockReturnValue(true); - }); - - it('returns the plaintext content and type from the Rust engine', async () => { - engineDecryptPush.mockResolvedValue({ - event_type: 'm.room.message', - sender: '@real:example.org', - body: 'hello', - clear_event: JSON.stringify({ - type: 'm.room.message', - sender: '@real:example.org', - content: { msgtype: 'm.text', body: 'hello' }, - }), - }); - - await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toEqual({ - eventType: 'm.room.message', - content: { msgtype: 'm.text', body: 'hello' }, - sender: '@real:example.org', - }); - }); - - it('passes the payload as a reconstructed m.room.encrypted event', async () => { - engineDecryptPush.mockResolvedValue({ - event_type: 'm.room.message', - clear_event: JSON.stringify({ content: { body: 'hi' } }), - }); - - await decryptPushEventNatively('@me:example.org', 'DEVICE', event); - - const params = engineDecryptPush.mock.calls[0]?.[0]; - if (!params) throw new Error('the engine command was never called'); - expect(params).toMatchObject({ - userId: '@me:example.org', - deviceId: 'DEVICE', - roomId: '!room:example.org', - }); - expect(JSON.parse(params.eventJson)).toMatchObject({ - type: 'm.room.encrypted', - room_id: '!room:example.org', - event_id: '$event:example.org', - sender: '@sender:example.org', - content: event.content, - }); - }); - - // Each of these is a case the js-sdk fallback must still get a chance to retry. - it('returns null when the Megolm key has not arrived, so the caller can fall back', async () => { - engineDecryptPush.mockRejectedValue( - new Error('decrypting push event failed: UnknownMessageIndex') - ); - - await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); - }); - - it('lets SDK key recovery proceed when native decryption stalls', async () => { - vi.useFakeTimers(); - engineDecryptPush.mockReturnValue(new Promise(() => {})); - let result: unknown = 'pending'; - void decryptPushEventNatively('@me:example.org', 'DEVICE', event).then((value) => { - result = value; - }); - await vi.advanceTimersByTimeAsync(2_000); - expect(result).toBeNull(); - expect(vi.getTimerCount()).toBe(0); - }); - - it('returns null rather than throwing when isTauri itself throws', async () => { - isTauri.mockImplementation(() => { - throw new Error('not in a tauri context'); - }); - - await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); - expect(engineDecryptPush).not.toHaveBeenCalled(); - }); - - it('returns null off Tauri without calling the engine', async () => { - isTauri.mockReturnValue(false); - - await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); - expect(engineDecryptPush).not.toHaveBeenCalled(); - }); - - it('returns null without a session identity', async () => { - await expect(decryptPushEventNatively(null, 'DEVICE', event)).resolves.toBeNull(); - await expect(decryptPushEventNatively('@me:example.org', null, event)).resolves.toBeNull(); - expect(engineDecryptPush).not.toHaveBeenCalled(); - }); - - it('returns null when the clear event carries no content', async () => { - engineDecryptPush.mockResolvedValue({ - event_type: 'm.room.message', - clear_event: JSON.stringify({ type: 'm.room.message' }), - }); - - await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); - }); -}); diff --git a/src/app/crypto/pushDecrypt.ts b/src/app/crypto/pushDecrypt.ts deleted file mode 100644 index e7c193e20c..0000000000 --- a/src/app/crypto/pushDecrypt.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { isTauri } from '@tauri-apps/api/core'; -import { engineDecryptPush } from '$generated/tauri/commands'; -import type { IContent } from '$types/matrix-sdk'; -import { createDebugLogger } from '$utils/debugLogger'; - -const pushDecryptLog = createDebugLogger('push-decrypt'); -const NATIVE_DECRYPT_TIMEOUT_MS = 2_000; - -export type DecryptedPushEvent = { - eventType: string; - content: IContent; - sender?: string; -}; - -export type EncryptedPushEvent = { - roomId: string; - eventId: string; - sender?: string; - content: IContent; -}; - -/** - * Decrypts a push payload straight through the Rust `OlmMachine`. Null when the engine - * cannot answer — usually a late Megolm key — and the caller falls back to the js-sdk - * path, which retries as keys land. - */ -export const decryptPushEventNatively = async ( - userId: string | null, - deviceId: string | null, - event: EncryptedPushEvent -): Promise => { - if (!userId || !deviceId) return null; - - let timeoutId: ReturnType | undefined; - try { - if (!isTauri()) return null; - - const nativeDecryption = engineDecryptPush({ - userId, - deviceId, - roomId: event.roomId, - eventJson: JSON.stringify({ - type: 'm.room.encrypted', - content: event.content, - room_id: event.roomId, - event_id: event.eventId, - sender: event.sender, - origin_server_ts: Date.now(), - }), - passphrase: null, - }); - const decrypted = await Promise.race([ - nativeDecryption, - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), NATIVE_DECRYPT_TIMEOUT_MS); - }), - ]); - if (!decrypted) return null; - - // `engine_decrypt_push` reports snake_case, and hands the clear event over as JSON text. - const clearEvent = JSON.parse(decrypted.clear_event) as { content?: IContent }; - const content = clearEvent?.content; - if (!decrypted.event_type || !content) return null; - - return { - eventType: decrypted.event_type, - content, - sender: decrypted.sender ?? event.sender, - }; - } catch (error) { - pushDecryptLog.warn( - 'notification', - 'Native push decryption unavailable, falling back to the js-sdk path', - { reason: error instanceof Error ? error.message : String(error) } - ); - return null; - } finally { - clearTimeout(timeoutId); - } -}; diff --git a/src/app/crypto/verification/qr.test.ts b/src/app/crypto/verification/qr.test.ts deleted file mode 100644 index 8116406534..0000000000 --- a/src/app/crypto/verification/qr.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { encodeBase64 } from 'matrix-js-sdk/lib/base64'; -import { VerificationPhase } from '$types/matrix-sdk'; -import { EngineVerificationRequest } from './request'; -import { EnginePhase, type EngineVerificationState } from './state'; -import { EngineQrVerifier } from './verifier'; - -const state = (patch: Partial = {}): EngineVerificationState => ({ - ownUserId: '@me:e.org', - otherUserId: '@them:e.org', - otherDeviceId: 'THEIRS', - flowId: '$f', - roomId: null, - phase: EnginePhase.Ready, - weStarted: false, - isSelfVerification: true, - isPassive: false, - isReady: true, - isDone: false, - isCancelled: false, - timedOut: false, - timeRemainingMillis: 600000, - theirSupportedMethods: [1, 2, 3], - ourSupportedMethods: [1, 2, 3], - cancelInfo: null, - verification: null, - ...patch, -}); - -describe('QR: showing our code', () => { - // The payload is base64, not a byte array; reading it as an array yields a dead code. - it('decodes the base64 payload the engine returns', async () => { - const bytes = new Uint8Array([1, 2, 3, 250]); - const call = vi.fn<(m: string) => Promise>(async (method) => - method === 'verificationRequest.generateQrCode' - ? { className: 'Qr', qrCodeBytes: encodeBase64(bytes) } - : state() - ); - - const qr = await new EngineVerificationRequest(call, state()).generateQRCode(); - - expect(qr).toBeInstanceOf(Uint8ClampedArray); - expect(Array.from(qr ?? [])).toEqual([1, 2, 3, 250]); - }); - - it('returns undefined when the engine cannot produce a code', async () => { - const call = vi.fn<() => Promise>(async () => null); - - await expect( - new EngineVerificationRequest(call, state()).generateQRCode() - ).resolves.toBeUndefined(); - }); -}); - -describe('QR: scanning their code', () => { - const scanning = () => { - const calls: { method: string; args?: Record }[] = []; - const call = vi.fn<(m: string, a?: Record) => Promise>( - async (method, args) => { - calls.push({ method, args }); - if (method === 'verificationRequest.state') { - return state({ - phase: EnginePhase.Transitioned, - verification: { className: 'Qr' }, - }); - } - return { className: 'Qr' }; - } - ); - return { call, calls }; - }; - - it('sends the scanned payload as base64', async () => { - const { call, calls } = scanning(); - await new EngineVerificationRequest(call, state()).scanQRCode(new Uint8ClampedArray([9, 8, 7])); - - const scan = calls.find((c) => c.method === 'verificationRequest.scanQrCode'); - expect(scan?.args?.qrCodeData).toBe(encodeBase64(new Uint8Array([9, 8, 7]))); - }); - - // Scanning alone tells them nothing; without this our side thinks it succeeded. - it('reciprocates after scanning', async () => { - const { call, calls } = scanning(); - const verifier = await new EngineVerificationRequest(call, state()).scanQRCode( - new Uint8ClampedArray([1]) - ); - - expect(calls.map((c) => c.method)).toContain('qr.reciprocate'); - expect(verifier).toBeInstanceOf(EngineQrVerifier); - }); -}); - -describe('QR verifier phase', () => { - it('maps the engine state ordinals as js-sdk does', () => { - const call = vi.fn<() => Promise>(async () => null); - const phases: [number, VerificationPhase][] = [ - [0, VerificationPhase.Ready], - [1, VerificationPhase.Started], - [2, VerificationPhase.Started], - [3, VerificationPhase.Started], - [4, VerificationPhase.Done], - [5, VerificationPhase.Cancelled], - ]; - - phases.forEach(([code, phase]) => { - const verifier = new EngineQrVerifier(call, { userId: '@t:e', flowId: '$f' }, {}, '@t:e'); - verifier.onChange({ state: code }); - expect(verifier.verificationPhase).toBe(phase); - }); - }); -}); diff --git a/src/app/crypto/verification/request.test.ts b/src/app/crypto/verification/request.test.ts deleted file mode 100644 index 57526deaaf..0000000000 --- a/src/app/crypto/verification/request.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { VerificationMethod, VerificationPhase } from '$types/matrix-sdk'; -import { EngineVerificationRequest } from './request'; -import { - EnginePhase, - SUPPORTED_VERIFICATION_METHOD_CODES, - type EngineVerificationState, -} from './state'; - -const state = (patch: Partial = {}): EngineVerificationState => ({ - ownUserId: '@me:example.org', - otherUserId: '@them:example.org', - otherDeviceId: 'THEIRS', - flowId: '$flow', - roomId: null, - phase: EnginePhase.Requested, - weStarted: false, - isSelfVerification: false, - isPassive: false, - isReady: false, - isDone: false, - isCancelled: false, - timedOut: false, - timeRemainingMillis: 600000, - theirSupportedMethods: [0], - ourSupportedMethods: null, - cancelInfo: null, - verification: null, - ...patch, -}); - -describe('EngineVerificationRequest', () => { - it('re-accepts when our SAS is replaced after losing the start tie-break', async () => { - const call = vi.fn<(m: string, a?: Record) => Promise>( - async () => null - ); - const started = state({ - phase: EnginePhase.Transitioned, - verification: { className: 'Sas', hasBeenAccepted: true }, - }); - const request = new EngineVerificationRequest(call, started); - call.mockClear(); - - request.apply( - state({ - phase: EnginePhase.Transitioned, - verification: { className: 'Sas', hasBeenAccepted: false }, - }) - ); - - expect(call.mock.calls.filter(([method]) => method === 'sas.accept')).toHaveLength(1); - }); - - it('does not re-accept while the same SAS stays accepted', async () => { - const call = vi.fn<(m: string, a?: Record) => Promise>( - async () => null - ); - const started = state({ - phase: EnginePhase.Transitioned, - verification: { className: 'Sas', hasBeenAccepted: true }, - }); - const request = new EngineVerificationRequest(call, started); - call.mockClear(); - - request.apply( - state({ - phase: EnginePhase.Transitioned, - verification: { className: 'Sas', hasBeenAccepted: true }, - }) - ); - - expect(call.mock.calls.filter(([method]) => method === 'sas.accept')).toHaveLength(0); - }); - - 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 - ); - const request = new EngineVerificationRequest(call, state()); - - await request.accept(); - - expect(call).toHaveBeenCalledWith('verificationRequest.accept', { - userId: '@them:example.org', - flowId: '$flow', - methods: SUPPORTED_VERIFICATION_METHOD_CODES, - }); - expect(SUPPORTED_VERIFICATION_METHOD_CODES).toEqual([0, 1, 2, 3]); - expect(request.phase).toBe(VerificationPhase.Ready); - }); - - // js-sdk throws rather than silently doing nothing, and callers rely on that. - it('refuses to accept outside the Requested phase', async () => { - const call = vi.fn<() => Promise>(async () => null); - const request = new EngineVerificationRequest(call, state({ phase: EnginePhase.Ready })); - - await expect(request.accept()).rejects.toThrow('Cannot accept a verification request'); - expect(call).not.toHaveBeenCalled(); - }); - - it('refuses to start a method other than SAS', async () => { - const call = vi.fn<() => Promise>(async () => null); - const request = new EngineVerificationRequest(call, state()); - - await expect(request.startVerification(VerificationMethod.ShowQrCode)).rejects.toThrow( - 'Unsupported verification method' - ); - expect(call).not.toHaveBeenCalled(); - }); - - // js-sdk builds its verifier only on change, so one first seen here has none. - it('builds a verifier for a request that is already transitioned', () => { - const call = vi.fn<() => Promise>(async () => null); - const request = new EngineVerificationRequest( - call, - state({ phase: EnginePhase.Transitioned, verification: { className: 'Sas' } }) - ); - - expect(request.verifier).toBeDefined(); - expect(request.phase).toBe(VerificationPhase.Started); - }); - - it('reports the flow identity the app displays', () => { - const call = vi.fn<() => Promise>(async () => null); - const request = new EngineVerificationRequest( - call, - state({ roomId: '!r:e.org', weStarted: true }) - ); - - expect(request.transactionId).toBe('$flow'); - expect(request.roomId).toBe('!r:e.org'); - expect(request.initiatedByMe).toBe(true); - expect(request.otherUserId).toBe('@them:example.org'); - expect(request.otherDeviceId).toBe('THEIRS'); - expect(request.pending).toBe(true); - expect(request.otherPartySupportsMethod(VerificationMethod.Sas)).toBe(true); - }); -}); diff --git a/src/app/crypto/verification/request.ts b/src/app/crypto/verification/request.ts deleted file mode 100644 index 13210664ec..0000000000 --- a/src/app/crypto/verification/request.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; -import { decodeBase64, encodeBase64 } from 'matrix-js-sdk/lib/base64'; -import { - VerificationMethod, - VerificationRequestEvent, - type VerificationPhase, - type VerificationRequest, - type VerificationRequestEventHandlerMap, - type Verifier, -} from '$types/matrix-sdk'; -import { - cancellingUserId, - chosenMethod, - EnginePhase, - isPending, - methodsFromCodes, - otherPartySupportsMethod, - SUPPORTED_VERIFICATION_METHOD_CODES, - toVerificationPhase, - type EngineVerificationState, -} from './state'; -import { - EngineQrVerifier, - EngineSasVerifier, - type EngineCall, - type QrState, - type SasState, -} from './verifier'; - -/** The interface's getters are synchronous, so state is held as a refreshed snapshot. */ -export class EngineVerificationRequest - extends TypedEventEmitter - implements VerificationRequest -{ - readonly #call: EngineCall; - - #state: EngineVerificationState; - - #verifier: EngineSasVerifier | EngineQrVerifier | undefined; - - #accepting = false; - - #declining = false; - - #sasAccepted = false; - - #sasWeStarted = false; - - constructor(call: EngineCall, state: EngineVerificationState) { - super(); - this.#call = call; - this.#state = state; - // js-sdk builds its verifier only on change, so an already-transitioned request has - // none and its `phase` throws. - this.#syncVerifier(); - } - - get #flow(): { userId: string; flowId: string } { - return { userId: this.#state.otherUserId, flowId: this.#state.flowId }; - } - - #syncVerifier(): void { - const verification = this.#state.verification; - if (!verification) { - if (this.#state.phase === EnginePhase.Done) this.#verifier?.settle(true); - else if (this.#state.phase === EnginePhase.Cancelled) this.#verifier?.settle(false); - return; - } - - const wanted = verification.className; - const current = - // eslint-disable-next-line no-nested-ternary - this.#verifier instanceof EngineSasVerifier - ? 'Sas' - : this.#verifier instanceof EngineQrVerifier - ? 'Qr' - : undefined; - - const accepted = (verification as SasState).hasBeenAccepted === true; - const weStarted = (verification as SasState).weStarted === true; - const replaced = - wanted === 'Sas' && - current === 'Sas' && - ((this.#sasAccepted && !accepted) || (this.#sasWeStarted && !weStarted)); - const lostTieBreak = replaced; - this.#sasAccepted = wanted === 'Sas' ? accepted : false; - this.#sasWeStarted = wanted === 'Sas' ? weStarted : false; - - if (current !== wanted || lostTieBreak) { - if (wanted === 'Sas') { - this.#verifier = new EngineSasVerifier( - this.#call, - this.#flow, - verification as SasState, - this.#state.otherUserId - ); - if (current !== undefined || lostTieBreak) void this.#reaccept(); - } else if (wanted === 'Qr') { - this.#verifier = new EngineQrVerifier( - this.#call, - this.#flow, - verification as QrState, - this.#state.otherUserId - ); - } - } - - // Every snapshot, including the first: the verifier emits ShowSas off these. - if (this.#verifier instanceof EngineSasVerifier) { - this.#verifier.onChange(verification as SasState); - } else if (this.#verifier instanceof EngineQrVerifier) { - this.#verifier.onChange(verification as QrState); - } - } - - 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', - this.#flow - )) as EngineVerificationState | null; - if (!next) { - if (this.#state.phase === EnginePhase.Done || this.#state.phase === EnginePhase.Cancelled) { - return; - } - this.#state = { ...this.#state, phase: EnginePhase.Cancelled, isCancelled: true }; - this.#syncVerifier(); - this.emit(VerificationRequestEvent.Change); - return; - } - - this.#state = next; - this.#syncVerifier(); - this.emit(VerificationRequestEvent.Change); - } - - apply(state: EngineVerificationState): void { - this.#state = state; - this.#syncVerifier(); - this.emit(VerificationRequestEvent.Change); - } - - markDone(): void { - this.#state = { - ...this.#state, - phase: EnginePhase.Done, - isDone: true, - verification: this.#state.verification - ? { ...this.#state.verification, isDone: true } - : this.#state.verification, - }; - this.#syncVerifier(); - this.emit(VerificationRequestEvent.Change); - } - - get transactionId(): string | undefined { - return this.#state.flowId; - } - - get roomId(): string | undefined { - return this.#state.roomId ?? undefined; - } - - get initiatedByMe(): boolean { - return this.#state.weStarted; - } - - get otherUserId(): string { - return this.#state.otherUserId; - } - - get otherDeviceId(): string | undefined { - return this.#state.otherDeviceId ?? undefined; - } - - get isSelfVerification(): boolean { - return this.#state.isSelfVerification; - } - - get phase(): VerificationPhase { - return toVerificationPhase(this.#state, { - accepting: this.#accepting, - startedPhase: this.#verifier?.verificationPhase, - }); - } - - get pending(): boolean { - return isPending(this.#state, this.phase); - } - - get accepting(): boolean { - return this.#accepting; - } - - get declining(): boolean { - return this.#declining; - } - - get timeout(): number | null { - return this.#state.timeRemainingMillis; - } - - get methods(): string[] { - return methodsFromCodes(this.#state.theirSupportedMethods); - } - - get chosenMethod(): string | null { - return chosenMethod(this.#state, this.phase); - } - - get verifier(): Verifier | undefined { - return this.#verifier; - } - - get cancellationCode(): string | null { - return this.#state.cancelInfo?.cancelCode ?? null; - } - - get cancellingUserId(): string | undefined { - return cancellingUserId(this.#state); - } - - otherPartySupportsMethod(method: string): boolean { - return otherPartySupportsMethod(this.#state, method); - } - - async accept(): Promise { - // Matches js-sdk: accepting outside Requested is a caller error, not a no-op. - if (this.#state.phase !== EnginePhase.Requested || this.#accepting) { - throw new Error(`Cannot accept a verification request in phase ${this.phase}`); - } - this.#accepting = true; - try { - await this.#call('verificationRequest.accept', { - ...this.#flow, - methods: SUPPORTED_VERIFICATION_METHOD_CODES, - }); - await this.refresh(); - } finally { - this.#accepting = false; - } - this.emit(VerificationRequestEvent.Change); - } - - async cancel(params?: { reason?: string; code?: string }): Promise { - this.#declining = true; - try { - await this.#call('verificationRequest.cancel', { ...this.#flow, ...params }); - await this.refresh(); - } finally { - this.#declining = false; - } - this.emit(VerificationRequestEvent.Change); - } - - async startVerification(method: string): Promise { - // Only SAS can be started this way; QR is entered through scanQRCode. - if (method !== VerificationMethod.Sas) { - throw new Error(`Unsupported verification method ${method}`); - } - - await this.#call('verificationRequest.startSas', this.#flow); - await this.refresh(); - - if (!this.#verifier) { - throw new Error( - `Could not start ${method}: the other device is no longer available for verification` - ); - } - return this.#verifier; - } - - async scanQRCode(qrCodeData: Uint8ClampedArray): Promise { - await this.#call('verificationRequest.scanQrCode', { - ...this.#flow, - // The engine decodes base64; a number array is rejected as a missing string. - qrCodeData: encodeBase64(new Uint8Array(qrCodeData)), - }); - await this.refresh(); - - if (!(this.#verifier instanceof EngineQrVerifier)) { - throw new Error('Scanning the QR code produced no verifier'); - } - - // Scanning alone tells them nothing; reciprocating is what they see. - await this.#verifier.reciprocate(); - await this.refresh(); - return this.#verifier; - } - - async generateQRCode(): Promise { - // Returns the QR verification state, with the payload as base64 — not a byte array. - const qr = (await this.#call('verificationRequest.generateQrCode', this.#flow)) as { - qrCodeBytes?: string | null; - } | null; - if (!qr?.qrCodeBytes) return undefined; - - await this.refresh(); - return new Uint8ClampedArray(decodeBase64(qr.qrCodeBytes)); - } -} diff --git a/src/app/crypto/verification/state.test.ts b/src/app/crypto/verification/state.test.ts deleted file mode 100644 index 73adf74e60..0000000000 --- a/src/app/crypto/verification/state.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { VerificationPhase, VerificationMethod } from '$types/matrix-sdk'; -import { - EnginePhase, - chosenMethod, - cancellingUserId, - codeFromMethod, - isPending, - methodFromCode, - methodsFromCodes, - otherPartySupportsMethod, - toVerificationPhase, - type EngineVerificationState, -} from './state'; - -const state = (patch: Partial = {}): EngineVerificationState => ({ - ownUserId: '@me:example.org', - otherUserId: '@them:example.org', - otherDeviceId: 'THEIRDEVICE', - flowId: '$flow', - roomId: null, - phase: EnginePhase.Requested, - weStarted: false, - isSelfVerification: false, - isPassive: false, - isReady: false, - isDone: false, - isCancelled: false, - timedOut: false, - timeRemainingMillis: 600000, - theirSupportedMethods: null, - ourSupportedMethods: null, - cancelInfo: null, - verification: null, - ...patch, -}); - -describe('verification method codes', () => { - // These must track matrix-sdk-crypto's VerificationMethod ordering, which the Rust - // engine's method_from_code mirrors. A shift here silently breaks QR negotiation. - it('matches the engine ordering', () => { - expect(methodFromCode(0)).toBe(VerificationMethod.Sas); - expect(methodFromCode(1)).toBe(VerificationMethod.ScanQrCode); - expect(methodFromCode(2)).toBe(VerificationMethod.ShowQrCode); - expect(methodFromCode(3)).toBe(VerificationMethod.Reciprocate); - expect(methodFromCode(4)).toBeUndefined(); - }); - - it('round-trips every known method', () => { - [ - VerificationMethod.Sas, - VerificationMethod.ScanQrCode, - VerificationMethod.ShowQrCode, - VerificationMethod.Reciprocate, - ].forEach((method) => { - expect(methodFromCode(codeFromMethod(method) as number)).toBe(method); - }); - }); - - it('drops codes it cannot name rather than emitting undefined entries', () => { - expect(methodsFromCodes([0, 99, 3])).toEqual([ - VerificationMethod.Sas, - VerificationMethod.Reciprocate, - ]); - expect(methodsFromCodes(null)).toEqual([]); - }); -}); - -describe('toVerificationPhase', () => { - it('folds Created and Requested into Requested', () => { - expect(toVerificationPhase(state({ phase: EnginePhase.Created }), { accepting: false })).toBe( - VerificationPhase.Requested - ); - expect(toVerificationPhase(state({ phase: EnginePhase.Requested }), { accepting: false })).toBe( - VerificationPhase.Requested - ); - }); - - // The ready event is still in flight, so the request is not yet Ready to the caller. - it('keeps a locally-accepting request in Requested', () => { - const ready = state({ phase: EnginePhase.Ready }); - expect(toVerificationPhase(ready, { accepting: true })).toBe(VerificationPhase.Requested); - expect(toVerificationPhase(ready, { accepting: false })).toBe(VerificationPhase.Ready); - }); - - it('defers to the verifier once transitioned', () => { - const transitioned = state({ phase: EnginePhase.Transitioned }); - expect( - toVerificationPhase(transitioned, { accepting: false, startedPhase: VerificationPhase.Done }) - ).toBe(VerificationPhase.Done); - expect(toVerificationPhase(transitioned, { accepting: false })).toBe(VerificationPhase.Started); - }); - - it('maps the terminal phases', () => { - expect(toVerificationPhase(state({ phase: EnginePhase.Done }), { accepting: false })).toBe( - VerificationPhase.Done - ); - expect(toVerificationPhase(state({ phase: EnginePhase.Cancelled }), { accepting: false })).toBe( - VerificationPhase.Cancelled - ); - }); - - it('throws on a phase it does not know instead of guessing', () => { - expect(() => toVerificationPhase(state({ phase: 99 }), { accepting: false })).toThrow( - 'Unknown verification phase 99' - ); - }); -}); - -describe('isPending', () => { - it('is false for a passive request regardless of phase', () => { - expect(isPending(state({ isPassive: true }), VerificationPhase.Requested)).toBe(false); - }); - - it('is false once done or cancelled', () => { - expect(isPending(state(), VerificationPhase.Done)).toBe(false); - expect(isPending(state(), VerificationPhase.Cancelled)).toBe(false); - expect(isPending(state(), VerificationPhase.Ready)).toBe(true); - }); -}); - -describe('chosenMethod', () => { - it('names the method only once started', () => { - const sas = state({ verification: { className: 'Sas' } }); - expect(chosenMethod(sas, VerificationPhase.Started)).toBe(VerificationMethod.Sas); - expect(chosenMethod(sas, VerificationPhase.Ready)).toBeNull(); - - const qr = state({ verification: { className: 'Qr' } }); - expect(chosenMethod(qr, VerificationPhase.Started)).toBe(VerificationMethod.Reciprocate); - expect(chosenMethod(state(), VerificationPhase.Started)).toBeNull(); - }); -}); - -describe('otherPartySupportsMethod', () => { - // Absent is not the same as empty: before the other side speaks we must not claim support. - it('is false when the other side has not spoken', () => { - expect(otherPartySupportsMethod(state(), VerificationMethod.Sas)).toBe(false); - }); - - it('reads the advertised codes', () => { - const advertised = state({ theirSupportedMethods: [0, 3] }); - expect(otherPartySupportsMethod(advertised, VerificationMethod.Sas)).toBe(true); - expect(otherPartySupportsMethod(advertised, VerificationMethod.Reciprocate)).toBe(true); - expect(otherPartySupportsMethod(advertised, VerificationMethod.ShowQrCode)).toBe(false); - }); - - it('is false for a method it cannot map', () => { - expect(otherPartySupportsMethod(state({ theirSupportedMethods: [0] }), 'm.nonsense')).toBe( - false - ); - }); -}); - -describe('cancellingUserId', () => { - it('attributes the cancellation to the right side', () => { - expect(cancellingUserId(state())).toBeUndefined(); - expect(cancellingUserId(state({ cancelInfo: { cancelledbyUs: true } }))).toBe( - '@me:example.org' - ); - expect(cancellingUserId(state({ cancelInfo: { cancelledbyUs: false } }))).toBe( - '@them:example.org' - ); - }); -}); diff --git a/src/app/crypto/verification/state.ts b/src/app/crypto/verification/state.ts deleted file mode 100644 index 57860499f7..0000000000 --- a/src/app/crypto/verification/state.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { VerificationPhase, VerificationMethod } from '$types/matrix-sdk'; - -/** Phase codes as `request_state` in the Rust engine emits them. */ -export const EnginePhase = { - Created: 0, - Requested: 1, - Ready: 2, - Transitioned: 3, - Done: 4, - Cancelled: 5, -} as const; - -/** Method codes as matrix-sdk-crypto's `VerificationMethod` orders them. */ -const METHOD_BY_CODE: Record = { - 0: VerificationMethod.Sas, - 1: VerificationMethod.ScanQrCode, - 2: VerificationMethod.ShowQrCode, - 3: VerificationMethod.Reciprocate, -}; - -const CODE_BY_METHOD: Record = Object.fromEntries( - Object.entries(METHOD_BY_CODE).map(([code, method]) => [method, Number(code)]) -); - -export type EngineCancelInfo = { - cancelCode?: string; - reason?: string; - cancelledbyUs?: boolean; -}; - -export type EngineVerificationState = { - className?: string; - ownUserId: string; - otherUserId: string; - otherDeviceId?: string | null; - flowId: string; - roomId?: string | null; - phase: number; - weStarted: boolean; - isSelfVerification: boolean; - isPassive: boolean; - isReady: boolean; - isDone: boolean; - isCancelled: boolean; - timedOut: boolean; - timeRemainingMillis: number; - theirSupportedMethods?: number[] | null; - ourSupportedMethods?: number[] | null; - cancelInfo?: EngineCancelInfo | null; - verification?: { className?: string; isDone?: boolean; hasBeenAccepted?: boolean } | null; -}; - -export const methodFromCode = (code: number): string | undefined => METHOD_BY_CODE[code]; - -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); - -/** - * Mirrors matrix-js-sdk's phase mapping. `accepting` is local: while the ready event is - * in flight the request still reads as Requested though the engine says Ready. - */ -export const toVerificationPhase = ( - state: EngineVerificationState, - local: { accepting: boolean; startedPhase?: VerificationPhase } -): VerificationPhase => { - switch (state.phase) { - case EnginePhase.Created: - case EnginePhase.Requested: - return VerificationPhase.Requested; - case EnginePhase.Ready: - return local.accepting ? VerificationPhase.Requested : VerificationPhase.Ready; - case EnginePhase.Transitioned: - return local.startedPhase ?? VerificationPhase.Started; - case EnginePhase.Done: - return VerificationPhase.Done; - case EnginePhase.Cancelled: - return VerificationPhase.Cancelled; - default: - throw new Error(`Unknown verification phase ${state.phase}`); - } -}; - -export const isPending = (state: EngineVerificationState, phase: VerificationPhase): boolean => { - if (state.isPassive) return false; - return phase !== VerificationPhase.Done && phase !== VerificationPhase.Cancelled; -}; - -export const chosenMethod = ( - state: EngineVerificationState, - phase: VerificationPhase -): string | null => { - if (phase !== VerificationPhase.Started) return null; - if (state.verification?.className === 'Sas') return VerificationMethod.Sas; - if (state.verification?.className === 'Qr') return VerificationMethod.Reciprocate; - return null; -}; - -export const otherPartySupportsMethod = ( - state: EngineVerificationState, - method: string -): boolean => { - // Undefined rather than empty means the other side has not spoken yet. - if (!state.theirSupportedMethods) return false; - const code = codeFromMethod(method); - if (code === undefined) return false; - return state.theirSupportedMethods.includes(code); -}; - -export const cancellingUserId = (state: EngineVerificationState): string | undefined => { - if (!state.cancelInfo) return undefined; - return state.cancelInfo.cancelledbyUs ? state.ownUserId : state.otherUserId; -}; diff --git a/src/app/crypto/verification/verifier.test.ts b/src/app/crypto/verification/verifier.test.ts deleted file mode 100644 index b661e82842..0000000000 --- a/src/app/crypto/verification/verifier.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - VerificationPhase, - VerifierEvent, - type MatrixEvent, - type ShowQrCodeCallbacks, - type ShowSasCallbacks, -} from '$types/matrix-sdk'; -import { EngineQrVerifier, EngineSasVerifier } from './verifier'; - -const flow = { userId: '@them:e.org', flowId: '$f' }; - -describe('EngineSasVerifier', () => { - it('emits Cancel when the other side cancels', async () => { - const call = vi.fn<(m: string, a?: Record) => Promise>( - async () => null - ); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - const cancelled = vi.fn<(error: Error | MatrixEvent) => void>(); - verifier.on(VerifierEvent.Cancel, cancelled); - const verifying = verifier.verify(); - - verifier.onChange({ isCancelled: true }); - - await expect(verifying).rejects.toThrow('Verification cancelled'); - expect(cancelled).toHaveBeenCalledOnce(); - expect(call.mock.calls.filter(([method]) => method === 'sas.cancel')).toHaveLength(0); - }); - - it('does not cancel twice when the engine echoes our own mismatch', async () => { - const call = vi.fn<(m: string, a?: Record) => Promise>( - async () => null - ); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - const cancelled = vi.fn<(error: Error | MatrixEvent) => void>(); - verifier.on(VerifierEvent.Cancel, cancelled); - const verifying = verifier.verify(); - - verifier.onChange({ decimals: [1, 2, 3] }); - verifier.getShowSasCallbacks()?.mismatch(); - verifier.onChange({ isCancelled: true }); - - await expect(verifying).rejects.toThrow('The codes did not match'); - expect(cancelled).toHaveBeenCalledOnce(); - expect(call.mock.calls.filter(([method]) => method === 'sas.cancel')).toHaveLength(1); - }); - - it('settles the flow when the user says the codes do not match', async () => { - const call = vi.fn<(method: string, args: unknown) => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - const cancelled = vi.fn<(error: Error | MatrixEvent) => void>(); - verifier.on(VerifierEvent.Cancel, cancelled); - const completion = verifier.verify(); - - verifier.onChange({ - emoji: [{ symbol: '🐶', description: 'Dog' }], - decimals: [1, 2, 3], - }); - verifier.getShowSasCallbacks()?.mismatch(); - - await expect(completion).rejects.toThrow('The codes did not match'); - expect(cancelled).toHaveBeenCalledOnce(); - expect(call).toHaveBeenCalledWith( - 'sas.cancel', - expect.objectContaining({ code: 'm.mismatched_sas' }) - ); - }); - - it('does not send a second cancel when the user double taps', async () => { - const call = vi.fn<(method: string, args: unknown) => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - verifier.verify().catch(() => undefined); - - verifier.onChange({ - emoji: [{ symbol: '🐶', description: 'Dog' }], - decimals: [1, 2, 3], - }); - const callbacks = verifier.getShowSasCallbacks(); - callbacks?.cancel(); - callbacks?.cancel(); - - expect(call.mock.calls.filter(([method]) => method === 'sas.cancel')).toHaveLength(1); - }); - - // Reading the digits straight after accepting yields nothing: they arrive later. - it('emits ShowSas when the digits arrive, not when accept is sent', async () => { - const call = vi.fn<() => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - const shown = vi.fn<(sas: ShowSasCallbacks) => void>(); - verifier.on(VerifierEvent.ShowSas, shown); - - verifier.onChange({ canBePresented: false }); - expect(shown).not.toHaveBeenCalled(); - expect(verifier.getShowSasCallbacks()).toBeNull(); - - verifier.onChange({ - emoji: [{ symbol: '🐶', description: 'Dog' }], - decimals: [1, 2, 3], - }); - - expect(shown).toHaveBeenCalledOnce(); - expect(verifier.getShowSasCallbacks()?.sas).toEqual({ - emoji: [['🐶', 'Dog']], - decimal: [1, 2, 3], - }); - }); - - it('emits once even as further snapshots arrive', () => { - const call = vi.fn<() => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - const shown = vi.fn<(sas: ShowSasCallbacks) => void>(); - verifier.on(VerifierEvent.ShowSas, shown); - - verifier.onChange({ decimals: [1, 2, 3] }); - verifier.onChange({ decimals: [1, 2, 3], haveWeConfirmed: true }); - - expect(shown).toHaveBeenCalledOnce(); - }); - - // Resolving early would let the UI close the prompt before the peer confirmed. - it('resolves verify only once the flow is done', async () => { - const call = vi.fn<() => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - - let settled = false; - const verifying = verifier.verify().then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - - verifier.onChange({ isDone: true }); - await verifying; - expect(settled).toBe(true); - }); - - it('confirms and cancels through the engine with the right codes', async () => { - const call = vi.fn<(m: string, a?: Record) => Promise>( - async () => null - ); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - verifier.onChange({ decimals: [1, 2, 3] }); - - await verifier.getShowSasCallbacks()?.confirm(); - expect(call).toHaveBeenCalledWith('sas.confirm', flow); - - verifier.getShowSasCallbacks()?.mismatch(); - expect(call).toHaveBeenCalledWith('sas.cancel', { ...flow, code: 'm.mismatched_sas' }); - expect(verifier.hasBeenCancelled).toBe(true); - }); - - // js-sdk reports Started throughout SAS; Done is the request's phase, not the verifier's. - it('reports Started while running, leaving Done to the request', () => { - const call = vi.fn<() => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - - expect(verifier.verificationPhase).toBe(VerificationPhase.Started); - verifier.onChange({ isDone: true }); - expect(verifier.verificationPhase).toBe(VerificationPhase.Started); - }); - - it('rejects verify when the flow is cancelled', async () => { - const call = vi.fn<() => Promise>(async () => null); - const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); - const verifying = verifier.verify(); - - verifier.onChange({ isCancelled: true }); - - await expect(verifying).rejects.toThrow('Verification cancelled'); - expect(verifier.hasBeenCancelled).toBe(true); - }); -}); - -describe('EngineQrVerifier', () => { - it('settles the flow when the user declines the reciprocated code', async () => { - const call = vi.fn<(m: string, a?: Record) => Promise>( - async () => null - ); - const verifier = new EngineQrVerifier(call, flow, {}, '@them:e.org'); - const verifying = verifier.verify(); - - verifier.onChange({ hasBeenScanned: true }); - verifier.getReciprocateQrCodeCallbacks()?.cancel(); - - await expect(verifying).rejects.toThrow('Verification cancelled'); - expect(call).toHaveBeenCalledWith('qr.cancel', expect.objectContaining({ code: 'm.user' })); - }); - - it('offers reciprocate callbacks only once our code has been scanned', () => { - const call = vi.fn<() => Promise>(async () => null); - const verifier = new EngineQrVerifier(call, flow, {}, '@them:e.org'); - const shown = vi.fn<(qr: ShowQrCodeCallbacks) => void>(); - verifier.on(VerifierEvent.ShowReciprocateQr, shown); - - verifier.onChange({ hasBeenScanned: false }); - expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); - - verifier.onChange({ hasBeenScanned: true }); - expect(shown).toHaveBeenCalledOnce(); - expect(verifier.getReciprocateQrCodeCallbacks()).not.toBeNull(); - }); -}); diff --git a/src/app/crypto/verification/verifier.ts b/src/app/crypto/verification/verifier.ts deleted file mode 100644 index 6b4d6664d2..0000000000 --- a/src/app/crypto/verification/verifier.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; -import { - VerificationPhase, - VerifierEvent, - type GeneratedSas, - type ShowQrCodeCallbacks, - type ShowSasCallbacks, - type Verifier, - type VerifierEventHandlerMap, -} from '$types/matrix-sdk'; - -export type EngineCall = (method: string, args?: Record) => Promise; - -export type SasState = { - className?: string; - weStarted?: boolean; - hasBeenAccepted?: boolean; - canBePresented?: boolean; - haveWeConfirmed?: boolean; - isDone?: boolean; - isCancelled?: boolean; - emoji?: { symbol: string; description: string }[] | null; - decimals?: number[] | null; -}; - -export type QrState = { - className?: string; - hasBeenScanned?: boolean; - hasBeenConfirmed?: boolean; - reciprocated?: boolean; - isDone?: boolean; - isCancelled?: boolean; - /** QrVerificationState ordinal; see qr_state_code in matrix_crypto/verification.rs. */ - state?: number; -}; - -const QrVerificationState = { - Started: 0, - Scanned: 1, - Confirmed: 2, - Reciprocated: 3, - Done: 4, - Cancelled: 5, -} as const; - -type Flow = { userId: string; flowId: string }; - -const deferred = () => { - let resolve!: () => void; - let reject!: (error: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -}; - -abstract class EngineVerifier - extends TypedEventEmitter - implements Verifier -{ - protected readonly call: EngineCall; - - protected readonly flow: Flow; - - protected state: TState; - - protected readonly completion = deferred(); - - #cancelled = false; - - readonly #userId: string; - - constructor(call: EngineCall, flow: Flow, state: TState, userId: string) { - super(); - this.call = call; - this.flow = flow; - this.state = state; - this.#userId = userId; - // Nothing awaits this until verify() does; an unhandled rejection would crash first. - this.completion.promise.catch(() => undefined); - } - - get hasBeenCancelled(): boolean { - return this.#cancelled; - } - - get userId(): string { - return this.#userId; - } - - protected markCancelled(): void { - this.#cancelled = true; - } - - settle(done: boolean): void { - if (done) { - this.completion.resolve(); - return; - } - if (this.hasBeenCancelled) return; - this.markCancelled(); - const error = new Error('Verification cancelled'); - this.completion.reject(error); - this.emit(VerifierEvent.Cancel, error); - } - - abstract onChange(state: TState): void; - - abstract get verificationPhase(): VerificationPhase; - - abstract verify(): Promise; - - cancel(error: Error): void { - this.finishCancelled(this.flow, error); - } - - protected cancelWithCode(code: string, error: Error): void { - this.finishCancelled({ ...this.flow, code }, error); - } - - private finishCancelled(flow: Record, error: Error): void { - if (this.hasBeenCancelled) return; - this.markCancelled(); - void this.call(this.cancelMethod, flow).catch(() => undefined); - this.completion.reject(error); - this.emit(VerifierEvent.Cancel, error); - } - - protected abstract get cancelMethod(): string; - - getShowSasCallbacks(): ShowSasCallbacks | null { - return null; - } - - getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { - return null; - } -} - -/** The SAS digits only exist once the other side answers, hence the change handler. */ -export class EngineSasVerifier extends EngineVerifier { - #callbacks: ShowSasCallbacks | null = null; - - protected get cancelMethod(): string { - return 'sas.cancel'; - } - - get verificationPhase(): VerificationPhase { - return VerificationPhase.Started; - } - - async verify(): Promise { - await this.call('sas.accept', this.flow); - // Resolves only once both sides have confirmed, matching js-sdk. - await this.completion.promise; - } - - onChange(state: SasState): void { - this.state = state; - - if (state.isCancelled) { - this.settle(false); - return; - } - - if (!this.#callbacks) { - const sas = generatedSas(state); - if (sas) { - this.#callbacks = this.#buildCallbacks(sas); - this.emit(VerifierEvent.ShowSas, this.#callbacks); - } - } - - if (state.isDone) this.completion.resolve(); - } - - #buildCallbacks(sas: GeneratedSas): ShowSasCallbacks { - return { - sas, - confirm: async () => { - await this.call('sas.confirm', this.flow); - }, - mismatch: () => { - this.cancelWithCode('m.mismatched_sas', new Error('The codes did not match')); - }, - cancel: () => { - this.cancelWithCode('m.user', new Error('Verification cancelled')); - }, - }; - } - - getShowSasCallbacks(): ShowSasCallbacks | null { - return this.#callbacks; - } -} - -export class EngineQrVerifier extends EngineVerifier { - #callbacks: ShowQrCodeCallbacks | null = null; - - protected get cancelMethod(): string { - return 'qr.cancel'; - } - - get verificationPhase(): VerificationPhase { - switch (this.state.state) { - case QrVerificationState.Started: - return VerificationPhase.Ready; - case QrVerificationState.Done: - return VerificationPhase.Done; - case QrVerificationState.Cancelled: - return VerificationPhase.Cancelled; - default: - return VerificationPhase.Started; - } - } - - async verify(): Promise { - // Already scanned: the user only has to confirm, so surface the prompt again. - if (this.#callbacks) this.emit(VerifierEvent.ShowReciprocateQr, this.#callbacks); - await this.completion.promise; - } - - async reciprocate(): Promise { - await this.call('qr.reciprocate', this.flow); - } - - onChange(state: QrState): void { - this.state = state; - - if (state.isCancelled) { - this.settle(false); - return; - } - - if (!this.#callbacks && state.hasBeenScanned) { - this.#callbacks = { - confirm: () => { - void this.call('qr.confirm', this.flow); - }, - cancel: () => { - this.cancelWithCode('m.user', new Error('Verification cancelled')); - }, - }; - this.emit(VerifierEvent.ShowReciprocateQr, this.#callbacks); - } - - if (state.isDone) this.completion.resolve(); - } - - getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { - return this.#callbacks; - } -} - -const generatedSas = (state: SasState): GeneratedSas | null => { - const emoji = state.emoji?.map(({ symbol, description }) => [symbol, description]) as - | GeneratedSas['emoji'] - | undefined; - const decimals = state.decimals; - const decimal = - decimals && decimals.length >= 3 - ? ([decimals[0], decimals[1], decimals[2]] as [number, number, number]) - : undefined; - - if (!emoji?.length && !decimal) return null; - return { emoji, decimal }; -}; diff --git a/src/app/crypto/verificationInternals.test.ts b/src/app/crypto/verificationInternals.test.ts deleted file mode 100644 index 7a1fe190a1..0000000000 --- a/src/app/crypto/verificationInternals.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { describe, expect, it } from 'vitest'; -import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; - -/** - * The crypto backend implements js-sdk's public `CryptoBackend`, but js-sdk still offers no - * seam for supplying one, so `install.ts` assigns the private `cryptoBackend` field. These - * assertions run against the installed js-sdk so a version bump that moves it fails here - * rather than leaving the app running with no crypto at all. - */ -describe('matrix-js-sdk internals the crypto engine relies on', () => { - it('MatrixClient still exposes crypto through the cryptoBackend field', () => { - const require = createRequire(import.meta.url); - const source = readFileSync(require.resolve('matrix-js-sdk/lib/client.js'), 'utf8'); - - expect(source).toContain('this.cryptoBackend = '); - expect(source).toMatch(/getCrypto\(\)\s*\{\s*return this\.cryptoBackend;/); - }); - - /** - * Verification methods cross the IPC boundary as bare integers: the webview sends codes - * from this wasm enum, and the engine's `method_from_code` maps them back with hardcoded - * 0..3. The wasm package and the native matrix-sdk-crypto crate are versioned separately, - * so if either reorders this enum the two sides desync silently and QR verification stops - * negotiating. Keep these in step with `method_from_code` in matrix_crypto/verification.rs. - */ - it('pins the method codes the engine decodes by number', () => { - expect(RustSdkCryptoJs.VerificationMethod.SasV1).toBe(0); - expect(RustSdkCryptoJs.VerificationMethod.QrCodeScanV1).toBe(1); - expect(RustSdkCryptoJs.VerificationMethod.QrCodeShowV1).toBe(2); - expect(RustSdkCryptoJs.VerificationMethod.ReciprocateV1).toBe(3); - }); -}); diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts index c760af6c61..c1d284cf33 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -56,6 +56,8 @@ const matrixClient = vi.hoisted(() => ({ const invoke = vi.hoisted(() => vi.fn<(cmd: string, args?: Record) => Promise>() ); +const isTauri = vi.hoisted(() => vi.fn<() => boolean>(() => false)); +const engineDecryptPush = vi.hoisted(() => vi.fn<() => Promise>()); const getWebPushServerSupport = vi.hoisted(() => vi.fn<() => Promise>() @@ -84,9 +86,11 @@ vi.mock('./TauriNotificationsApiClient', () => ({ vi.mock('@tauri-apps/api/core', () => ({ addPluginListener, invoke, - isTauri: () => false, + isTauri, })); +vi.mock('$generated/tauri/commands', () => ({ engineDecryptPush })); + vi.mock('$utils/fetch', () => ({ fetch: (...args: Parameters) => globalThis.fetch(...args), })); @@ -135,6 +139,7 @@ describe('UnifiedPushNotifications', () => { matrixClient.setPusher.mockClear(); matrixClient.getPushers.mockResolvedValue({ pushers: [] }); matrixClient.getCrypto.mockReturnValue(undefined); + isTauri.mockReturnValue(false); matrixClient.decryptEventIfNeeded.mockImplementation(async (event) => { const crypto = matrixClient.getCrypto(); const mEvent = event as { @@ -437,6 +442,24 @@ describe('UnifiedPushNotifications', () => { }); }); + it('uses the SDK crypto backend for encrypted previews on Tauri', async () => { + matrixClient.getRoom.mockReturnValue(makeRoom()); + isTauri.mockReturnValue(true); + const decryptEvent = vi.fn<() => Promise>>().mockResolvedValue({ + clearEvent: { + type: 'm.room.message', + content: { body: 'SDK decrypted message' }, + }, + }); + matrixClient.getCrypto.mockReturnValue({ decryptEvent }); + + await listenAndPush(encryptedPush('$tauri-sdk:example.com')); + + await vi.waitFor(() => expect(notificationsApi.sendNotification).toHaveBeenCalledTimes(2)); + expect(decryptEvent).toHaveBeenCalledOnce(); + expect(engineDecryptPush).not.toHaveBeenCalled(); + }); + it('retries an immediate missing-room-key failure until the key arrives', async () => { vi.useFakeTimers(); try { diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index 7a10699ddc..95315ada88 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -5,6 +5,7 @@ import { MatrixEventEvent, type CryptoApi, type CryptoBackend, + type IContent, } from '$types/matrix-sdk'; import { EventType } from 'matrix-js-sdk/lib/@types/event'; import { @@ -15,8 +16,6 @@ import { fetch } from '$utils/fetch'; import { getMxIdLocalPart } from '$utils/matrix'; import { getStateEvent } from '$utils/room/hierarchy'; import { createDebugLogger } from '$utils/debugLogger'; -import type { DecryptedPushEvent } from '$app/crypto/pushDecrypt'; -import { decryptPushEventNatively } from '$app/crypto/pushDecrypt'; import { pushAccount } from './pushAccount'; import { registerUnifiedPushTransport, @@ -413,6 +412,12 @@ type NotifMessage = { sender?: NotifPerson; }; +type DecryptedPushEvent = { + eventType: string; + content: IContent; + sender?: string; +}; + function hashCode(str: string): number { let hash = 0; for (let i = 0; i < str.length; i += 1) { @@ -914,37 +919,17 @@ function scheduleEncryptedPreviewEnrichment( }); }; - const fallBackToSdkDecryption = (): void => { - whenDecrypted( - decrypted, - () => - applyDecryptedPreview({ - content: decrypted.getContent(), - eventType: decrypted.getType(), - sender: decrypted.getSender(), - }), - initialSettings.mx - ); - void initialSettings.mx.decryptEventIfNeeded(decrypted).catch(() => undefined); - }; - - // The engine reads the crypto store directly, so it answers without waiting on the SDK - // pipeline; it returns null exactly in the late-key case the SDK path retries. - void decryptPushEventNatively(initialSettings.mx.getUserId(), initialSettings.mx.getDeviceId(), { - roomId, - eventId, - sender: pushData.sender, - content: encryptedContent, - }) - .then((plaintext) => { - if (plaintext) { - void applyDecryptedPreview(plaintext); - return; - } - fallBackToSdkDecryption(); - }) - // Without this the preview would stay at its "Encrypted message" baseline forever. - .catch(fallBackToSdkDecryption); + whenDecrypted( + decrypted, + () => + applyDecryptedPreview({ + content: decrypted.getContent(), + eventType: decrypted.getType(), + sender: decrypted.getSender(), + }), + initialSettings.mx + ); + void initialSettings.mx.decryptEventIfNeeded(decrypted).catch(() => undefined); } async function handleMinimalPushPayload( diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 29bd5695a3..58d85388bb 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -38,10 +38,6 @@ export async function engineClose(params: types.EngineCloseParams): Promise { - return invoke('engine_decrypt_push', params); -} - export async function engineInvoke(params: types.EngineInvokeParams): Promise { return invoke('engine_invoke', params); } @@ -50,6 +46,10 @@ export async function engineOpen(params: types.EngineOpenParams): Promise { + return invoke('engine_store_exists', params); +} + export async function engineWipe(params: types.EngineWipeParams): Promise { return invoke('engine_wipe', params); } diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index e9250d8e2e..bf181ceae3 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -10,13 +10,6 @@ import type { Channel } from '@tauri-apps/api/core'; -export interface DecryptedPush { - event_type?: string | null; - sender?: string | null; - body?: string | null; - clear_event: string; -} - export interface DesktopRuntimeState { trayAvailable: boolean; toggleWindowShortcut?: string | null; @@ -101,15 +94,6 @@ export interface EngineCloseParams { [key: string]: unknown; } -export interface EngineDecryptPushParams { - userId: string; - deviceId: string; - roomId: string; - eventJson: string; - passphrase?: string | null; - [key: string]: unknown; -} - export interface EngineInvokeParams { userId: string; deviceId: string; @@ -126,6 +110,12 @@ export interface EngineOpenParams { [key: string]: unknown; } +export interface EngineStoreExistsParams { + userId: string; + deviceId: string; + [key: string]: unknown; +} + export interface EngineWipeParams { userId: string; deviceId: string; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 4f46be9247..2f2b4d569a 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -15,9 +15,8 @@ import { initClient, logoutClient, startClient, - stopClient, } from '$client/initMatrix'; -import { isLegacyWasmCryptoStoreError } from '$app/crypto/install'; +import { isNativeCryptoStoreError } from '$app/crypto/install'; import { LegacyKeyExport } from './LegacyKeyExport'; import { AsyncError } from '$components/AsyncError'; import { SplashScreen } from '$components/splash-screen'; @@ -272,11 +271,10 @@ export function ClientRoot({ children }: ClientRootProps) { const sessionExpired = useSessionLogout(loadedClient); const mx = sessionExpired ? undefined : loadedClient; - const legacyCryptoError = - loadState.status === AsyncStatus.Error && isLegacyWasmCryptoStoreError(loadState.error) + const nativeCryptoError = + loadState.status === AsyncStatus.Error && isNativeCryptoStoreError(loadState.error) ? loadState.error : undefined; - const legacyCryptoClient = legacyCryptoError?.client; const roomMatch = matchPath(HOME_ROOM_PATH, location.pathname) ?? @@ -338,17 +336,16 @@ export function ClientRoot({ children }: ClientRootProps) { window.location.reload(); }, [mx, activeSession, sessions, setSessions, setActiveSessionId]); - const [upgradeState, signOutForCryptoUpgrade] = useAsyncCallback( + const [recoveryState, signOutForCryptoRecovery] = useAsyncCallback( useCallback(async () => { if (!activeSession) return; - if (legacyCryptoClient) stopClient(legacyCryptoClient); await discardSessionStores(activeSession); setSessions({ type: 'DELETE', session: activeSession } as SessionsAction); setActiveSessionId( sessions.find((session) => session.userId !== activeSession.userId)?.userId ?? undefined ); window.location.reload(); - }, [activeSession, legacyCryptoClient, sessions, setSessions, setActiveSessionId]) + }, [activeSession, sessions, setSessions, setActiveSessionId]) ); useSyncNicknames(mx); @@ -406,7 +403,7 @@ export function ClientRoot({ children }: ClientRootProps) { ); const isError = loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error; - const legacyCryptoUpgradeRequired = legacyCryptoError !== undefined; + const nativeCryptoRecoveryRequired = nativeCryptoError !== undefined; // Set matrix client context: homeserver and sync type (not PII) useEffect(() => { @@ -447,7 +444,7 @@ export function ClientRoot({ children }: ClientRootProps) { // Capture fatal client failures — useAsyncCallback swallows these into state so // they never reach the React ErrorBoundary; explicit capture is required. useEffect(() => { - if (loadState.status === AsyncStatus.Error && !isLegacyWasmCryptoStoreError(loadState.error)) { + if (loadState.status === AsyncStatus.Error && !isNativeCryptoStoreError(loadState.error)) { Sentry.captureException(loadState.error, { tags: { phase: 'load' } }); } }, [loadState]); @@ -499,26 +496,22 @@ export function ClientRoot({ children }: ClientRootProps) { {loadState.status === AsyncStatus.Error && - (legacyCryptoUpgradeRequired ? ( + (nativeCryptoRecoveryRequired ? ( <> - Encrypted chat needs a one-time upgrade. + Sign in again to continue using encrypted chats. - Sign out and sign in again to use native crypto. Local encrypted-message - keys from this installation must be restored from backup. + Export your message keys first, or restore them from backup after signing + in. - {legacyCryptoClient && } - + + @@ -528,7 +521,7 @@ export function ClientRoot({ children }: ClientRootProps) { {startState.status === AsyncStatus.Error && ( {`Failed to start. ${errorMessage(startState.error)}`} )} - {!legacyCryptoUpgradeRequired && ( + {!nativeCryptoRecoveryRequired && (