diff --git a/knip.json b/knip.json index 6b0e9046d5..90ea196464 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 1cc987ce9f..0f032b67ef 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", "@lottiefiles/dotlottie-react": "^0.12.0", + "@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 3ca5c94739..529252563d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: '@lottiefiles/dotlottie-react': specifier: ^0.12.0 version: 0.12.3(react@18.3.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 52f340daaf..c8e6b0122d 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 f58607f692..208f5972af 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,6 +45,16 @@ 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", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -188,6 +210,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 +260,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 +277,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 +287,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 +323,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 +378,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 +505,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.6.20" @@ -469,6 +573,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" @@ -496,6 +611,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[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" @@ -526,6 +647,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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block" version = "0.1.6" @@ -541,6 +682,15 @@ dependencies = [ "generic-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" @@ -646,6 +796,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" @@ -771,6 +927,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", +] + [[package]] name = "cc" version = "1.3.0" @@ -854,6 +1019,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", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -865,6 +1041,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", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -909,6 +1098,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1024,6 +1214,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" @@ -1058,6 +1265,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-random" version = "0.1.18" @@ -1078,6 +1291,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" @@ -1251,6 +1479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1325,6 +1554,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", + "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" @@ -1365,6 +1622,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" @@ -1376,6 +1639,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" @@ -1386,6 +1678,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" @@ -1417,6 +1715,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", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1426,6 +1748,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" @@ -1450,13 +1783,33 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "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]] @@ -1469,6 +1822,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1513,7 +1867,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", @@ -1527,6 +1881,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1733,23 +2088,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "embed-resource" -version = "3.0.11" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 1.1.3+spec-1.1.0", - "vswhom", - "winreg", + "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", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", ] [[package]] @@ -1885,6 +2266,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" @@ -1915,6 +2346,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" @@ -2033,6 +2470,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" @@ -2455,6 +2898,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" @@ -2466,6 +2934,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" @@ -2594,6 +3074,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" @@ -2618,6 +3107,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", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2651,6 +3158,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" @@ -3004,6 +3521,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" @@ -3074,6 +3645,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -3108,6 +3680,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" @@ -3117,6 +3698,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" @@ -3277,6 +3867,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" @@ -3320,6 +3928,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" @@ -3332,6 +3950,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" @@ -3431,6 +4055,16 @@ 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 = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3502,6 +4136,54 @@ dependencies = [ "uuid", ] +[[package]] +name = "macroific" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.119", +] + +[[package]] +name = "macroific_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.119", +] + +[[package]] +name = "macroific_macro" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3511,6 +4193,12 @@ 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" @@ -3536,6 +4224,17 @@ dependencies = [ "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" @@ -3569,8 +4268,318 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] -name = "memchr" -version = "2.8.3" +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", + "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", + "aquamarine", + "as_variant", + "async-trait", + "bs58", + "byteorder", + "cfg-if", + "ctr", + "eyeball", + "futures-core", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "js_option", + "matrix-sdk-common", + "matrix-sdk-qrcode", + "pbkdf2", + "rand 0.10.2", + "rmp-serde", + "ruma", + "serde", + "serde_json", + "sha2", + "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", + "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", + "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" @@ -3598,6 +4607,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" @@ -3795,6 +4810,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" @@ -3832,6 +4857,35 @@ 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", + "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 = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2" +dependencies = [ + "oauth2", + "reqwest 0.13.4", +] + [[package]] name = "objc" version = "0.2.7" @@ -4141,6 +5195,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" @@ -4306,6 +5366,15 @@ dependencies = [ "regex", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4575,6 +5644,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" @@ -4640,6 +5719,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" @@ -4738,6 +5828,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" @@ -4760,7 +5871,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]] @@ -4770,7 +5891,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", @@ -4782,7 +5916,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" dependencies = [ - "prost", + "prost 0.12.6", ] [[package]] @@ -4807,6 +5941,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" @@ -4848,6 +5988,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", @@ -4940,7 +6081,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", ] @@ -5035,12 +6176,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" @@ -5166,91 +6331,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 = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344" 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", + "as_variant", + "indexmap 2.14.0", + "js_int", + "js_option", + "ruma-common", + "ruma-macros", "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", + "thiserror 2.0.19", + "tracing", + "web-time", + "wildmatch", + "zeroize", ] [[package]] -name = "rfd" -version = "0.16.0" +name = "ruma-html" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43" 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", + "as_variant", + "html5ever 0.39.0", + "tracing", + "wildmatch", ] [[package]] -name = "ring" -version = "0.17.14" +name = "ruma-identifiers-validation" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432" dependencies = [ - "cc", + "js_int", + "thiserror 2.0.19", +] + +[[package]] +name = "ruma-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c" +dependencies = [ + "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]] @@ -5303,6 +6636,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", @@ -5367,6 +6701,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", @@ -5397,10 +6732,14 @@ dependencies = [ "enigo", "futures-util", "gtk", + "http 1.4.2", "infer 0.22.0", "jni 0.21.1", "libloading 0.8.9", "log", + "matrix-sdk", + "matrix-sdk-crypto", + "matrix-sdk-sqlite", "objc2", "objc2-avf-audio", "objc2-call-kit", @@ -5441,6 +6780,7 @@ dependencies = [ "ts-rs", "webkit2gtk", "windows", + "zeroize", "zip", ] @@ -5538,6 +6878,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" @@ -5741,6 +7092,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" @@ -5772,6 +7144,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" @@ -5785,6 +7170,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" @@ -5941,6 +7337,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" @@ -5996,6 +7401,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" @@ -6113,6 +7521,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" @@ -7374,6 +8792,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -7528,7 +8947,7 @@ dependencies = [ "hyper-timeout", "percent-encoding", "pin-project", - "prost", + "prost 0.12.6", "tokio", "tokio-stream", "tower 0.4.13", @@ -7544,7 +8963,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", @@ -7629,12 +9048,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", @@ -7794,6 +9218,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" @@ -7811,6 +9241,16 @@ dependencies = [ "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]] name = "uname" version = "0.1.1" @@ -7867,6 +9307,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" @@ -7879,12 +9328,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", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -7937,6 +9402,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" @@ -7992,6 +9463,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" @@ -8004,6 +9481,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", + "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", + "subtle", + "thiserror 2.0.19", + "x25519-dalek", + "zeroize", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -8145,6 +9652,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" @@ -8297,6 +9822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8416,6 +9942,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" @@ -9304,6 +10836,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" @@ -9350,6 +10894,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" @@ -9481,6 +11031,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 c8c27dac89..85c23f1892 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.22" +zeroize = "1" tokio-util = { version = "0.7", features = ["codec"] } futures-util = "0.3" regex = "1" @@ -59,6 +60,18 @@ 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"], optional = true } +matrix-sdk-sqlite = { version = "0.18", features = ["crypto-store"], optional = true } +http = { version = "1", optional = true } + tauri-plugin-log = "2.9.0" tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2" @@ -185,6 +198,8 @@ 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"] +# Rust Matrix crypto engine. Off by default. +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 0d51c14d82..44d1107dbe 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"))] @@ -306,6 +308,9 @@ pub fn run() { desktop::windows::window_tracking::TrackingState::new(), )); + #[cfg(feature = "matrix-crypto")] + let builder = builder.manage(matrix_crypto::CryptoEngineState::default()); + #[cfg(desktop)] let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { let _ = show_or_create_main_window(app); @@ -410,6 +415,12 @@ 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, share_inbox::share_inbox_drain, share_inbox::share_inbox_read, share_inbox::share_inbox_clear, diff --git a/src-tauri/src/matrix_crypto/backup.rs b/src-tauri/src/matrix_crypto/backup.rs new file mode 100644 index 0000000000..20bef52c75 --- /dev/null +++ b/src-tauri/src/matrix_crypto/backup.rs @@ -0,0 +1,199 @@ +//! 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::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 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}`")) +} + +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 0000000000..2b7291332c --- /dev/null +++ b/src-tauri/src/matrix_crypto/bundles.rs @@ -0,0 +1,464 @@ +//! 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::ruma::{RoomId, UserId}; +use matrix_sdk_crypto::dehydrated_devices::RehydratedDevice; +use matrix_sdk_crypto::store::types::{ + Changes, DehydratedDeviceKey, RoomKeyInfo, 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, DecryptionSettings, + MediaEncryptionInfo, OlmMachine, TrustRequirement, +}; +use serde_json::{json, Map, Value}; +use zeroize::Zeroizing; + +use super::wasm_enums::encryption_algorithm; +use super::wasm_enums::request_type::TO_DEVICE as REQUEST_TYPE_TO_DEVICE; + +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}`")) +} + +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}")) +} + +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}")) +} + +/// 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, + })) +} + +fn room_key_info_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, + }) +} + +/// 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 { + format!("{}|{}", machine.user_id(), machine.device_id()) +} + +fn decryption_settings() -> DecryptionSettings { + DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + } +} + +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", + "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_info_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 0000000000..4812208912 --- /dev/null +++ b/src-tauri/src/matrix_crypto/cross_signing.rs @@ -0,0 +1,158 @@ +//! 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_json::{json, Map, Value}; + +use super::wasm_enums::request_type::{ + KEYS_UPLOAD as KEYS_UPLOAD_REQUEST_TYPE, SIGNATURE_UPLOAD as SIGNATURE_UPLOAD_REQUEST_TYPE, +}; + +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, + + // Gated in matrix-sdk-crypto 0.18 behind `experimental-push-secrets`. + "pushSecretToVerifiedDevices" => Err("pushSecretToVerifiedDevices: unavailable, \ + matrix-sdk-crypto gates push_secret_to_verified_devices behind the \ + `experimental-push-secrets` feature" + .to_owned()), + + _ => 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", + "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) => json!({ + "masterKey": export.master_key, + "self_signing_key": export.self_signing_key, + "userSigningKey": export.user_signing_key, + }), + 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) +} diff --git a/src-tauri/src/matrix_crypto/devices.rs b/src-tauri/src/matrix_crypto/devices.rs new file mode 100644 index 0000000000..2886d69a38 --- /dev/null +++ b/src-tauri/src/matrix_crypto/devices.rs @@ -0,0 +1,340 @@ +//! 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, OwnedUserId, 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::wasm_enums::{encryption_algorithm, request_type}; + +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}`")) +} + +fn user_id(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}")) +} + +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)?; + 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)?; + 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)?; + 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": 1, + "className": "KeysQueryRequest", + "body": json!({ + "timeout": request.timeout, + "device_keys": request.device_keys, + }).to_string(), + })) +} + +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 0000000000..c216939a90 --- /dev/null +++ b/src-tauri/src/matrix_crypto/dispatch.rs @@ -0,0 +1,282 @@ +//! 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; +use matrix_sdk::ruma::api::client::sync::sync_events::DeviceLists; +use matrix_sdk::ruma::events::secret::request::SecretName; +use matrix_sdk::ruma::events::AnyMessageLikeEventContent; +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::{DeviceKeyAlgorithm, OneTimeKeyAlgorithm, RoomId, UInt, UserId}; +use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; +use matrix_sdk_crypto::{DecryptionSettings, EncryptionSyncChanges, OlmMachine, TrustRequirement}; +use serde_json::{json, Value}; + +use super::requests::{mark_request_sent, outgoing_requests}; + +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}`")) +} + +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}")) +} + +fn decryption_settings() -> DecryptionSettings { + DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + } +} + +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}"))?; + + Ok(Value::Array( + processed + .iter() + .map(|event| { + serde_json::from_str::(event.to_raw().json().get()) + .unwrap_or(Value::Null) + }) + .collect(), + )) + } + + "outgoingRequests" => outgoing_requests(machine).await, + "markRequestAsSent" => mark_request_sent(machine, &args).await, + + "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, &decryption_settings()) + .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), + }; + + Ok(json!({ + "clearEvent": serde_json::from_str::(decrypted.event.json().get()) + .map_err(|e| format!("decryptRoomEvent: bad clear event json: {e}"))?, + "senderCurve25519Key": sender_curve25519_key, + "claimedEd25519Key": claimed_ed25519_key, + "forwardingCurve25519KeyChain": Vec::::new(), + "verificationState": format!("{:?}", info.verification_state), + "sessionId": info.session_id().map(str::to_owned), + })) + } + "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:?}"))?; + serde_json::from_str::(encrypted.content.json().get()) + .map_err(|e| format!("encryptRoomEvent: bad encrypted content json: {e}")) + } + + "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}" + )) + } + } +} diff --git a/src-tauri/src/matrix_crypto/events.rs b/src-tauri/src/matrix_crypto/events.rs new file mode 100644 index 0000000000..926f47eaf8 --- /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"; + +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 0000000000..7ce9ae65d4 --- /dev/null +++ b/src-tauri/src/matrix_crypto/mod.rs @@ -0,0 +1,283 @@ +//! Rust crypto engine: `OlmMachine`s on passphrase-protected sqlite stores. + +pub mod backup; +pub mod bundles; +pub mod cross_signing; +pub mod devices; +pub mod dispatch; +pub mod events; +pub mod requests; +pub mod rooms; +pub mod verification; +pub mod wasm_enums; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; + +use matrix_sdk_crypto::OlmMachine; +use matrix_sdk_sqlite::SqliteCryptoStore; +use serde::Serialize; +use tauri::{Manager as _, State}; + +/// Owned, open OlmMachines keyed by `"{user_id}|{device_id}"`. +#[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 { + fn machine(&self, user_id: &str, device_id: &str) -> Result, String> { + self.machines + .lock() + .map_err(|e| e.to_string())? + .get(&format!("{user_id}|{device_id}")) + .cloned() + .ok_or_else(|| format!("no open crypto engine for {user_id}|{device_id}")) + } +} + +#[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, +} + +/// 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 = app + .path() + .app_local_data_dir() + .map_err(|e| format!("resolving app data dir failed: {e}"))?; + // `/` and `:` in a user id are not path-safe. + let account = format!("{user_id}|{device_id}").replace(['/', ':'], "_"); + Ok(base.join("matrix-crypto").join(account)) +} + +#[tauri::command] +pub async fn engine_open( + app: tauri::AppHandle, + state: State<'_, CryptoEngineState>, + dir: Option, + passphrase: Option, + user_id: String, + device_id: String, +) -> Result { + let user: &matrix_sdk::ruma::UserId = user_id + .as_str() + .try_into() + .map_err(|e| format!("bad user id: {e}"))?; + let device: &matrix_sdk::ruma::DeviceId = device_id.as_str().into(); + + let dir = match dir { + Some(dir) => PathBuf::from(dir), + None => store_dir(&app, &user_id, &device_id)?, + }; + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| e.to_string())?; + let db_path = dir.join("matrix-sdk-crypto.sqlite3"); + + let store = SqliteCryptoStore::open(&db_path, passphrase.as_deref()) + .await + .map_err(|e| format!("opening crypto store failed: {e}"))?; + let machine = OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .map_err(|e| format!("creating OlmMachine failed: {e}"))?; + let keys = machine.identity_keys(); + let account = format!("{user_id}|{device_id}"); + + let listeners = events::spawn(&app, &machine, account.clone()); + state + .listeners + .lock() + .map_err(|e| e.to_string())? + .insert(account.clone(), listeners); + + state + .machines + .lock() + .map_err(|e| e.to_string())? + .insert(account, Arc::new(machine)); + + Ok(EngineInfo { + user_id, + device_id, + ed25519_key: keys.ed25519.to_base64(), + curve25519_key: keys.curve25519.to_base64(), + store_path: db_path.display().to_string(), + }) +} + +#[tauri::command] +pub async fn engine_close( + state: State<'_, CryptoEngineState>, + user_id: String, + device_id: String, +) -> Result { + let account = format!("{user_id}|{device_id}"); + + if let Some(listeners) = state + .listeners + .lock() + .map_err(|e| e.to_string())? + .remove(&account) + { + for listener in listeners { + listener.abort(); + } + } + + Ok(state + .machines + .lock() + .map_err(|e| e.to_string())? + .remove(&account) + .is_some()) +} + +#[tauri::command] +pub async fn engine_invoke( + state: State<'_, CryptoEngineState>, + user_id: String, + device_id: String, + method: String, + args_json: String, +) -> Result { + let machine = state.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/requests.rs b/src-tauri/src/matrix_crypto/requests.rs new file mode 100644 index 0000000000..341fd1babe --- /dev/null +++ b/src-tauri/src/matrix_crypto/requests.rs @@ -0,0 +1,137 @@ +//! 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_json::{json, Value}; + +use super::wasm_enums::request_type; + +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": json!({ + "device_keys": req.device_keys, + "one_time_keys": req.one_time_keys, + "fallback_keys": req.fallback_keys, + }).to_string(), + }), + AnyOutgoingRequest::KeysQuery(req) => json!({ + "type": request_type::KEYS_QUERY, + "className": "KeysQueryRequest", + "body": json!({ + "timeout": req.timeout, + "device_keys": req.device_keys, + }).to_string(), + }), + AnyOutgoingRequest::KeysClaim(req) => json!({ + "type": request_type::KEYS_CLAIM, + "className": "KeysClaimRequest", + "body": json!({ + "timeout": req.timeout, + "one_time_keys": req.one_time_keys, + }).to_string(), + }), + 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) +} diff --git a/src-tauri/src/matrix_crypto/rooms.rs b/src-tauri/src/matrix_crypto/rooms.rs new file mode 100644 index 0000000000..66ef89ecc8 --- /dev/null +++ b/src-tauri/src/matrix_crypto/rooms.rs @@ -0,0 +1,368 @@ +//! 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, RoomId, 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::wasm_enums::{encryption_algorithm as algorithm_to_wasm, request_type}; + +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}`")) +} + +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}")) +} + +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": json!({ + "timeout": request.timeout, + "one_time_keys": request.one_time_keys, + }).to_string(), + })), + 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!({ + "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, + }) +} diff --git a/src-tauri/src/matrix_crypto/verification.rs b/src-tauri/src/matrix_crypto/verification.rs new file mode 100644 index 0000000000..398e979bbf --- /dev/null +++ b/src-tauri/src/matrix_crypto/verification.rs @@ -0,0 +1,564 @@ +//! 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::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 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}`")) +} + +fn flow(args: &Value, method: &str) -> Result<(OwnedUserId, String), String> { + let raw = str_arg(args, method, "userId")?; + let user = + UserId::parse(&raw).map_err(|e| format!("{method}: bad user id in `userId`: {e}"))?; + 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!({ + "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 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)), + "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(); + // No `id`: that is how js-sdk knows to skip `markRequestAsSent`. + if let Some(upload) = signature_upload { + out.push(json!({ + "type": REQUEST_TYPE_SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "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 0000000000..cc382cafb8 --- /dev/null +++ b/src-tauri/src/matrix_crypto/wasm_enums.rs @@ -0,0 +1,23 @@ +//! 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 `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 0000000000..5b4d82ae96 --- /dev/null +++ b/src/app/crypto/install.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isTauri } from '@tauri-apps/api/core'; +import { LegacyWasmCryptoStoreError, 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 + ); + }); +}); diff --git a/src/app/crypto/install.ts b/src/app/crypto/install.ts new file mode 100644 index 0000000000..3b330482bb --- /dev/null +++ b/src/app/crypto/install.ts @@ -0,0 +1,151 @@ +import { logger } from 'matrix-js-sdk/lib/logger'; +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 { 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; + +export const installRustCrypto = async ( + mx: MatrixClient, + options: { storeDir?: string; passphrase?: string } = {} +): Promise => { + 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 + ); + + 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 = () => { + 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/engineInvoke.ts b/src/app/crypto/olmMachine/engineInvoke.ts new file mode 100644 index 0000000000..bcc0addd75 --- /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 0000000000..edea22e356 --- /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 0000000000..8b6a8fdc05 --- /dev/null +++ b/src/app/crypto/olmMachine/hydrate.test.ts @@ -0,0 +1,439 @@ +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: () => {}, + }; +}; + +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('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); + }); +}); + +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('returns undefined, queues the request, and acks it locally', async () => { + bridge.engineInvoke.mockImplementation(async (_identity, method) => { + if (method === 'getVerificationRequest') return request; + if (method === 'verificationRequest.accept') return readyRequest; + if (method === 'outgoingRequests') return []; + throw new Error(`unexpected engine call ${method}`); + }); + + const proxy = new OlmMachineProxy(info); + const inner = (await proxy.getVerificationRequest( + '@bob:example.org', + 'flow' + )) 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); + const proxy = new OlmMachineProxy(info); + const inner = (await proxy.getVerificationRequest( + '@bob:example.org', + 'flow' + )) as unknown as RustSdkCryptoJs.VerificationRequest; + const onChange = vi.fn<() => Promise>(); + + inner.registerChangesCallback(onChange); + proxy.emit.verificationChanged('flow'); + + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/crypto/olmMachine/hydrate.ts b/src/app/crypto/olmMachine/hydrate.ts new file mode 100644 index 0000000000..db85e0c89f --- /dev/null +++ b/src/app/crypto/olmMachine/hydrate.ts @@ -0,0 +1,329 @@ +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) => void; + watchChanges: (flowId: string, callback: () => void) => 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) })); + 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) + ); +}; + +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); + asMethod( + record, + 'isCancelled', + 'isDone', + 'isPassive', + 'isReady', + 'isSelfVerification', + 'phase', + 'timeRemainingMillis', + 'timedOut', + 'weStarted', + 'getVerification' + ); + 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); + asMethod( + record, + 'canBePresented', + 'cancelInfo', + 'decimals', + 'emoji', + 'emojiIndex', + 'hasBeenAccepted', + 'haveWeConfirmed', + 'isCancelled', + 'isDone', + 'isSelfVerification', + 'startedFromRequest', + 'supportsEmoji', + 'timedOut', + 'weStarted' + ); + 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' + ); + 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 '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 0000000000..7b29f1db8b --- /dev/null +++ b/src/app/crypto/olmMachine/ipcContract.test.ts @@ -0,0 +1,35 @@ +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([]); + }); +}); diff --git a/src/app/crypto/olmMachine/proxy.ts b/src/app/crypto/olmMachine/proxy.ts new file mode 100644 index 0000000000..5bb39c6b83 --- /dev/null +++ b/src/app/crypto/olmMachine/proxy.ts @@ -0,0 +1,500 @@ +import { createDebugLogger } from '$utils/debugLogger'; +import { graftWasmPrototypes, RustSdkCryptoJs } 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 idValue = (id: string) => ({ + toString: () => id, + localpart: () => id.replace(/^@/, '').split(':')[0], +}); + +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 #hydration: HydrationContext = { + call: (method, args) => this.#call(method, args), + queueOutgoing: (label, pending) => { + void pending.then( + (request) => { + if (request) this.#pending.push(request as { id?: unknown }); + }, + (error) => proxyLog.error('error', `Rust crypto engine failed on ${label}`, error) + ); + }, + watchChanges: (flowId, callback) => { + const callbacks = this.#changesCallbacks.get(flowId) ?? new Set(); + callbacks.add(callback); + this.#changesCallbacks.set(flowId, callbacks); + }, + }; + + 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'); + } + return graftWasmPrototypes(await engineInvoke(this.#identity, method, args), this.#hydration); + } + + 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 { + this.#closed = true; + } + + free(): void { + this.#closed = true; + } + + 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 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 { + return this.#call('decryptRoomEvent', { + event, + roomId: String(roomId), + decryptionSettings: rest.at(-1) ?? null, + }); + } + + 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, + }); + } + + 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 }); + } + + 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: String(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'); + } + + async importBackedUpRoomKeys(keys: unknown, backupVersion?: string): Promise { + return this.#call('importBackedUpRoomKeys', { + keys, + backupVersion: backupVersion ?? null, + }); + } + + async importExportedRoomKeys(keys: unknown): Promise { + return this.#call('importExportedRoomKeys', { keys }); + } + + async exportRoomKeys(): Promise { + return this.#call('exportRoomKeys'); + } + + async getVerificationRequest(userId: unknown, flowId: string): Promise { + return this.#call('getVerificationRequest', { + userId: String(userId), + flowId, + }); + } + + async getVerificationRequests(userId: unknown): Promise { + return (await this.#call('getVerificationRequests', { + userId: String(userId), + })) as unknown[]; + } + + 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), + }); + } + + async receiveRoomKeyBundle(roomId: unknown, bundle: unknown): Promise { + await this.#call('receiveRoomKeyBundle', { + roomId: String(roomId), + bundle, + }); + } + + dehydratedDevices() { + const call = (method: string, args: Record = {}) => + this.#call(`dehydratedDevices.${method}`, args); + return { + create: () => call('create'), + keysForUpload: (deviceDisplayName: string, key: unknown) => + call('keysForUpload', { deviceDisplayName, key: String(key) }), + rehydrate: (key: unknown, deviceId: unknown, deviceData: string) => + call('rehydrate', { + key: String(key), + deviceId: String(deviceId), + deviceData, + }), + getDehydratedDeviceKey: () => call('getDehydratedDeviceKey'), + saveDehydratedDeviceKey: (key: unknown) => + call('saveDehydratedDeviceKey', { + key: String(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; diff --git a/src/app/crypto/olmMachine/qrCodeScan.ts b/src/app/crypto/olmMachine/qrCodeScan.ts new file mode 100644 index 0000000000..4abb80d808 --- /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/wasmClasses.ts b/src/app/crypto/olmMachine/wasmClasses.ts new file mode 100644 index 0000000000..da2384c92e --- /dev/null +++ b/src/app/crypto/olmMachine/wasmClasses.ts @@ -0,0 +1,44 @@ +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { hydrate, type HydrationContext } from './hydrate'; + +// Every payload carries a `className` because js-sdk dispatches on `instanceof`. +type WasmClassName = keyof typeof RustSdkCryptoJs; + +const hasOwnPrototype = (name: string): boolean => { + const candidate = (RustSdkCryptoJs as Record)[name]; + return typeof candidate === 'function' && 'prototype' in candidate; +}; + +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); + hydrate(className, record, ctx); + } + return value; +}; + +export const wasmClass = (name: WasmClassName): unknown => + (RustSdkCryptoJs as Record)[name as string]; + +export { RustSdkCryptoJs }; diff --git a/src/app/crypto/verificationOverrides.test.ts b/src/app/crypto/verificationOverrides.test.ts new file mode 100644 index 0000000000..aab38bc66b --- /dev/null +++ b/src/app/crypto/verificationOverrides.test.ts @@ -0,0 +1,168 @@ +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.mockResolvedValue({ + 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 }; + } + 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).toHaveBeenLastCalledWith( + 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 0000000000..d7758574f8 --- /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 1dd132bbba..b0ee35ea23 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-07-31T11:14:58.599659+00:00 + * Generated at: 2026-08-03T21:21:48.961230311+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -34,6 +34,18 @@ 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 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 0260d39ed0..45774bd99d 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-07-31T11:14:58.602356+00:00 + * Generated at: 2026-08-03T21:21:48.961906939+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 727bb5d08e..7bda4a3c6e 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-07-31T11:14:58.603222+00:00 + * Generated at: 2026-08-03T21:21:48.961975919+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 5c41032aff..c752ef5593 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-07-31T11:14:58.596290+00:00 + * Generated at: 2026-08-03T21:21:48.959843893+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,28 @@ 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 ExportDiagnosticsParams { frontendLogs?: string | null; [key: string]: unknown; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 19389f1fc0..4183126ed4 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -22,11 +22,14 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { clearCacheAndReload, clearLoginData, + discardSessionStores, initClient, logoutClient, startClient, stopClient, } from '$client/initMatrix'; +import { isLegacyWasmCryptoStoreError } from '../../crypto/install'; +import { AsyncError } from '$components/AsyncError'; import { clearSecretStorageKeys } from '$client/secretStorageKeys'; import { resetBackupRestoreAtom } from '$state/backupRestore'; import { SplashScreen } from '$components/splash-screen'; @@ -349,6 +352,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); @@ -413,6 +428,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(() => { @@ -453,7 +470,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]); @@ -473,17 +490,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 74a4967491..284fbe4888 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -31,6 +31,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 { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; @@ -183,6 +184,19 @@ 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); +}; + const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -255,9 +269,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, @@ -556,15 +574,10 @@ 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); } else { await mx.clearStores(); window.localStorage.clear();