diff --git a/.github/workflows/rust-quality.yml b/.github/workflows/rust-quality.yml index 48ea6ac96..42730cb3a 100644 --- a/.github/workflows/rust-quality.yml +++ b/.github/workflows/rust-quality.yml @@ -78,10 +78,10 @@ jobs: save-if: ${{ github.ref == 'refs/heads/dev' }} - name: Check native app - run: cargo check --locked --manifest-path src-tauri/Cargo.toml + run: cargo check --locked --manifest-path src-tauri/Cargo.toml --features matrix-crypto - name: Run Rust tests - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --features matrix-crypto - name: Run Clippy - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets --features matrix-crypto -- -D warnings diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index c8a63e3c2..8f684ac79 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -173,7 +173,7 @@ jobs: - name: Build desktop bundles if: ${{ !matrix.cef }} shell: bash - run: pnpm tauri build ${{ matrix.args }} $TAURI_BUILD_ARGS + run: pnpm tauri:wry build ${{ matrix.args }} $TAURI_BUILD_ARGS - name: Package macOS .app for updater if: ${{ matrix.name == 'macOS' }} @@ -379,7 +379,7 @@ jobs: if [ "$IS_NIGHTLY" = "true" ]; then export SABLE_BUILD_FLAVOR=dev fi - pnpm tauri android build --apk --aab --target aarch64 armv7 + pnpm tauri android build --apk --aab --target aarch64 armv7 -- --features matrix-crypto OUT='src-tauri/gen/android/app/build/outputs' APK=$(find "$OUT/apk/universal/release" -name '*.apk' -type f | head -1) AAB=$(find "$OUT/bundle/universalRelease" -name '*.aab' -type f | head -1) @@ -508,7 +508,7 @@ jobs: if [ "$IS_NIGHTLY" = "true" ]; then export SABLE_BUILD_FLAVOR=dev fi - pnpm tauri ios build --no-sign --ci + pnpm tauri ios build --no-sign --ci -- --features matrix-crypto IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -type f | head -1) [ -n "$IPA" ] || { echo "IPA not found"; ls -R src-tauri/gen/apple/build 2>/dev/null || true; exit 1; } NORMALIZED_IPA="$(dirname "$IPA")/Sable-${VERSION}-ios-arm64.ipa" diff --git a/knip.json b/knip.json index 6b0e9046d..90ea19646 100644 --- a/knip.json +++ b/knip.json @@ -7,12 +7,7 @@ "type": true }, "ignoreFiles": ["src/app/generated/**/*"], - "ignoreDependencies": [ - "buffer", - "@sableclient/sable-call-embedded", - "@matrix-org/matrix-sdk-crypto-wasm", - "@sableclient/twemoji-font" - ], + "ignoreDependencies": ["buffer", "@sableclient/sable-call-embedded", "@sableclient/twemoji-font"], "ignoreBinaries": ["knope", "mise"], "rules": { "exports": "off", diff --git a/package.json b/package.json index 44e8aada8..9f7bd313a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@fontsource/space-mono": "5.2.9", "@lottiefiles/dotlottie-react": "^0.12.0", "@lottiefiles/dotlottie-web": "0.40.1", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1", "@noble/hashes": "^2.2.0", "@phosphor-icons/react": "^2.1.10", "@sableclient/twemoji-font": "^1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eea31c8f3..05ea4b906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: '@lottiefiles/dotlottie-web': specifier: 0.40.1 version: 0.40.1 + '@matrix-org/matrix-sdk-crypto-wasm': + specifier: ^18.3.1 + version: 18.3.1 '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 diff --git a/scripts/tauri.js b/scripts/tauri.js index 52f340daa..c8e6b0122 100755 --- a/scripts/tauri.js +++ b/scripts/tauri.js @@ -68,7 +68,8 @@ async function main() { logger.info('Building without the auto-updater (--no-updater)'); } - const features = noUpdater ? platform : `${platform},updater`; + const base = noUpdater ? platform : `${platform},updater`; + const features = `${base},matrix-crypto`; const args = [cmd, '--features', features, ...tauriArgs]; if (!tauriArgs.includes('--')) { args.push('--'); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7bfd9f29c..538ce4a92 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,18 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accessory" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e416a3ab45838bac2ab2d81b1088d738d7b2d2c5272a54d39366565a29bd80" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -33,13 +45,34 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.0", ] @@ -168,7 +201,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -179,7 +212,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -188,6 +221,26 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -218,6 +271,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" + [[package]] name = "arrayref" version = "0.3.9" @@ -229,6 +288,9 @@ name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] [[package]] name = "as-raw-xcb-connection" @@ -236,6 +298,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as_variant" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a" + +[[package]] +name = "assign" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -260,6 +334,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -303,6 +389,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "async-process" version = "2.5.0" @@ -424,6 +516,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.6.20" @@ -469,6 +584,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -502,6 +628,12 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -532,6 +664,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block" version = "0.1.6" @@ -556,6 +708,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -661,6 +822,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + [[package]] name = "bzip2" version = "0.6.1" @@ -786,6 +953,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.3.0" @@ -869,6 +1045,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -880,6 +1067,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -916,6 +1116,17 @@ dependencies = [ "phf_codegen 0.11.3", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + [[package]] name = "cipher" version = "0.5.2" @@ -924,7 +1135,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.2", ] [[package]] @@ -1027,7 +1238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1040,6 +1251,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1074,6 +1302,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1100,6 +1334,21 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" @@ -1279,6 +1528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1347,13 +1597,22 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctr" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -1362,6 +1621,34 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -1402,6 +1689,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "date_header" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d" + [[package]] name = "dbus" version = "0.9.12" @@ -1413,6 +1706,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "deadpool-sync" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf" +dependencies = [ + "deadpool-runtime", +] + [[package]] name = "debugid" version = "0.8.0" @@ -1423,6 +1745,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "decancer" +version = "3.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" + [[package]] name = "defmt" version = "1.1.1" @@ -1454,6 +1782,30 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "delegate-display" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9926686c832494164c33a36bf65118f4bd6e704000b58c94681bf62e9ad67a74" +dependencies = [ + "impartial-ord", + "itoa", + "macroific", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1463,6 +1815,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1489,15 +1852,35 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.1.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" dependencies = [ - "derive_more-impl", + "derive_more-impl 1.0.0", ] [[package]] -name = "derive_more-impl" +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more-impl" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" @@ -1506,6 +1889,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1550,7 +1934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c06ffa9aeb3fb248b41d4e71ab3c0aa89177afc6669459da4320b97a4c77948" dependencies = [ "bitflags 2.13.1", - "prost", + "prost 0.12.6", "prost-types", "tonic", "tracing-core", @@ -1564,6 +1948,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1573,7 +1958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1601,7 +1986,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1780,6 +2165,32 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -1903,7 +2314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1933,6 +2344,56 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyeball" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f" +dependencies = [ + "futures-core", + "readlock", + "readlock-tokio", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "eyeball-im" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754" +dependencies = [ + "futures-core", + "imbl", + "tokio", + "tracing", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy_constructor" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a27643a5d05f3a22f5afd6e0d0e6e354f92d37907006f97b84b9cb79082198" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1963,6 +2424,12 @@ dependencies = [ "log", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2081,6 +2548,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -2503,6 +2976,31 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2514,6 +3012,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "growable-bloom-filter" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "xxhash-rust", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2642,6 +3152,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2666,6 +3185,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2699,6 +3236,16 @@ dependencies = [ "markup5ever 0.38.0", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", +] + [[package]] name = "http" version = "0.2.12" @@ -3061,6 +3608,60 @@ dependencies = [ "tiff", ] +[[package]] +name = "imbl" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501" +dependencies = [ + "archery", + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.9.5", + "rand_xoshiro", + "serde", + "version_check", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + +[[package]] +name = "impartial-ord" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3125,6 +3726,16 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inout" version = "0.2.2" @@ -3165,6 +3776,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3174,6 +3794,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3334,6 +3963,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "js_int" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a" +dependencies = [ + "serde", +] + +[[package]] +name = "js_option" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c" +dependencies = [ + "serde_core", +] + [[package]] name = "json-patch" version = "3.0.1" @@ -3377,6 +4024,16 @@ dependencies = [ "serde", ] +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "typewit", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3389,6 +4046,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3488,6 +4151,17 @@ dependencies = [ "redox_syscall 0.9.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3560,52 +4234,117 @@ dependencies = [ ] [[package]] -name = "malloc_buf" -version = "0.0.6" +name = "macroific" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285" dependencies = [ - "libc", + "macroific_attr_parse", + "macroific_core", + "macroific_macro", ] [[package]] -name = "markup5ever" -version = "0.14.1" +name = "macroific_attr_parse" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9" dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", + "proc-macro2", + "quote", + "sealed", + "syn 2.0.119", ] [[package]] -name = "markup5ever" -version = "0.38.0" +name = "macroific_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3" dependencies = [ - "log", - "tendril 0.5.1", - "web_atoms", + "proc-macro2", + "quote", + "sealed", + "syn 2.0.119", ] [[package]] -name = "match_token" -version = "0.1.0" +name = "macroific_macro" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b" dependencies = [ + "macroific_attr_parse", + "macroific_core", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "matchers" +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.1", + "web_atoms", +] + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril 0.5.1", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "matchers" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" @@ -3625,6 +4364,316 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matrix-pickle" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0" +dependencies = [ + "matrix-pickle-derive", + "thiserror 2.0.19", +] + +[[package]] +name = "matrix-pickle-derive" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "matrix-sdk" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae" +dependencies = [ + "anymap2", + "aquamarine", + "as_variant", + "async-channel", + "async-once-cell", + "async-stream", + "async-trait", + "backon", + "bytes", + "bytesize", + "cfg-if", + "event-listener", + "eyeball", + "eyeball-im", + "futures-core", + "futures-util", + "gloo-timers", + "http 1.4.2", + "imbl", + "indexmap 2.14.0", + "itertools 0.14.0", + "js_int", + "language-tags", + "matrix-sdk-base", + "matrix-sdk-common", + "matrix-sdk-indexeddb", + "matrix-sdk-sqlite", + "mime", + "mime2ext", + "oauth2", + "oauth2-reqwest", + "percent-encoding", + "pin-project-lite", + "reqwest 0.13.4", + "ruma", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_html_form", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "vodozemac", + "webpki-roots", + "zeroize", +] + +[[package]] +name = "matrix-sdk-base" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5" +dependencies = [ + "as_variant", + "async-trait", + "bitflags 2.13.1", + "decancer", + "eyeball", + "eyeball-im", + "futures-util", + "growable-bloom-filter", + "matrix-sdk-common", + "matrix-sdk-crypto", + "matrix-sdk-store-encryption", + "regex", + "ruma", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", + "unicode-normalization", +] + +[[package]] +name = "matrix-sdk-common" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836" +dependencies = [ + "eyeball-im", + "futures-core", + "futures-executor", + "futures-util", + "gloo-timers", + "imbl", + "ruma", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", + "tracing-subscriber", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "matrix-sdk-crypto" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf" +dependencies = [ + "aes 0.8.4", + "aquamarine", + "as_variant", + "async-trait", + "bs58", + "byteorder", + "cfg-if", + "ctr 0.9.2", + "eyeball", + "futures-core", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "js_option", + "matrix-sdk-common", + "matrix-sdk-qrcode", + "pbkdf2", + "rand 0.10.2", + "rmp-serde", + "ruma", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-stream", + "tracing", + "ulid", + "url", + "vodozemac", + "zeroize", +] + +[[package]] +name = "matrix-sdk-indexeddb" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures-util", + "getrandom 0.4.3", + "gloo-utils", + "hkdf", + "js-sys", + "matrix-sdk-base", + "matrix-sdk-crypto", + "matrix-sdk-store-encryption", + "matrix_indexed_db_futures", + "rmp-serde", + "ruma", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "tokio", + "tracing", + "uuid", + "wasm-bindgen", + "web-sys", + "zeroize", +] + +[[package]] +name = "matrix-sdk-qrcode" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc199f424cd31ad8a63717965779f6ecc877b3ecebe31db8030ef3be8200b0cd" +dependencies = [ + "byteorder", + "qrcode", + "ruma", + "thiserror 2.0.19", + "vodozemac", +] + +[[package]] +name = "matrix-sdk-sqlite" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670" +dependencies = [ + "as_variant", + "async-trait", + "deadpool", + "deadpool-sync", + "itertools 0.14.0", + "matrix-sdk-base", + "matrix-sdk-crypto", + "matrix-sdk-store-encryption", + "num_cpus", + "rmp-serde", + "ruma", + "rusqlite", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.19", + "tokio", + "tracing", + "vodozemac", + "zeroize", +] + +[[package]] +name = "matrix-sdk-store-encryption" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e" +dependencies = [ + "base64 0.22.1", + "blake3", + "chacha20poly1305", + "getrandom 0.2.17", + "getrandom 0.4.3", + "hmac", + "pbkdf2", + "rand 0.10.2", + "rmp-serde", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "zeroize", +] + +[[package]] +name = "matrix_indexed_db_futures" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "derive_more 2.1.1", + "fancy_constructor", + "futures-core", + "js-sys", + "matrix_indexed_db_futures_macros_internal", + "sealed", + "serde", + "serde-wasm-bindgen", + "smallvec", + "thiserror 2.0.19", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_evt_listener", + "web-sys", + "web-time", +] + +[[package]] +name = "matrix_indexed_db_futures_macros_internal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3655,6 +4704,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime2ext" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -3710,7 +4765,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3834,7 +4889,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3852,6 +4907,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3875,20 +4940,49 @@ dependencies = [ ] [[package]] -name = "num_threads" -version = "0.1.7" +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http 1.4.2", + "rand 0.8.7", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "oauth2-reqwest" +version = "0.1.0-alpha.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2" dependencies = [ - "libc", + "oauth2", + "reqwest 0.13.4", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "objc" version = "0.2.7" @@ -4198,6 +5292,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.0" @@ -4274,7 +5374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4363,6 +5463,15 @@ dependencies = [ "regex", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4632,6 +5741,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -4697,6 +5816,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -4795,6 +5925,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -4817,7 +5968,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -4827,7 +5988,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.119", @@ -4839,7 +6013,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" dependencies = [ - "prost", + "prost 0.12.6", ] [[package]] @@ -4864,6 +6038,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" @@ -4905,6 +6085,7 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -4932,7 +6113,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4997,7 +6178,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -5092,12 +6273,36 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "readlock" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34" + +[[package]] +name = "readlock-tokio" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff" +dependencies = [ + "tokio", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5223,91 +6428,259 @@ dependencies = [ ] [[package]] -name = "reqwest" -version = "0.13.4" +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "ruma" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59" +dependencies = [ + "assign", + "js_int", + "js_option", + "ruma-client-api", + "ruma-common", + "ruma-events", + "ruma-html", + "web-time", +] + +[[package]] +name = "ruma-client-api" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c" +dependencies = [ + "as_variant", + "assign", + "bytes", + "http 1.4.2", + "js_int", + "js_option", + "maplit", + "ruma-common", + "ruma-events", + "serde", + "serde_html_form", + "serde_json", + "thiserror 2.0.19", + "url", + "web-time", +] + +[[package]] +name = "ruma-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc" +dependencies = [ + "as_variant", + "base64 0.22.1", + "bytes", + "date_header", + "form_urlencoded", + "getrandom 0.4.3", + "http 1.4.2", + "indexmap 2.14.0", + "js_int", + "konst", + "percent-encoding", + "rand 0.10.2", + "regex", + "ruma-identifiers-validation", + "ruma-macros", + "serde", + "serde_html_form", + "serde_json", + "thiserror 2.0.19", + "time", + "tracing", + "url", + "uuid", + "web-time", + "wildmatch", + "zeroize", +] + +[[package]] +name = "ruma-events" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344" +dependencies = [ + "as_variant", + "indexmap 2.14.0", + "js_int", + "js_option", + "ruma-common", + "ruma-macros", + "serde", + "serde_json", + "thiserror 2.0.19", + "tracing", + "web-time", + "wildmatch", + "zeroize", +] + +[[package]] +name = "ruma-html" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http 1.4.2", - "http-body 1.1.0", - "http-body-util", - "hyper 1.11.0", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper 1.0.2", - "tokio", - "tokio-rustls", - "tokio-util", - "tower 0.5.3", - "tower-http 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", - "web-sys", + "as_variant", + "html5ever 0.39.0", + "tracing", + "wildmatch", ] [[package]] -name = "rfd" -version = "0.16.0" +name = "ruma-identifiers-validation" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432" dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", + "js_int", + "thiserror 2.0.19", ] [[package]] -name = "ring" -version = "0.17.14" +name = "ruma-macros" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c" dependencies = [ - "cc", + "as_variant", "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "ruma-identifiers-validation", + "serde", + "syn 2.0.119", + "toml 1.1.3+spec-1.1.0", ] [[package]] -name = "ringbuf" -version = "0.4.8" +name = "rusqlite" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "crossbeam-utils", - "portable-atomic", - "portable-atomic-util", + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -5351,7 +6724,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5360,6 +6733,7 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -5409,7 +6783,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5424,6 +6798,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5445,19 +6820,23 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" name = "sable" version = "1.20.0" dependencies = [ - "aes", + "aes 0.9.2", "async-stream", "base64 0.23.0", "block2", "cef", - "ctr", + "ctr 0.10.1", "enigo", "futures-util", "gtk", + "http 1.4.2", "infer 0.22.0", "jni 0.22.4", "libloading 0.9.0", "log", + "matrix-sdk", + "matrix-sdk-crypto", + "matrix-sdk-sqlite", "objc2", "objc2-avf-audio", "objc2-call-kit", @@ -5499,6 +6878,7 @@ dependencies = [ "ts-rs", "webkit2gtk", "windows 0.62.2", + "zeroize", "zip 8.6.0", ] @@ -5596,6 +6976,17 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5799,6 +7190,27 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -5830,6 +7242,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_html_form" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "itoa", + "serde_core", + "zmij", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -5843,6 +7268,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -6010,6 +7446,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6065,6 +7510,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -6120,7 +7568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6182,6 +7630,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7189,7 +8647,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7454,6 +8912,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -7608,7 +9067,7 @@ dependencies = [ "hyper-timeout", "percent-encoding", "pin-project", - "prost", + "prost 0.12.6", "tokio", "tokio-stream", "tower 0.4.13", @@ -7624,7 +9083,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f80db390246dfb46553481f6024f0082ba00178ea495dbb99e70ba9a4fafb5e1" dependencies = [ "async-stream", - "prost", + "prost 0.12.6", "tokio", "tokio-stream", "tonic", @@ -7709,12 +9168,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags 2.13.1", "bytes", + "futures-core", "futures-util", "http 1.4.2", "http-body 1.1.0", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.3", "tower-layer", "tower-service", @@ -7814,7 +9278,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7880,6 +9344,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + [[package]] name = "ucd-trie" version = "0.1.7" @@ -7894,7 +9364,17 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.5", + "web-time", ] [[package]] @@ -7953,6 +9433,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -7965,12 +9454,28 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -8023,6 +9528,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -8078,6 +9589,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -8090,6 +9607,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vodozemac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b" +dependencies = [ + "aes 0.8.4", + "arrayvec", + "base64 0.22.1", + "base64ct", + "cbc", + "chacha20poly1305", + "curve25519-dalek", + "ed25519-dalek", + "getrandom 0.2.17", + "hkdf", + "hmac", + "matrix-pickle", + "prost 0.14.4", + "rand 0.8.7", + "serde", + "serde_bytes", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.19", + "x25519-dalek", + "zeroize", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -8231,6 +9778,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_evt_listener" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963" +dependencies = [ + "accessory", + "derivative", + "derive_more 1.0.0", + "fancy_constructor", + "futures-core", + "js-sys", + "smallvec", + "tokio", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wayland-backend" version = "0.3.16" @@ -8383,6 +9948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8502,6 +10068,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + [[package]] name = "winapi" version = "0.3.9" @@ -8524,7 +10096,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9441,6 +11013,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" @@ -9487,6 +11071,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yoke" version = "0.8.3" @@ -9618,6 +11208,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7a258bff7..cb1ece907 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,7 @@ percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["stream"] } async-stream = "0.3" base64 = "0.23" +zeroize = "1" tokio-util = { version = "0.7", features = ["codec"] } futures-util = "0.3" regex = "1" @@ -59,6 +60,23 @@ tauri = { version = "2.11.5", default-features = false, features = [ "image-png", ] } +# Rust Matrix crypto engine. Optional so regular builds don't pay the +# matrix-rust-sdk compile cost. +matrix-sdk = { version = "0.18", default-features = false, features = [ + "e2e-encryption", + "sqlite", +], optional = true } +# `qrcode` is off by default upstream; Sable already supports QR device +# verification, so the engine needs it for parity with the wasm backend. +matrix-sdk-crypto = { version = "0.18", features = [ + "qrcode", + "experimental-push-secrets", +], optional = true } +# `bundled` compiles SQLite from source. Android's NDK ships no libsqlite3 to link +# against at any API level, so without this the aarch64 build fails at link time. +matrix-sdk-sqlite = { version = "0.18", features = ["crypto-store", "bundled"], optional = true } +http = { version = "1", optional = true } + tauri-plugin-log = "2.9.0" tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2" @@ -176,7 +194,7 @@ tauri-plugin-android-fs = { version = "=29.0.0", features = ["legacy-storage-per jni = "0.22" [features] -default = ["wry", "updater"] +default = ["wry", "updater", "matrix-crypto"] custom-protocol = ["tauri/custom-protocol"] wry = ["tauri/wry", "dep:webkit2gtk"] # Tauri auto-updater. Disable with --no-default-features --features wry,cef. @@ -186,6 +204,7 @@ cef = ["dep:tauri-runtime-cef", "dep:cef"] # CrabNebula devtools. Off by default: its aggregator retains every metadata record for the # session, which costs hundreds of MB per minute once tauri-plugin-log feeds it. devtools = ["dep:tauri-plugin-devtools"] +matrix-crypto = ["dep:matrix-sdk", "dep:matrix-sdk-crypto", "dep:matrix-sdk-sqlite", "dep:http"] [patch.crates-io] tauri-typegen = { git = "https://github.com/SableClient/tauri-typegen", branch = "fix/nondeterministic-generation-cache" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94593c809..6f2b74963 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ mod desktop; mod diagnostics; #[cfg(target_os = "ios")] mod ios; +#[cfg(feature = "matrix-crypto")] +mod matrix_crypto; #[cfg(target_os = "android")] mod mobile; #[cfg(any(target_os = "android", target_os = "ios"))] @@ -447,6 +449,14 @@ pub fn run() { network::media_protocol::clear_media_session, network::media_protocol::set_media_encryption, sentry::set_native_sentry_enabled, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_invoke, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_open, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_close, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_wipe, share_inbox::share_inbox_drain, share_inbox::share_inbox_read, share_inbox::share_inbox_clear, diff --git a/src-tauri/src/matrix_crypto/args.rs b/src-tauri/src/matrix_crypto/args.rs new file mode 100644 index 000000000..b1864dc5b --- /dev/null +++ b/src-tauri/src/matrix_crypto/args.rs @@ -0,0 +1,85 @@ +//! Argument parsing shared by the `OlmMachine` dispatch modules. + +use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId, RoomId, UserId}; +use matrix_sdk_crypto::{DecryptionSettings, TrustRequirement}; +use serde_json::Value; + +pub fn str_arg(args: &Value, method: &str, field: &str) -> Result { + args.get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("{method}: missing string argument `{field}`")) +} + +pub fn room_id(args: &Value, method: &str, field: &str) -> Result { + let raw = str_arg(args, method, field)?; + RoomId::parse(&raw).map_err(|e| format!("{method}: bad room id in `{field}`: {e}")) +} + +pub fn user_id(args: &Value, method: &str, field: &str) -> Result { + let raw = str_arg(args, method, field)?; + UserId::parse(&raw).map_err(|e| format!("{method}: bad user id in `{field}`: {e}")) +} + +pub fn decryption_settings() -> DecryptionSettings { + DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + } +} + +/// Codes are wasm's `TrustRequirement`; anything unrecognised stays permissive. +pub fn caller_decryption_settings(args: &Value) -> DecryptionSettings { + let requirement = args + .get("decryptionSettings") + .and_then(|settings| settings.get("senderDeviceTrustRequirement")) + .and_then(Value::as_u64); + + DecryptionSettings { + sender_device_trust_requirement: match requirement { + Some(1) => TrustRequirement::CrossSignedOrLegacy, + Some(2) => TrustRequirement::CrossSigned, + _ => TrustRequirement::Untrusted, + }, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn requirement(args: &Value) -> TrustRequirement { + caller_decryption_settings(args).sender_device_trust_requirement + } + + #[test] + fn reads_the_callers_trust_requirement() { + let args = json!({ "decryptionSettings": { "senderDeviceTrustRequirement": 1 } }); + assert!(matches!( + requirement(&args), + TrustRequirement::CrossSignedOrLegacy + )); + + let args = json!({ "decryptionSettings": { "senderDeviceTrustRequirement": 2 } }); + assert!(matches!(requirement(&args), TrustRequirement::CrossSigned)); + } + + #[test] + fn falls_back_to_untrusted_when_absent_or_unknown() { + assert!(matches!( + requirement(&json!({})), + TrustRequirement::Untrusted + )); + + let args = json!({ "decryptionSettings": { "senderDeviceTrustRequirement": 99 } }); + assert!(matches!(requirement(&args), TrustRequirement::Untrusted)); + } + + #[test] + fn rejects_a_missing_string_argument() { + let error = str_arg(&json!({}), "someMethod", "roomId").unwrap_err(); + assert!(error.contains("someMethod"), "{error}"); + assert!(error.contains("roomId"), "{error}"); + } +} diff --git a/src-tauri/src/matrix_crypto/backup.rs b/src-tauri/src/matrix_crypto/backup.rs new file mode 100644 index 000000000..03da14813 --- /dev/null +++ b/src-tauri/src/matrix_crypto/backup.rs @@ -0,0 +1,193 @@ +//! Server-side key backup and room-key import/export for the `OlmMachine` IPC proxy. + +use std::collections::BTreeMap; + +use matrix_sdk::ruma::RoomId; +use matrix_sdk_crypto::backups::{MegolmV1BackupKey, SignatureState}; +use matrix_sdk_crypto::olm::{BackedUpRoomKey, ExportedRoomKey}; +use matrix_sdk_crypto::store::types::BackupDecryptionKey; +use matrix_sdk_crypto::types::RoomKeyBackupInfo; +use matrix_sdk_crypto::{OlmMachine, RoomKeyImportResult}; +use serde_json::{json, Value}; + +use super::args::str_arg; +use super::wasm_enums::request_type::KEYS_BACKUP as REQUEST_TYPE_KEYS_BACKUP; + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + match handle(machine, method, args).await { + Ok(Some(value)) => Some(Ok(value)), + Ok(None) => None, + Err(error) => Some(Err(error)), + } +} + +fn signature_state(state: SignatureState) -> u8 { + match state { + SignatureState::Missing => 0, + SignatureState::Invalid => 1, + SignatureState::ValidButNotTrusted => 2, + SignatureState::ValidAndTrusted => 3, + } +} + +fn ignore_progress(_progress: usize, _total: usize) {} + +fn import_result(result: RoomKeyImportResult) -> Value { + json!({ + "importedCount": result.imported_count, + "totalCount": result.total_count, + "keys": result.keys, + }) +} + +async fn handle(machine: &OlmMachine, method: &str, args: &Value) -> Result, String> { + let value = match method { + "getBackupKeys" => { + let keys = machine + .backup_machine() + .get_backup_keys() + .await + .map_err(|e| format!("getBackupKeys failed: {e}"))?; + json!({ + "className": "BackupKeys", + "backupVersion": keys.backup_version, + "decryptionKeyBase64": keys.decryption_key.map(|key| key.to_base64()), + }) + } + "saveBackupDecryptionKey" => { + let version = str_arg(args, method, "version")?; + // Never let the key itself reach a log or an error string. + let key = BackupDecryptionKey::from_base64(&str_arg(args, method, "decryptionKey")?) + .map_err(|e| format!("saveBackupDecryptionKey: bad decryption key: {e}"))?; + machine + .backup_machine() + .save_decryption_key(Some(key), Some(version)) + .await + .map_err(|e| format!("saveBackupDecryptionKey failed: {e}"))?; + Value::Null + } + + "enableBackupV1" => { + let version = str_arg(args, method, "version")?; + let key = MegolmV1BackupKey::from_base64(&str_arg(args, method, "publicKeyBase64")?) + .map_err(|e| format!("enableBackupV1: bad public key: {e}"))?; + key.set_version(version); + machine + .backup_machine() + .enable_backup_v1(key) + .await + .map_err(|e| format!("enableBackupV1 failed: {e}"))?; + Value::Null + } + "disableBackup" => { + machine + .backup_machine() + .disable_backup() + .await + .map_err(|e| format!("disableBackup failed: {e}"))?; + Value::Null + } + "isBackupEnabled" => Value::Bool(machine.backup_machine().enabled().await), + "verifyBackup" => { + let info = args + .get("backupInfo") + .ok_or_else(|| format!("{method}: missing argument `backupInfo`"))?; + let info: RoomKeyBackupInfo = serde_json::from_value(info.clone()) + .map_err(|e| format!("verifyBackup: bad backupInfo: {e}"))?; + let verification = machine + .backup_machine() + .verify_backup(info, false) + .await + .map_err(|e| format!("verifyBackup failed: {e}"))?; + json!({ + "className": "SignatureVerification", + "deviceState": signature_state(verification.device_signature), + "userState": signature_state(verification.user_identity_signature), + "trusted": verification.trusted(), + }) + } + "roomKeyCounts" => { + let counts = machine + .backup_machine() + .room_key_counts() + .await + .map_err(|e| format!("roomKeyCounts failed: {e}"))?; + json!({ "total": counts.total, "backedUp": counts.backed_up }) + } + + "backupRoomKeys" => { + let pending = machine + .backup_machine() + .backup() + .await + .map_err(|e| format!("backupRoomKeys failed: {e}"))?; + match pending { + Some((id, request)) => json!({ + "type": REQUEST_TYPE_KEYS_BACKUP, + "className": "KeysBackupRequest", + "id": id.to_string(), + "version": request.version, + "body": json!({ "rooms": request.rooms }).to_string(), + }), + None => Value::Null, + } + } + + "importBackedUpRoomKeys" => { + let backup_version = str_arg(args, method, "backupVersion")?; + let rooms = args + .get("keys") + .and_then(Value::as_object) + .ok_or_else(|| format!("{method}: missing object argument `keys`"))?; + + let mut exported = Vec::new(); + for (room, sessions) in rooms { + let room = RoomId::parse(room) + .map_err(|e| format!("importBackedUpRoomKeys: bad room id: {e}"))?; + let sessions: BTreeMap = + serde_json::from_value(sessions.clone()).map_err(|e| { + format!("importBackedUpRoomKeys: bad keys for room {room}: {e}") + })?; + exported.extend(sessions.into_iter().map(|(session_id, key)| { + ExportedRoomKey::from_backed_up_room_key(room.clone(), session_id, key) + })); + } + + let result = machine + .store() + .import_room_keys(exported, Some(&backup_version), ignore_progress) + .await + .map_err(|e| format!("importBackedUpRoomKeys failed: {e}"))?; + import_result(result) + } + "importExportedRoomKeys" => { + let keys: Vec = serde_json::from_str(&str_arg(args, method, "keys")?) + .map_err(|e| format!("importExportedRoomKeys: bad key export json: {e}"))?; + let result = machine + .store() + .import_exported_room_keys(keys, ignore_progress) + .await + .map_err(|e| format!("importExportedRoomKeys failed: {e}"))?; + import_result(result) + } + "exportRoomKeys" => { + let keys = machine + .store() + .export_room_keys(|_| true) + .await + .map_err(|e| format!("exportRoomKeys failed: {e}"))?; + Value::String( + serde_json::to_string(&keys) + .map_err(|e| format!("exportRoomKeys: serialising the export failed: {e}"))?, + ) + } + + _ => return Ok(None), + }; + + Ok(Some(value)) +} diff --git a/src-tauri/src/matrix_crypto/bundles.rs b/src-tauri/src/matrix_crypto/bundles.rs new file mode 100644 index 000000000..f686c36ac --- /dev/null +++ b/src-tauri/src/matrix_crypto/bundles.rs @@ -0,0 +1,422 @@ +//! Room key bundles (MSC4268) and dehydrated devices for the `OlmMachine` IPC proxy. + +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::sync::{Arc, Mutex, OnceLock}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use matrix_sdk::ruma::events::room::EncryptedFile; +use matrix_sdk_crypto::dehydrated_devices::RehydratedDevice; +use matrix_sdk_crypto::store::types::{ + Changes, DehydratedDeviceKey, RoomPendingKeyBundleDetails, StoredRoomKeyBundleData, +}; +use matrix_sdk_crypto::types::events::room_key_bundle::RoomKeyBundleContent; +use matrix_sdk_crypto::types::room_history::RoomKeyBundle; +use matrix_sdk_crypto::{ + AttachmentDecryptor, AttachmentEncryptor, CollectStrategy, MediaEncryptionInfo, OlmMachine, +}; +use serde_json::{json, Map, Value}; +use zeroize::Zeroizing; + +use super::args::{decryption_settings, room_id, str_arg, user_id}; +use super::events::room_key_json; +use super::wasm_enums::request_type::TO_DEVICE as REQUEST_TYPE_TO_DEVICE; + +/// Never put the key in an error: it would end up in a log line. +fn dehydration_key(args: &Value, method: &str) -> Result { + let raw = str_arg(args, method, "dehydratedDeviceKey")?; + let bytes = BASE64 + .decode(raw) + .map_err(|_| format!("{method}: `dehydratedDeviceKey` is not valid base64"))?; + DehydratedDeviceKey::from_slice(&bytes) + .map_err(|e| format!("{method}: unusable dehydrated device key: {e}")) +} + +fn collect_strategy(args: &Value, method: &str) -> Result { + match args.get("sharingStrategy").and_then(Value::as_str) { + None | Some("identityBasedStrategy") => Ok(CollectStrategy::IdentityBasedStrategy), + Some("allDevices") => Ok(CollectStrategy::AllDevices), + Some("errorOnVerifiedUserProblem") => Ok(CollectStrategy::ErrorOnVerifiedUserProblem), + Some("onlyTrustedDevices") => Ok(CollectStrategy::OnlyTrustedDevices), + Some(other) => Err(format!("{method}: unknown sharing strategy `{other}`")), + } +} + +fn pending_details_json(details: &RoomPendingKeyBundleDetails) -> Value { + json!({ + "roomId": details.room_id.to_string(), + "inviterId": details.inviter.to_string(), + "inviteAcceptedAtMillis": u64::from(details.invite_accepted_at.0), + }) +} + +fn stored_bundle_json(data: &StoredRoomKeyBundleData) -> Result { + let encryption_info = + serde_json::to_string(&MediaEncryptionInfo::from(data.bundle_data.file.clone())) + .map_err(|e| format!("getReceivedRoomKeyBundleData: bad encryption info: {e}"))?; + Ok(json!({ + "senderUser": data.sender_user.to_string(), + "roomId": data.bundle_data.room_id.to_string(), + "url": data.bundle_data.file.url.to_string(), + "encryptionInfo": encryption_info, + })) +} + +/// Must persist across the paged `/dehydrated_device/{id}/events` fetches. +type RehydratedDevices = Mutex)>>; + +fn rehydrated_devices() -> &'static RehydratedDevices { + static DEVICES: OnceLock = OnceLock::new(); + DEVICES.get_or_init(Default::default) +} + +fn account_key(machine: &OlmMachine) -> String { + super::account_key(machine.user_id().as_str(), machine.device_id().as_str()) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getAllRoomsPendingKeyBundles" => machine + .store() + .get_all_rooms_pending_key_bundles() + .await + .map_err(|e| format!("getAllRoomsPendingKeyBundles failed: {e}")) + .map(|rooms| Value::Array(rooms.iter().map(pending_details_json).collect())), + + "getPendingKeyBundleDetailsForRoom" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .get_pending_key_bundle_details_for_room(&room) + .await + .map_err(|e| format!("getPendingKeyBundleDetailsForRoom failed: {e}")) + .map(|details| { + details + .as_ref() + .map(pending_details_json) + .unwrap_or(Value::Null) + }), + Err(e) => Err(e), + }, + + "storeRoomPendingKeyBundle" => store_room_pending_key_bundle(machine, method, args).await, + + "clearRoomPendingKeyBundle" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .clear_room_pending_key_bundle(&room) + .await + .map_err(|e| format!("clearRoomPendingKeyBundle failed: {e}")) + .map(|()| Value::Null), + Err(e) => Err(e), + }, + + "getReceivedRoomKeyBundleData" => received_bundle_data(machine, method, args).await, + "receiveRoomKeyBundle" => receive_bundle(machine, method, args).await, + + "hasDownloadedAllRoomKeys" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .has_downloaded_all_room_keys(&room) + .await + .map_err(|e| format!("hasDownloadedAllRoomKeys failed: {e}")) + .map(Value::Bool), + Err(e) => Err(e), + }, + "setHasDownloadedAllRoomKeys" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .save_changes(Changes { + room_key_backups_fully_downloaded: [room].into_iter().collect(), + ..Default::default() + }) + .await + .map_err(|e| format!("setHasDownloadedAllRoomKeys failed: {e}")) + .map(|()| Value::Null), + Err(e) => Err(e), + }, + "buildRoomKeyBundle" => build_bundle(machine, method, args).await, + "shareRoomKeyBundleData" => share_bundle_data(machine, method, args).await, + + "dehydratedDevices.getDehydratedDeviceKey" => machine + .dehydrated_devices() + .get_dehydrated_device_pickle_key() + .await + .map_err(|e| format!("{method} failed: {e}")) + .map(|key| match key { + Some(key) => Value::String(key.to_base64()), + None => Value::Null, + }), + "dehydratedDevices.saveDehydratedDeviceKey" => match dehydration_key(args, method) { + Ok(key) => machine + .dehydrated_devices() + .save_dehydrated_device_pickle_key(&key) + .await + .map_err(|e| format!("{method} failed: {e}")) + .map(|()| Value::Null), + Err(e) => Err(e), + }, + "dehydratedDevices.deleteDehydratedDeviceKey" => machine + .dehydrated_devices() + .delete_dehydrated_device_pickle_key() + .await + .map_err(|e| format!("{method} failed: {e}")) + .map(|()| Value::Null), + + "dehydratedDevices.create" => Ok(Value::Null), + "dehydratedDevices.keysForUpload" => keys_for_upload(machine, method, args).await, + + "dehydratedDevices.rehydrate" => rehydrate(machine, method, args).await, + "dehydratedDevices.receiveEvents" => receive_dehydrated_events(machine, method, args).await, + + _ => return None, + }) +} + +async fn store_room_pending_key_bundle( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let room = room_id(args, method, "roomId")?; + let inviter = user_id(args, method, "inviterId")?; + machine + .store() + .store_room_pending_key_bundle(&room, &inviter) + .await + .map_err(|e| format!("storeRoomPendingKeyBundle failed: {e}"))?; + Ok(Value::Null) +} + +async fn received_bundle_data( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let room = room_id(args, method, "roomId")?; + let inviter = user_id(args, method, "inviterId")?; + let data = machine + .store() + .get_received_room_key_bundle_data(&room, &inviter) + .await + .map_err(|e| format!("getReceivedRoomKeyBundleData failed: {e}"))?; + match data { + Some(data) => stored_bundle_json(&data), + None => Ok(Value::Null), + } +} + +async fn receive_bundle(machine: &OlmMachine, method: &str, args: &Value) -> Result { + let room = room_id(args, method, "roomId")?; + let inviter = user_id(args, method, "inviterId")?; + let encrypted = BASE64 + .decode(str_arg(args, method, "bundle")?) + .map_err(|e| format!("receiveRoomKeyBundle: `bundle` is not valid base64: {e}"))?; + + let info = machine + .store() + .get_received_room_key_bundle_data(&room, &inviter) + .await + .map_err(|e| format!("receiveRoomKeyBundle failed: {e}"))? + .ok_or_else(|| { + format!("receiveRoomKeyBundle: no stored bundle data for {room} from {inviter}") + })?; + + let bundle: RoomKeyBundle = { + let mut cursor = Cursor::new(encrypted.as_slice()); + let mut decryptor = AttachmentDecryptor::new( + &mut cursor, + MediaEncryptionInfo::from(info.bundle_data.file.clone()), + ) + .map_err(|e| format!("receiveRoomKeyBundle: bundle is not decryptable: {e}"))?; + + let mut decrypted = Zeroizing::new(Vec::new()); + decryptor + .read_to_end(&mut decrypted) + .map_err(|e| format!("receiveRoomKeyBundle: decrypting the bundle failed: {e}"))?; + serde_json::from_slice(&decrypted) + .map_err(|e| format!("receiveRoomKeyBundle: malformed bundle: {e}"))? + }; + + machine + .store() + .receive_room_key_bundle(&info, bundle, |_, _| {}) + .await + .map_err(|e| format!("receiveRoomKeyBundle failed: {e}"))?; + Ok(Value::Null) +} + +async fn build_bundle(machine: &OlmMachine, method: &str, args: &Value) -> Result { + let room = room_id(args, method, "roomId")?; + let bundle = machine + .store() + .build_room_key_bundle(&room) + .await + .map_err(|e| format!("buildRoomKeyBundle failed: {e}"))?; + + if bundle.is_empty() { + return Ok(Value::Null); + } + + let json = Zeroizing::new( + serde_json::to_vec(&bundle) + .map_err(|e| format!("buildRoomKeyBundle: serialising the bundle failed: {e}"))?, + ); + let mut cursor = Cursor::new(json.as_slice()); + let mut encryptor = AttachmentEncryptor::new(&mut cursor); + let mut encrypted = Vec::new(); + encryptor + .read_to_end(&mut encrypted) + .map_err(|e| format!("buildRoomKeyBundle: encrypting the bundle failed: {e}"))?; + let encryption_info = serde_json::to_string(&encryptor.finish()) + .map_err(|e| format!("buildRoomKeyBundle: bad encryption info: {e}"))?; + + Ok(json!({ + "encryptedData": BASE64.encode(&encrypted), + "mediaEncryptionInfo": encryption_info, + })) +} + +async fn share_bundle_data( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let user = user_id(args, method, "userId")?; + let room = room_id(args, method, "roomId")?; + let url = str_arg(args, method, "url")?; + let strategy = collect_strategy(args, method)?; + + let mut file_json: Value = serde_json::from_str(&str_arg(args, method, "mediaEncryptionInfo")?) + .map_err(|e| format!("shareRoomKeyBundleData: bad media encryption info: {e}"))?; + file_json + .as_object_mut() + .ok_or_else(|| { + "shareRoomKeyBundleData: `mediaEncryptionInfo` must be an object".to_owned() + })? + .insert("url".to_owned(), Value::String(url)); + let file: EncryptedFile = serde_json::from_value(file_json) + .map_err(|e| format!("shareRoomKeyBundleData: unusable bundle file: {e}"))?; + + let requests = machine + .share_room_key_bundle_data( + &user, + &strategy, + RoomKeyBundleContent { + room_id: room, + file, + }, + ) + .await + .map_err(|e| format!("shareRoomKeyBundleData failed: {e}"))?; + + Ok(Value::Array( + requests + .iter() + .map(|request| { + json!({ + "type": REQUEST_TYPE_TO_DEVICE, + "className": "ToDeviceRequest", + "id": request.txn_id.to_string(), + "event_type": request.event_type.to_string(), + "txn_id": request.txn_id.to_string(), + "body": json!({ "messages": request.messages }).to_string(), + }) + }) + .collect(), + )) +} + +async fn keys_for_upload( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let display_name = str_arg(args, method, "initialDeviceDisplayName")?; + let key = dehydration_key(args, method)?; + + let devices = machine.dehydrated_devices(); + let device = devices + .create() + .await + .map_err(|e| format!("{method}: creating the dehydrated device failed: {e}"))?; + let request = device + .keys_for_upload(display_name, &key) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + + let mut body = Map::new(); + body.insert("device_id".to_owned(), json!(request.device_id)); + body.insert("device_data".to_owned(), json!(request.device_data)); + body.insert("device_keys".to_owned(), json!(request.device_keys)); + if let Some(name) = &request.initial_device_display_name { + body.insert("initial_device_display_name".to_owned(), json!(name)); + } + if !request.one_time_keys.is_empty() { + body.insert("one_time_keys".to_owned(), json!(request.one_time_keys)); + } + if !request.fallback_keys.is_empty() { + body.insert("fallback_keys".to_owned(), json!(request.fallback_keys)); + } + let body = Value::Object(body); + + // No `id`: that is how js-sdk knows to skip `markRequestAsSent`. + Ok(json!({ + "className": "PutDehydratedDeviceRequest", + "id": Value::Null, + "body": body.to_string(), + })) +} + +async fn rehydrate(machine: &OlmMachine, method: &str, args: &Value) -> Result { + let key = dehydration_key(args, method)?; + let device_id = str_arg(args, method, "deviceId")?; + let device_data = serde_json::from_str(&str_arg(args, method, "deviceData")?) + .map_err(|e| format!("{method}: bad device data json: {e}"))?; + + let device = machine + .dehydrated_devices() + .rehydrate(&key, device_id.as_str().into(), device_data) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + + rehydrated_devices() + .lock() + .map_err(|e| format!("{method}: rehydrated device registry poisoned: {e}"))? + .insert(account_key(machine), (device_id.clone(), Arc::new(device))); + + Ok(json!({ "deviceId": device_id })) +} + +async fn receive_dehydrated_events( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let device_id = str_arg(args, method, "deviceId")?; + let events = serde_json::from_str(&str_arg(args, method, "toDeviceEvents")?) + .map_err(|e| format!("{method}: bad to-device events json: {e}"))?; + + let device = { + let registry = rehydrated_devices() + .lock() + .map_err(|e| format!("{method}: rehydrated device registry poisoned: {e}"))?; + match registry.get(&account_key(machine)) { + Some((id, device)) if *id == device_id => Arc::clone(device), + _ => { + return Err(format!( + "{method}: device {device_id} has not been rehydrated" + )) + } + } + }; + + let room_keys = device + .receive_events(events, &decryption_settings()) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Array(room_keys.iter().map(room_key_json).collect())) +} diff --git a/src-tauri/src/matrix_crypto/cross_signing.rs b/src-tauri/src/matrix_crypto/cross_signing.rs new file mode 100644 index 000000000..3ea43984c --- /dev/null +++ b/src-tauri/src/matrix_crypto/cross_signing.rs @@ -0,0 +1,206 @@ +//! Cross-signing bootstrap and private key transfer for the `OlmMachine` IPC proxy. + +use matrix_sdk_crypto::types::requests::AnyOutgoingRequest; +use matrix_sdk_crypto::types::SecretsBundle; +use matrix_sdk_crypto::{CrossSigningKeyExport, OlmMachine}; +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use super::wasm_enums::request_type::{ + KEYS_UPLOAD as KEYS_UPLOAD_REQUEST_TYPE, SIGNATURE_UPLOAD as SIGNATURE_UPLOAD_REQUEST_TYPE, +}; + +/// Absent keys are omitted, not null: js-sdk gates on `!== undefined` and would otherwise +/// store a null into 4S. The mixed casing is wasm's own, not a typo. +#[derive(Serialize)] +struct CrossSigningKeyExportSnapshot { + #[serde(rename = "masterKey", skip_serializing_if = "Option::is_none")] + master_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + self_signing_key: Option, + #[serde(rename = "userSigningKey", skip_serializing_if = "Option::is_none")] + user_signing_key: Option, +} + +fn opt_str_arg(args: &Value, field: &str) -> Option { + args.get(field).and_then(Value::as_str).map(str::to_owned) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "bootstrapCrossSigning" => bootstrap(machine, args).await, + "exportCrossSigningKeys" => export_keys(machine).await, + "importCrossSigningKeys" => import_keys(machine, args).await, + "exportSecretsBundle" => export_secrets_bundle(machine).await, + "importSecretsBundle" => import_secrets_bundle(machine, args).await, + + "pushSecretToVerifiedDevices" => push_secret(machine, args).await, + + _ => return None, + }) +} + +async fn bootstrap(machine: &OlmMachine, args: &Value) -> Result { + let reset = args + .get("reset") + .and_then(Value::as_bool) + .ok_or_else(|| "bootstrapCrossSigning: missing boolean argument `reset`".to_owned())?; + + let requests = machine + .bootstrap_cross_signing(reset) + .await + .map_err(|e| format!("bootstrapCrossSigning failed: {e}"))?; + + let upload_keys_request = match requests.upload_keys_req.as_ref() { + Some(request) => match request.request() { + AnyOutgoingRequest::KeysUpload(req) => json!({ + "id": request.request_id().to_string(), + "type": KEYS_UPLOAD_REQUEST_TYPE, + "className": "KeysUploadRequest", + "body": json!({ + "device_keys": req.device_keys, + "one_time_keys": req.one_time_keys, + "fallback_keys": req.fallback_keys, + }).to_string(), + }), + _ => { + return Err( + "bootstrapCrossSigning: upload_keys_req was not a /keys/upload request" + .to_owned(), + ) + } + }, + None => Value::Null, + }; + + let signing_keys = &requests.upload_signing_keys_req; + let mut signing_keys_body = Map::new(); + for (field, key) in [ + ("master_key", &signing_keys.master_key), + ("self_signing_key", &signing_keys.self_signing_key), + ("user_signing_key", &signing_keys.user_signing_key), + ] { + if let Some(key) = key { + signing_keys_body.insert(field.to_owned(), json!(key)); + } + } + + Ok(json!({ + "uploadKeysRequest": upload_keys_request, + // No `id`: that is how js-sdk knows to skip `markRequestAsSent`. + "uploadSigningKeysRequest": { + "className": "UploadSigningKeysRequest", + "id": Value::Null, + "body": Value::Object(signing_keys_body).to_string(), + }, + "uploadSignaturesRequest": { + "type": SIGNATURE_UPLOAD_REQUEST_TYPE, + "className": "SignatureUploadRequest", + "body": json!(requests.upload_signatures_req.signed_keys).to_string(), + }, + })) +} + +/// wasm's `self_signing_key` is snake_case while its siblings are camelCase. +async fn export_keys(machine: &OlmMachine) -> Result { + let export = machine + .export_cross_signing_keys() + .await + .map_err(|e| format!("exportCrossSigningKeys failed: {e}"))?; + + Ok(match export { + Some(export) => serde_json::to_value(CrossSigningKeyExportSnapshot { + master_key: export.master_key.clone(), + self_signing_key: export.self_signing_key.clone(), + user_signing_key: export.user_signing_key.clone(), + }) + .map_err(|e| format!("exportCrossSigningKeys: serialising the export failed: {e}"))?, + None => Value::Null, + }) +} + +async fn import_keys(machine: &OlmMachine, args: &Value) -> Result { + let export = CrossSigningKeyExport { + master_key: opt_str_arg(args, "master_key"), + self_signing_key: opt_str_arg(args, "self_signing_key"), + user_signing_key: opt_str_arg(args, "user_signing_key"), + }; + + let status = machine + .import_cross_signing_keys(export) + .await + .map_err(|e| format!("importCrossSigningKeys failed: {e}"))?; + + Ok(json!({ + "hasMaster": status.has_master, + "hasSelfSigning": status.has_self_signing, + "hasUserSigning": status.has_user_signing, + })) +} + +async fn export_secrets_bundle(machine: &OlmMachine) -> Result { + let bundle = machine + .store() + .export_secrets_bundle() + .await + .map_err(|e| format!("exportSecretsBundle failed: {e}"))?; + + serde_json::to_value(&bundle) + .map_err(|e| format!("exportSecretsBundle: serializing the bundle failed: {e}")) +} + +async fn import_secrets_bundle(machine: &OlmMachine, args: &Value) -> Result { + let bundle = args + .get("bundle") + .ok_or_else(|| "importSecretsBundle: missing argument `bundle`".to_owned())?; + let bundle: SecretsBundle = serde_json::from_value(bundle.clone()) + .map_err(|e| format!("importSecretsBundle: bad bundle json: {e}"))?; + + machine + .store() + .import_secrets_bundle(&bundle) + .await + .map_err(|e| format!("importSecretsBundle failed: {e}"))?; + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use super::CrossSigningKeyExportSnapshot; + + #[test] + fn absent_keys_are_omitted_so_null_is_never_stored_in_secret_storage() { + let value = serde_json::to_value(CrossSigningKeyExportSnapshot { + master_key: Some("master".to_owned()), + self_signing_key: None, + user_signing_key: None, + }) + .unwrap(); + + let object = value.as_object().unwrap(); + assert_eq!(object.get("masterKey").unwrap(), "master"); + assert!(!object.contains_key("self_signing_key"), "{value}"); + assert!(!object.contains_key("userSigningKey"), "{value}"); + } +} + +async fn push_secret(machine: &OlmMachine, args: &Value) -> Result { + let name = opt_str_arg(args, "secretName") + .ok_or_else(|| "pushSecretToVerifiedDevices: missing `secretName`".to_owned())?; + + let failures = machine + .push_secret_to_verified_devices(name.as_str().into()) + .await + .map_err(|e| format!("pushSecretToVerifiedDevices failed: {e}"))?; + + Ok(Value::Array( + failures + .keys() + .map(|device| Value::String(device.to_string())) + .collect(), + )) +} diff --git a/src-tauri/src/matrix_crypto/devices.rs b/src-tauri/src/matrix_crypto/devices.rs new file mode 100644 index 000000000..edfd3f741 --- /dev/null +++ b/src-tauri/src/matrix_crypto/devices.rs @@ -0,0 +1,329 @@ +//! Devices, user identities and key queries for the `OlmMachine` IPC proxy. + +use std::collections::BTreeMap; +use std::time::Duration; + +use matrix_sdk::ruma::api::client::keys::upload_signatures::v3::Request as SignatureUploadRequest; +use matrix_sdk::ruma::{OwnedDeviceId, UserId}; +use matrix_sdk_crypto::types::Signatures; +use matrix_sdk_crypto::{ + CollectStrategy, Device, LocalTrust, OlmMachine, UserDevices, UserIdentity, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::args::{str_arg, user_id}; +use super::wasm_enums::{encryption_algorithm, request_type}; + +fn device_id(args: &Value, method: &str) -> Result { + Ok(str_arg(args, method, "deviceId")?.into()) +} + +fn timeout(args: &Value) -> Option { + args.get("timeoutSecs") + .and_then(Value::as_f64) + .map(Duration::from_secs_f64) +} + +fn signatures_json(signatures: &Signatures, method: &str) -> Result { + let json = serde_json::to_string(signatures) + .map_err(|e| format!("{method}: serializing signatures failed: {e}"))?; + Ok(json!({ "className": "Signatures", "json": json })) +} + +fn key_json(key: &T, method: &str) -> Result { + serde_json::to_string(key) + .map(Value::String) + .map_err(|e| format!("{method}: serializing cross-signing key failed: {e}")) +} + +/// No `id`: that is how js-sdk knows to skip `markRequestAsSent`. +fn signature_upload_json(request: &SignatureUploadRequest) -> Value { + json!({ + "id": Value::Null, + "type": request_type::SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "body": json!(request.signed_keys).to_string(), + }) +} + +fn device_json(device: &Device, method: &str) -> Result { + let keys: BTreeMap = device + .keys() + .iter() + .map(|(key_id, key)| (key_id.to_string(), key.to_base64())) + .collect(); + + Ok(json!({ + "className": "Device", + "userId": device.user_id(), + "deviceId": device.device_id(), + "displayName": device.display_name(), + "algorithms": device + .algorithms() + .iter() + .map(encryption_algorithm) + .collect::>(), + "keys": keys, + "signatures": signatures_json(device.signatures(), method)?, + "curve25519Key": device.curve25519_key().map(|key| key.to_base64()), + "ed25519Key": device.ed25519_key().map(|key| key.to_base64()), + "localTrustState": device.local_trust_state() as u8, + "isVerified": device.is_verified(), + "isCrossSigningTrusted": device.is_cross_signing_trusted(), + "isCrossSignedByOwner": device.is_cross_signed_by_owner(), + "isLocallyTrusted": device.is_locally_trusted(), + "isBlacklisted": device.is_blacklisted(), + "isDeleted": device.is_deleted(), + "isDehydrated": device.is_dehydrated(), + "firstTimeSeen": device.first_time_seen_ts().0, + })) +} + +fn user_devices_json(devices: &UserDevices, user: &UserId, method: &str) -> Result { + Ok(json!({ + "className": "UserDevices", + "userId": user, + "devices": devices + .devices() + .map(|device| device_json(&device, method)) + .collect::, String>>()?, + "keys": devices.keys().map(|id| id.to_string()).collect::>(), + "isAnyVerified": devices.is_any_verified(), + })) +} + +/// `className` is required: js-sdk tells own from other identities with `instanceof`. +fn identity_json(identity: &UserIdentity, method: &str) -> Result { + match identity { + UserIdentity::Own(own) => Ok(json!({ + "className": "OwnUserIdentity", + "userId": own.user_id(), + "isVerified": own.is_verified(), + "wasPreviouslyVerified": own.was_previously_verified(), + "hasVerificationViolation": own.has_verification_violation(), + "masterKey": key_json(own.master_key(), method)?, + "selfSigningKey": key_json(own.self_signing_key(), method)?, + "userSigningKey": key_json(own.user_signing_key(), method)?, + })), + UserIdentity::Other(other) => Ok(json!({ + "className": "OtherUserIdentity", + "userId": other.user_id(), + "isVerified": other.is_verified(), + "wasPreviouslyVerified": other.was_previously_verified(), + "hasVerificationViolation": other.has_verification_violation(), + "identityNeedsUserApproval": other.identity_needs_user_approval(), + "masterKey": key_json(other.master_key(), method)?, + "selfSigningKey": key_json(other.self_signing_key(), method)?, + })), + } +} + +async fn device_for( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result, String> { + let user = user_id(args, method, "userId")?; + let device = device_id(args, method)?; + machine + .get_device(&user, &device, timeout(args)) + .await + .map_err(|e| format!("{method} failed: {e}")) +} + +async fn identity_for( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result, String> { + let user = user_id(args, method, "userId")?; + machine + .get_identity(&user, timeout(args)) + .await + .map_err(|e| format!("{method} failed: {e}")) +} + +async fn get_device(machine: &OlmMachine, args: &Value, method: &str) -> Result { + match device_for(machine, args, method).await? { + Some(device) => device_json(&device, method), + None => Ok(Value::Null), + } +} + +async fn get_user_devices( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let user = user_id(args, method, "userId")?; + let devices = machine + .get_user_devices(&user, timeout(args)) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + user_devices_json(&devices, &user, method) +} + +async fn get_identity(machine: &OlmMachine, args: &Value, method: &str) -> Result { + match identity_for(machine, args, method).await? { + Some(identity) => identity_json(&identity, method), + None => Ok(Value::Null), + } +} + +fn query_keys_for_users(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let users = args + .get("users") + .and_then(Value::as_array) + .ok_or_else(|| format!("{method}: missing array argument `users`"))? + .iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect::>(); + + let (id, request) = machine.query_keys_for_users(users.iter().map(AsRef::as_ref)); + Ok(json!({ + "id": id.to_string(), + "type": request_type::KEYS_QUERY, + "className": "KeysQueryRequest", + "body": super::requests::keys_query_body( + &request.timeout, + &json!(request.device_keys), + ), + })) +} + +async fn verify_device(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let Some(device) = device_for(machine, args, method).await? else { + return Ok(Value::Null); + }; + let request = device + .verify() + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(signature_upload_json(&request)) +} + +async fn set_local_trust( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let trust = args + .get("trustState") + .and_then(Value::as_i64) + .ok_or_else(|| format!("{method}: missing numeric argument `trustState`"))?; + let Some(device) = device_for(machine, args, method).await? else { + return Ok(Value::Null); + }; + device + .set_local_trust(LocalTrust::from(trust)) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Null) +} + +async fn encrypt_to_device_event( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let event_type = str_arg(args, method, "eventType")?; + let content = args + .get("content") + .ok_or_else(|| format!("{method}: missing argument `content`"))?; + let Some(device) = device_for(machine, args, method).await? else { + return Err(format!("{method}: unknown device")); + }; + + let encrypted = device + .encrypt_event_raw(&event_type, content, CollectStrategy::AllDevices) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::String(encrypted.json().get().to_owned())) +} + +async fn verify_identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let Some(identity) = identity_for(machine, args, method).await? else { + return Ok(Value::Null); + }; + let request = match identity { + UserIdentity::Own(own) => own.verify().await, + UserIdentity::Other(other) => other.verify().await, + } + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(signature_upload_json(&request)) +} + +async fn pin_identity(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let Some(identity) = identity_for(machine, args, method).await? else { + return Err(format!("{method}: unknown user identity")); + }; + identity + .pin() + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Null) +} + +async fn withdraw_identity_verification( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let Some(identity) = identity_for(machine, args, method).await? else { + return Err(format!("{method}: unknown user identity")); + }; + identity + .withdraw_verification() + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Null) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getDevice" => get_device(machine, args, method).await, + "getUserDevices" => get_user_devices(machine, args, method).await, + "getIdentity" => get_identity(machine, args, method).await, + "queryKeysForUsers" => query_keys_for_users(machine, args, method), + "trackedUsers" => machine + .tracked_users() + .await + .map(|users| { + Value::Array( + users + .into_iter() + .map(|user| Value::String(user.to_string())) + .collect(), + ) + }) + .map_err(|e| format!("trackedUsers failed: {e}")), + "sign" => match str_arg(args, method, "message") { + Ok(message) => match machine.sign(&message).await { + Ok(signatures) => signatures_json(&signatures, method), + Err(e) => Err(format!("sign failed: {e}")), + }, + Err(e) => Err(e), + }, + + "device.verify" => verify_device(machine, args, method).await, + "device.setLocalTrust" => set_local_trust(machine, args, method).await, + "device.encryptToDeviceEvent" => encrypt_to_device_event(machine, args, method).await, + "userIdentity.verify" => verify_identity(machine, args, method).await, + "userIdentity.pin" => pin_identity(machine, args, method).await, + "userIdentity.withdrawVerification" => { + withdraw_identity_verification(machine, args, method).await + } + + _ => return None, + }) +} diff --git a/src-tauri/src/matrix_crypto/dispatch.rs b/src-tauri/src/matrix_crypto/dispatch.rs new file mode 100644 index 000000000..0d6ba3984 --- /dev/null +++ b/src-tauri/src/matrix_crypto/dispatch.rs @@ -0,0 +1,438 @@ +//! Generic `OlmMachine` dispatch for the IPC proxy: one Tauri command carries all +//! ~57 wasm `OlmMachine` members as a method name plus a JSON argument object. +//! Argument and return shapes mirror the wasm bindings, not the Rust API. + +use std::collections::BTreeMap; + +use matrix_sdk::deserialized_responses::{ + AlgorithmInfo, ProcessedToDeviceEvent, VerificationState, +}; +use matrix_sdk::ruma::api::client::sync::sync_events::DeviceLists; +use matrix_sdk::ruma::events::secret::request::SecretName; +use matrix_sdk::ruma::events::{AnyMessageLikeEventContent, AnySyncMessageLikeEvent}; +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::{DeviceKeyAlgorithm, OneTimeKeyAlgorithm, UInt, UserId}; +use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; +use matrix_sdk_crypto::types::events::ToDeviceEvents; +use matrix_sdk_crypto::{EncryptionSyncChanges, OlmMachine}; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::args::{caller_decryption_settings, decryption_settings, room_id, str_arg}; +use super::requests::{mark_request_sent, outgoing_requests}; +use super::wasm_enums::processed_to_device_event_type; + +#[derive(Serialize)] +#[serde(tag = "className")] +enum ProcessedToDeviceEventSnapshot<'a> { + DecryptedToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + #[serde(rename = "encryptionInfo")] + encryption_info: ToDeviceEncryptionInfoSnapshot, + }, + UTDToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + }, + PlainTextToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + }, + InvalidToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + }, +} + +#[derive(Serialize)] +#[serde(tag = "className")] +enum ToDeviceEncryptionInfoSnapshot { + ToDeviceEncryptionInfo { + sender: String, + #[serde(rename = "senderDevice")] + sender_device: Option, + #[serde(rename = "senderCurve25519Key")] + sender_curve25519_key: String, + #[serde(rename = "isSenderVerified")] + is_sender_verified: bool, + }, +} + +fn processed_to_device_event_json( + event: &ProcessedToDeviceEvent, + verification_request: Option, +) -> Result { + let raw_event = event.as_raw().json().get(); + + let snapshot = match event { + ProcessedToDeviceEvent::Decrypted { + encryption_info, .. + } => { + let sender_curve25519_key = match &encryption_info.algorithm_info { + AlgorithmInfo::OlmV1Curve25519AesSha2 { + curve25519_public_key_base64, + } => curve25519_public_key_base64.as_str(), + _ => { + return Err( + "receiveSyncChanges: decrypted to-device event did not use Olm v1" + .to_owned(), + ) + } + }; + + ProcessedToDeviceEventSnapshot::DecryptedToDeviceEvent { + event_type: processed_to_device_event_type::DECRYPTED, + raw_event, + encryption_info: ToDeviceEncryptionInfoSnapshot::ToDeviceEncryptionInfo { + sender: encryption_info.sender.to_string(), + sender_device: encryption_info + .sender_device + .as_ref() + .map(ToString::to_string), + sender_curve25519_key: sender_curve25519_key.to_owned(), + is_sender_verified: matches!( + encryption_info.verification_state, + VerificationState::Verified + ), + }, + } + } + ProcessedToDeviceEvent::UnableToDecrypt { .. } => { + ProcessedToDeviceEventSnapshot::UTDToDeviceEvent { + event_type: processed_to_device_event_type::UNABLE_TO_DECRYPT, + raw_event, + } + } + ProcessedToDeviceEvent::PlainText(_) => { + ProcessedToDeviceEventSnapshot::PlainTextToDeviceEvent { + event_type: processed_to_device_event_type::PLAIN_TEXT, + raw_event, + } + } + ProcessedToDeviceEvent::Invalid(_) => { + ProcessedToDeviceEventSnapshot::InvalidToDeviceEvent { + event_type: processed_to_device_event_type::INVALID, + raw_event, + } + } + }; + + let mut value = serde_json::to_value(snapshot) + .map_err(|e| format!("receiveSyncChanges: failed to serialize processed event: {e}"))?; + if let Some(request) = verification_request { + value["verificationRequest"] = request; + } + Ok(value) +} + +fn verification_request_snapshot( + machine: &OlmMachine, + event: &ProcessedToDeviceEvent, +) -> Option { + let ToDeviceEvents::KeyVerificationRequest(event) = + event.as_raw().deserialize_as::().ok()? + else { + return None; + }; + machine + .get_verification_request(&event.sender, &event.content.transaction_id) + .map(|request| super::verification::request_state(&request)) +} + +pub async fn invoke(machine: &OlmMachine, method: &str, args: Value) -> Result { + match method { + "identityKeys" => { + let keys = machine.identity_keys(); + Ok(json!({ + "ed25519": keys.ed25519.to_base64(), + "curve25519": keys.curve25519.to_base64(), + })) + } + "deviceCreationTimeMs" => Ok(json!(machine.device_creation_time().get())), + + "receiveSyncChanges" => { + let to_device_events = args + .get("toDeviceEvents") + .and_then(Value::as_str) + .map(|raw| { + serde_json::value::RawValue::from_string(raw.to_owned()) + .map(Raw::from_json) + .map_err(|e| format!("receiveSyncChanges: bad toDeviceEvents json: {e}")) + }) + .transpose()? + .map(|raw: Raw| { + serde_json::from_str::>>(raw.json().get()).map_err(|e| { + format!("receiveSyncChanges: toDeviceEvents not an array: {e}") + }) + }) + .transpose()? + .unwrap_or_default(); + + let mut device_lists = DeviceLists::new(); + device_lists.changed = args + .get("changedDevices") + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect() + }) + .unwrap_or_default(); + device_lists.left = args + .get("leftDevices") + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect() + }) + .unwrap_or_default(); + + let key_counts: BTreeMap = args + .get("oneTimeKeysCounts") + .and_then(Value::as_object) + .map(|counts| { + counts + .iter() + .filter_map(|(alg, count)| { + Some(( + OneTimeKeyAlgorithm::from(alg.as_str()), + UInt::try_from(count.as_u64()?).ok()?, + )) + }) + .collect() + }) + .unwrap_or_default(); + + let fallback_keys: Vec = args + .get("unusedFallbackKeys") + .and_then(Value::as_array) + .map(|keys| { + keys.iter() + .filter_map(Value::as_str) + .map(OneTimeKeyAlgorithm::from) + .collect() + }) + .unwrap_or_default(); + + let (processed, _room_keys) = machine + .receive_sync_changes( + EncryptionSyncChanges { + to_device_events, + changed_devices: &device_lists, + one_time_keys_counts: &key_counts, + unused_fallback_keys: Some(&fallback_keys), + next_batch_token: args + .get("nextBatchToken") + .and_then(Value::as_str) + .map(str::to_owned), + }, + &decryption_settings(), + ) + .await + .map_err(|e| format!("receiveSyncChanges failed: {e}"))?; + + processed + .iter() + .map(|event| { + processed_to_device_event_json( + event, + verification_request_snapshot(machine, event), + ) + }) + .collect::, _>>() + .map(Value::Array) + } + + "outgoingRequests" => outgoing_requests(machine).await, + "markRequestAsSent" => mark_request_sent(machine, &args).await, + + "receiveVerificationEvent" => { + let room = room_id(&args, method, "roomId")?; + let event_json = str_arg(&args, method, "event")?; + let event: AnySyncMessageLikeEvent = serde_json::from_str(&event_json) + .map_err(|e| format!("receiveVerificationEvent: bad event json: {e}"))?; + + machine + .receive_verification_event(&event.into_full_event(room)) + .await + .map_err(|e| format!("receiveVerificationEvent failed: {e}"))?; + Ok(Value::Null) + } + + "decryptRoomEvent" => { + let room = room_id(&args, method, "roomId")?; + let event_json = str_arg(&args, method, "event")?; + let event: Raw = serde_json::from_str(&event_json) + .map_err(|e| format!("decryptRoomEvent: bad event json: {e}"))?; + + let decrypted = machine + .decrypt_room_event(&event, &room, &caller_decryption_settings(&args)) + .await + .map_err(|e| format!("decryptRoomEvent failed: {e:?}"))?; + + let info = decrypted.encryption_info; + let (sender_curve25519_key, claimed_ed25519_key) = match &info.algorithm_info { + AlgorithmInfo::MegolmV1AesSha2 { + curve25519_key, + sender_claimed_keys, + .. + } => ( + Some(curve25519_key.clone()), + sender_claimed_keys + .get(&DeviceKeyAlgorithm::Ed25519) + .cloned(), + ), + _ => (None, None), + }; + + // Names follow wasm's `DecryptedRoomEvent`; `event` is a JSON string, not an object. + Ok(json!({ + "className": "DecryptedRoomEvent", + "event": decrypted.event.json().get(), + "sender": info.sender.to_string(), + "senderDevice": info.sender_device.as_ref().map(ToString::to_string), + "senderCurve25519Key": sender_curve25519_key, + "senderClaimedEd25519Key": claimed_ed25519_key, + "forwarder": Value::Null, + "forwarderDevice": Value::Null, + "forwardingCurve25519KeyChain": Vec::::new(), + })) + } + "encryptRoomEvent" => { + let room = room_id(&args, method, "roomId")?; + let event_type = str_arg(&args, method, "eventType")?; + let content_json = str_arg(&args, method, "content")?; + let content_box = serde_json::value::RawValue::from_string(content_json) + .map_err(|e| format!("encryptRoomEvent: bad content json: {e}"))?; + let content: Raw = Raw::from_json(content_box); + + let encrypted = machine + .encrypt_room_event_raw(&room, &event_type, &content) + .await + .map_err(|e| format!("encryptRoomEvent failed: {e:?}"))?; + let encrypted_json = encrypted.content.json().get(); + serde_json::from_str::(encrypted_json) + .map_err(|e| format!("encryptRoomEvent: bad encrypted content json: {e}"))?; + + // Match wasm's JSON-string return type. + Ok(Value::String(encrypted_json.to_owned())) + } + + "getSecretsFromInbox" => { + let name: SecretName = str_arg(&args, method, "secretName")?.as_str().into(); + let secrets = machine + .store() + .get_secrets_from_inbox(&name) + .await + .map_err(|e| format!("getSecretsFromInbox failed: {e}"))?; + Ok(Value::Array( + secrets + .into_iter() + .map(|secret| Value::String(secret.as_str().to_owned())) + .collect(), + )) + } + "deleteSecretsFromInbox" => { + let name: SecretName = str_arg(&args, method, "secretName")?.as_str().into(); + machine + .store() + .delete_secrets_from_inbox(&name) + .await + .map_err(|e| format!("deleteSecretsFromInbox failed: {e}"))?; + Ok(Value::Null) + } + + "crossSigningStatus" => { + let status = machine.cross_signing_status().await; + Ok(json!({ + "hasMaster": status.has_master, + "hasSelfSigning": status.has_self_signing, + "hasUserSigning": status.has_user_signing, + })) + } + + "updateTrackedUsers" => { + let users = args + .get("users") + .and_then(Value::as_array) + .ok_or_else(|| format!("{method}: missing array argument `users`"))? + .iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect::>(); + machine + .update_tracked_users(users.iter().map(AsRef::as_ref)) + .await + .map_err(|e| format!("updateTrackedUsers failed: {e}"))?; + Ok(Value::Null) + } + "markAllTrackedUsersAsDirty" => { + machine + .mark_all_tracked_users_as_dirty() + .await + .map_err(|e| format!("markAllTrackedUsersAsDirty failed: {e}"))?; + Ok(Value::Null) + } + + other => { + if let Some(result) = super::devices::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::cross_signing::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::backup::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::rooms::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::verification::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::bundles::invoke(machine, other, &args).await { + return result; + } + Err(format!( + "OlmMachine method not implemented by the Rust engine: {other}" + )) + } + } +} + +#[cfg(test)] +mod tests { + use matrix_sdk::ruma::events::AnyToDeviceEvent; + + use super::*; + + #[test] + fn plaintext_to_device_event_uses_the_wasm_wrapper_shape() { + let raw_json = r#"{"type":"m.test","sender":"@alice:example.org","content":{}}"#; + let raw: Raw = serde_json::from_str(raw_json).unwrap(); + + let value = + processed_to_device_event_json(&ProcessedToDeviceEvent::PlainText(raw), None).unwrap(); + + assert_eq!( + value, + json!({ + "className": "PlainTextToDeviceEvent", + "type": processed_to_device_event_type::PLAIN_TEXT, + "rawEvent": raw_json, + }) + ); + } +} diff --git a/src-tauri/src/matrix_crypto/events.rs b/src-tauri/src/matrix_crypto/events.rs new file mode 100644 index 000000000..664b738a8 --- /dev/null +++ b/src-tauri/src/matrix_crypto/events.rs @@ -0,0 +1,114 @@ +//! Forwards the engine's change streams to the webview as Tauri events. + +use futures_util::StreamExt as _; +use matrix_sdk_crypto::store::types::{RoomKeyInfo, RoomKeyWithheldInfo}; +use matrix_sdk_crypto::OlmMachine; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter as _}; +use tokio::task::JoinHandle; + +use super::wasm_enums::encryption_algorithm; + +pub const ROOM_KEYS_RECEIVED: &str = "matrix-crypto://room-keys-received"; +pub const ROOM_KEYS_WITHHELD: &str = "matrix-crypto://room-keys-withheld"; +pub const IDENTITIES_UPDATED: &str = "matrix-crypto://identities-updated"; +pub const SECRET_RECEIVED: &str = "matrix-crypto://secret-received"; + +pub(super) fn room_key_json(info: &RoomKeyInfo) -> Value { + json!({ + "algorithm": encryption_algorithm(&info.algorithm), + "roomId": info.room_id.to_string(), + "senderKey": info.sender_key.to_base64(), + "sessionId": info.session_id, + }) +} + +fn withheld_json(info: &RoomKeyWithheldInfo) -> Value { + json!({ + "roomId": info.room_id.to_string(), + "sessionId": info.session_id, + }) +} + +/// One task per stream. The returned handles must be aborted when the engine is +/// closed: the streams outlive the machine, so they would otherwise keep the +/// listeners alive for an account that is no longer open. +pub fn spawn(app: &AppHandle, machine: &OlmMachine, account: String) -> Vec> { + let store = machine.store(); + + let mut room_keys = store.room_keys_received_stream(); + let mut withheld = store.room_keys_withheld_received_stream(); + let mut identities = store.identities_stream_raw(); + let mut secrets = store.secrets_stream(); + + let emit = |app: AppHandle, event: &'static str, account: String, payload: Value| { + // A failed emit means the webview is gone; the abort on close is what + // stops these tasks, so there is nothing to recover here. + let _ = app.emit(event, json!({ "account": account, "payload": payload })); + }; + + vec![ + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some(update) = room_keys.next().await { + // Lagging drops updates rather than ending the stream; js-sdk + // recovers on the next key or a retry, so keep listening. + if let Ok(keys) = update { + let payload = Value::Array(keys.iter().map(room_key_json).collect()); + emit(app.clone(), ROOM_KEYS_RECEIVED, account.clone(), payload); + } + } + } + }), + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some(sessions) = withheld.next().await { + let payload = Value::Array(sessions.iter().map(withheld_json).collect()); + emit(app.clone(), ROOM_KEYS_WITHHELD, account.clone(), payload); + } + } + }), + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some((identity_changes, device_changes)) = identities.next().await { + let identity_users: Vec = identity_changes + .new + .iter() + .chain(identity_changes.changed.iter()) + .map(|identity| identity.user_id().to_string()) + .collect(); + let device_users: Vec = device_changes + .new + .iter() + .chain(device_changes.changed.iter()) + .map(|device| device.user_id().to_string()) + .collect(); + emit( + app.clone(), + IDENTITIES_UPDATED, + account.clone(), + json!({ "identities": identity_users, "devices": device_users }), + ); + } + } + }), + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some(secret) = secrets.next().await { + // Only the name travels: js-sdk re-reads the value from the + // inbox, and the value must not sit in an event payload. + emit( + app.clone(), + SECRET_RECEIVED, + account.clone(), + json!({ "name": secret.secret_name.to_string() }), + ); + } + } + }), + ] +} diff --git a/src-tauri/src/matrix_crypto/mod.rs b/src-tauri/src/matrix_crypto/mod.rs new file mode 100644 index 000000000..7b8c3ef6d --- /dev/null +++ b/src-tauri/src/matrix_crypto/mod.rs @@ -0,0 +1,364 @@ +//! Rust crypto engine: `OlmMachine`s on passphrase-protected sqlite stores. + +pub mod args; +pub mod backup; +pub mod bundles; +pub mod cross_signing; +pub mod devices; +pub mod dispatch; +pub mod events; +pub mod push; +pub mod requests; +pub mod rooms; +pub mod verification; +pub mod wasm_enums; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::sync::OnceLock; + +use matrix_sdk_crypto::OlmMachine; +use matrix_sdk_sqlite::SqliteCryptoStore; +use serde::Serialize; +use tauri::Manager as _; + +pub fn account_key(user_id: &str, device_id: &str) -> String { + format!("{user_id}|{device_id}") +} + +static ENGINES: OnceLock = OnceLock::new(); + +/// Process-global registry. Not Tauri-managed state: a push arriving while the app is +/// cold has to reach the same machines without an `AppHandle`. +pub fn engines() -> &'static CryptoEngineState { + ENGINES.get_or_init(CryptoEngineState::default) +} + +/// Owned, open OlmMachines keyed by [`account_key`]. +#[derive(Default)] +pub struct CryptoEngineState { + machines: StdMutex>>, + /// Stream-forwarding tasks per account; the streams outlive the machine, so + /// closing an account has to abort them explicitly. + listeners: StdMutex>>>, +} + +impl CryptoEngineState { + pub fn machine(&self, user_id: &str, device_id: &str) -> Result, String> { + self.machines + .lock() + .map_err(|e| e.to_string())? + .get(&account_key(user_id, device_id)) + .cloned() + .ok_or_else(|| format!("no open crypto engine for {user_id}|{device_id}")) + } + + pub fn close_account(&self, account: &str) -> Result { + if let Some(listeners) = self + .listeners + .lock() + .map_err(|e| e.to_string())? + .remove(account) + { + for listener in listeners { + listener.abort(); + } + } + + Ok(self + .machines + .lock() + .map_err(|e| e.to_string())? + .remove(account) + .is_some()) + } +} + +#[derive(Debug, Serialize)] +pub struct EngineInfo { + pub user_id: String, + pub device_id: String, + pub ed25519_key: String, + pub curve25519_key: String, + pub store_path: String, +} + +/// App Group that the iOS notification service extension shares with the app. An +/// extension is a separate process and can only reach the store through this container. +#[cfg(target_os = "ios")] +pub const APP_GROUP: &str = "group.moe.sable.client"; + +/// `None` until the App Group entitlement is present in the generated Xcode project, +/// in which case the caller falls back to the app-local directory. +#[cfg(target_os = "ios")] +fn app_group_dir() -> Option { + use objc2_foundation::{NSFileManager, NSString}; + + let identifier = NSString::from_str(APP_GROUP); + unsafe { + let manager = NSFileManager::defaultManager(); + let url = manager.containerURLForSecurityApplicationGroupIdentifier(&identifier)?; + url.path().map(|path| PathBuf::from(path.to_string())) + } +} + +#[cfg(not(target_os = "ios"))] +fn app_group_dir() -> Option { + None +} + +/// Per-account store directory, appended to whichever base directory the platform +/// exposes to background code. +pub fn store_subpath(user_id: &str, device_id: &str) -> PathBuf { + // `/` and `:` in a user id are not path-safe. + let account = account_key(user_id, device_id).replace(['/', ':'], "_"); + PathBuf::from("matrix-crypto").join(account) +} + +/// 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. +fn store_dir(app: &tauri::AppHandle, user_id: &str, device_id: &str) -> Result { + let base = match app_group_dir() { + Some(shared) => shared, + None => app + .path() + .app_local_data_dir() + .map_err(|e| format!("resolving app data dir failed: {e}"))?, + }; + Ok(base.join(store_subpath(user_id, device_id))) +} + +/// Opens a store and registers its machine, replacing any machine already open for the +/// account. Tauri-free so a cold push can open the same store without an `AppHandle`. +pub async fn open_machine( + dir: &Path, + passphrase: Option<&str>, + user_id: &str, + device_id: &str, +) -> Result<(Arc, EngineInfo), String> { + let user: &matrix_sdk::ruma::UserId = user_id + .try_into() + .map_err(|e| format!("bad user id: {e}"))?; + let device: &matrix_sdk::ruma::DeviceId = device_id.into(); + + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| e.to_string())?; + let db_path = dir.join("matrix-sdk-crypto.sqlite3"); + + let account = account_key(user_id, device_id); + engines().close_account(&account)?; + + let store = SqliteCryptoStore::open(&db_path, passphrase) + .await + .map_err(|e| format!("opening crypto store failed: {e}"))?; + let machine = Arc::new( + OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .map_err(|e| format!("creating OlmMachine failed: {e}"))?, + ); + let keys = machine.identity_keys(); + + engines() + .machines + .lock() + .map_err(|e| e.to_string())? + .insert(account, Arc::clone(&machine)); + + let info = EngineInfo { + user_id: user_id.to_owned(), + device_id: device_id.to_owned(), + ed25519_key: keys.ed25519.to_base64(), + curve25519_key: keys.curve25519.to_base64(), + store_path: db_path.display().to_string(), + }; + Ok((machine, info)) +} + +#[tauri::command] +pub async fn engine_open( + app: tauri::AppHandle, + dir: Option, + passphrase: Option, + user_id: String, + device_id: String, +) -> Result { + let dir = match dir { + Some(dir) => PathBuf::from(dir), + None => store_dir(&app, &user_id, &device_id)?, + }; + + let (machine, info) = open_machine(&dir, passphrase.as_deref(), &user_id, &device_id).await?; + + let account = account_key(&user_id, &device_id); + let listeners = events::spawn(&app, &machine, account.clone()); + engines() + .listeners + .lock() + .map_err(|e| e.to_string())? + .insert(account, listeners); + + Ok(info) +} + +#[tauri::command] +pub async fn engine_close(user_id: String, device_id: String) -> Result { + engines().close_account(&account_key(&user_id, &device_id)) +} + +#[tauri::command] +pub async fn engine_wipe( + app: tauri::AppHandle, + user_id: String, + device_id: String, +) -> Result<(), String> { + let account = account_key(&user_id, &device_id); + let _ = engines().close_account(&account)?; + + let dir = store_dir(&app, &user_id, &device_id)?; + if dir.exists() { + tokio::fs::remove_dir_all(&dir) + .await + .map_err(|e| format!("deleting crypto store failed: {e}"))?; + } + Ok(()) +} + +#[tauri::command] +pub async fn engine_invoke( + user_id: String, + device_id: String, + method: String, + args_json: String, +) -> Result { + let machine = engines().machine(&user_id, &device_id)?; + let args: serde_json::Value = serde_json::from_str(&args_json) + .map_err(|e| format!("engine_invoke({method}): bad args json: {e}"))?; + + let result = dispatch::invoke(&machine, &method, args).await?; + serde_json::to_string(&result) + .map_err(|e| format!("engine_invoke({method}): serialising result failed: {e}")) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use matrix_sdk::ruma::api::client::sync::sync_events::DeviceLists; + use matrix_sdk::ruma::serde::Raw; + use matrix_sdk::ruma::{OneTimeKeyAlgorithm, UInt}; + use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; + use matrix_sdk_crypto::types::requests::AnyOutgoingRequest; + use matrix_sdk_crypto::{DecryptionSettings, EncryptionSyncChanges, TrustRequirement}; + + use super::*; + + #[tokio::test] + async fn engine_plumbing() { + let dir = std::env::temp_dir().join(format!("sable-engine-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let user: &matrix_sdk::ruma::UserId = "@engine:example.org".try_into().unwrap(); + let device: &matrix_sdk::ruma::DeviceId = "ENGINEDEVICE".into(); + let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), Some("pw")) + .await + .unwrap(); + let machine = OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .unwrap(); + + let mut device_lists = DeviceLists::new(); + device_lists.changed.push(user.to_owned()); + let counts: BTreeMap = BTreeMap::new(); + let settings = DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + }; + machine + .receive_sync_changes( + EncryptionSyncChanges { + to_device_events: vec![], + changed_devices: &device_lists, + one_time_keys_counts: &counts, + unused_fallback_keys: None, + next_batch_token: None, + }, + &settings, + ) + .await + .unwrap(); + + let out = machine.outgoing_requests().await.unwrap(); + assert!( + out.iter() + .any(|r| matches!(r.request(), AnyOutgoingRequest::KeysUpload(_))), + "expected at least one keys-upload request" + ); + + let bogus = serde_json::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", + }, + }); + let raw: Raw = serde_json::from_value(bogus).unwrap(); + let room: &matrix_sdk::ruma::RoomId = "!room:example.org".try_into().unwrap(); + let result = machine.decrypt_room_event(&raw, room, &settings).await; + assert!(result.is_err(), "bogus megolm event must not decrypt"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn identity_reads_and_room_key_round_trip() { + let dir = std::env::temp_dir().join(format!("sable-engine-1c-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let user: &matrix_sdk::ruma::UserId = "@engine:example.org".try_into().unwrap(); + let device: &matrix_sdk::ruma::DeviceId = "ENGINEDEVICE".into(); + let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), Some("pw")) + .await + .unwrap(); + let machine = OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .unwrap(); + + let status = machine.cross_signing_status().await; + assert!(!status.has_master); + assert!(!status.has_self_signing); + assert!(!status.has_user_signing); + + let other: &matrix_sdk::ruma::UserId = "@other:example.org".try_into().unwrap(); + assert!(machine + .get_device(other, "NODEVICE".into(), None) + .await + .unwrap() + .is_none()); + assert!(machine.get_identity(other, None).await.unwrap().is_none()); + + let exported = machine.store().export_room_keys(|_| true).await.unwrap(); + assert_eq!(serde_json::to_string(&exported).unwrap(), "[]"); + + let result = machine + .store() + .import_exported_room_keys(exported, |_, _| {}) + .await + .unwrap(); + assert_eq!(result.imported_count, 0); + assert_eq!(result.total_count, 0); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/matrix_crypto/push.rs b/src-tauri/src/matrix_crypto/push.rs new file mode 100644 index 000000000..1171ab405 --- /dev/null +++ b/src-tauri/src/matrix_crypto/push.rs @@ -0,0 +1,176 @@ +//! 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 tokio::sync::Mutex as AsyncMutex; + +use super::args::decryption_settings; +use super::{account_key, engines, open_machine}; + +/// Serialises open-if-absent. Two pushes racing here would otherwise build two +/// `OlmMachine`s over one sqlite store, which wedges Olm sessions. +static OPEN_GUARD: AsyncMutex<()> = AsyncMutex::const_new(()); + +/// Returns the machine already registered for the account, opening one if the process +/// is cold. Never evicts a machine the webview is using. +pub async fn machine_for_push( + dir: &Path, + passphrase: Option<&str>, + user_id: &str, + device_id: &str, +) -> Result, String> { + if let Ok(machine) = engines().machine(user_id, device_id) { + return Ok(machine); + } + + let _guard = OPEN_GUARD.lock().await; + // Another push may have opened it while we waited for the guard. + if let Ok(machine) = engines().machine(user_id, device_id) { + return Ok(machine); + } + + let (machine, _) = open_machine(dir, passphrase, user_id, device_id).await?; + Ok(machine) +} + +/// 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, + pub clear_event: Value, +} + +/// 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, + }) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + path.iter() + .try_fold(value, |current, key| current.get(key))? + .as_str() + .map(str::to_owned) +} + +/// Closes the machine this process opened for a push, leaving a webview-owned machine +/// alone. Callers on a cold path should release the store once the notification is shown. +pub fn release_after_push(user_id: &str, device_id: &str, was_cold: bool) -> Result<(), String> { + if was_cold { + engines().close_account(&account_key(user_id, device_id))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use matrix_sdk_sqlite::SqliteCryptoStore; + use serde_json::json; + + 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 = machine_for_push(&dir, None, "@push:example.org", "PUSHDEVICE") + .await + .unwrap(); + + 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 = machine_for_push(&dir, None, "@cold:example.org", "COLDDEVICE") + .await + .unwrap(); + + assert_eq!(machine.user_id().as_str(), "@cold:example.org"); + + engines() + .close_account(&account_key("@cold:example.org", "COLDDEVICE")) + .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-tauri/src/matrix_crypto/requests.rs b/src-tauri/src/matrix_crypto/requests.rs new file mode 100644 index 000000000..d80b8a905 --- /dev/null +++ b/src-tauri/src/matrix_crypto/requests.rs @@ -0,0 +1,212 @@ +//! Outgoing-request pump, shaped for matrix-js-sdk's `OutgoingRequestProcessor`. + +use matrix_sdk::ruma::api::client::{ + backup::add_backup_keys::v3::Response as KeysBackupResponse, + keys::{ + claim_keys::v3::Response as KeysClaimResponse, get_keys::v3::Response as KeysQueryResponse, + upload_keys::v3::Response as KeysUploadResponse, + upload_signatures::v3::Response as SignatureUploadResponse, + }, + message::send_message_event::v3::Response as RoomMessageResponse, + to_device::send_event_to_device::v3::Response as ToDeviceResponse, +}; +use matrix_sdk::ruma::api::IncomingResponse as _; +use matrix_sdk::ruma::events::MessageLikeEventContent as _; +use matrix_sdk_crypto::types::requests::AnyOutgoingRequest; +use matrix_sdk_crypto::OlmMachine; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::wasm_enums::request_type; + +/// Declared with ruma's serde attributes rather than built by hand: `json!` would emit +/// `{"secs":10,"nanos":0}` for a `Duration` where the wire format is milliseconds. +#[derive(Serialize)] +pub(super) struct KeysClaimBody<'a> { + #[serde(with = "matrix_sdk::ruma::serde::duration::opt_ms")] + #[serde(skip_serializing_if = "Option::is_none")] + timeout: &'a Option, + one_time_keys: &'a Value, +} + +pub(super) fn keys_claim_body( + timeout: &Option, + one_time_keys: &Value, +) -> String { + serde_json::to_string(&KeysClaimBody { + timeout, + one_time_keys, + }) + .unwrap_or_default() +} + +pub(super) fn keys_query_body( + timeout: &Option, + device_keys: &Value, +) -> String { + serde_json::to_string(&KeysQueryBody { + timeout, + device_keys, + }) + .unwrap_or_default() +} + +/// `device_keys` is omitted when absent; an explicit `null` is not the same thing. +fn keys_upload_body(req: &matrix_sdk::ruma::api::client::keys::upload_keys::v3::Request) -> String { + let mut body = json!({ + "one_time_keys": req.one_time_keys, + "fallback_keys": req.fallback_keys, + }); + if let Some(device_keys) = &req.device_keys { + body["device_keys"] = json!(device_keys); + } + body.to_string() +} + +#[derive(Serialize)] +pub(super) struct KeysQueryBody<'a> { + #[serde(with = "matrix_sdk::ruma::serde::duration::opt_ms")] + #[serde(skip_serializing_if = "Option::is_none")] + timeout: &'a Option, + device_keys: &'a Value, +} + +pub async fn outgoing_requests(machine: &OlmMachine) -> Result { + let requests = machine + .outgoing_requests() + .await + .map_err(|e| format!("outgoingRequests failed: {e}"))?; + + Ok(Value::Array( + requests + .into_iter() + .map(|request| { + let id = request.request_id().to_string(); + // `className` is required: js-sdk dispatches on `instanceof`. + // `body` must be a JSON string; js-sdk forwards it verbatim. + let mut entry = match request.request() { + AnyOutgoingRequest::KeysUpload(req) => json!({ + "type": request_type::KEYS_UPLOAD, + "className": "KeysUploadRequest", + "body": keys_upload_body(req), + }), + AnyOutgoingRequest::KeysQuery(req) => json!({ + "type": request_type::KEYS_QUERY, + "className": "KeysQueryRequest", + "body": keys_query_body(&req.timeout, &json!(req.device_keys)), + }), + AnyOutgoingRequest::KeysClaim(req) => json!({ + "type": request_type::KEYS_CLAIM, + "className": "KeysClaimRequest", + "body": keys_claim_body(&req.timeout, &json!(req.one_time_keys)), + }), + AnyOutgoingRequest::ToDeviceRequest(req) => json!({ + "type": request_type::TO_DEVICE, + "className": "ToDeviceRequest", + "body": json!({ "messages": req.messages }).to_string(), + "event_type": req.event_type.to_string(), + "txn_id": req.txn_id.to_string(), + }), + AnyOutgoingRequest::SignatureUpload(req) => json!({ + "type": request_type::SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "body": json!(req.signed_keys).to_string(), + }), + AnyOutgoingRequest::RoomMessage(req) => json!({ + "type": request_type::ROOM_MESSAGE, + "className": "RoomMessageRequest", + "body": json!(req.content).to_string(), + "room_id": req.room_id.to_string(), + "txn_id": req.txn_id.to_string(), + "event_type": req.content.as_ref().event_type().to_string(), + }), + }; + entry["id"] = Value::String(id); + entry + }) + .collect(), + )) +} + +pub async fn mark_request_sent(machine: &OlmMachine, args: &Value) -> Result { + let request_id = args + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| "markRequestAsSent: missing `requestId`".to_owned())?; + let request_type = args + .get("requestType") + .and_then(Value::as_u64) + .ok_or_else(|| "markRequestAsSent: missing numeric `requestType`".to_owned())? + as u8; + let response_body = args + .get("response") + .and_then(Value::as_str) + .ok_or_else(|| "markRequestAsSent: missing `response`".to_owned())?; + + let id: matrix_sdk::ruma::OwnedTransactionId = request_id.into(); + + let http_response = || { + http::Response::builder() + .status(http::StatusCode::OK) + .body(response_body.as_bytes().to_vec()) + .map_err(|e| e.to_string()) + }; + + macro_rules! mark { + ($resp_ty:ty) => {{ + let parsed = <$resp_ty>::try_from_http_response(http_response()?).map_err(|e| { + format!("markRequestAsSent: parsing type {request_type} response failed: {e}") + })?; + machine + .mark_request_as_sent(&id, &parsed) + .await + .map_err(|e| format!("markRequestAsSent failed: {e}"))?; + }}; + } + + match request_type { + request_type::KEYS_UPLOAD => mark!(KeysUploadResponse), + request_type::KEYS_QUERY => mark!(KeysQueryResponse), + request_type::KEYS_CLAIM => mark!(KeysClaimResponse), + request_type::TO_DEVICE => mark!(ToDeviceResponse), + request_type::SIGNATURE_UPLOAD => mark!(SignatureUploadResponse), + request_type::ROOM_MESSAGE => mark!(RoomMessageResponse), + // Without this ack the backup machine never clears the batch and re-uploads forever. + request_type::KEYS_BACKUP => mark!(KeysBackupResponse), + other => return Err(format!("markRequestAsSent: unknown request type {other}")), + } + + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serde_json::json; + + use super::{KeysClaimBody, KeysQueryBody}; + + #[test] + fn claim_keys_timeout_is_millis_not_a_duration_struct() { + let timeout = Some(Duration::from_secs(10)); + let body = serde_json::to_value(KeysClaimBody { + timeout: &timeout, + one_time_keys: &json!({}), + }) + .unwrap(); + + assert_eq!(body["timeout"], json!(10_000)); + } + + #[test] + fn absent_timeout_is_omitted_rather_than_null() { + let body = serde_json::to_value(KeysQueryBody { + timeout: &None, + device_keys: &json!({}), + }) + .unwrap(); + + assert!(!body.as_object().unwrap().contains_key("timeout"), "{body}"); + } +} diff --git a/src-tauri/src/matrix_crypto/rooms.rs b/src-tauri/src/matrix_crypto/rooms.rs new file mode 100644 index 000000000..a35e93146 --- /dev/null +++ b/src-tauri/src/matrix_crypto/rooms.rs @@ -0,0 +1,420 @@ +//! Per-room encryption settings and megolm sessions for the `OlmMachine` IPC proxy. + +use std::time::Duration; + +use matrix_sdk::deserialized_responses::{ + AlgorithmInfo, ShieldState, ShieldStateCode, VerificationState, +}; +use matrix_sdk::ruma::events::room::history_visibility::HistoryVisibility; +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::{DeviceKeyAlgorithm, OwnedUserId, UserId}; +use matrix_sdk_crypto::olm::EncryptionSettings; +use matrix_sdk_crypto::store::types::RoomSettings; +use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; +use matrix_sdk_crypto::types::EventEncryptionAlgorithm; +use matrix_sdk_crypto::{CollectStrategy, OlmMachine}; +use serde_json::{json, Value}; + +use super::args::{room_id, str_arg}; +use super::wasm_enums::{encryption_algorithm as algorithm_to_wasm, request_type}; + +fn user_ids(args: &Value, method: &str, field: &str) -> Result, String> { + args.get(field) + .and_then(Value::as_array) + .ok_or_else(|| format!("{method}: missing array argument `{field}`"))? + .iter() + .map(|id| { + let id = id + .as_str() + .ok_or_else(|| format!("{method}: `{field}` must contain user id strings"))?; + UserId::parse(id).map_err(|e| format!("{method}: bad user id `{id}` in `{field}`: {e}")) + }) + .collect() +} + +fn as_u64(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_u64().or_else(|| { + let f = n.as_f64()?; + (f >= 0.0).then_some(f as u64) + }), + Value::String(s) => s.parse().ok(), + _ => None, + } +} + +fn algorithm(value: &Value, method: &str) -> Result { + match value { + Value::Number(_) => match as_u64(value) { + Some(0) => Ok(EventEncryptionAlgorithm::OlmV1Curve25519AesSha2), + Some(1) => Ok(EventEncryptionAlgorithm::MegolmV1AesSha2), + _ => Err(format!("{method}: unsupported algorithm {value}")), + }, + Value::String(name) => Ok(EventEncryptionAlgorithm::from(name.as_str())), + _ => Err(format!( + "{method}: `algorithm` must be a number or a string" + )), + } +} + +fn history_visibility(value: &Value, method: &str) -> Result { + match value { + Value::Number(_) => match as_u64(value) { + Some(0) => Ok(HistoryVisibility::Invited), + Some(1) => Ok(HistoryVisibility::Joined), + Some(2) => Ok(HistoryVisibility::Shared), + Some(3) => Ok(HistoryVisibility::WorldReadable), + _ => Err(format!("{method}: unknown history visibility {value}")), + }, + Value::String(name) => Ok(HistoryVisibility::from(name.as_str())), + _ => Err(format!( + "{method}: `historyVisibility` must be a number or a string" + )), + } +} + +fn collect_strategy(value: Option<&Value>, method: &str) -> Result { + match value { + None | Some(Value::Null) => Ok(CollectStrategy::AllDevices), + Some(Value::String(name)) => match name.as_str() { + "allDevices" => Ok(CollectStrategy::AllDevices), + "errorOnVerifiedUserProblem" => Ok(CollectStrategy::ErrorOnVerifiedUserProblem), + "identityBasedStrategy" => Ok(CollectStrategy::IdentityBasedStrategy), + "onlyTrustedDevices" => Ok(CollectStrategy::OnlyTrustedDevices), + other => Err(format!("{method}: unknown sharing strategy `{other}`")), + }, + Some(Value::Object(fields)) => { + let flag = |name: &str| fields.get(name).and_then(Value::as_bool).unwrap_or(false); + if flag("identityBasedStrategy") { + Ok(CollectStrategy::IdentityBasedStrategy) + } else if flag("onlyAllowTrustedDevices") { + Ok(CollectStrategy::OnlyTrustedDevices) + } else if flag("errorOnVerifiedUserProblem") { + Ok(CollectStrategy::ErrorOnVerifiedUserProblem) + } else { + Ok(CollectStrategy::AllDevices) + } + } + Some(_) => Err(format!( + "{method}: `sharingStrategy` must be a string or an object" + )), + } +} + +fn encryption_settings(args: &Value, method: &str) -> Result { + let settings = args + .get("encryptionSettings") + .ok_or_else(|| format!("{method}: missing object argument `encryptionSettings`"))?; + + let mut out = EncryptionSettings::default(); + if let Some(value) = settings.get("algorithm") { + out.algorithm = algorithm(value, method)?; + } + if let Some(value) = settings.get("historyVisibility") { + out.history_visibility = history_visibility(value, method)?; + } + if let Some(micros) = settings.get("rotationPeriod").and_then(as_u64) { + out.rotation_period = Duration::from_micros(micros); + } + if let Some(count) = settings.get("rotationPeriodMessages").and_then(as_u64) { + out.rotation_period_msgs = count; + } + out.sharing_strategy = collect_strategy(settings.get("sharingStrategy"), method)?; + Ok(out) +} + +fn room_settings(args: &Value, method: &str) -> Result { + let settings = args + .get("settings") + .ok_or_else(|| format!("{method}: missing object argument `settings`"))?; + + let mut out = RoomSettings::default(); + if let Some(value) = settings.get("algorithm") { + out.algorithm = algorithm(value, method)?; + } + out.only_allow_trusted_devices = settings + .get("onlyAllowTrustedDevices") + .and_then(Value::as_bool) + .unwrap_or(false); + out.session_rotation_period = settings + .get("sessionRotationPeriodMs") + .and_then(as_u64) + .map(Duration::from_millis); + out.session_rotation_period_messages = settings + .get("sessionRotationPeriodMessages") + .and_then(as_u64) + .map(|count| count as usize); + Ok(out) +} + +fn shield_state_json(state: ShieldState) -> Value { + let (color, code, message) = match state { + ShieldState::Red { code, message } => (0, Some(code), Some(message)), + ShieldState::Grey { code, message } => (1, Some(code), Some(message)), + ShieldState::None => (2, None, None), + }; + json!({ + "color": color, + "code": code.map(|code| match code { + ShieldStateCode::AuthenticityNotGuaranteed => 0, + ShieldStateCode::UnknownDevice => 1, + ShieldStateCode::UnsignedDevice => 2, + ShieldStateCode::UnverifiedIdentity => 3, + ShieldStateCode::VerificationViolation => 4, + ShieldStateCode::MismatchedSender => 5, + }), + "message": message, + }) +} + +fn shield_states_json(state: &VerificationState) -> (Value, Value) { + ( + shield_state_json(state.to_shield_state_lax()), + shield_state_json(state.to_shield_state_strict()), + ) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getRoomSettings" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + match machine.room_settings(&room).await { + Ok(Some(settings)) => Ok(json!({ + "algorithm": algorithm_to_wasm(&settings.algorithm), + "encryptStateEvents": false, + "onlyAllowTrustedDevices": settings.only_allow_trusted_devices, + "sessionRotationPeriodMs": settings + .session_rotation_period + .map(|period| period.as_millis() as u64), + "sessionRotationPeriodMessages": settings + .session_rotation_period_messages + .map(|count| count as u64), + })), + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("getRoomSettings failed: {e}")), + } + } + "setRoomSettings" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + let settings = match room_settings(args, method) { + Ok(settings) => settings, + Err(e) => return Some(Err(e)), + }; + machine + .set_room_settings(&room, &settings) + .await + .map(|()| Value::Null) + .map_err(|e| format!("setRoomSettings failed: {e}")) + } + + "shareRoomKey" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + let users = match user_ids(args, method, "users") { + Ok(users) => users, + Err(e) => return Some(Err(e)), + }; + let settings = match encryption_settings(args, method) { + Ok(settings) => settings, + Err(e) => return Some(Err(e)), + }; + + match machine + .share_room_key(&room, users.iter().map(AsRef::as_ref), settings) + .await + { + Ok(requests) => Ok(Value::Array( + requests + .into_iter() + .map(|request| { + json!({ + "type": request_type::TO_DEVICE, + "className": "ToDeviceRequest", + "id": request.txn_id.to_string(), + "event_type": request.event_type.to_string(), + "txn_id": request.txn_id.to_string(), + "body": json!({ "messages": request.messages }).to_string(), + }) + }) + .collect(), + )), + Err(e) => Err(format!("shareRoomKey failed: {e:?}")), + } + } + "getMissingSessions" => { + let users = match user_ids(args, method, "users") { + Ok(users) => users, + Err(e) => return Some(Err(e)), + }; + match machine + .get_missing_sessions(users.iter().map(AsRef::as_ref)) + .await + { + Ok(Some((txn_id, request))) => Ok(json!({ + "type": request_type::KEYS_CLAIM, + "className": "KeysClaimRequest", + "id": txn_id.to_string(), + "body": super::requests::keys_claim_body( + &request.timeout, + &json!(request.one_time_keys), + ), + })), + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("getMissingSessions failed: {e}")), + } + } + "invalidateGroupSession" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + machine + .discard_room_key(&room) + .await + .map(Value::Bool) + .map_err(|e| format!("invalidateGroupSession failed: {e}")) + } + + "getRoomEventEncryptionInfo" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + let event_json = match str_arg(args, method, "event") { + Ok(json) => json, + Err(e) => return Some(Err(e)), + }; + let event: Raw = match serde_json::from_str(&event_json) { + Ok(event) => event, + Err(e) => { + return Some(Err(format!( + "getRoomEventEncryptionInfo: bad event json: {e}" + ))) + } + }; + + match machine.get_room_event_encryption_info(&event, &room).await { + Ok(info) => { + let AlgorithmInfo::MegolmV1AesSha2 { + curve25519_key, + sender_claimed_keys, + .. + } = &info.algorithm_info + else { + return Some(Err( + "getRoomEventEncryptionInfo: event was not encrypted with megolm v1" + .to_owned(), + )); + }; + let (lax, strict) = shield_states_json(&info.verification_state); + Ok(json!({ + // Without this js-sdk's `shieldState()` call hits a plain object. + "className": "EncryptionInfo", + "sender": info.sender.to_string(), + "senderDevice": info.sender_device.as_ref().map(ToString::to_string), + "senderCurve25519Key": curve25519_key, + "senderClaimedEd25519Key": sender_claimed_keys + .get(&DeviceKeyAlgorithm::Ed25519), + "forwarder": info + .forwarder + .as_ref() + .map(|forwarder| forwarder.user_id.to_string()), + "forwarderDevice": info + .forwarder + .as_ref() + .map(|forwarder| forwarder.device_id.to_string()), + "shieldStateLax": lax, + "shieldStateStrict": strict, + })) + } + Err(e) => Err(format!("getRoomEventEncryptionInfo failed: {e:?}")), + } + } + + // Gated in matrix-sdk-crypto 0.18 behind `experimental-encrypted-state-events`. + "encryptStateEvent" => Err( + "encryptStateEvent: state-event encryption requires matrix-sdk-crypto's \ + `experimental-encrypted-state-events` feature, which this build does not enable" + .to_owned(), + ), + + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use matrix_sdk_crypto::CollectStrategy; + + use super::{collect_strategy, encryption_settings}; + + #[test] + fn parses_every_sharing_strategy_the_webview_can_send() { + let cases = [ + ( + "identityBasedStrategy", + CollectStrategy::IdentityBasedStrategy, + ), + ("onlyTrustedDevices", CollectStrategy::OnlyTrustedDevices), + ( + "errorOnVerifiedUserProblem", + CollectStrategy::ErrorOnVerifiedUserProblem, + ), + ("allDevices", CollectStrategy::AllDevices), + ]; + + for (name, expected) in cases { + let parsed = collect_strategy(Some(&json!(name)), "shareRoomKey") + .unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!( + std::mem::discriminant(&parsed), + std::mem::discriminant(&expected), + "{name} parsed to the wrong strategy" + ); + } + } + + #[test] + fn rejects_an_unknown_strategy_rather_than_silently_sharing_with_everyone() { + let error = collect_strategy(Some(&json!("somethingElse")), "shareRoomKey").unwrap_err(); + assert!(error.contains("somethingElse"), "{error}"); + } + + #[test] + fn reads_the_settings_shape_the_webview_encodes() { + let settings = encryption_settings( + &json!({ + "encryptionSettings": { + "algorithm": 1, + "historyVisibility": 2, + "rotationPeriod": 604_800_000_000u64, + "rotationPeriodMessages": 100, + "sharingStrategy": "onlyTrustedDevices", + } + }), + "shareRoomKey", + ) + .unwrap(); + + assert_eq!(settings.rotation_period_msgs, 100); + assert_eq!(settings.rotation_period.as_secs(), 604_800); + assert_eq!( + std::mem::discriminant(&settings.sharing_strategy), + std::mem::discriminant(&CollectStrategy::OnlyTrustedDevices), + ); + } +} diff --git a/src-tauri/src/matrix_crypto/verification.rs b/src-tauri/src/matrix_crypto/verification.rs new file mode 100644 index 000000000..bfcf09c6f --- /dev/null +++ b/src-tauri/src/matrix_crypto/verification.rs @@ -0,0 +1,577 @@ +//! Interactive verification (SAS and QR) for the `OlmMachine` IPC proxy. + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use matrix_sdk::ruma::events::key::verification::VerificationMethod; +use matrix_sdk::ruma::events::room::message::RoomMessageEventContent; +use matrix_sdk::ruma::events::AnyMessageLikeEventContent; +use matrix_sdk::ruma::events::MessageLikeEventContent as _; +use matrix_sdk::ruma::{EventId, OwnedDeviceId, OwnedUserId, RoomId, TransactionId, UserId}; +use matrix_sdk_crypto::matrix_sdk_qrcode::QrVerificationData; +use matrix_sdk_crypto::types::requests::{OutgoingVerificationRequest, RoomMessageRequest}; +use matrix_sdk_crypto::{ + CancelInfo, Device, OlmMachine, OtherUserIdentity, OwnUserIdentity, QrVerification, + QrVerificationState, Sas, UserIdentity, Verification, VerificationRequest, + VerificationRequestState, +}; +use serde_json::{json, Value}; + +use super::args::{str_arg, user_id}; +use super::wasm_enums::request_type::{ + ROOM_MESSAGE as REQUEST_TYPE_ROOM_MESSAGE, SIGNATURE_UPLOAD as REQUEST_TYPE_SIGNATURE_UPLOAD, + TO_DEVICE as REQUEST_TYPE_TO_DEVICE, +}; + +fn flow(args: &Value, method: &str) -> Result<(OwnedUserId, String), String> { + let user = user_id(args, method, "userId")?; + Ok((user, str_arg(args, method, "flowId")?)) +} + +fn request( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let (user, flow_id) = flow(args, method)?; + machine + .get_verification_request(&user, &flow_id) + .ok_or_else(|| format!("{method}: no verification request for {user} / {flow_id}")) +} + +fn sas(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let (user, flow_id) = flow(args, method)?; + machine + .get_verification(&user, &flow_id) + .and_then(|verification| verification.sas_v1()) + .map(|sas| *sas) + .ok_or_else(|| format!("{method}: no SAS verification for {user} / {flow_id}")) +} + +fn qr(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let (user, flow_id) = flow(args, method)?; + machine + .get_verification(&user, &flow_id) + .and_then(|verification| verification.qr_v1()) + .map(|qr| *qr) + .ok_or_else(|| format!("{method}: no QR verification for {user} / {flow_id}")) +} + +fn user_arg(args: &Value, method: &str) -> Result { + let raw = str_arg(args, method, "userId")?; + UserId::parse(&raw).map_err(|e| format!("{method}: bad user id in `userId`: {e}")) +} + +async fn device(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let user = user_arg(args, method)?; + let device_id: OwnedDeviceId = str_arg(args, method, "deviceId")?.into(); + machine + .get_device(&user, &device_id, None) + .await + .map_err(|e| format!("{method}: cannot load device {user} / {device_id}: {e}"))? + .ok_or_else(|| format!("{method}: unknown device {user} / {device_id}")) +} + +async fn identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let user = user_arg(args, method)?; + machine + .get_identity(&user, None) + .await + .map_err(|e| format!("{method}: cannot load the identity of {user}: {e}"))? + .ok_or_else(|| format!("{method}: no cross-signing identity for {user}")) +} + +async fn own_identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let identity = identity(machine, args, method).await?; + let user = identity.user_id().to_owned(); + identity.own().ok_or_else(|| { + format!("{method}: {user} is not our own user, use userIdentity.requestVerificationDm") + }) +} + +async fn other_identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let identity = identity(machine, args, method).await?; + let user = identity.user_id().to_owned(); + identity.other().ok_or_else(|| { + format!("{method}: {user} is our own user, use userIdentity.requestVerification") + }) +} + +/// Absent or empty means "let the crate pick its default method set". +fn methods_arg(args: &Value, method: &str) -> Result>, String> { + let Some(codes) = args.get("methods").and_then(Value::as_array) else { + return Ok(None); + }; + if codes.is_empty() { + return Ok(None); + } + codes + .iter() + .map(|code| { + code.as_u64() + .and_then(method_from_code) + .ok_or_else(|| format!("{method}: unknown verification method {code}")) + }) + .collect::, _>>() + .map(Some) +} + +fn started(request: &VerificationRequest, outgoing_request: Value) -> Value { + json!({ "request": request_state(request), "outgoingRequest": outgoing_request }) +} + +fn method_code(method: &VerificationMethod) -> Option { + match method { + VerificationMethod::SasV1 => Some(0), + VerificationMethod::QrCodeScanV1 => Some(1), + VerificationMethod::QrCodeShowV1 => Some(2), + VerificationMethod::ReciprocateV1 => Some(3), + _ => None, + } +} + +fn method_from_code(code: u64) -> Option { + match code { + 0 => Some(VerificationMethod::SasV1), + 1 => Some(VerificationMethod::QrCodeScanV1), + 2 => Some(VerificationMethod::QrCodeShowV1), + 3 => Some(VerificationMethod::ReciprocateV1), + _ => None, + } +} + +fn method_codes(methods: Option>) -> Value { + match methods { + Some(methods) => Value::Array( + methods + .iter() + .filter_map(method_code) + .map(|code| json!(code)) + .collect(), + ), + None => Value::Null, + } +} + +fn outgoing(request: OutgoingVerificationRequest) -> Value { + let id = request.request_id().to_string(); + let mut entry = match request { + OutgoingVerificationRequest::ToDevice(req) => json!({ + "type": REQUEST_TYPE_TO_DEVICE, + "className": "ToDeviceRequest", + "body": json!({ "messages": req.messages }).to_string(), + "event_type": req.event_type.to_string(), + "txn_id": req.txn_id.to_string(), + }), + OutgoingVerificationRequest::InRoom(req) => json!({ + "type": REQUEST_TYPE_ROOM_MESSAGE, + "className": "RoomMessageRequest", + "body": json!(req.content).to_string(), + "room_id": req.room_id.to_string(), + "txn_id": req.txn_id.to_string(), + "event_type": req.content.as_ref().event_type().to_string(), + }), + }; + entry["id"] = Value::String(id); + entry +} + +fn optional_outgoing(request: Option) -> Value { + request.map(outgoing).unwrap_or(Value::Null) +} + +fn cancel_info(info: Option) -> Value { + match info { + Some(info) => json!({ + "className": "CancelInfo", + "cancelCode": info.cancel_code().to_string(), + "cancelledbyUs": info.cancelled_by_us(), + "reason": info.reason(), + }), + None => Value::Null, + } +} + +fn emoji_list(sas: &Sas) -> Value { + match sas.emoji() { + Some(emojis) => Value::Array( + emojis + .iter() + .map(|emoji| json!({ "symbol": emoji.symbol, "description": emoji.description })) + .collect(), + ), + None => Value::Null, + } +} + +fn decimals(sas: &Sas) -> Value { + match sas.decimals() { + Some((a, b, c)) => json!([a, b, c]), + None => Value::Null, + } +} + +/// `className` is required: js-sdk dispatches on `instanceof RustSdkCryptoJs.Sas` / `.Qr`. +fn sas_state(sas: &Sas) -> Value { + json!({ + "className": "Sas", + "userId": sas.user_id().to_string(), + "deviceId": sas.device_id().to_string(), + "otherUserId": sas.other_user_id().to_string(), + "otherDeviceId": sas.other_device_id().to_string(), + "flowId": sas.flow_id().as_str(), + "roomId": sas.room_id().map(ToString::to_string), + "weStarted": sas.we_started(), + "isSelfVerification": sas.is_self_verification(), + "startedFromRequest": sas.started_from_request(), + "supportsEmoji": sas.supports_emoji(), + "haveWeConfirmed": sas.have_we_confirmed(), + "hasBeenAccepted": sas.has_been_accepted(), + "canBePresented": sas.can_be_presented(), + "timedOut": sas.timed_out(), + "isDone": sas.is_done(), + "isCancelled": sas.is_cancelled(), + "cancelInfo": cancel_info(sas.cancel_info()), + "emoji": emoji_list(sas), + "emojiIndex": sas.emoji_index().map(|indices| indices.to_vec()), + "decimals": decimals(sas), + }) +} + +fn qr_state_code(state: &QrVerificationState) -> u8 { + match state { + QrVerificationState::Started => 0, + QrVerificationState::Scanned => 1, + QrVerificationState::Confirmed => 2, + QrVerificationState::Reciprocated => 3, + QrVerificationState::Done { .. } => 4, + QrVerificationState::Cancelled(_) => 5, + } +} + +fn qr_state(qr: &QrVerification) -> Value { + json!({ + "className": "Qr", + "userId": qr.user_id().to_string(), + "otherUserId": qr.other_user_id().to_string(), + "otherDeviceId": qr.other_device_id().to_string(), + "flowId": qr.flow_id().as_str(), + "roomId": qr.room_id().map(ToString::to_string), + "weStarted": qr.we_started(), + "isSelfVerification": qr.is_self_verification(), + "hasBeenScanned": qr.has_been_scanned(), + "hasBeenConfirmed": qr.has_been_confirmed(), + "reciprocated": qr.reciprocated(), + "isDone": qr.is_done(), + "isCancelled": qr.is_cancelled(), + "cancelInfo": cancel_info(qr.cancel_info()), + "state": qr_state_code(&qr.state()), + "qrCodeBytes": qr.to_bytes().ok().map(|bytes| BASE64.encode(bytes)), + }) +} + +fn verification_state(verification: Verification) -> Value { + match verification { + Verification::SasV1(sas) => sas_state(&sas), + Verification::QrV1(qr) => qr_state(&qr), + _ => Value::Null, + } +} + +pub(crate) fn request_state(request: &VerificationRequest) -> Value { + let state = request.state(); + let phase = match &state { + VerificationRequestState::Created { .. } => 0, + VerificationRequestState::Requested { .. } => 1, + VerificationRequestState::Ready { .. } => 2, + VerificationRequestState::Transitioned { .. } => 3, + VerificationRequestState::Done => 4, + VerificationRequestState::Cancelled(_) => 5, + }; + + let verification = match &state { + VerificationRequestState::Transitioned { verification, .. } => match verification { + Verification::SasV1(sas) => sas_state(sas), + Verification::QrV1(qr) => qr_state(qr), + _ => Value::Null, + }, + _ => Value::Null, + }; + + json!({ + "className": "VerificationRequest", + "ownUserId": request.own_user_id().to_string(), + "otherUserId": request.other_user().to_string(), + "otherDeviceId": request.other_device_id().map(|id| id.to_string()), + "flowId": request.flow_id().as_str(), + "roomId": request.room_id().map(ToString::to_string), + "phase": phase, + "weStarted": request.we_started(), + "isSelfVerification": request.is_self_verification(), + "isPassive": request.is_passive(), + "isReady": request.is_ready(), + "isDone": request.is_done(), + "isCancelled": request.is_cancelled(), + "timedOut": request.timed_out(), + "timeRemainingMillis": request.time_remaining().as_millis() as f64, + "theirSupportedMethods": method_codes(request.their_supported_methods()), + "ourSupportedMethods": method_codes(request.our_supported_methods()), + "cancelInfo": cancel_info(request.cancel_info()), + "verification": verification, + }) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getVerificationRequest" => { + let (user, flow_id) = match flow(args, method) { + Ok(flow) => flow, + Err(e) => return Some(Err(e)), + }; + Ok(machine + .get_verification_request(&user, &flow_id) + .map(|request| request_state(&request)) + .unwrap_or(Value::Null)) + } + "getVerificationRequests" => { + let raw = match str_arg(args, method, "userId") { + Ok(raw) => raw, + Err(e) => return Some(Err(e)), + }; + match UserId::parse(&raw) { + Ok(user) => Ok(Value::Array( + machine + .get_verification_requests(&user) + .iter() + .map(request_state) + .collect(), + )), + Err(e) => Err(format!("{method}: bad user id in `userId`: {e}")), + } + } + + "device.requestVerification" => match device(machine, args, method).await { + Ok(device) => match methods_arg(args, method) { + Ok(Some(methods)) => { + let (request, out) = device.request_verification_with_methods(methods); + Ok(started(&request, outgoing(out))) + } + Ok(None) => { + let (request, out) = device.request_verification(); + Ok(started(&request, outgoing(out))) + } + Err(e) => Err(e), + }, + Err(e) => Err(e), + }, + "userIdentity.requestVerification" => match own_identity(machine, args, method).await { + Ok(identity) => match methods_arg(args, method) { + Ok(methods) => { + let result = match methods { + Some(methods) => identity.request_verification_with_methods(methods).await, + None => identity.request_verification().await, + }; + match result { + Ok((request, out)) => Ok(started(&request, outgoing(out))), + Err(e) => Err(format!("{method} failed: {e}")), + } + } + Err(e) => Err(e), + }, + Err(e) => Err(e), + }, + // The crate has no one-shot in-room start: the content is sent first, then the request is + // built from the event id it landed on. + "userIdentity.verificationRequestContent" => { + match other_identity(machine, args, method).await { + Ok(identity) => { + let room = str_arg(args, method, "roomId").and_then(|raw| { + RoomId::parse(&raw) + .map_err(|e| format!("{method}: bad room id in `roomId`: {e}")) + }); + match (room, methods_arg(args, method)) { + (Ok(room_id), Ok(methods)) => { + let content = identity.verification_request_content(methods); + let mut out = outgoing( + RoomMessageRequest { + room_id, + txn_id: TransactionId::new(), + content: Box::new(AnyMessageLikeEventContent::RoomMessage( + RoomMessageEventContent::new(content), + )), + } + .into(), + ); + // No `id`: the machine never issued this txn, so it cannot be acked. + if let Some(entry) = out.as_object_mut() { + entry.remove("id"); + } + Ok(json!({ "request": Value::Null, "outgoingRequest": out })) + } + (Err(e), _) | (_, Err(e)) => Err(e), + } + } + Err(e) => Err(e), + } + } + "userIdentity.requestVerificationDm" => match other_identity(machine, args, method).await { + Ok(identity) => { + let room = str_arg(args, method, "roomId").and_then(|raw| { + RoomId::parse(&raw) + .map_err(|e| format!("{method}: bad room id in `roomId`: {e}")) + }); + let event = str_arg(args, method, "requestEventId").and_then(|raw| { + EventId::parse(&raw) + .map_err(|e| format!("{method}: bad event id in `requestEventId`: {e}")) + }); + match (room, event, methods_arg(args, method)) { + (Ok(room_id), Ok(event_id), Ok(methods)) => { + let request = identity.request_verification(&room_id, &event_id, methods); + Ok(started(&request, Value::Null)) + } + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(e), + } + } + Err(e) => Err(e), + }, + + "verificationRequest.state" => request(machine, args, method).map(|r| request_state(&r)), + "verification.state" => { + let (user, flow_id) = match flow(args, method) { + Ok(flow) => flow, + Err(e) => return Some(Err(e)), + }; + Ok(machine + .get_verification(&user, &flow_id) + .map(verification_state) + .unwrap_or(Value::Null)) + } + "verificationRequest.accept" => { + request(machine, args, method).and_then(|request| { + match args.get("methods").and_then(Value::as_array) { + Some(codes) => { + let methods = codes + .iter() + .filter_map(Value::as_u64) + .map(|code| { + method_from_code(code).ok_or_else(|| { + format!("{method}: unknown verification method {code}") + }) + }) + .collect::, _>>()?; + Ok(optional_outgoing(request.accept_with_methods(methods))) + } + None => Ok(optional_outgoing(request.accept())), + } + }) + } + "verificationRequest.cancel" => { + request(machine, args, method).map(|request| optional_outgoing(request.cancel())) + } + "verificationRequest.startSas" => match request(machine, args, method) { + Ok(request) => match request.start_sas().await { + Ok(Some((sas, outgoing_request))) => { + Ok(json!([sas_state(&sas), outgoing(outgoing_request)])) + } + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("verificationRequest.startSas failed: {e}")), + }, + Err(e) => Err(e), + }, + "verificationRequest.generateQrCode" => match request(machine, args, method) { + Ok(request) => match request.generate_qr_code().await { + Ok(Some(qr)) => match qr.to_bytes() { + Ok(_) => Ok(qr_state(&qr)), + Err(e) => Err(format!("{method}: cannot encode the QR code payload: {e}")), + }, + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("{method} failed: {e}")), + }, + Err(e) => Err(e), + }, + "verificationRequest.scanQrCode" => match request(machine, args, method) { + Ok(request) => { + let decoded = str_arg(args, method, "qrCodeData") + .and_then(|data| { + BASE64 + .decode(data) + .map_err(|e| format!("{method}: `qrCodeData` is not valid base64: {e}")) + }) + .and_then(|bytes| { + QrVerificationData::from_bytes(bytes) + .map_err(|e| format!("{method}: undecodable QR code: {e}")) + }); + match decoded { + Ok(data) => match request.scan_qr_code(data).await { + Ok(Some(qr)) => Ok(qr_state(&qr)), + Ok(None) => Err(format!( + "{method}: the request cannot take a scanned QR code in its current state" + )), + Err(e) => Err(format!("{method} failed: {e}")), + }, + Err(e) => Err(e), + } + } + Err(e) => Err(e), + }, + + "sas.state" => sas(machine, args, method).map(|sas| sas_state(&sas)), + "sas.accept" => sas(machine, args, method).map(|sas| optional_outgoing(sas.accept())), + "sas.confirm" => match sas(machine, args, method) { + Ok(sas) => match sas.confirm().await { + Ok((requests, signature_upload)) => { + let mut out: Vec = requests.into_iter().map(outgoing).collect(); + // Null `id` tells js-sdk to skip `markRequestAsSent`. It must be present + // and null: an absent key reaches the wasm `get id()` with no pointer. + if let Some(upload) = signature_upload { + out.push(json!({ + "type": REQUEST_TYPE_SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "id": Value::Null, + "body": json!(upload.signed_keys).to_string(), + })); + } + Ok(Value::Array(out)) + } + Err(e) => Err(format!("sas.confirm failed: {e}")), + }, + Err(e) => Err(e), + }, + "sas.cancel" => { + sas(machine, args, method).map(|sas| match args.get("code").and_then(Value::as_str) { + Some(code) => optional_outgoing(sas.cancel_with_code(code.into())), + None => optional_outgoing(sas.cancel()), + }) + } + "sas.emoji" => sas(machine, args, method).map(|sas| emoji_list(&sas)), + "sas.decimals" => sas(machine, args, method).map(|sas| decimals(&sas)), + + "qr.state" => qr(machine, args, method).map(|qr| qr_state(&qr)), + "qr.confirm" => { + qr(machine, args, method).map(|qr| optional_outgoing(qr.confirm_scanning())) + } + "qr.reciprocate" => qr(machine, args, method).map(|qr| optional_outgoing(qr.reciprocate())), + "qr.cancel" => { + qr(machine, args, method).map(|qr| match args.get("code").and_then(Value::as_str) { + Some(code) => optional_outgoing(qr.cancel_with_code(code.into())), + None => optional_outgoing(qr.cancel()), + }) + } + + _ => return None, + }) +} diff --git a/src-tauri/src/matrix_crypto/wasm_enums.rs b/src-tauri/src/matrix_crypto/wasm_enums.rs new file mode 100644 index 000000000..02a93ec2e --- /dev/null +++ b/src-tauri/src/matrix_crypto/wasm_enums.rs @@ -0,0 +1,31 @@ +//! The numeric values matrix-sdk-crypto-wasm's enums cross the IPC boundary as. + +use matrix_sdk_crypto::types::EventEncryptionAlgorithm; + +/// wasm's `RequestType`. +pub mod request_type { + pub const KEYS_UPLOAD: u8 = 0; + pub const KEYS_QUERY: u8 = 1; + pub const KEYS_CLAIM: u8 = 2; + pub const TO_DEVICE: u8 = 3; + pub const SIGNATURE_UPLOAD: u8 = 4; + pub const ROOM_MESSAGE: u8 = 5; + pub const KEYS_BACKUP: u8 = 6; +} + +/// wasm's `ProcessedToDeviceEventType`. +pub mod processed_to_device_event_type { + pub const DECRYPTED: u8 = 0; + pub const UNABLE_TO_DECRYPT: u8 = 1; + pub const PLAIN_TEXT: u8 = 2; + pub const INVALID: u8 = 3; +} + +/// wasm's `EncryptionAlgorithm`; anything the bindings do not name maps to `Unknown`. +pub fn encryption_algorithm(algorithm: &EventEncryptionAlgorithm) -> u8 { + match algorithm { + EventEncryptionAlgorithm::OlmV1Curve25519AesSha2 => 0, + EventEncryptionAlgorithm::MegolmV1AesSha2 => 1, + _ => 2, + } +} diff --git a/src/app/crypto/install.test.ts b/src/app/crypto/install.test.ts new file mode 100644 index 000000000..d72182da1 --- /dev/null +++ b/src/app/crypto/install.test.ts @@ -0,0 +1,76 @@ +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'; + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn<() => boolean>() })); + +vi.mock('$generated/tauri/commands', () => ({ + engineOpen: vi.fn<(...args: never[]) => unknown>(), +})); + +const mockIsTauri = vi.mocked(isTauri); + +describe('rustEngineEnabled', () => { + beforeEach(() => { + mockIsTauri.mockReset(); + }); + + it('keeps WASM crypto for non-Tauri clients', async () => { + mockIsTauri.mockReturnValue(false); + + await expect(rustEngineEnabled('sync@alice:example.org')).resolves.toBe(false); + }); + + it('enables the native engine when no legacy crypto store exists', async () => { + mockIsTauri.mockReturnValue(true); + const databases = vi.fn<() => Promise>().mockResolvedValue([]); + vi.stubGlobal('indexedDB', { databases }); + + await expect(rustEngineEnabled('sync@alice:example.org')).resolves.toBe(true); + expect(databases).toHaveBeenCalledOnce(); + }); + + it('requires re-authentication instead of retaining a legacy WASM engine', async () => { + mockIsTauri.mockReturnValue(true); + vi.stubGlobal('indexedDB', { + databases: vi + .fn<() => Promise>() + .mockResolvedValue([{ name: 'sync@alice:example.org::matrix-sdk-crypto' }]), + }); + + await expect(rustEngineEnabled('sync@alice:example.org')).rejects.toBeInstanceOf( + LegacyWasmCryptoStoreError + ); + }); + + it('requires re-authentication when the legacy store cannot be inspected safely', async () => { + mockIsTauri.mockReturnValue(true); + vi.stubGlobal('indexedDB', {}); + + await expect(rustEngineEnabled('sync@alice:example.org')).rejects.toBeInstanceOf( + LegacyWasmCryptoStoreError + ); + }); +}); + +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); + + const stop = reEmitCryptoEvents(mx as never, rustCrypto as never); + const request = { transactionId: 'verification-request' }; + rustCrypto.emit(CryptoEvent.VerificationRequestReceived, request); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(request, rustCrypto); + + stop(); + rustCrypto.emit(CryptoEvent.VerificationRequestReceived, request); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/crypto/install.ts b/src/app/crypto/install.ts new file mode 100644 index 000000000..f23c80e5f --- /dev/null +++ b/src/app/crypto/install.ts @@ -0,0 +1,189 @@ +import { logger } from 'matrix-js-sdk/lib/logger'; +import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; +import { ReEmitter } from 'matrix-js-sdk/lib/ReEmitter'; +import { RustCrypto } from 'matrix-js-sdk/lib/rust-crypto/rust-crypto'; +import { isTauri } from '@tauri-apps/api/core'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; +import { engineOpen } from '$generated/tauri/commands'; +import { RustSdkCryptoJs } from './olmMachine/wasmClasses'; +import { OlmMachineProxy, type EngineOpenInfo } from './olmMachine/proxy'; +import { engineInvoke } from './olmMachine/engineInvoke'; +import { startEngineEventBridge } from './olmMachine/eventBridge'; +import { patchQrCodeScan } from './olmMachine/qrCodeScan'; +import { installVerificationOverrides } from './verificationOverrides'; + +const cryptoLog = createDebugLogger('rust-crypto-install'); + +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 { + constructor() { + 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.name = 'LegacyWasmCryptoStoreError'; + } +} + +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: RustCrypto; + proxy: OlmMachineProxy; +}; + +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, rustCrypto: RustCrypto): (() => void) => { + const reEmitter = new ReEmitter(mx); + reEmitter.reEmit(rustCrypto, REEMITTED_CRYPTO_EVENTS); + return () => reEmitter.stopReEmitting(rustCrypto, REEMITTED_CRYPTO_EVENTS); +}; + +export const installRustCrypto = async ( + mx: MatrixClient, + options: { storeDir?: string; passphrase?: string } = {} +): Promise => { + // js-sdk does this inside `initRustCrypto`, which the engine bypasses. Without it the + // wasm classes the proxy grafts onto engine payloads throw on their first real call. + await RustSdkCryptoJs.initAsync(); + + patchQrCodeScan(); + + 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'); + } + + const opened = await engineOpen({ + dir: options.storeDir ?? null, + passphrase: options.passphrase ?? null, + userId, + deviceId, + }); + + // `engine_open` reports snake_case. + const deviceCreationTimeMs = (await engineInvoke( + { userId, deviceId }, + 'deviceCreationTimeMs' + )) as number; + + const info: EngineOpenInfo = { + userId: opened.user_id, + deviceId: opened.device_id, + ed25519Key: opened.ed25519_key, + curve25519Key: opened.curve25519_key, + deviceCreationTimeMs, + }; + + const proxy = new OlmMachineProxy(info); + proxy.roomKeyRequestsEnabled = false; + + const rustCrypto = new RustCrypto( + logger, + proxy as never, + mx.http as never, + userId, + deviceId, + mx.secretStorage, + mx.cryptoCallbacks + ); + + // `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, rustCrypto); + + installVerificationOverrides(rustCrypto, proxy); + + proxy.registerRoomKeyUpdatedCallback((sessions) => + rustCrypto.onRoomKeysUpdated(sessions as never) + ); + proxy.registerRoomKeysWithheldCallback((withheld) => + rustCrypto.onRoomKeysWithheld(withheld as never) + ); + proxy.registerUserIdentityUpdatedCallback((updated) => + rustCrypto.onUserIdentityUpdated(updated as never) + ); + proxy.registerDevicesUpdatedCallback((userIds) => rustCrypto.onDevicesUpdated(userIds)); + + void rustCrypto.checkSecrets('m.megolm_backup.v1'); + proxy.registerReceiveSecretCallback((name) => rustCrypto.checkSecrets(name)); + + // Torn down with the client, or a re-login leaks a listener. + const stopEventBridge = await startEngineEventBridge(proxy, { userId, deviceId }); + const stopRustCrypto = rustCrypto.stop.bind(rustCrypto); + rustCrypto.stop = () => { + stopReEmittingCryptoEvents(); + stopEventBridge(); + stopRustCrypto(); + }; + + await proxy.outgoingRequests(); + + await acceptPendingKeyBundles(proxy, rustCrypto); + + (mx as unknown as { cryptoBackend?: unknown }).cryptoBackend = rustCrypto; + cryptoLog.info('general', 'Installed the Rust IPC crypto engine', { userId, deviceId }); + + return { rustCrypto, proxy }; +}; + +const acceptPendingKeyBundles = async ( + proxy: OlmMachineProxy, + rustCrypto: RustCrypto +): Promise => { + const pending = (await proxy.getAllRoomsPendingKeyBundles()) as { + roomId: string; + inviterId: string; + inviteAcceptedAtMillis: number; + }[]; + + for (const details of pending) { + const { roomId, inviterId } = details; + if (Date.now() - details.inviteAcceptedAtMillis <= MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE) { + // eslint-disable-next-line no-await-in-loop + await rustCrypto.maybeAcceptKeyBundle(roomId, inviterId); + } else { + // eslint-disable-next-line no-await-in-loop + await proxy.clearRoomPendingKeyBundle(roomId); + } + } +}; diff --git a/src/app/crypto/olmMachine/backupImport.test.ts b/src/app/crypto/olmMachine/backupImport.test.ts new file mode 100644 index 000000000..8a16ec636 --- /dev/null +++ b/src/app/crypto/olmMachine/backupImport.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { OlmMachineProxy } from './proxy'; +import { engineInvoke } from './engineInvoke'; + +vi.mock('./engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(async () => ({ + importedCount: 0, + totalCount: 0, + })), +})); +vi.mock('$generated/tauri/commands', () => ({ engineClose: vi.fn<() => Promise>() })); + +const mockInvoke = vi.mocked(engineInvoke); + +const proxy = () => + new OlmMachineProxy({ + userId: '@me:example.org', + deviceId: 'DEVICE', + ed25519Key: 'ed', + curve25519Key: 'curve', + deviceCreationTimeMs: 0, + }); + +const roomId = (id: string) => ({ toString: () => id }); + +describe('importBackedUpRoomKeys', () => { + it('keeps every session when one room arrives as several map entries', async () => { + mockInvoke.mockClear(); + const keysByRoom = new Map([ + [roomId('!room:example.org'), new Map([['session-a', { session_id: 'session-a' }]])], + [roomId('!room:example.org'), new Map([['session-b', { session_id: 'session-b' }]])], + [roomId('!other:example.org'), new Map([['session-c', { session_id: 'session-c' }]])], + ]); + + await proxy().importBackedUpRoomKeys(keysByRoom, undefined, '7'); + + const args = mockInvoke.mock.calls[0]?.[2] as { + keys: Record>; + }; + expect(Object.keys(args.keys['!room:example.org'] ?? {})).toEqual(['session-a', 'session-b']); + expect(Object.keys(args.keys['!other:example.org'] ?? {})).toEqual(['session-c']); + }); + + it('passes the backup version through, not the progress listener', async () => { + mockInvoke.mockClear(); + const listener = vi.fn<(a: bigint, b: bigint, c: bigint) => void>(); + + await proxy().importBackedUpRoomKeys(new Map(), listener, '7'); + + const args = mockInvoke.mock.calls[0]?.[2] as { backupVersion: unknown }; + expect(args.backupVersion).toBe('7'); + expect(listener).toHaveBeenCalled(); + }); +}); diff --git a/src/app/crypto/olmMachine/engineInvoke.ts b/src/app/crypto/olmMachine/engineInvoke.ts new file mode 100644 index 000000000..bcc0addd7 --- /dev/null +++ b/src/app/crypto/olmMachine/engineInvoke.ts @@ -0,0 +1,20 @@ +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/olmMachine/eventBridge.ts b/src/app/crypto/olmMachine/eventBridge.ts new file mode 100644 index 000000000..edea22e35 --- /dev/null +++ b/src/app/crypto/olmMachine/eventBridge.ts @@ -0,0 +1,65 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { EngineIdentity } from './engineInvoke'; +import type { OlmMachineProxy } from './proxy'; + +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 RoomKeyPayload = { roomId: string; senderKey: string; sessionId: string; algorithm: number }; +type WithheldPayload = { roomId: string; sessionId: string }; + +const roomKey = (key: RoomKeyPayload) => ({ + ...key, + roomId: { toString: () => key.roomId }, + senderKey: { toBase64: () => key.senderKey, toString: () => key.senderKey }, +}); + +const withheld = (session: WithheldPayload) => ({ + ...session, + roomId: { toString: () => session.roomId }, +}); + +// Must be torn down with the client, or a re-login leaks a listener. +export const startEngineEventBridge = async ( + proxy: OlmMachineProxy, + 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) => proxy.emit.roomKeysUpdated(keys.map(roomKey))) + ), + listen>( + ROOM_KEYS_WITHHELD, + forAccount((sessions) => + proxy.emit.roomKeysWithheld(sessions.map(withheld)) + ) + ), + listen>( + IDENTITIES_UPDATED, + forAccount<{ identities: string[]; devices: string[] }>(({ identities, devices }) => { + identities.forEach((userId) => proxy.emit.userIdentityUpdated(userId)); + if (devices.length > 0) proxy.emit.devicesUpdated(devices); + }) + ), + listen>( + SECRET_RECEIVED, + // Secret values never leave the host; only the name travels. + forAccount<{ name: string }>(({ name }) => proxy.emit.secretReceived(name, '')) + ), + ]); + + return () => unlisten.forEach((stop) => stop()); +}; diff --git a/src/app/crypto/olmMachine/hydrate.test.ts b/src/app/crypto/olmMachine/hydrate.test.ts new file mode 100644 index 000000000..dda39a6ea --- /dev/null +++ b/src/app/crypto/olmMachine/hydrate.test.ts @@ -0,0 +1,750 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { graftWasmPrototypes, RustSdkCryptoJs } from './wasmClasses'; +import type { HydrationContext } from './hydrate'; +import { OlmMachineProxy } from './proxy'; +import { patchQrCodeScan } from './qrCodeScan'; + +const bridge = vi.hoisted(() => ({ + engineInvoke: vi.fn<(identity: unknown, method: string, args: never) => Promise>(), +})); + +vi.mock('./engineInvoke', () => bridge); + +const context = (): HydrationContext & { calls: [string, unknown][] } => { + const calls: [string, unknown][] = []; + return { + calls, + call: async (method, args) => { + calls.push([method, args]); + return undefined; + }, + queueOutgoing: () => {}, + watchChanges: () => {}, + trackVerification: () => {}, + }; +}; + +const info = { + userId: '@alice:example.org', + deviceId: 'ALICE', + ed25519Key: 'ed', + curve25519Key: 'curve', + deviceCreationTimeMs: 0, +}; + +const deviceSnapshot = () => ({ + className: 'Device', + userId: '@bob:example.org', + deviceId: 'BOBDEVICE', + displayName: 'Bob phone', + localTrustState: RustSdkCryptoJs.LocalTrust.Unset, + algorithms: [], + isDehydrated: false, + isVerified: false, + isBlacklisted: false, + isCrossSignedByOwner: true, + isCrossSigningTrusted: false, + isDeleted: false, + isLocallyTrusted: false, + firstTimeSeen: 1700000000, + keys: { 'ed25519:BOBDEVICE': 'edkey', 'curve25519:BOBDEVICE': 'curvekey' }, +}); + +describe('Device hydration', () => { + it('turns wasm methods into functions and leaves wasm getters as fields', () => { + const ctx = context(); + const device = graftWasmPrototypes(deviceSnapshot(), ctx) as unknown as RustSdkCryptoJs.Device; + + expect(device).toBeInstanceOf(RustSdkCryptoJs.Device); + expect(device.isVerified()).toBe(false); + expect(device.isCrossSignedByOwner()).toBe(true); + expect(device.isDeleted()).toBe(false); + expect(device.firstTimeSeen()).toBe(1700000000); + expect(device.isDehydrated).toBe(false); + expect(device.displayName).toBe('Bob phone'); + expect(String(device.deviceId)).toBe('BOBDEVICE'); + }); + + it('never lets an absent boolean read as verified', () => { + const partial = deviceSnapshot() as Record; + delete partial.isVerified; + const device = graftWasmPrototypes(partial, context()) as unknown as RustSdkCryptoJs.Device; + + expect(() => device.isVerified()).toThrow(/null pointer/); + }); + + it('exposes keys as a Map of base64 values', () => { + const device = graftWasmPrototypes( + deviceSnapshot(), + context() + ) as unknown as RustSdkCryptoJs.Device; + const entries = Array.from(device.keys.entries()).map(([keyId, key]) => [ + keyId.toString(), + key.toBase64(), + ]); + + expect(entries).toEqual([ + ['ed25519:BOBDEVICE', 'edkey'], + ['curve25519:BOBDEVICE', 'curvekey'], + ]); + expect(device.getKey(RustSdkCryptoJs.DeviceKeyAlgorithmName.Curve25519)?.toBase64()).toBe( + 'curvekey' + ); + }); + + it('forwards async members over IPC and makes free a no-op', async () => { + const ctx = context(); + const device = graftWasmPrototypes(deviceSnapshot(), ctx) as unknown as RustSdkCryptoJs.Device; + + await device.setLocalTrust(RustSdkCryptoJs.LocalTrust.Verified); + expect(ctx.calls).toEqual([ + [ + 'device.setLocalTrust', + { + userId: '@bob:example.org', + deviceId: 'BOBDEVICE', + trustState: RustSdkCryptoJs.LocalTrust.Verified, + }, + ], + ]); + expect(() => device.free()).not.toThrow(); + }); +}); + +describe('SAS hydration', () => { + it('matches WASM optional values before the SAS can be presented', () => { + const sas = graftWasmPrototypes( + { + className: 'Sas', + otherUserId: '@bob:example.org', + flowId: 'flow', + cancelInfo: null, + decimals: null, + emoji: null, + emojiIndex: null, + }, + context() + ) as unknown as RustSdkCryptoJs.Sas; + + expect(sas.cancelInfo()).toBeUndefined(); + expect(sas.decimals()).toBeUndefined(); + expect(sas.emoji()).toBeUndefined(); + expect(sas.emojiIndex()).toBeUndefined(); + }); +}); + +describe('UserDevices hydration', () => { + const snapshot = () => ({ + className: 'UserDevices', + userId: '@bob:example.org', + devices: [deviceSnapshot()], + keys: ['BOBDEVICE'], + isAnyVerified: false, + }); + + it('turns every wasm member into a method', () => { + const devices = graftWasmPrototypes( + snapshot(), + context() + ) as unknown as RustSdkCryptoJs.UserDevices; + + expect(devices).toBeInstanceOf(RustSdkCryptoJs.UserDevices); + expect(devices.isAnyVerified()).toBe(false); + expect(devices.keys().map(String)).toEqual(['BOBDEVICE']); + expect(devices.devices()).toHaveLength(1); + expect(devices.devices()[0]).toBeInstanceOf(RustSdkCryptoJs.Device); + expect(String(devices.get('BOBDEVICE' as never)?.deviceId)).toBe('BOBDEVICE'); + expect(devices.get('OTHER' as never)).toBeUndefined(); + expect(() => devices.free()).not.toThrow(); + }); + + it('never lets a missing device list read as no devices', () => { + const partial = snapshot() as Record; + delete partial.devices; + const devices = graftWasmPrototypes( + partial, + context() + ) as unknown as RustSdkCryptoJs.UserDevices; + + expect(() => devices.devices()).toThrow(/null pointer/); + expect(() => devices.get('BOBDEVICE' as never)).toThrow(/DeviceId/); + }); +}); + +const own = () => ({ + className: 'OwnUserIdentity', + userId: '@alice:example.org', + isVerified: false, + wasPreviouslyVerified: true, + hasVerificationViolation: true, + masterKey: '{"keys":{}}', + selfSigningKey: '{"keys":{}}', + userSigningKey: '{"keys":{}}', +}); + +const other = () => ({ + className: 'OtherUserIdentity', + userId: '@bob:example.org', + isVerified: true, + wasPreviouslyVerified: true, + hasVerificationViolation: false, + identityNeedsUserApproval: true, + masterKey: '{"keys":{}}', + selfSigningKey: '{"keys":{}}', +}); + +describe('user identity hydration', () => { + it('splits own-identity booleans from the cross-signing key getters', async () => { + const ctx = context(); + const identity = graftWasmPrototypes(own(), ctx) as unknown as RustSdkCryptoJs.OwnUserIdentity; + + expect(identity).toBeInstanceOf(RustSdkCryptoJs.OwnUserIdentity); + expect(identity.isVerified()).toBe(false); + expect(identity.wasPreviouslyVerified()).toBe(true); + expect(identity.hasVerificationViolation()).toBe(true); + expect(identity.masterKey).toBe('{"keys":{}}'); + expect(identity.userSigningKey).toBe('{"keys":{}}'); + expect(() => identity.free()).not.toThrow(); + + await identity.verify(); + await identity.withdrawVerification(); + expect(ctx.calls).toEqual([ + ['userIdentity.verify', { userId: '@alice:example.org' }], + ['userIdentity.withdrawVerification', { userId: '@alice:example.org' }], + ]); + }); + + it('hydrates the other-identity members and reports no userSigningKey', async () => { + const ctx = context(); + const identity = graftWasmPrototypes( + other(), + ctx + ) as unknown as RustSdkCryptoJs.OtherUserIdentity; + + expect(identity).toBeInstanceOf(RustSdkCryptoJs.OtherUserIdentity); + expect(identity.identityNeedsUserApproval()).toBe(true); + expect(identity.isVerified()).toBe(true); + expect('userSigningKey' in identity).toBe(false); + + await identity.pinCurrentMasterKey(); + expect(ctx.calls).toEqual([['userIdentity.pin', { userId: '@bob:example.org' }]]); + }); + + it('never lets an absent verification flag read as verified', () => { + const partial = other() as Record; + delete partial.isVerified; + const identity = graftWasmPrototypes( + partial, + context() + ) as unknown as RustSdkCryptoJs.OtherUserIdentity; + + expect(() => identity.isVerified()).toThrow(/null pointer/); + }); +}); + +describe('Signatures hydration', () => { + const valid = btoa(String.fromCharCode(...new Uint8Array(64).fill(7))).replace(/=+$/, ''); + + const signatures = () => + graftWasmPrototypes( + { + className: 'Signatures', + json: JSON.stringify({ + '@bob:example.org': { + 'ed25519:BOBDEVICE': valid, + 'ed25519:OTHER': 'short', + }, + }), + }, + context() + ) as unknown as RustSdkCryptoJs.Signatures; + + it('rebuilds per-key entries and rejects malformed signatures', () => { + const perKey = signatures().get(new RustSdkCryptoJs.UserId('@bob:example.org')); + const good = perKey?.get('ed25519:BOBDEVICE'); + const bad = perKey?.get('ed25519:OTHER'); + + expect(good?.isValid()).toBe(true); + expect(good?.signature?.toBase64()).toBe(valid); + expect(bad?.isValid()).toBe(false); + expect(signatures().get(new RustSdkCryptoJs.UserId('@carol:example.org'))).toBeUndefined(); + }); + + it('returns the serde string from asJSON', () => { + expect(JSON.parse(signatures().asJSON())).toHaveProperty('@bob:example.org'); + }); +}); + +describe('Qr hydration', () => { + const qr = (qrCodeBytes: string | null) => + graftWasmPrototypes( + { + className: 'Qr', + flowId: 'flow', + otherUserId: '@bob:example.org', + otherDeviceId: 'BOBDEVICE', + userId: '@alice:example.org', + qrCodeBytes, + }, + context() + ) as unknown as RustSdkCryptoJs.Qr; + + it('round-trips base64 qr bytes synchronously', () => { + const bytes = new Uint8Array([0, 1, 2, 250, 251, 255]); + const encoded = btoa(String.fromCharCode(...bytes)).replace(/=+$/, ''); + + expect(Array.from(qr(encoded).toBytes())).toEqual(Array.from(bytes)); + }); + + it('reports no qr code rather than an empty one', () => { + expect(qr(null).toBytes()).toBeUndefined(); + }); +}); + +const bareRequest = (ctx: HydrationContext) => + graftWasmPrototypes( + { + className: 'VerificationRequest', + flowId: 'flow', + otherUserId: '@bob:example.org', + ownUserId: '@alice:example.org', + }, + ctx + ) as unknown as RustSdkCryptoJs.VerificationRequest; + +describe('QrCodeScan interception', () => { + it('carries the scanned bytes through fromBytes to base64 over IPC', async () => { + patchQrCodeScan(); + const bytes = new Uint8Array([1, 2, 3, 253, 254, 255]); + const scan = RustSdkCryptoJs.QrCodeScan.fromBytes(new Uint8ClampedArray(bytes)); + + expect(scan).toBeInstanceOf(RustSdkCryptoJs.QrCodeScan); + expect(() => scan.free()).not.toThrow(); + + const ctx = context(); + await bareRequest(ctx).scanQrCode(scan); + expect(ctx.calls).toEqual([ + [ + 'verificationRequest.scanQrCode', + { + userId: '@bob:example.org', + flowId: 'flow', + qrCodeData: btoa(String.fromCharCode(...bytes)).replace(/=+$/, ''), + }, + ], + ]); + }); + + it('patches once', () => { + patchQrCodeScan(); + const first = RustSdkCryptoJs.QrCodeScan.fromBytes; + patchQrCodeScan(); + + expect(RustSdkCryptoJs.QrCodeScan.fromBytes).toBe(first); + }); + + it('refuses a handle it cannot read back', () => { + expect(() => bareRequest(context()).scanQrCode({} as never)).toThrow(/scanned bytes/); + }); +}); + +describe('EncryptionInfo hydration', () => { + it('serves shieldState synchronously from the precomputed states', () => { + const encryptionInfo = graftWasmPrototypes( + { + className: 'EncryptionInfo', + sender: '@bob:example.org', + senderCurve25519Key: 'curve', + shieldStateLax: { + className: 'ShieldState', + color: RustSdkCryptoJs.ShieldColor.None, + }, + shieldStateStrict: { + className: 'ShieldState', + color: RustSdkCryptoJs.ShieldColor.Red, + }, + }, + context() + ) as unknown as RustSdkCryptoJs.EncryptionInfo; + + expect(encryptionInfo.shieldState(false).color).toBe(RustSdkCryptoJs.ShieldColor.None); + expect(encryptionInfo.shieldState(true).color).toBe(RustSdkCryptoJs.ShieldColor.Red); + }); + + it('restores the to-device sender verification method', () => { + const encryptionInfo = graftWasmPrototypes( + { + className: 'ToDeviceEncryptionInfo', + sender: '@bob:example.org', + senderDevice: 'BOBDEVICE', + senderCurve25519Key: 'curve', + isSenderVerified: true, + }, + context() + ) as unknown as RustSdkCryptoJs.ToDeviceEncryptionInfo; + + expect(encryptionInfo.isSenderVerified()).toBe(true); + }); +}); + +describe('synchronous verification actions', () => { + const request = { + className: 'VerificationRequest', + flowId: 'flow', + otherUserId: '@bob:example.org', + ownUserId: '@alice:example.org', + otherDeviceId: 'BOBDEVICE', + phase: RustSdkCryptoJs.VerificationRequestPhase.Requested, + isCancelled: false, + isDone: false, + isPassive: false, + isReady: false, + isSelfVerification: false, + timeRemainingMillis: 600000, + timedOut: false, + weStarted: false, + getVerification: null, + }; + + const readyRequest = { + className: 'ToDeviceRequest', + id: 'txn-1', + type: RustSdkCryptoJs.RequestType.ToDevice, + event_type: 'm.key.verification.ready', + txn_id: 'txn-1', + body: '{}', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards in-room verification events to the native machine', async () => { + bridge.engineInvoke.mockResolvedValue(undefined); + const proxy = new OlmMachineProxy(info); + const event = JSON.stringify({ + event_id: '$verification', + type: 'm.room.message', + sender: '@bob:example.org', + origin_server_ts: 1700000000000, + content: { + msgtype: 'm.key.verification.request', + from_device: 'BOBDEVICE', + methods: ['m.sas.v1'], + to: '@alice:example.org', + }, + }); + + await proxy.receiveVerificationEvent(event, '!verification:example.org'); + + expect(bridge.engineInvoke).toHaveBeenCalledWith( + expect.anything(), + 'receiveVerificationEvent', + { + event, + roomId: '!verification:example.org', + } + ); + }); + + it('returns undefined, queues the request, and acks it locally', async () => { + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'device.requestVerification') { + return { request, outgoingRequest: null }; + } + if (method === 'verificationRequest.accept') return readyRequest; + if (method === 'verificationRequest.state') return request; + if (method === 'outgoingRequests') return []; + throw new Error(`unexpected engine call ${method}`); + }); + + const proxy = new OlmMachineProxy(info); + const inner = (await proxy.requestDeviceVerification('@bob:example.org', 'BOBDEVICE', [])) + .request as unknown as RustSdkCryptoJs.VerificationRequest; + + expect(inner.acceptWithMethods([])).toBeUndefined(); + await vi.waitFor(async () => expect(await proxy.outgoingRequests()).toHaveLength(1)); + + const queued = (await proxy.outgoingRequests())[0] as RustSdkCryptoJs.ToDeviceRequest; + expect(queued).toBeInstanceOf(RustSdkCryptoJs.ToDeviceRequest); + expect(queued.id).toBe('txn-1'); + + await proxy.markRequestAsSent('txn-1', queued.type, '{}'); + expect(await proxy.outgoingRequests()).toHaveLength(0); + expect(bridge.engineInvoke).not.toHaveBeenCalledWith( + expect.anything(), + 'markRequestAsSent', + expect.anything() + ); + }); + + it('forwards an ack the engine owns', async () => { + bridge.engineInvoke.mockResolvedValue(undefined); + + await new OlmMachineProxy(info).markRequestAsSent('engine-txn', 0, '{}'); + expect(bridge.engineInvoke).toHaveBeenCalledWith( + expect.anything(), + 'markRequestAsSent', + expect.objectContaining({ requestId: 'engine-txn' }) + ); + }); + + it('invokes the stored changes callback through the event bridge', async () => { + bridge.engineInvoke.mockResolvedValue({ request, outgoingRequest: null }); + const proxy = new OlmMachineProxy(info); + const inner = (await proxy.requestDeviceVerification('@bob:example.org', 'BOBDEVICE', [])) + .request as unknown as RustSdkCryptoJs.VerificationRequest; + const onChange = vi.fn<() => Promise>(); + + inner.registerChangesCallback(onChange); + proxy.emit.verificationChanged('flow'); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('hydrates an incoming verification request atomically with its processed event', async () => { + const incoming = { + className: 'PlainTextToDeviceEvent', + type: RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText, + rawEvent: JSON.stringify({ + type: 'm.key.verification.request', + sender: '@bob:example.org', + content: { transaction_id: 'flow' }, + }), + verificationRequest: request, + }; + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'receiveSyncChanges') return [incoming]; + throw new Error(`unexpected engine call ${method}`); + }); + + const proxy = new OlmMachineProxy(info); + const processed = (await proxy.receiveSyncChanges( + JSON.stringify([JSON.parse(incoming.rawEvent)]), + { changed: [], left: [] }, + {} + )) as Array<{ verificationRequest: RustSdkCryptoJs.VerificationRequest }>; + + const [first] = processed; + expect(first).toBeDefined(); + if (!first) throw new Error('missing processed verification request'); + expect(first.verificationRequest).toBeInstanceOf(RustSdkCryptoJs.VerificationRequest); + expect(proxy.getVerificationRequest('@bob:example.org', 'flow')).toBe( + first.verificationRequest + ); + expect(bridge.engineInvoke).toHaveBeenCalledTimes(1); + }); + + it('refreshes verification snapshots after sync and notifies watchers', async () => { + const initial = { + ...request, + phase: RustSdkCryptoJs.VerificationRequestPhase.Requested, + isReady: false, + verification: null, + }; + const ready = { + ...request, + phase: RustSdkCryptoJs.VerificationRequestPhase.Ready, + isReady: true, + verification: null, + }; + + let receiveCount = 0; + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'receiveSyncChanges') { + receiveCount += 1; + return receiveCount === 1 + ? [ + { + className: 'PlainTextToDeviceEvent', + type: RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText, + rawEvent: JSON.stringify({ + type: 'm.key.verification.request', + sender: '@bob:example.org', + content: { transaction_id: 'flow' }, + }), + verificationRequest: initial, + }, + ] + : []; + } + if (method === 'verificationRequest.state') return ready; + throw new Error(`unexpected engine call ${method}`); + }); + + const proxy = new OlmMachineProxy(info); + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + const inner = proxy.getVerificationRequest( + '@bob:example.org', + 'flow' + ) as RustSdkCryptoJs.VerificationRequest; + const onChange = vi.fn<() => Promise>(); + inner.registerChangesCallback(onChange); + + expect(inner.isReady()).toBe(false); + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + expect(onChange).toHaveBeenCalled(); + expect(inner.isReady()).toBe(true); + expect(inner.phase()).toBe(RustSdkCryptoJs.VerificationRequestPhase.Ready); + }); + + it('retains verification watchers across a transient missing native request', async () => { + const initial = { + ...request, + phase: RustSdkCryptoJs.VerificationRequestPhase.Requested, + isReady: false, + verification: null, + }; + const ready = { + ...request, + phase: RustSdkCryptoJs.VerificationRequestPhase.Ready, + isReady: true, + verification: null, + }; + + let receiveCount = 0; + let stateCount = 0; + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'receiveSyncChanges') { + receiveCount += 1; + return receiveCount === 1 + ? [ + { + className: 'PlainTextToDeviceEvent', + type: RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText, + rawEvent: JSON.stringify({ + type: 'm.key.verification.request', + sender: '@bob:example.org', + content: { transaction_id: 'flow' }, + }), + verificationRequest: initial, + }, + ] + : []; + } + if (method === 'verificationRequest.state') { + stateCount += 1; + if (stateCount === 1) throw new Error('request temporarily transitioning'); + return ready; + } + throw new Error(`unexpected engine call ${method}`); + }); + + const proxy = new OlmMachineProxy(info); + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + const inner = proxy.getVerificationRequest( + '@bob:example.org', + 'flow' + ) as RustSdkCryptoJs.VerificationRequest; + const onChange = vi.fn<() => Promise>(); + inner.registerChangesCallback(onChange); + + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + expect(proxy.getVerificationRequest('@bob:example.org', 'flow')).toBe(inner); + expect(onChange).not.toHaveBeenCalled(); + + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + expect(onChange).toHaveBeenCalledOnce(); + expect(inner.isReady()).toBe(true); + }); + + it('loads SAS from the verifier store after Rust transitions the request out', async () => { + const initial = { + ...request, + phase: RustSdkCryptoJs.VerificationRequestPhase.Ready, + isReady: true, + verification: null, + }; + const sas = { + className: 'Sas', + userId: '@alice:example.org', + deviceId: 'ALICE', + otherUserId: '@bob:example.org', + otherDeviceId: 'BOBDEVICE', + flowId: 'flow', + roomId: null, + weStarted: false, + isSelfVerification: false, + startedFromRequest: true, + supportsEmoji: true, + haveWeConfirmed: false, + hasBeenAccepted: true, + canBePresented: true, + timedOut: false, + isDone: false, + isCancelled: false, + cancelInfo: null, + emoji: [{ symbol: '🐶', description: 'Dog' }], + emojiIndex: [0], + decimals: [1, 2, 3], + }; + const pendingSas = { + ...sas, + canBePresented: false, + cancelInfo: null, + emoji: null, + emojiIndex: null, + decimals: null, + }; + const confirmedSas = { ...sas, haveWeConfirmed: true }; + + let receiveCount = 0; + let verificationStateCount = 0; + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'receiveSyncChanges') { + receiveCount += 1; + return receiveCount === 1 + ? [ + { + className: 'PlainTextToDeviceEvent', + type: RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText, + rawEvent: JSON.stringify({ + type: 'm.key.verification.request', + sender: '@bob:example.org', + content: { transaction_id: 'flow' }, + }), + verificationRequest: initial, + }, + ] + : []; + } + if (method === 'verificationRequest.state') { + throw new Error('request transitioned to verification store'); + } + if (method === 'verification.state') { + verificationStateCount += 1; + if (verificationStateCount === 1) return pendingSas; + if (verificationStateCount === 2) return confirmedSas; + return null; + } + throw new Error(`unexpected engine call ${method}`); + }); + + const proxy = new OlmMachineProxy(info); + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + const inner = proxy.getVerificationRequest( + '@bob:example.org', + 'flow' + ) as RustSdkCryptoJs.VerificationRequest; + const onChange = vi.fn<() => Promise>(); + inner.registerChangesCallback(onChange); + + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + const verification = inner.getVerification() as RustSdkCryptoJs.Sas; + expect(verification).toBeInstanceOf(RustSdkCryptoJs.Sas); + expect(verification.canBePresented()).toBe(false); + expect(verification.emoji()).toBeUndefined(); + expect(verification.decimals()).toBeUndefined(); + + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + expect(onChange).toHaveBeenCalledTimes(2); + expect(verification.canBePresented()).toBe(true); + expect(verification.emoji()).toEqual([{ symbol: '🐶', description: 'Dog' }]); + + await proxy.receiveSyncChanges('[]', { changed: [], left: [] }, {}); + expect(onChange).toHaveBeenCalledTimes(3); + expect(verification.isDone()).toBe(true); + expect(inner.isDone()).toBe(true); + expect(inner.phase()).toBe(RustSdkCryptoJs.VerificationRequestPhase.Done); + }); +}); diff --git a/src/app/crypto/olmMachine/hydrate.ts b/src/app/crypto/olmMachine/hydrate.ts new file mode 100644 index 000000000..842129805 --- /dev/null +++ b/src/app/crypto/olmMachine/hydrate.ts @@ -0,0 +1,350 @@ +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { scannedQrCodeBytes } from './qrCodeScan'; + +export type HydrationContext = { + call: (method: string, args?: Record) => Promise; + queueOutgoing: (label: string, pending: Promise, flowId?: string) => void; + watchChanges: (flowId: string, callback: () => void) => void; + trackVerification: (kind: 'request' | 'sas' | 'qr', record: Snapshot) => void; +}; + +type Snapshot = Record; + +// Shadowing a wasm member must use defineProperty: assigning to a setter-less accessor throws. +const define = (record: Snapshot, name: string, value: unknown): void => { + Object.defineProperty(record, name, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +}; + +// Only OWN properties: an absent field must fall through and throw, not fabricate a verification. +const asMethod = (record: Snapshot, ...names: string[]): void => { + for (const name of names) { + if (!Object.hasOwn(record, name)) continue; + const value = record[name]; + define(record, name, () => value); + } +}; + +// Return undefined synchronously and queue the request; wasm returns it synchronously. +const queueAction = + ( + ctx: HydrationContext, + method: string, + target: Snapshot, + argsOf?: (args: unknown[]) => Snapshot + ) => + (...args: unknown[]): undefined => { + ctx.queueOutgoing( + method, + ctx.call(method, { ...target, ...argsOf?.(args) }), + String(target.flowId) + ); + return undefined; + }; + +const base64Value = (value: string) => ({ + toBase64: () => value, + toString: () => value, +}); + +const decodeBase64 = (value: string): Uint8Array => { + const unpadded = value.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, ''); + const binary = atob(unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=')); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +}; + +const encodeBase64 = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)).replace(/=+$/, ''); + +const keyAlgorithmNames: Record = { + [RustSdkCryptoJs.DeviceKeyAlgorithmName.Ed25519]: 'ed25519', + [RustSdkCryptoJs.DeviceKeyAlgorithmName.Curve25519]: 'curve25519', +}; + +const deviceKeys = (value: unknown): Map string }> => + new Map( + Object.entries((value ?? {}) as Record).map(([keyId, key]) => [ + keyId, + base64Value(String(key)), + ]) + ); + +const signatureIsValid = (keyId: string, signature: string): boolean => { + if (!keyId.startsWith('ed25519:')) return true; + try { + return decodeBase64(signature).length === 64; + } catch { + return false; + } +}; + +const flowTarget = (record: Snapshot): Snapshot => ({ + userId: String(record.otherUserId), + flowId: String(record.flowId), +}); + +const watchChanges = (record: Snapshot, ctx: HydrationContext): void => { + define(record, 'registerChangesCallback', (callback: () => void) => { + ctx.watchChanges(String(record.flowId), callback); + // js-sdk builds its verifier inside `onChange` and never calls it from the constructor, + // so an already-transitioned snapshot would leave `phase` throwing "no verifier". + if (record.verification) queueMicrotask(callback); + }); +}; + +const qrCodeBytes = (data: unknown): Uint8Array => { + const scanned = scannedQrCodeBytes(data); + if (scanned) return scanned; + if (data instanceof Uint8Array) return data; + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return Uint8Array.from(data as number[]); + throw new Error('scanQrCode() needs the scanned bytes; a wasm QrCodeScan cannot be read back'); +}; + +export const hydrate = (className: string, record: Snapshot, ctx: HydrationContext): void => { + define(record, 'free', () => {}); + + switch (className) { + case 'Device': { + const target = { + userId: String(record.userId), + deviceId: String(record.deviceId), + }; + asMethod( + record, + 'isVerified', + 'isBlacklisted', + 'isCrossSignedByOwner', + 'isCrossSigningTrusted', + 'isDeleted', + 'isLocallyTrusted', + 'firstTimeSeen' + ); + const keys = deviceKeys(record.keys); + define(record, 'keys', keys); + define(record, 'getKey', (algorithm: unknown) => { + const name = keyAlgorithmNames[algorithm as number]; + return name ? keys.get(`${name}:${target.deviceId}`) : undefined; + }); + define(record, 'setLocalTrust', (trustState: unknown) => + ctx.call('device.setLocalTrust', { ...target, trustState }) + ); + define(record, 'verify', () => ctx.call('device.verify', target)); + define( + record, + 'encryptToDeviceEvent', + (eventType: string, content: unknown, shareStrategy?: unknown) => + ctx.call('device.encryptToDeviceEvent', { + ...target, + eventType, + content, + shareStrategy: shareStrategy ?? null, + }) + ); + break; + } + + case 'UserDevices': { + const devices = Object.hasOwn(record, 'devices') + ? (record.devices as Snapshot[] | undefined) + : undefined; + asMethod(record, 'devices', 'keys', 'isAnyVerified'); + if (devices) { + define(record, 'get', (deviceId: unknown) => + devices.find((device) => String(device.deviceId) === String(deviceId)) + ); + } + break; + } + + case 'OwnUserIdentity': + case 'OtherUserIdentity': { + const target = { userId: String(record.userId) }; + asMethod( + record, + 'isVerified', + 'wasPreviouslyVerified', + 'hasVerificationViolation', + 'identityNeedsUserApproval' + ); + define(record, 'verify', () => ctx.call('userIdentity.verify', target)); + define(record, 'withdrawVerification', () => + ctx.call('userIdentity.withdrawVerification', target) + ); + if (className === 'OtherUserIdentity') { + define(record, 'pinCurrentMasterKey', () => ctx.call('userIdentity.pin', target)); + } + break; + } + + case 'Signatures': { + const json = typeof record.json === 'string' ? record.json : '{}'; + const parsed = JSON.parse(json) as Record>; + define(record, 'asJSON', () => json); + define(record, 'get', (signer: unknown) => { + const perKey = parsed[String(signer)]; + if (!perKey) return undefined; + return new Map( + Object.entries(perKey).map(([keyId, signature]) => [ + keyId, + { + isValid: () => signatureIsValid(keyId, signature), + isInvalid: () => !signatureIsValid(keyId, signature), + signature: base64Value(signature), + }, + ]) + ); + }); + break; + } + + case 'VerificationRequest': { + const target = flowTarget(record); + if (Object.hasOwn(record, 'verification') && !Object.hasOwn(record, 'getVerification')) { + define(record, 'getVerification', record.verification); + } + asMethod( + record, + 'isCancelled', + 'isDone', + 'isPassive', + 'isReady', + 'isSelfVerification', + 'phase', + 'timeRemainingMillis', + 'timedOut', + 'weStarted', + 'getVerification' + ); + ctx.trackVerification('request', record); + watchChanges(record, ctx); + define(record, 'accept', queueAction(ctx, 'verificationRequest.accept', target)); + define( + record, + 'acceptWithMethods', + queueAction(ctx, 'verificationRequest.accept', target, ([methods]) => ({ + methods, + })) + ); + define(record, 'cancel', queueAction(ctx, 'verificationRequest.cancel', target)); + define(record, 'generateQrCode', () => + ctx.call('verificationRequest.generateQrCode', target) + ); + define(record, 'scanQrCode', (data: unknown) => + ctx.call('verificationRequest.scanQrCode', { + ...target, + qrCodeData: encodeBase64(qrCodeBytes(data)), + }) + ); + define(record, 'startSas', () => ctx.call('verificationRequest.startSas', target)); + break; + } + + case 'Sas': { + const target = flowTarget(record); + for (const optional of ['cancelInfo', 'decimals', 'emoji', 'emojiIndex']) { + if (record[optional] === null) record[optional] = undefined; + } + asMethod( + record, + 'canBePresented', + 'cancelInfo', + 'decimals', + 'emoji', + 'emojiIndex', + 'hasBeenAccepted', + 'haveWeConfirmed', + 'isCancelled', + 'isDone', + 'isSelfVerification', + 'startedFromRequest', + 'supportsEmoji', + 'timedOut', + 'weStarted' + ); + ctx.trackVerification('sas', record); + watchChanges(record, ctx); + define(record, 'accept', queueAction(ctx, 'sas.accept', target)); + define(record, 'cancel', queueAction(ctx, 'sas.cancel', target)); + define( + record, + 'cancelWithCode', + queueAction(ctx, 'sas.cancel', target, ([code]) => ({ code })) + ); + define(record, 'confirm', () => ctx.call('sas.confirm', target)); + break; + } + + case 'Qr': { + const target = flowTarget(record); + asMethod( + record, + 'cancelInfo', + 'hasBeenConfirmed', + 'hasBeenScanned', + 'isCancelled', + 'isDone', + 'isSelfVerification', + 'reciprocated', + 'state', + 'weStarted' + ); + ctx.trackVerification('qr', record); + watchChanges(record, ctx); + const bytes = record.qrCodeBytes; + define(record, 'toBytes', () => + typeof bytes === 'string' ? decodeBase64(bytes) : undefined + ); + define(record, 'cancel', queueAction(ctx, 'qr.cancel', target)); + define( + record, + 'cancelWithCode', + queueAction(ctx, 'qr.cancel', target, ([code]) => ({ code })) + ); + define(record, 'confirmScanning', queueAction(ctx, 'qr.confirm', target)); + define(record, 'reciprocate', queueAction(ctx, 'qr.reciprocate', target)); + break; + } + + case 'CancelInfo': + asMethod(record, 'cancelCode', 'cancelledbyUs', 'reason'); + break; + + case 'EncryptionInfo': { + const lax = record.shieldStateLax; + const strict = record.shieldStateStrict; + define(record, 'shieldState', (isStrict: boolean) => (isStrict ? strict : lax)); + break; + } + + case 'ToDeviceEncryptionInfo': + asMethod(record, 'isSenderVerified'); + break; + + case 'BackupKeys': { + const base64 = record.decryptionKeyBase64; + define( + record, + 'decryptionKey', + typeof base64 === 'string' + ? RustSdkCryptoJs.BackupDecryptionKey.fromBase64(base64) + : undefined + ); + break; + } + + case 'SignatureVerification': + asMethod(record, 'trusted'); + break; + + default: + break; + } +}; diff --git a/src/app/crypto/olmMachine/ipcContract.test.ts b/src/app/crypto/olmMachine/ipcContract.test.ts new file mode 100644 index 000000000..46a18e00a --- /dev/null +++ b/src/app/crypto/olmMachine/ipcContract.test.ts @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// The engine boundary is stringly typed, so a rename on either side fails only at runtime. +const RUST_DIR = 'src-tauri/src/matrix_crypto'; +const TS_DIRS = ['src/app/crypto', 'src/app/crypto/olmMachine']; + +const readAll = (dir: string, extension: string): string => + readdirSync(dir) + .filter((name) => name.endsWith(extension) && !name.includes('.test.')) + .map((name) => readFileSync(join(dir, name), 'utf8')) + .join('\n'); + +const matchAll = (source: string, pattern: RegExp): Set => + new Set([...source.matchAll(pattern)].map((match) => match[1]!)); + +describe('engine IPC contract', () => { + const rust = readAll(RUST_DIR, '.rs'); + const ts = TS_DIRS.map((dir) => readAll(dir, '.ts')).join('\n'); + + const arms = matchAll(rust, /^\s+"([a-zA-Z.]+)" =>/gm); + const called = matchAll(ts, /(?:#call|ctx\.call|\bcall)\(\s*'([a-zA-Z.]+)'/g); + + it('parses both sides', () => { + expect(arms.size).toBeGreaterThan(40); + expect(called.size).toBeGreaterThan(40); + }); + + it('every method the TS proxy calls is handled by the Rust dispatch', () => { + const handled = (method: string) => arms.has(method) || arms.has(`dehydratedDevices.${method}`); + + expect([...called].filter((method) => !handled(method)).toSorted()).toEqual([]); + }); + + it('returns encrypted room content using the wasm JSON-string contract', () => { + expect(rust).toMatch( + /"encryptRoomEvent" =>[\s\S]*Ok\(Value::String\(encrypted_json\.to_owned\(\)\)\)/ + ); + }); +}); diff --git a/src/app/crypto/olmMachine/proxy.ts b/src/app/crypto/olmMachine/proxy.ts new file mode 100644 index 000000000..d81fc5290 --- /dev/null +++ b/src/app/crypto/olmMachine/proxy.ts @@ -0,0 +1,774 @@ +import { createDebugLogger } from '$utils/debugLogger'; +import { engineClose } from '$generated/tauri/commands'; +import { + encodeDecryptionSettings, + encodeEncryptionSettings, + encodeRoomSettings, + graftWasmPrototypes, + keyToBase64, + RustSdkCryptoJs, + toMegolmDecryptionError, +} from './wasmClasses'; +import { engineInvoke, type EngineIdentity } from './engineInvoke'; +import type { HydrationContext } from './hydrate'; + +const proxyLog = createDebugLogger('rust-crypto-proxy'); + +// Stands in for matrix-sdk-crypto-wasm's `OlmMachine`, forwarding calls to the Tauri host. + +export type EngineOpenInfo = { + userId: string; + deviceId: string; + ed25519Key: string; + curve25519Key: string; + deviceCreationTimeMs: number; +}; + +export type StartedVerification = { + request: unknown; + outgoingRequest: unknown; +}; + +const base64Key = (key: string) => ({ + toBase64: () => key, + toString: () => key, +}); + +const toBase64 = (bytes: unknown): string => + bytes instanceof Uint8Array + ? btoa(String.fromCharCode(...bytes)).replace(/=+$/, '') + : String(bytes); + +const idValue = (id: string) => ({ + toString: () => id, + localpart: () => id.replace(/^@/, '').split(':')[0], +}); + +type WatchedFlow = { + userId: string; + request?: Record; + sas?: Record; + qr?: Record; +}; + +const VERIFICATION_SLOT: Record = { + Sas: 'sas', + Qr: 'qr', +}; + +const VERIFICATION_MUTATION_PREFIXES = [ + 'verificationRequest.', + 'sas.', + 'qr.', + 'device.requestVerification', + 'userIdentity.requestVerification', + 'userIdentity.requestVerificationDm', +] as const; + +const isVerificationMutation = (method: string): boolean => + method === 'receiveSyncChanges' || + method === 'receiveVerificationEvent' || + VERIFICATION_MUTATION_PREFIXES.some((prefix) => method.startsWith(prefix)); + +export class OlmMachineProxy { + roomKeyRequestsEnabled = false; + + readonly #identity: EngineIdentity; + + readonly #info: EngineOpenInfo; + + #closed = false; + + #roomKeyUpdatedCallback?: (sessions: unknown[]) => void; + + #roomKeysWithheldCallback?: (withheld: unknown[]) => void; + + #userIdentityUpdatedCallback?: (userId: unknown) => void; + + #devicesUpdatedCallback?: (userIds: string[]) => void; + + #receiveSecretCallback?: (name: string, value: string) => void; + + readonly #pending: { id?: unknown }[] = []; + + readonly #changesCallbacks = new Map void>>(); + + readonly #watchedFlows = new Map(); + + readonly #hydration: HydrationContext = { + call: (method, args) => this.#call(method, args), + queueOutgoing: (label, pending, flowId) => { + void pending.then( + (request) => { + if (request) this.#pending.push(request as { id?: unknown }); + }, + (error) => { + proxyLog.error('error', `Rust crypto engine failed on ${label}`, error); + // js-sdk already resolved this action; nudge the flow so the UI re-reads state. + if (flowId) this.emit.verificationChanged(flowId); + } + ); + }, + watchChanges: (flowId, callback) => { + const callbacks = this.#changesCallbacks.get(flowId) ?? new Set(); + callbacks.add(callback); + this.#changesCallbacks.set(flowId, callbacks); + }, + trackVerification: (kind, record) => { + const flowId = typeof record.flowId === 'string' ? record.flowId : ''; + const userId = + typeof record.otherUserId === 'string' + ? record.otherUserId + : typeof record.userId === 'string' + ? record.userId + : ''; + if (!flowId || !userId) return; + const watched = this.#watchedFlows.get(flowId) ?? { userId }; + watched.userId = userId; + if (kind === 'request') watched.request ??= record; + if (kind === 'sas') watched.sas ??= record; + if (kind === 'qr') watched.qr ??= record; + this.#watchedFlows.set(flowId, watched); + }, + }; + + constructor(info: EngineOpenInfo) { + this.#info = info; + this.#identity = { userId: info.userId, deviceId: info.deviceId }; + } + + async #call(method: string, args: Record = {}): Promise { + if (this.#closed) { + throw new Error('Attempt to use a moved value'); + } + const rawResult = await engineInvoke(this.#identity, method, args); + if (isVerificationMutation(method)) { + const flowId = typeof args.flowId === 'string' ? args.flowId : undefined; + await this.#refreshVerificationFlows(flowId); + } + return graftWasmPrototypes(rawResult, this.#hydration); + } + + async #refreshVerificationFlows(onlyFlowId?: string): Promise { + const flowIds = onlyFlowId ? [onlyFlowId] : [...this.#watchedFlows.keys()]; + for (const flowId of flowIds) { + // eslint-disable-next-line no-await-in-loop + await this.#refreshVerificationFlow(flowId); + } + } + + async #refreshVerificationFlow(flowId: string): Promise { + const watched = this.#watchedFlows.get(flowId); + if (!watched) return; + + let raw: Record; + try { + raw = (await engineInvoke(this.#identity, 'verificationRequest.state', { + userId: watched.userId, + flowId, + })) as Record; + } catch { + // A transitioned flow can move out of Rust's request store before its SAS/QR verifier is + // exposed there. Query the verifier store directly and keep the original request wrapper. + let verification: Record | null; + try { + verification = (await engineInvoke(this.#identity, 'verification.state', { + userId: watched.userId, + flowId, + })) as Record | null; + } catch { + return; + } + if (!verification || typeof verification !== 'object') { + const confirmed = + typeof watched.sas?.haveWeConfirmed === 'function' && watched.sas.haveWeConfirmed(); + if (!confirmed) return; + if (watched.sas) { + patchSnapshot(watched.sas, { isDone: true }, []); + } + if (watched.request) { + patchSnapshot( + watched.request, + { + phase: RustSdkCryptoJs.VerificationRequestPhase.Done, + isDone: true, + isReady: false, + }, + [] + ); + } + this.emit.verificationChanged(flowId); + return; + } + this.#patchVerificationSnapshot(flowId, watched, verification); + this.emit.verificationChanged(flowId); + return; + } + + if (!raw || typeof raw !== 'object' || raw.className !== 'VerificationRequest') { + return; + } + + if (watched.request) { + patchSnapshot(watched.request, raw, ['className', 'flowId']); + } + + const nestedRaw = raw.verification as Record | null | undefined; + if (nestedRaw && typeof nestedRaw === 'object') { + this.#patchVerificationSnapshot(flowId, watched, nestedRaw); + } else if (watched.request) { + defineMethodField(watched.request, 'getVerification', null); + } + + this.emit.verificationChanged(flowId); + } + + #patchVerificationSnapshot( + flowId: string, + watched: WatchedFlow, + snapshot: Record + ): void { + const slot = VERIFICATION_SLOT[snapshot.className as string]; + if (!slot) return; + const current = watched[slot]; + if (current) { + patchSnapshot(current, snapshot, ['className', 'flowId']); + } else { + watched[slot] = graftWasmPrototypes({ ...snapshot }, this.#hydration) as Record< + string, + unknown + >; + } + if (watched.request) { + defineMethodField(watched.request, 'getVerification', watched[slot]); + } + } + + get userId() { + return idValue(this.#info.userId); + } + + get deviceId() { + return idValue(this.#info.deviceId); + } + + get identityKeys() { + return { + ed25519: base64Key(this.#info.ed25519Key), + curve25519: base64Key(this.#info.curve25519Key), + }; + } + + get deviceCreationTimeMs() { + return this.#info.deviceCreationTimeMs; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + void engineClose({ + userId: this.#identity.userId, + deviceId: this.#identity.deviceId, + }).catch((error) => proxyLog.error('error', 'Failed to close the Rust crypto engine', error)); + } + + free(): void { + this.close(); + } + + async receiveSyncChanges( + toDeviceEvents: string, + changedDevices: unknown, + oneTimeKeysCounts: Map | Record, + unusedFallbackKeys?: unknown + ): Promise { + return this.#call('receiveSyncChanges', { + toDeviceEvents, + changedDevices: toStringArray((changedDevices as { changed?: unknown })?.changed), + leftDevices: toStringArray((changedDevices as { left?: unknown })?.left), + oneTimeKeysCounts: toRecord(oneTimeKeysCounts), + unusedFallbackKeys: unusedFallbackKeys ? toStringArray(unusedFallbackKeys) : null, + }); + } + + async receiveVerificationEvent(event: string, roomId: unknown): Promise { + await this.#call('receiveVerificationEvent', { + event, + roomId: String(roomId), + }); + + let parsed: { + event_id?: unknown; + type?: unknown; + sender?: unknown; + content?: { msgtype?: unknown }; + }; + try { + parsed = JSON.parse(event) as typeof parsed; + } catch { + return; + } + + if ( + parsed.type === 'm.room.message' && + parsed.content?.msgtype === 'm.key.verification.request' && + typeof parsed.sender === 'string' && + typeof parsed.event_id === 'string' + ) { + // js-sdk performs a synchronous lookup as soon as this promise resolves. + await this.#call('getVerificationRequest', { + userId: parsed.sender, + flowId: parsed.event_id, + }); + } + } + + async outgoingRequests(): Promise { + return [...this.#pending, ...((await this.#call('outgoingRequests')) as unknown[])]; + } + + async markRequestAsSent(requestId: string, requestType: number, response: string): Promise { + const queued = this.#pending.findIndex((request) => String(request.id) === requestId); + if (queued !== -1) { + // Ack proxy-owned ids locally: the engine's machine never issued them. + this.#pending.splice(queued, 1); + return; + } + await this.#call('markRequestAsSent', { requestId, requestType, response }); + } + + async decryptRoomEvent(event: string, roomId: unknown, ...rest: unknown[]): Promise { + try { + return await this.#call('decryptRoomEvent', { + event, + roomId: String(roomId), + decryptionSettings: encodeDecryptionSettings(rest.at(-1)), + }); + } catch (error) { + throw toMegolmDecryptionError(error); + } + } + + async encryptRoomEvent(roomId: unknown, eventType: string, content: string): Promise { + return this.#call('encryptRoomEvent', { + roomId: String(roomId), + eventType, + content, + }); + } + + async encryptStateEvent(roomId: unknown, eventType: string, content: string): Promise { + return this.#call('encryptStateEvent', { + roomId: String(roomId), + eventType, + content, + }); + } + + async getRoomEventEncryptionInfo(event: string, roomId: unknown): Promise { + return this.#call('getRoomEventEncryptionInfo', { + event, + roomId: String(roomId), + }); + } + + async shareRoomKey( + roomId: unknown, + users: unknown[], + encryptionSettings: unknown + ): Promise { + return this.#call('shareRoomKey', { + roomId: String(roomId), + users: toStringArray(users), + encryptionSettings: encodeEncryptionSettings(encryptionSettings), + }); + } + + async getMissingSessions(users: unknown[]): Promise { + return this.#call('getMissingSessions', { users: toStringArray(users) }); + } + + async invalidateGroupSession(roomId: unknown): Promise { + return this.#call('invalidateGroupSession', { roomId: String(roomId) }); + } + + async getRoomSettings(roomId: unknown): Promise { + return this.#call('getRoomSettings', { roomId: String(roomId) }); + } + + async setRoomSettings(roomId: unknown, settings: unknown): Promise { + await this.#call('setRoomSettings', { + roomId: String(roomId), + settings: encodeRoomSettings(settings), + }); + } + + async roomKeyCounts(): Promise { + return this.#call('roomKeyCounts'); + } + + async getDevice(userId: unknown, deviceId: unknown, timeoutSecs?: number): Promise { + return this.#call('getDevice', { + userId: String(userId), + deviceId: String(deviceId), + timeoutSecs: timeoutSecs ?? null, + }); + } + + async getUserDevices(userId: unknown, timeoutSecs?: number): Promise { + return this.#call('getUserDevices', { + userId: String(userId), + timeoutSecs: timeoutSecs ?? null, + }); + } + + async getIdentity(userId: unknown): Promise { + return this.#call('getIdentity', { userId: String(userId) }); + } + + async queryKeysForUsers(users: unknown[]): Promise { + return this.#call('queryKeysForUsers', { users: toStringArray(users) }); + } + + async trackedUsers(): Promise> { + const users = (await this.#call('trackedUsers')) as string[]; + return new Set(users.map(idValue)); + } + + async updateTrackedUsers(users: unknown[]): Promise { + await this.#call('updateTrackedUsers', { users: toStringArray(users) }); + } + + async markAllTrackedUsersAsDirty(): Promise { + await this.#call('markAllTrackedUsersAsDirty'); + } + + async sign(message: string): Promise { + return this.#call('sign', { message }); + } + + async crossSigningStatus(): Promise { + return this.#call('crossSigningStatus'); + } + + async bootstrapCrossSigning(reset: boolean): Promise { + return this.#call('bootstrapCrossSigning', { reset }); + } + + async exportCrossSigningKeys(): Promise { + return this.#call('exportCrossSigningKeys'); + } + + async importCrossSigningKeys( + master?: string, + selfSigning?: string, + userSigning?: string + ): Promise { + return this.#call('importCrossSigningKeys', { + master_key: master ?? null, + self_signing_key: selfSigning ?? null, + user_signing_key: userSigning ?? null, + }); + } + + async pushSecretToVerifiedDevices(secretName: string): Promise { + return this.#call('pushSecretToVerifiedDevices', { secretName }); + } + + async getSecretsFromInbox(secretName: string): Promise> { + const secrets = (await this.#call('getSecretsFromInbox', { + secretName, + })) as string[]; + return new Set(secrets); + } + + async deleteSecretsFromInbox(secretName: string): Promise { + await this.#call('deleteSecretsFromInbox', { secretName }); + } + + async getBackupKeys(): Promise { + return this.#call('getBackupKeys'); + } + + async saveBackupDecryptionKey(decryptionKey: unknown, version: string): Promise { + await this.#call('saveBackupDecryptionKey', { + decryptionKey: keyToBase64(decryptionKey), + version, + }); + } + + async enableBackupV1(publicKeyBase64: string, version: string): Promise { + await this.#call('enableBackupV1', { publicKeyBase64, version }); + } + + async disableBackup(): Promise { + await this.#call('disableBackup'); + } + + async isBackupEnabled(): Promise { + return (await this.#call('isBackupEnabled')) as boolean; + } + + async verifyBackup(backupInfo: unknown): Promise { + return this.#call('verifyBackup', { backupInfo }); + } + + async backupRoomKeys(): Promise { + return this.#call('backupRoomKeys'); + } + + // js-sdk passes nested Maps, which JSON.stringify flattens to `{}`. + async importBackedUpRoomKeys( + keysByRoom: unknown, + progressListener?: (progress: bigint, total: bigint, failures: bigint) => void, + backupVersion?: string + ): Promise { + // RoomId keys compare by identity, so merge entries by their string value. + const keys: Record> = {}; + if (keysByRoom instanceof Map) { + for (const [roomId, sessions] of keysByRoom) { + const room = (keys[String(roomId)] ??= {}); + if (sessions instanceof Map) { + for (const [sessionId, key] of sessions) room[String(sessionId)] = key; + } + } + } + + const result = (await this.#call('importBackedUpRoomKeys', { + keys, + backupVersion: backupVersion ?? null, + })) as { importedCount?: number; totalCount?: number } | null; + + // The engine imports in one shot, so report completion rather than nothing. + const imported = BigInt(result?.importedCount ?? 0); + const total = BigInt(result?.totalCount ?? 0); + progressListener?.(imported, total, total - imported); + return result; + } + + async importExportedRoomKeys(keys: unknown): Promise { + return this.#call('importExportedRoomKeys', { keys }); + } + + async exportRoomKeys(): Promise { + return this.#call('exportRoomKeys'); + } + + getVerificationRequest(userId: unknown, flowId: string): unknown { + const watched = this.#watchedFlows.get(flowId); + return watched?.userId === String(userId) ? watched.request : undefined; + } + + getVerificationRequests(userId: unknown): unknown[] { + const expectedUserId = String(userId); + return [...this.#watchedFlows.values()] + .filter((watched) => watched.userId === expectedUserId && watched.request) + .map((watched) => watched.request); + } + + async requestDeviceVerification( + userId: unknown, + deviceId: unknown, + methods: number[] + ): Promise { + return this.#startVerification('device.requestVerification', { + userId: String(userId), + deviceId: String(deviceId), + methods, + }); + } + + async requestOwnUserVerification(methods: number[]): Promise { + return this.#startVerification('userIdentity.requestVerification', { + userId: this.#info.userId, + methods, + }); + } + + async verificationRequestContent( + userId: unknown, + roomId: unknown, + methods: number[] + ): Promise { + const content = (await this.#call('userIdentity.verificationRequestContent', { + userId: String(userId), + roomId: String(roomId), + methods, + })) as { outgoingRequest?: { body?: unknown } } | null; + + const body = content?.outgoingRequest?.body; + if (typeof body !== 'string') { + throw new Error('Rust crypto engine returned no verification request content'); + } + return body; + } + + async requestVerificationDm( + userId: unknown, + roomId: unknown, + requestEventId: string, + methods: number[] + ): Promise { + return this.#startVerification('userIdentity.requestVerificationDm', { + userId: String(userId), + roomId: String(roomId), + requestEventId, + methods, + }); + } + + async #startVerification( + method: string, + args: Record + ): Promise { + const started = (await this.#call(method, args)) as StartedVerification | null; + if (!started?.request) { + throw new Error(`Rust crypto engine returned no verification request from ${method}`); + } + return started; + } + + async getAllRoomsPendingKeyBundles(): Promise { + return (await this.#call('getAllRoomsPendingKeyBundles')) as unknown[]; + } + + async storeRoomPendingKeyBundle(roomId: unknown, inviterId: unknown): Promise { + await this.#call('storeRoomPendingKeyBundle', { + roomId: String(roomId), + inviterId: String(inviterId), + }); + } + + async clearRoomPendingKeyBundle(roomId: unknown): Promise { + await this.#call('clearRoomPendingKeyBundle', { roomId: String(roomId) }); + } + + async getPendingKeyBundleDetailsForRoom(roomId: unknown): Promise { + return this.#call('getPendingKeyBundleDetailsForRoom', { + roomId: String(roomId), + }); + } + + async getReceivedRoomKeyBundleData(roomId: unknown, inviterId: unknown): Promise { + return this.#call('getReceivedRoomKeyBundleData', { + roomId: String(roomId), + inviterId: String(inviterId), + }); + } + + // The engine keys the lookup on room + inviter and takes the bundle as base64. + async receiveRoomKeyBundle(bundleData: unknown, encryptedBundle: unknown): Promise { + const data = (bundleData ?? {}) as { roomId?: unknown; senderUser?: unknown }; + await this.#call('receiveRoomKeyBundle', { + roomId: String(data.roomId), + inviterId: String(data.senderUser), + bundle: toBase64(encryptedBundle), + }); + } + + dehydratedDevices() { + const call = (method: string, args: Record = {}) => + this.#call(`dehydratedDevices.${method}`, args); + return { + // js-sdk calls `(await create()).keysForUpload(...)`; the engine creates the device + // inside `keysForUpload`, so `create` only has to carry the handle. + create: async () => ({ + keysForUpload: (initialDeviceDisplayName: string, key: unknown) => + call('keysForUpload', { + initialDeviceDisplayName, + dehydratedDeviceKey: keyToBase64(key), + }), + }), + rehydrate: async (key: unknown, deviceId: unknown, deviceData: string) => { + const device = (await call('rehydrate', { + dehydratedDeviceKey: keyToBase64(key), + deviceId: String(deviceId), + deviceData, + })) as { deviceId: string }; + return { + ...device, + receiveEvents: (toDeviceEvents: string) => + call('receiveEvents', { deviceId: device.deviceId, toDeviceEvents }), + }; + }, + getDehydratedDeviceKey: () => call('getDehydratedDeviceKey'), + saveDehydratedDeviceKey: (key: unknown) => + call('saveDehydratedDeviceKey', { + dehydratedDeviceKey: keyToBase64(key), + }), + deleteDehydratedDeviceKey: () => call('deleteDehydratedDeviceKey'), + }; + } + + registerRoomKeyUpdatedCallback(callback: (sessions: unknown[]) => void): void { + this.#roomKeyUpdatedCallback = callback; + } + + registerRoomKeysWithheldCallback(callback: (withheld: unknown[]) => void): void { + this.#roomKeysWithheldCallback = callback; + } + + registerUserIdentityUpdatedCallback(callback: (userId: unknown) => void): void { + this.#userIdentityUpdatedCallback = callback; + } + + registerDevicesUpdatedCallback(callback: (userIds: string[]) => void): void { + this.#devicesUpdatedCallback = callback; + } + + registerReceiveSecretCallback(callback: (name: string, value: string) => void): void { + this.#receiveSecretCallback = callback; + } + + readonly emit = { + roomKeysUpdated: (sessions: unknown[]) => this.#roomKeyUpdatedCallback?.(sessions), + roomKeysWithheld: (withheld: unknown[]) => this.#roomKeysWithheldCallback?.(withheld), + userIdentityUpdated: (userId: string) => + this.#userIdentityUpdatedCallback?.(new RustSdkCryptoJs.UserId(userId)), + devicesUpdated: (userIds: string[]) => this.#devicesUpdatedCallback?.(userIds), + secretReceived: (name: string, value: string) => this.#receiveSecretCallback?.(name, value), + verificationChanged: (flowId: string) => + this.#changesCallbacks.get(flowId)?.forEach((callback) => callback()), + }; +} + +const toStringArray = (value: unknown): string[] => { + if (!value) return []; + const items = value instanceof Set ? [...value] : Array.isArray(value) ? value : [value]; + return items.map(String); +}; + +const toRecord = (value: Map | Record): Record => + value instanceof Map ? Object.fromEntries(value) : value; + +const defineMethodField = (record: Record, name: string, value: unknown): void => { + Object.defineProperty(record, name, { + value: () => value, + writable: true, + enumerable: true, + configurable: true, + }); +}; + +const SAS_OPTIONAL_FIELDS = new Set(['cancelInfo', 'decimals', 'emoji', 'emojiIndex']); + +const patchSnapshot = ( + target: Record, + source: Record, + skip: string[] +): void => { + for (const [key, value] of Object.entries(source)) { + if (skip.includes(key) || key === 'verification' || key === 'getVerification') continue; + if (typeof value === 'function') continue; + const normalized = + value === null && target instanceof RustSdkCryptoJs.Sas && SAS_OPTIONAL_FIELDS.has(key) + ? undefined + : value; + Object.defineProperty(target, key, { + value: typeof target[key] === 'function' ? () => normalized : normalized, + writable: true, + enumerable: true, + configurable: true, + }); + } +}; diff --git a/src/app/crypto/olmMachine/qrCodeScan.ts b/src/app/crypto/olmMachine/qrCodeScan.ts new file mode 100644 index 000000000..4abb80d80 --- /dev/null +++ b/src/app/crypto/olmMachine/qrCodeScan.ts @@ -0,0 +1,30 @@ +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; + +const SCANNED_BYTES = Symbol('sable.scannedQrCodeBytes'); + +let patched = false; + +type QrCodeScanClass = { + prototype: object; + fromBytes: (buffer: Uint8ClampedArray) => unknown; +}; + +// A wasm `QrCodeScan` handle cannot be read back, so keep the bytes on the object. +export const patchQrCodeScan = (): void => { + if (patched) return; + patched = true; + + const QrCodeScan = RustSdkCryptoJs.QrCodeScan as unknown as QrCodeScanClass; + QrCodeScan.fromBytes = (buffer) => { + const scan = Object.create(QrCodeScan.prototype) as object; + Object.defineProperty(scan, SCANNED_BYTES, { value: Uint8Array.from(buffer) }); + Object.defineProperty(scan, 'free', { value: () => {} }); + return scan; + }; +}; + +export const scannedQrCodeBytes = (scan: unknown): Uint8Array | undefined => { + if (scan === null || typeof scan !== 'object') return undefined; + const bytes = (scan as Record)[SCANNED_BYTES]; + return bytes instanceof Uint8Array ? bytes : undefined; +}; diff --git a/src/app/crypto/olmMachine/verificationContract.test.ts b/src/app/crypto/olmMachine/verificationContract.test.ts new file mode 100644 index 000000000..a6fcb01c0 --- /dev/null +++ b/src/app/crypto/olmMachine/verificationContract.test.ts @@ -0,0 +1,161 @@ +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { RustVerificationRequest } from 'matrix-js-sdk/lib/rust-crypto/verification'; +import { logger } from 'matrix-js-sdk/lib/logger'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { graftWasmPrototypes } from './wasmClasses'; +import type { HydrationContext } from './hydrate'; + +// Drives matrix-js-sdk's real `RustVerificationRequest` against engine-shaped payloads. +// Keep the fixtures in step with `request_state` / `sas_state` in matrix_crypto/verification.rs. + +const FLOW_ID = '$flow:example.org'; + +const ctx = (): HydrationContext => ({ + call: vi.fn(async () => undefined), + queueOutgoing: vi.fn(), + watchChanges: vi.fn(), + trackVerification: vi.fn(), +}); + +const sasPayload = (overrides: Record = {}) => ({ + className: 'Sas', + userId: '@me:example.org', + deviceId: 'DEVICE', + otherUserId: '@me:example.org', + otherDeviceId: 'OTHER', + flowId: FLOW_ID, + roomId: null, + weStarted: true, + isSelfVerification: true, + startedFromRequest: true, + supportsEmoji: true, + haveWeConfirmed: false, + hasBeenAccepted: true, + canBePresented: false, + timedOut: false, + isDone: false, + isCancelled: false, + cancelInfo: null, + emoji: null, + emojiIndex: null, + decimals: null, + ...overrides, +}); + +const requestPayload = (overrides: Record = {}) => ({ + className: 'VerificationRequest', + ownUserId: '@me:example.org', + otherUserId: '@me:example.org', + otherDeviceId: 'OTHER', + flowId: FLOW_ID, + roomId: null, + phase: 3, // Transitioned + weStarted: true, + isSelfVerification: true, + isPassive: false, + isReady: true, + isDone: false, + isCancelled: false, + timedOut: false, + timeRemainingMillis: 600000, + theirSupportedMethods: null, + ourSupportedMethods: null, + cancelInfo: null, + verification: sasPayload(), + ...overrides, +}); + +const wrap = (payload: Record) => { + const inner = graftWasmPrototypes(payload, ctx()); + return new RustVerificationRequest( + logger, + { userId: new RustSdkCryptoJs.UserId('@me:example.org') } as never, + inner as never, + { makeOutgoingRequest: vi.fn<() => Promise>(async () => {}) } as never, + ['m.sas.v1'] + ); +}; + +describe('verification IPC contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exposes the nested verification as a real wasm Sas so `instanceof` dispatch works', () => { + const inner = graftWasmPrototypes(requestPayload(), ctx()) as unknown as { + getVerification: () => unknown; + }; + + expect(inner.getVerification()).toBeInstanceOf(RustSdkCryptoJs.Sas); + }); + + it('does not throw on `phase` when the request has already transitioned', async () => { + const request = wrap(requestPayload()); + await Promise.resolve(); + + expect(() => request.phase).not.toThrow(); + expect(request.verifier).toBeDefined(); + }); + + it('treats an absent supported-method list as "no message yet", not a crash', () => { + const request = wrap(requestPayload({ theirSupportedMethods: null })); + + expect(() => request.otherPartySupportsMethod('m.sas.v1')).not.toThrow(); + expect(request.otherPartySupportsMethod('m.sas.v1')).toBe(false); + }); + + it('reports a to-device request as having no room, so it is not filtered out', () => { + const inner = graftWasmPrototypes(requestPayload(), ctx()) as unknown as { roomId?: unknown }; + + expect(inner.roomId).toBeUndefined(); + }); + + it('leaves cancellation empty while the request is live', () => { + const request = wrap(requestPayload()); + + expect(request.cancellationCode).toBeNull(); + expect(request.cancellingUserId).toBeUndefined(); + }); + + it('reads a cancellation through CancelInfo methods once one is present', () => { + const request = wrap( + requestPayload({ + isCancelled: true, + phase: 5, + cancelInfo: { + className: 'CancelInfo', + cancelCode: 'm.user', + cancelledbyUs: true, + reason: 'cancelled by user', + }, + }) + ); + + expect(request.cancellationCode).toBe('m.user'); + expect(request.cancellingUserId).toBe('@me:example.org'); + }); + + it('reports SAS values as undefined before key exchange, so ShowSas is not latched early', () => { + const sas = graftWasmPrototypes(sasPayload(), ctx()) as unknown as { + emoji: () => unknown; + decimals: () => unknown; + }; + + expect(sas.emoji()).toBeUndefined(); + expect(sas.decimals()).toBeUndefined(); + }); + + it('surfaces emoji once the engine provides them', () => { + const sas = graftWasmPrototypes( + sasPayload({ + canBePresented: true, + emoji: [{ symbol: '🐶', description: 'Dog' }], + decimals: [1234, 5678, 9012], + }), + ctx() + ) as unknown as { emoji: () => { symbol: string }[]; decimals: () => number[] }; + + expect(sas.emoji().map((e) => e.symbol)).toEqual(['🐶']); + expect(sas.decimals()).toEqual([1234, 5678, 9012]); + }); +}); diff --git a/src/app/crypto/olmMachine/wasmClasses.test.ts b/src/app/crypto/olmMachine/wasmClasses.test.ts new file mode 100644 index 000000000..6b8faea5b --- /dev/null +++ b/src/app/crypto/olmMachine/wasmClasses.test.ts @@ -0,0 +1,141 @@ +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { describe, expect, it } from 'vitest'; +import { + encodeDecryptionSettings, + encodeRoomSettings, + encodeEncryptionSettings, + graftWasmPrototypes, + keyToBase64, + toMegolmDecryptionError, +} from './wasmClasses'; +import type { HydrationContext } from './hydrate'; + +describe('keyToBase64', () => { + it('reads key material through toBase64 rather than String()', () => { + const key = RustSdkCryptoJs.BackupDecryptionKey.createRandomKey(); + + // eslint-disable-next-line typescript/no-base-to-string + expect(String(key)).toBe('[object Object]'); + expect(keyToBase64(key)).toBe(key.toBase64()); + }); + + it('passes through keys the engine already returned as base64', () => { + expect(keyToBase64('c29tZS1rZXk')).toBe('c29tZS1rZXk'); + }); +}); + +describe('toMegolmDecryptionError', () => { + it('preserves the missing-room-key type needed by the backup downloader', () => { + const error = toMegolmDecryptionError( + 'decryptRoomEvent failed: MissingRoomKey(None)' + ) as RustSdkCryptoJs.MegolmDecryptionError; + + expect(error).toBeInstanceOf(RustSdkCryptoJs.MegolmDecryptionError); + expect(error.code).toBe(RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey); + expect(error.maybe_withheld).toBeUndefined(); + }); + + it('uses the generic wasm error code for an unclassified Rust failure', () => { + const error = toMegolmDecryptionError( + 'decryptRoomEvent failed: Store error' + ) as RustSdkCryptoJs.MegolmDecryptionError; + + expect(error.code).toBe(RustSdkCryptoJs.DecryptionErrorCode.UnableToDecrypt); + }); +}); + +describe('encodeEncryptionSettings', () => { + it('loses nothing that JSON.stringify would drop', () => { + const settings = new RustSdkCryptoJs.EncryptionSettings(); + settings.rotationPeriodMessages = 42n; + + expect(JSON.parse(JSON.stringify(settings))).not.toHaveProperty('rotationPeriodMessages'); + expect(encodeEncryptionSettings(settings)).toMatchObject({ + algorithm: settings.algorithm, + historyVisibility: settings.historyVisibility, + rotationPeriodMessages: 42, + }); + }); + + it.each([ + ['onlyTrustedDevices', () => RustSdkCryptoJs.CollectStrategy.onlyTrustedDevices()], + ['identityBasedStrategy', () => RustSdkCryptoJs.CollectStrategy.identityBasedStrategy()], + ['allDevices', () => RustSdkCryptoJs.CollectStrategy.allDevices()], + ])('preserves the %s sharing strategy', (expected, build) => { + const settings = new RustSdkCryptoJs.EncryptionSettings(); + settings.sharingStrategy = build(); + + expect(encodeEncryptionSettings(settings)?.sharingStrategy).toBe(expected); + }); + + it('does not silently widen a restrictive strategy to allDevices', () => { + const settings = new RustSdkCryptoJs.EncryptionSettings(); + settings.sharingStrategy = RustSdkCryptoJs.CollectStrategy.deviceBasedStrategy(true, false); + + expect(encodeEncryptionSettings(settings)?.sharingStrategy).toBe('onlyTrustedDevices'); + }); +}); + +describe('encodeDecryptionSettings', () => { + it('carries the caller trust requirement across the boundary', () => { + const settings = new RustSdkCryptoJs.DecryptionSettings( + RustSdkCryptoJs.TrustRequirement.CrossSignedOrLegacy + ); + + expect(encodeDecryptionSettings(settings)).toEqual({ + senderDeviceTrustRequirement: RustSdkCryptoJs.TrustRequirement.CrossSignedOrLegacy, + }); + }); + + it('falls back to Untrusted when no settings are supplied', () => { + expect(encodeDecryptionSettings(undefined)).toEqual({ + senderDeviceTrustRequirement: RustSdkCryptoJs.TrustRequirement.Untrusted, + }); + }); +}); + +describe('graftWasmPrototypes', () => { + const ctx: HydrationContext = { + call: async () => undefined, + queueOutgoing: () => {}, + watchChanges: () => {}, + trackVerification: () => {}, + }; + + it("turns nulls into undefined so js-sdk's `=== undefined` guards fire", () => { + const grafted = graftWasmPrototypes( + { className: 'SignatureUploadRequest', id: null, body: '{}' }, + ctx + ) as { id?: unknown; body?: unknown }; + + expect(grafted.id).toBeUndefined(); + expect(grafted.body).toBe('{}'); + }); + + it('keeps the key as an own property so the wasm getter stays shadowed', () => { + const grafted = graftWasmPrototypes( + { className: 'SignatureUploadRequest', id: null, body: '{}' }, + ctx + ); + + // Reading through the prototype would hit `get id()` with no backing pointer. + expect(Object.hasOwn(grafted, 'id')).toBe(true); + }); +}); + +describe('encodeRoomSettings', () => { + it('reads the wasm accessors that JSON.stringify would drop', () => { + const settings = new RustSdkCryptoJs.RoomSettings(); + settings.onlyAllowTrustedDevices = true; + settings.sessionRotationPeriodMs = 604800000; + settings.sessionRotationPeriodMessages = 100; + + expect(JSON.parse(JSON.stringify(settings))).not.toHaveProperty('onlyAllowTrustedDevices'); + expect(encodeRoomSettings(settings)).toMatchObject({ + algorithm: settings.algorithm, + onlyAllowTrustedDevices: true, + sessionRotationPeriodMs: 604800000, + sessionRotationPeriodMessages: 100, + }); + }); +}); diff --git a/src/app/crypto/olmMachine/wasmClasses.ts b/src/app/crypto/olmMachine/wasmClasses.ts new file mode 100644 index 000000000..f16ebf3be --- /dev/null +++ b/src/app/crypto/olmMachine/wasmClasses.ts @@ -0,0 +1,141 @@ +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { hydrate, type HydrationContext } from './hydrate'; + +// Wasm key objects hide their material behind `toBase64()`; `String()` gives `[object Object]`. +export const keyToBase64 = (key: unknown): string => { + const encode = (key as { toBase64?: () => string } | null)?.toBase64; + return typeof encode === 'function' ? encode.call(key) : String(key); +}; + +// `CollectStrategy` exposes no getters, only `eq()`, so the variant has to be recovered by +// comparison. Names are the ones `matrix_crypto::rooms::collect_strategy` parses. +const COLLECT_STRATEGIES: ReadonlyArray<[string, () => unknown]> = [ + ['identityBasedStrategy', () => RustSdkCryptoJs.CollectStrategy.identityBasedStrategy()], + ['onlyTrustedDevices', () => RustSdkCryptoJs.CollectStrategy.onlyTrustedDevices()], + [ + 'errorOnVerifiedUserProblem', + () => RustSdkCryptoJs.CollectStrategy.errorOnUnverifiedUserProblem(), + ], + ['allDevices', () => RustSdkCryptoJs.CollectStrategy.allDevices()], +]; + +export const collectStrategyName = (strategy: unknown): string => { + const candidate = strategy as { eq?: (other: unknown) => boolean } | null; + if (typeof candidate?.eq !== 'function') return 'allDevices'; + for (const [name, build] of COLLECT_STRATEGIES) { + try { + if (candidate.eq(build())) return name; + } catch { + // Not a variant this wasm build exposes. + } + } + return 'allDevices'; +}; + +const num = (value: unknown) => (typeof value === 'bigint' ? Number(value) : value); + +// Read wasm prototype getters before crossing the JSON boundary. +export const encodeRoomSettings = (settings: unknown): Record | null => { + if (settings === null || typeof settings !== 'object') return null; + const s = settings as Record; + return { + algorithm: s.algorithm, + encryptStateEvents: s.encryptStateEvents, + onlyAllowTrustedDevices: s.onlyAllowTrustedDevices, + sessionRotationPeriodMs: num(s.sessionRotationPeriodMs), + sessionRotationPeriodMessages: num(s.sessionRotationPeriodMessages), + }; +}; + +// Wasm accessors live on the prototype, so JSON would carry only the internal pointer, and a +// dropped `sharingStrategy` silently means "share room keys with every device". +export const encodeEncryptionSettings = (settings: unknown): Record | null => { + if (settings === null || typeof settings !== 'object') return null; + const s = settings as Record; + return { + algorithm: s.algorithm, + historyVisibility: s.historyVisibility, + rotationPeriod: num(s.rotationPeriod), + rotationPeriodMessages: num(s.rotationPeriodMessages), + sharingStrategy: collectStrategyName(s.sharingStrategy), + }; +}; + +export const encodeDecryptionSettings = (settings: unknown): Record => { + const trust = (settings as Record | null)?.sender_device_trust_requirement; + return { senderDeviceTrustRequirement: typeof trust === 'number' ? trust : 0 }; +}; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const toMegolmDecryptionError = (error: unknown): unknown => { + const description = errorMessage(error); + const codes = RustSdkCryptoJs.DecryptionErrorCode; + let code = codes.UnableToDecrypt; + + if (description.includes('MissingRoomKey')) code = codes.MissingRoomKey; + else if (description.includes('UnknownMessageIndex')) code = codes.UnknownMessageIndex; + else if (description.includes('MismatchedIdentityKeys')) code = codes.MismatchedIdentityKeys; + else if (description.includes('VerificationViolation')) { + code = codes.SenderIdentityVerificationViolation; + } else if (description.includes('UnsignedDevice')) code = codes.UnsignedSenderDevice; + else if (description.includes('SenderIdentityNotTrusted(None')) code = codes.UnknownSenderDevice; + else if (description.includes('MismatchedSender')) code = codes.MismatchedSender; + + const wrapped = { code, description, maybe_withheld: undefined }; + Object.setPrototypeOf(wrapped, RustSdkCryptoJs.MegolmDecryptionError.prototype); + return wrapped; +}; + +const hasOwnPrototype = (name: string): boolean => { + const candidate = (RustSdkCryptoJs as Record)[name]; + return typeof candidate === 'function' && 'prototype' in candidate; +}; + +// JSON has no `undefined`, so absent wasm values arrive as `null` and slip past js-sdk's +// `=== undefined` guards. Rewrite in place: an own property still shadows the wasm getter. +const nullsToUndefined = (record: Record): void => { + for (const [key, value] of Object.entries(record)) { + if (value !== null) continue; + Object.defineProperty(record, key, { + value: undefined, + writable: true, + enumerable: true, + configurable: true, + }); + } +}; + +// Every payload carries a `className` because js-sdk dispatches on `instanceof`. +export const graftWasmPrototypes = (value: T, ctx: HydrationContext): T => { + if (Array.isArray(value)) { + value.forEach((item) => graftWasmPrototypes(item, ctx)); + return value; + } + if (value === null || typeof value !== 'object') return value; + + const record = value as Record; + for (const nested of Object.values(record)) { + if (nested !== null && typeof nested === 'object') graftWasmPrototypes(nested, ctx); + } + + const className = record.className; + if (typeof className === 'string') { + if (!hasOwnPrototype(className)) { + throw new Error( + `Rust crypto engine returned unknown wasm className "${className}"; ` + + 'the engine and matrix-sdk-crypto-wasm are out of sync' + ); + } + const wasmClass = (RustSdkCryptoJs as Record)[className] as { + prototype: object; + }; + Object.setPrototypeOf(value, wasmClass.prototype); + nullsToUndefined(record); + hydrate(className, record, ctx); + } + return value; +}; + +export { RustSdkCryptoJs }; diff --git a/src/app/crypto/verificationOverrides.test.ts b/src/app/crypto/verificationOverrides.test.ts new file mode 100644 index 000000000..6cf1cc161 --- /dev/null +++ b/src/app/crypto/verificationOverrides.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RustCrypto } from 'matrix-js-sdk/lib/rust-crypto/rust-crypto'; +import { RustSdkCryptoJs } from './olmMachine/wasmClasses'; +import { OlmMachineProxy } from './olmMachine/proxy'; +import { installVerificationOverrides } from './verificationOverrides'; + +const bridge = vi.hoisted(() => ({ + engineInvoke: vi.fn<(identity: unknown, method: string, args: never) => Promise>(), +})); + +vi.mock('./olmMachine/engineInvoke', () => bridge); + +const info = { + userId: '@alice:example.org', + deviceId: 'ALICE', + ed25519Key: 'ed', + curve25519Key: 'curve', + deviceCreationTimeMs: 0, +}; + +const requestSnapshot = { + className: 'VerificationRequest', + flowId: 'flow', + otherUserId: '@bob:example.org', + ownUserId: '@alice:example.org', + phase: RustSdkCryptoJs.VerificationRequestPhase.Requested, +}; + +const outgoingSnapshot = { + className: 'ToDeviceRequest', + id: 'txn-1', + type: RustSdkCryptoJs.RequestType.ToDevice, + event_type: 'm.key.verification.request', + txn_id: 'txn-1', + body: '{}', +}; + +const harness = () => { + const sent: unknown[] = []; + const wrapped: unknown[] = []; + const sendVerificationRequestContent = vi.fn< + (roomId: string, content: string) => Promise + >(async () => '$event:example.org'); + const rustCrypto = { + _supportedVerificationMethods: ['m.sas.v1', 'm.qr_code.show.v1'], + makeVerificationRequest: (request: unknown) => { + wrapped.push(request); + return { wraps: request }; + }, + outgoingRequestProcessor: { + makeOutgoingRequest: async (request: unknown) => { + sent.push(request); + }, + }, + sendVerificationRequestContent, + } as unknown as RustCrypto; + + installVerificationOverrides(rustCrypto, new OlmMachineProxy(info)); + return { rustCrypto, sent, wrapped, sendVerificationRequestContent }; +}; + +describe('installVerificationOverrides', () => { + beforeEach(() => { + vi.clearAllMocks(); + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'verificationRequest.state') return requestSnapshot; + return { + request: requestSnapshot, + outgoingRequest: outgoingSnapshot, + }; + }); + }); + + it.each([ + [ + 'requestDeviceVerification', + 'device.requestVerification', + { userId: '@bob:example.org', deviceId: 'BOBDEVICE', methods: [0, 2] }, + (crypto: RustCrypto) => crypto.requestDeviceVerification('@bob:example.org', 'BOBDEVICE'), + ], + [ + 'requestOwnUserVerification', + 'userIdentity.requestVerification', + { userId: '@alice:example.org', methods: [0, 2] }, + (crypto: RustCrypto) => crypto.requestOwnUserVerification(), + ], + ] as const)( + 'sends the outgoing request and wraps the request for %s', + async (_name, method, args, call) => { + const { rustCrypto, sent, wrapped } = harness(); + const result = await call(rustCrypto); + + expect(bridge.engineInvoke).toHaveBeenCalledWith(expect.anything(), method, args); + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(RustSdkCryptoJs.ToDeviceRequest); + expect(wrapped[0]).toBeInstanceOf(RustSdkCryptoJs.VerificationRequest); + expect(result).toEqual({ wraps: wrapped[0] }); + } + ); + + it('sends the DM content itself and builds the request from the event id it landed on', async () => { + const content = JSON.stringify({ + msgtype: 'm.key.verification.request', + methods: ['m.sas.v1'], + }); + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'userIdentity.verificationRequestContent') { + return { + request: null, + outgoingRequest: { + className: 'RoomMessageRequest', + type: RustSdkCryptoJs.RequestType.RoomMessage, + room_id: '!room:example.org', + txn_id: 'txn-2', + event_type: 'm.room.message', + body: content, + }, + }; + } + if (method === 'userIdentity.requestVerificationDm') { + return { request: requestSnapshot, outgoingRequest: null }; + } + if (method === 'verificationRequest.state') { + return requestSnapshot; + } + throw new Error(`unexpected engine call ${method}`); + }); + + const { rustCrypto, sent, wrapped, sendVerificationRequestContent } = harness(); + const result = await rustCrypto.requestVerificationDM('@bob:example.org', '!room:example.org'); + + expect(sendVerificationRequestContent).toHaveBeenCalledWith('!room:example.org', content); + expect(bridge.engineInvoke).toHaveBeenCalledWith( + expect.anything(), + 'userIdentity.requestVerificationDm', + { + userId: '@bob:example.org', + roomId: '!room:example.org', + requestEventId: '$event:example.org', + methods: [0, 2], + } + ); + expect(sent).toHaveLength(0); + expect(wrapped[0]).toBeInstanceOf(RustSdkCryptoJs.VerificationRequest); + expect(result).toEqual({ wraps: wrapped[0] }); + }); + + it('refuses to send a DM request without content from the engine', async () => { + bridge.engineInvoke.mockResolvedValue({ request: null, outgoingRequest: null }); + const { rustCrypto, sendVerificationRequestContent } = harness(); + + await expect( + rustCrypto.requestVerificationDM('@bob:example.org', '!room:example.org') + ).rejects.toThrow(/no verification request content/); + expect(sendVerificationRequestContent).not.toHaveBeenCalled(); + }); + + it('skips the outgoing request when the engine has nothing to send', async () => { + bridge.engineInvoke.mockResolvedValue({ request: requestSnapshot, outgoingRequest: null }); + const { rustCrypto, sent } = harness(); + + await rustCrypto.requestOwnUserVerification(); + expect(sent).toHaveLength(0); + }); + + it('refuses to invent a request when the engine returns none', async () => { + bridge.engineInvoke.mockResolvedValue(null); + const { rustCrypto } = harness(); + + await expect(rustCrypto.requestOwnUserVerification()).rejects.toThrow( + /no verification request/ + ); + }); +}); diff --git a/src/app/crypto/verificationOverrides.ts b/src/app/crypto/verificationOverrides.ts new file mode 100644 index 000000000..d7758574f --- /dev/null +++ b/src/app/crypto/verificationOverrides.ts @@ -0,0 +1,46 @@ +import { verificationMethodIdentifierToMethod } from 'matrix-js-sdk/lib/rust-crypto/verification'; +import type { RustCrypto } from 'matrix-js-sdk/lib/rust-crypto/rust-crypto'; +import type { VerificationRequest } from '$types/matrix-sdk'; +import type { OlmMachineProxy, StartedVerification } from './olmMachine/proxy'; + +type RustCryptoInternals = { + // eslint-disable-next-line no-underscore-dangle + _supportedVerificationMethods: string[]; + makeVerificationRequest: (request: unknown) => VerificationRequest; + outgoingRequestProcessor: { makeOutgoingRequest: (request: unknown) => Promise }; + sendVerificationRequestContent: (roomId: string, content: string) => Promise; +}; + +const internals = (rustCrypto: RustCrypto): RustCryptoInternals => + rustCrypto as unknown as RustCryptoInternals; + +export const installVerificationOverrides = ( + rustCrypto: RustCrypto, + proxy: OlmMachineProxy +): void => { + const inner = internals(rustCrypto); + const methods = (): number[] => + // eslint-disable-next-line no-underscore-dangle + inner._supportedVerificationMethods.map(verificationMethodIdentifierToMethod); + + const complete = async (started: StartedVerification): Promise => { + if (started.outgoingRequest) { + await inner.outgoingRequestProcessor.makeOutgoingRequest(started.outgoingRequest); + } + return inner.makeVerificationRequest(started.request); + }; + + rustCrypto.requestDeviceVerification = async (userId, deviceId) => + complete(await proxy.requestDeviceVerification(userId, deviceId, methods())); + + rustCrypto.requestOwnUserVerification = async () => + complete(await proxy.requestOwnUserVerification(methods())); + + rustCrypto.requestVerificationDM = async (userId, roomId) => { + const chosen = methods(); + const content = await proxy.verificationRequestContent(userId, roomId, chosen); + const eventId = await inner.sendVerificationRequestContent(roomId, content); + const started = await proxy.requestVerificationDm(userId, roomId, eventId, chosen); + return inner.makeVerificationRequest(started.request); + }; +}; diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 73defdc45..661e6dc02 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.731056+00:00 + * Generated at: 2026-08-09T04:29:05.024797+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -34,6 +34,22 @@ export async function deactivateCallAudioSession(): Promise { return invoke('deactivate_call_audio_session'); } +export async function engineClose(params: types.EngineCloseParams): Promise { + return invoke('engine_close', params); +} + +export async function engineInvoke(params: types.EngineInvokeParams): Promise { + return invoke('engine_invoke', params); +} + +export async function engineOpen(params: types.EngineOpenParams): Promise { + return invoke('engine_open', params); +} + +export async function engineWipe(params: types.EngineWipeParams): Promise { + return invoke('engine_wipe', params); +} + export async function exportDiagnostics(params: types.ExportDiagnosticsParams): Promise { return invoke('export_diagnostics', params); } diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts index ff0d63855..8c48eca5a 100644 --- a/src/app/generated/tauri/events.ts +++ b/src/app/generated/tauri/events.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.732167+00:00 + * Generated at: 2026-08-09T04:29:05.025876200+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -27,4 +27,17 @@ export async function onOpenSettings( }); } +/** + * Listen for 'share-received' events + * @param handler - Callback function to handle the event + * @returns Promise that resolves to an unlisten function + */ +export async function onShareReceived( + handler: (payload: void) => void +): Promise { + return listen('share-received', (event) => { + handler(event.payload); + }); +} + diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 0b97ef4c5..4dbb1454e 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.732351+00:00 + * Generated at: 2026-08-09T04:29:05.026381300+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index cec661528..97a68140a 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.729365+00:00 + * Generated at: 2026-08-09T04:29:05.022439700+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -20,6 +20,14 @@ export interface DesktopSettings { useCustomTitleBar: boolean; } +export interface EngineInfo { + user_id: string; + device_id: string; + ed25519_key: string; + curve25519_key: string; + store_path: string; +} + export interface LoopbackFetchRequest { requestId: string; method: string; @@ -78,6 +86,34 @@ export interface BuildDiagnosticsArchiveParams { [key: string]: unknown; } +export interface EngineCloseParams { + userId: string; + deviceId: string; + [key: string]: unknown; +} + +export interface EngineInvokeParams { + userId: string; + deviceId: string; + method: string; + argsJson: string; + [key: string]: unknown; +} + +export interface EngineOpenParams { + dir?: string | null; + passphrase?: string | null; + userId: string; + deviceId: string; + [key: string]: unknown; +} + +export interface EngineWipeParams { + userId: string; + deviceId: string; + [key: string]: unknown; +} + export interface ExportDiagnosticsParams { frontendLogs?: string | null; [key: string]: unknown; diff --git a/src/app/hooks/useVerificationRequest.test.tsx b/src/app/hooks/useVerificationRequest.test.tsx new file mode 100644 index 000000000..571a5821c --- /dev/null +++ b/src/app/hooks/useVerificationRequest.test.tsx @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { VerifierEvent } from '$types/matrix-sdk'; +import { useVerifierShowSas } from './useVerificationRequest'; + +describe('useVerifierShowSas', () => { + it('publishes SAS callbacks that were ready before the listener subscribed', () => { + const sasCallbacks = { sas: { emoji: [] } }; + const on = vi.fn<(event: string, handler: () => void) => void>(); + const removeListener = vi.fn<(event: string, handler: () => void) => void>(); + const getShowSasCallbacks = vi.fn<() => typeof sasCallbacks | null>(() => sasCallbacks); + const onCallback = vi.fn<(callbacks: unknown) => void>(); + + const { unmount } = renderHook(() => + useVerifierShowSas( + { + on, + removeListener, + getShowSasCallbacks, + } as never, + onCallback as never + ) + ); + + expect(on).toHaveBeenCalledWith(VerifierEvent.ShowSas, onCallback); + expect(getShowSasCallbacks).toHaveBeenCalledOnce(); + expect(onCallback).toHaveBeenCalledWith(sasCallbacks); + expect(on.mock.invocationCallOrder[0]).toBeLessThan( + getShowSasCallbacks.mock.invocationCallOrder[0] ?? 0 + ); + + unmount(); + expect(removeListener).toHaveBeenCalledWith(VerifierEvent.ShowSas, onCallback); + }); +}); diff --git a/src/app/hooks/useVerificationRequest.ts b/src/app/hooks/useVerificationRequest.ts index 65c2c2e4b..187748da7 100644 --- a/src/app/hooks/useVerificationRequest.ts +++ b/src/app/hooks/useVerificationRequest.ts @@ -66,6 +66,8 @@ export const useVerifierShowSas = ( ) => { useEffect(() => { verifier.on(VerifierEvent.ShowSas, onCallback); + const current = verifier.getShowSasCallbacks(); + if (current) onCallback(current); return () => { verifier.removeListener(VerifierEvent.ShowSas, onCallback); }; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 74269175a..87cfd5e35 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -12,11 +12,14 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { clearCacheAndReload, clearLoginData, + discardSessionStores, initClient, logoutClient, startClient, stopClient, } from '$client/initMatrix'; +import { isLegacyWasmCryptoStoreError } from '$app/crypto/install'; +import { AsyncError } from '$components/AsyncError'; import { clearSecretStorageKeys } from '$client/secretStorageKeys'; import { resetBackupRestoreAtom } from '$state/backupRestore'; import { SplashScreen } from '$components/splash-screen'; @@ -340,6 +343,18 @@ export function ClientRoot({ children }: ClientRootProps) { window.location.reload(); }, [mx, activeSession, sessions, setSessions, setActiveSessionId]); + const [upgradeState, signOutForCryptoUpgrade] = useAsyncCallback( + useCallback(async () => { + if (!activeSession) return; + await discardSessionStores(activeSession); + setSessions({ type: 'DELETE', session: activeSession } as SessionsAction); + setActiveSessionId( + sessions.find((session) => session.userId !== activeSession.userId)?.userId ?? undefined + ); + window.location.reload(); + }, [activeSession, sessions, setSessions, setActiveSessionId]) + ); + useSyncNicknames(mx); useLogoutListener(mx); useAppVisibility(mx); @@ -405,6 +420,8 @@ export function ClientRoot({ children }: ClientRootProps) { ); const isError = loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error; + const legacyCryptoUpgradeRequired = + loadState.status === AsyncStatus.Error && isLegacyWasmCryptoStoreError(loadState.error); // Set matrix client context: homeserver and sync type (not PII) useEffect(() => { @@ -445,7 +462,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) { + if (loadState.status === AsyncStatus.Error && !isLegacyWasmCryptoStoreError(loadState.error)) { Sentry.captureException(loadState.error, { tags: { phase: 'load' } }); } }, [loadState]); @@ -465,17 +482,42 @@ export function ClientRoot({ children }: ClientRootProps) { - {loadState.status === AsyncStatus.Error && ( - {`Failed to load. ${loadState.error.message}`} - )} + {loadState.status === AsyncStatus.Error && + (legacyCryptoUpgradeRequired ? ( + <> + Encrypted chat needs a one-time upgrade. + + Sign out and sign in again to use native crypto. Local encrypted-message + keys from this installation must be restored from backup. + + + + + ) : ( + {`Failed to load. ${loadState.error.message}`} + ))} {startState.status === AsyncStatus.Error && ( {`Failed to start. ${startState.error.message}`} )} - + {!legacyCryptoUpgradeRequired && ( + + )} diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 05734821e..b54b98b09 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -15,6 +15,8 @@ import { import { fetch } from '$utils/fetch'; import { matrixFetch } from './matrixFetch'; import { clearMediaCache } from '$utils/mediaCache'; +import { isTauri } from '@tauri-apps/api/core'; +import { engineWipe } from '$generated/tauri/commands'; import { clearNavToActivePathStore } from '$state/navToActivePath'; import type { Session, Sessions, SessionStoreName } from '$state/sessions'; @@ -33,6 +35,7 @@ import { pushSessionToSW } from '../sw-session'; import { assertAuthMetadataIssuer, createSessionTokenRefresher } from './oidcTokenRefresher'; import { revokeOAuthToken } from './oauthTokenRevocation'; import { clearSecretStorageKeys, cryptoCallbacks } from './secretStorageKeys'; +import { installRustCrypto, rustEngineEnabled } from '$app/crypto/install'; import type { SlidingSyncDiagnostics } from './slidingSync'; import { markExpandedTimelinesLimited, @@ -217,6 +220,29 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise = ]); }; +const clearSessionCaches = (session: Session): void => { + SlidingSyncSidebarCache.clear(session.userId); + clearCachedVersions(session.baseUrl, session.userId); + clearCachedUserProfiles(session.userId); + clearSecretStorageKeys(); +}; + +export const discardSessionStores = async (session: Session): Promise => { + clearSessionCaches(session); + const storeName = getSessionStoreName(session); + await deleteSessionStores(storeName); + await wipeNativeCryptoStore(session); +}; + +const wipeNativeCryptoStore = async (session: Session): Promise => { + if (!isTauri() || !session.deviceId) return; + try { + await engineWipe({ userId: session.userId, deviceId: session.deviceId }); + } catch (error) { + log.warn('wipeNativeCryptoStore failed', session.userId, error); + } +}; + const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -289,9 +315,13 @@ const initializeClient = async ( }); const syncStorePromise = measureStartupPhase('sync_store', () => indexedDBStore.startup()); - const cryptoPromise = measureStartupPhase('rust_crypto', () => - mx.initRustCrypto({ cryptoDatabasePrefix }) - ); + const cryptoPromise = measureStartupPhase('rust_crypto', async () => { + if (await rustEngineEnabled(cryptoDatabasePrefix)) { + await installRustCrypto(mx); + return; + } + await mx.initRustCrypto({ cryptoDatabasePrefix }); + }); const [syncStoreResult, cryptoResult] = await Promise.allSettled([ syncStorePromise, cryptoPromise, @@ -648,15 +678,11 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { } if (session) { - SlidingSyncSidebarCache.clear(session.userId); - clearCachedVersions(session.baseUrl, session.userId); - clearCachedUserProfiles(session.userId); - clearSecretStorageKeys(); + clearSessionCaches(session); const storeName: SessionStoreName = getSessionStoreName(session); await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); - await deleteDatabase(storeName.sync); - await deleteDatabase(storeName.crypto); - await deleteDatabase(`${storeName.rustCryptoPrefix}::matrix-sdk-crypto`); + await deleteSessionStores(storeName); + await wipeNativeCryptoStore(session); } else { await mx.clearStores(); window.localStorage.clear();