diff --git a/.changeset/embedded-unifiedpush-distributor.md b/.changeset/embedded-unifiedpush-distributor.md new file mode 100644 index 0000000000..0ac9f4a975 --- /dev/null +++ b/.changeset/embedded-unifiedpush-distributor.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add a built-in UnifiedPush distributor so Android push works without Google Play Services or a separate distributor app, support MSC4174 so the homeserver can deliver web push directly, and decrypt push previews through the Rust crypto engine. diff --git a/.github/workflows/rust-quality.yml b/.github/workflows/rust-quality.yml index ca14b58c78..8cd8e05627 100644 --- a/.github/workflows/rust-quality.yml +++ b/.github/workflows/rust-quality.yml @@ -78,10 +78,10 @@ jobs: save-if: ${{ github.ref == 'refs/heads/dev' }} - name: Check native app - run: cargo check --locked --manifest-path src-tauri/Cargo.toml + run: cargo check --locked --manifest-path src-tauri/Cargo.toml --features matrix-crypto - name: Run Rust tests - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --features matrix-crypto - name: Run Clippy - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets --features matrix-crypto -- -D warnings diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 9de2f3de2d..41597aa4eb 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -170,7 +170,7 @@ jobs: - name: Build desktop bundles shell: bash - run: pnpm tauri build ${{ matrix.args }} $TAURI_BUILD_ARGS + run: pnpm tauri:wry build ${{ matrix.args }} $TAURI_BUILD_ARGS - name: Normalize desktop artifact names shell: bash @@ -313,7 +313,7 @@ jobs: if [ "$IS_NIGHTLY" = "true" ]; then export SABLE_BUILD_FLAVOR=dev fi - pnpm tauri android build --apk --aab --target aarch64 armv7 + pnpm tauri android build --apk --aab --target aarch64 armv7 -- --features matrix-crypto OUT='src-tauri/gen/android/app/build/outputs' APK=$(find "$OUT/apk/universal/release" -name '*.apk' -type f | head -1) AAB=$(find "$OUT/bundle/universalRelease" -name '*.aab' -type f | head -1) @@ -443,7 +443,7 @@ jobs: if [ "$IS_NIGHTLY" = "true" ]; then export SABLE_BUILD_FLAVOR=dev fi - pnpm tauri ios build --no-sign --ci + pnpm tauri ios build --no-sign --ci -- --features matrix-crypto IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -type f | head -1) [ -n "$IPA" ] || { echo "IPA not found"; ls -R src-tauri/gen/apple/build 2>/dev/null || true; exit 1; } NORMALIZED_IPA="$(dirname "$IPA")/Sable-${VERSION}-ios-arm64.ipa" diff --git a/knip.json b/knip.json index 33381b638d..e60b9e8e88 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 e98e55a72d..ef41ac7d0a 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,12 @@ "@livekit/components-react": "2.9.23", "@lottiefiles/dotlottie-react": "^0.19.14", "@lottiefiles/dotlottie-web": "0.79.1", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1", "@noble/hashes": "^2.3.0", "@phosphor-icons/react": "^2.1.10", "@sableclient/matrixrtc": "^0.1.0", "@sableclient/tauri-plugin-livekit-mobile": "^0.2.0", - "@sableclient/tauri-plugin-notifications-api": "^0.5.0", + "@sableclient/tauri-plugin-notifications-api": "^0.5.3", "@sableclient/twemoji-font": "^1.0.4", "@sentry/react": "^10.70.0", "@tanstack/react-query": "^5.101.4", @@ -181,4 +182,4 @@ } }, "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 640198c3fc..0b7ee1d970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: '@lottiefiles/dotlottie-web': specifier: 0.79.1 version: 0.79.1 + '@matrix-org/matrix-sdk-crypto-wasm': + specifier: ^18.3.1 + version: 18.3.1 '@noble/hashes': specifier: ^2.3.0 version: 2.3.0 @@ -65,8 +68,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@sableclient/tauri-plugin-notifications-api': - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.5.3 + version: 0.5.3 '@sableclient/twemoji-font': specifier: ^1.0.4 version: 1.0.4 @@ -2694,8 +2697,8 @@ packages: '@sableclient/tauri-plugin-livekit-mobile@0.2.0': resolution: {integrity: sha512-xpv+9qDEIYM1lmbdLpLgD8fwctyT7QNq8R7uwhm7T1Wt95MF7TqgtGUo8kHAUZq4v4ung0jxacbvkp9POMVutw==} - '@sableclient/tauri-plugin-notifications-api@0.5.0': - resolution: {integrity: sha512-Rr8knIgjx2onjAdTS+K17Ex4aDk/Wpj7lqJEHnayimtU8Rt6L1ScvpTMLbJkjheDuLQ0pLAsoV5I7v3SekRJLA==} + '@sableclient/tauri-plugin-notifications-api@0.5.3': + resolution: {integrity: sha512-dOkzunfNjpiHltzc4AZpCFBoUqFWxpBS1+tgCoO9AVqcsQGmjeaTcdeKOkBpL5h6GcxR+YyT8b7SnA1H0SsX0A==} '@sableclient/twemoji-font@1.0.4': resolution: {integrity: sha512-LzvQB/VZtv5KEq1VYq2azr7v7cYT8oklOVRGkAN2RNwa3sF0XcnqM3lBHSYRFXQleGLSUgs3gA0slbwkD2+sJw==} @@ -7890,7 +7893,7 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 - '@sableclient/tauri-plugin-notifications-api@0.5.0': + '@sableclient/tauri-plugin-notifications-api@0.5.3': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/scripts/tauri.js b/scripts/tauri.js index 9c0c71300f..022f7478b7 100755 --- a/scripts/tauri.js +++ b/scripts/tauri.js @@ -71,7 +71,8 @@ async function main() { // The frontend is built before Cargo, so mirror the updater feature into Vite. process.env.VITE_DESKTOP_UPDATER_ENABLED = String(!noUpdater); - 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 5fde54439e..a2e4d06114 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,18 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accessory" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e416a3ab45838bac2ab2d81b1088d738d7b2d2c5272a54d39366565a29bd80" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -33,13 +45,34 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.0", ] @@ -188,6 +221,26 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -218,6 +271,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" + [[package]] name = "arrayref" version = "0.3.9" @@ -229,6 +288,9 @@ name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] [[package]] name = "as-raw-xcb-connection" @@ -236,6 +298,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as_variant" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a" + +[[package]] +name = "assign" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -260,6 +334,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -303,6 +389,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "async-process" version = "2.5.0" @@ -424,6 +516,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.6.20" @@ -469,6 +584,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -502,6 +628,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -532,6 +664,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block" version = "0.1.6" @@ -556,6 +708,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -661,6 +822,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + [[package]] name = "bzip2" version = "0.6.1" @@ -786,6 +953,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.4.2" @@ -869,6 +1045,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -880,6 +1067,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -916,6 +1116,17 @@ dependencies = [ "phf_codegen 0.11.3", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + [[package]] name = "cipher" version = "0.5.2" @@ -924,7 +1135,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.2", ] [[package]] @@ -1040,6 +1251,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1074,6 +1302,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1100,6 +1334,21 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" @@ -1279,6 +1528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1347,13 +1597,22 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctr" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -1362,6 +1621,34 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -1402,6 +1689,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "date_header" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d" + [[package]] name = "dbus" version = "0.9.12" @@ -1413,6 +1706,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "deadpool-sync" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf" +dependencies = [ + "deadpool-runtime", +] + [[package]] name = "debugid" version = "0.8.0" @@ -1423,6 +1745,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "decancer" +version = "3.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" + [[package]] name = "defmt" version = "1.1.1" @@ -1454,6 +1782,30 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "delegate-display" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9926686c832494164c33a36bf65118f4bd6e704000b58c94681bf62e9ad67a74" +dependencies = [ + "impartial-ord", + "itoa", + "macroific", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1463,6 +1815,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1487,29 +1850,50 @@ 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 = "2.1.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "rustc_version", "syn 2.0.119", ] [[package]] -name = "deunicode" +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" @@ -1550,7 +1934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c06ffa9aeb3fb248b41d4e71ab3c0aa89177afc6669459da4320b97a4c77948" dependencies = [ "bitflags 2.13.1", - "prost", + "prost 0.12.6", "prost-types", "tonic", "tracing-core", @@ -1564,6 +1948,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1573,7 +1958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1780,6 +2165,32 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.17.0" @@ -1932,6 +2343,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" @@ -1962,6 +2423,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" @@ -2080,6 +2547,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" @@ -2502,6 +2975,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" @@ -2513,6 +3011,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" @@ -2641,6 +3151,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" @@ -2665,6 +3184,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2698,6 +3235,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" @@ -3060,6 +3607,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" @@ -3124,6 +3725,16 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inout" version = "0.2.2" @@ -3164,6 +3775,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" @@ -3173,6 +3793,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" @@ -3350,6 +3979,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" @@ -3393,6 +4040,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" @@ -3405,6 +4062,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" @@ -3504,6 +4167,17 @@ dependencies = [ "redox_syscall 0.9.2", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3575,6 +4249,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" @@ -3584,6 +4306,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" @@ -3609,6 +4337,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" @@ -3641,6 +4380,316 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matrix-pickle" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0" +dependencies = [ + "matrix-pickle-derive", + "thiserror 2.0.20", +] + +[[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.5.0", + "imbl", + "indexmap 2.14.0", + "itertools 0.14.0", + "js_int", + "language-tags", + "matrix-sdk-base", + "matrix-sdk-common", + "matrix-sdk-indexeddb", + "matrix-sdk-sqlite", + "mime", + "mime2ext", + "oauth2", + "oauth2-reqwest", + "percent-encoding", + "pin-project-lite", + "reqwest 0.13.4", + "ruma", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_html_form", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.20", + "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.20", + "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.20", + "tokio", + "tracing", + "tracing-subscriber", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "matrix-sdk-crypto" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf" +dependencies = [ + "aes 0.8.4", + "aquamarine", + "as_variant", + "async-trait", + "bs58", + "byteorder", + "cfg-if", + "ctr 0.9.2", + "eyeball", + "futures-core", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "js_option", + "matrix-sdk-common", + "matrix-sdk-qrcode", + "pbkdf2", + "rand 0.10.2", + "rmp-serde", + "ruma", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tracing", + "ulid", + "url", + "vodozemac", + "zeroize", +] + +[[package]] +name = "matrix-sdk-indexeddb" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures-util", + "getrandom 0.4.3", + "gloo-utils", + "hkdf", + "js-sys", + "matrix-sdk-base", + "matrix-sdk-crypto", + "matrix-sdk-store-encryption", + "matrix_indexed_db_futures", + "rmp-serde", + "ruma", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "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.20", + "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.20", + "tokio", + "tracing", + "vodozemac", + "zeroize", +] + +[[package]] +name = "matrix-sdk-store-encryption" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e" +dependencies = [ + "base64 0.22.1", + "blake3", + "chacha20poly1305", + "getrandom 0.2.17", + "getrandom 0.4.3", + "hmac", + "pbkdf2", + "rand 0.10.2", + "rmp-serde", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "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.20", + "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" @@ -3671,6 +4720,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" @@ -3868,6 +4923,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" @@ -3905,6 +4970,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.5.0", + "rand 0.8.7", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "oauth2-reqwest" +version = "0.1.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2" +dependencies = [ + "oauth2", + "reqwest 0.13.4", +] + [[package]] name = "objc" version = "0.2.7" @@ -4214,6 +5308,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.1" @@ -4379,6 +5479,15 @@ dependencies = [ "regex", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4648,6 +5757,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" @@ -4713,6 +5832,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.15.0" @@ -4811,6 +5941,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" @@ -4833,7 +5984,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]] @@ -4843,7 +6004,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", @@ -4855,7 +6029,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" dependencies = [ - "prost", + "prost 0.12.6", ] [[package]] @@ -4880,6 +6054,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" @@ -4921,6 +6101,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", @@ -5013,7 +6194,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", ] @@ -5108,12 +6289,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" @@ -5239,91 +6444,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.5.0", + "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.5.0", + "js_int", + "js_option", + "maplit", + "ruma-common", + "ruma-events", + "serde", + "serde_html_form", + "serde_json", + "thiserror 2.0.20", + "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.5.0", + "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.20", + "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.5.0", - "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.20", + "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.20", +] + +[[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.4+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]] @@ -5376,6 +6749,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -5440,6 +6814,7 @@ version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5461,19 +6836,23 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" name = "sable" version = "1.21.0" dependencies = [ - "aes", + "aes 0.9.2", "async-stream", "base64 0.23.1", "block2", "cef", - "ctr", + "ctr 0.10.1", "enigo", "futures-util", "gtk", + "http 1.5.0", "infer 0.22.0", "jni 0.22.4", "libloading 0.9.0", "log", + "matrix-sdk", + "matrix-sdk-crypto", + "matrix-sdk-sqlite", "objc2", "objc2-avf-audio", "objc2-call-kit", @@ -5517,6 +6896,7 @@ dependencies = [ "webkit2gtk", "windows 0.62.2", "zbus", + "zeroize", "zip 8.6.0", ] @@ -5614,6 +6994,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" @@ -5817,6 +7208,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" @@ -5848,6 +7260,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" @@ -5861,6 +7286,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" @@ -6029,6 +7465,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" @@ -6084,6 +7529,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" @@ -6201,6 +7649,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" @@ -6866,8 +8324,8 @@ dependencies = [ [[package]] name = "tauri-plugin-notifications" -version = "0.5.0-rc.11" -source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=301e4ac43970525764eb2f0c787884129db2847f#301e4ac43970525764eb2f0c787884129db2847f" +version = "0.5.0" +source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=302944f44e0a876b39a37e4f08741379202bbdd4#302944f44e0a876b39a37e4f08741379202bbdd4" dependencies = [ "log", "notify-rust", @@ -7487,6 +8945,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -7641,7 +9100,7 @@ dependencies = [ "hyper-timeout", "percent-encoding", "pin-project", - "prost", + "prost 0.12.6", "tokio", "tokio-stream", "tower 0.4.13", @@ -7657,7 +9116,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", @@ -7742,12 +9201,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.5.0", "http-body 1.1.0", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.3", "tower-layer", "tower-service", @@ -7913,6 +9377,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" @@ -7930,6 +9400,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" @@ -7986,6 +9466,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" @@ -7998,12 +9487,28 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -8056,6 +9561,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" @@ -8111,6 +9622,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" @@ -8123,6 +9640,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vodozemac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b" +dependencies = [ + "aes 0.8.4", + "arrayvec", + "base64 0.22.1", + "base64ct", + "cbc", + "chacha20poly1305", + "curve25519-dalek", + "ed25519-dalek", + "getrandom 0.2.17", + "hkdf", + "hmac", + "matrix-pickle", + "prost 0.14.4", + "rand 0.8.7", + "serde", + "serde_bytes", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.20", + "x25519-dalek", + "zeroize", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -8264,6 +9811,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" @@ -8416,6 +9981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8535,6 +10101,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" @@ -9474,6 +11046,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" @@ -9520,6 +11104,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" @@ -9660,6 +11250,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 0a3d180734..ef0a05e7f7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,7 @@ percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["stream"] } async-stream = "0.3" base64 = "0.23" +zeroize = "1" tokio-util = { version = "0.7", features = ["codec"] } futures-util = "0.3" regex = "1" @@ -59,6 +60,23 @@ tauri = { version = "2.11.5", default-features = false, features = [ "image-png", ] } +# Rust Matrix crypto engine. Optional so regular builds don't pay the +# matrix-rust-sdk compile cost. +matrix-sdk = { version = "0.18", default-features = false, features = [ + "e2e-encryption", + "sqlite", +], optional = true } +# `qrcode` is off by default upstream; Sable already supports QR device +# verification, so the engine needs it for parity with the wasm backend. +matrix-sdk-crypto = { version = "0.18", features = [ + "qrcode", + "experimental-push-secrets", +], optional = true } +# `bundled` compiles SQLite from source. Android's NDK ships no libsqlite3 to link +# against at any API level, so without this the aarch64 build fails at link time. +matrix-sdk-sqlite = { version = "0.18", features = ["crypto-store", "bundled"], optional = true } +http = { version = "1", optional = true } + tauri-plugin-log = "2.9.0" tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2" @@ -91,12 +109,12 @@ windows = { version = "0.62", features = [ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } [target.'cfg(any(windows, target_os = "linux"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "301e4ac43970525764eb2f0c787884129db2847f" } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "302944f44e0a876b39a37e4f08741379202bbdd4" } # default-features = false drops notify-rust so macOS uses the native # UNUserNotificationCenter backend (needs a signed .app to deliver). [target.'cfg(target_os = "macos")'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "301e4ac43970525764eb2f0c787884129db2847f", default-features = false } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "302944f44e0a876b39a37e4f08741379202bbdd4", default-features = false } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = { version = "2", optional = true } @@ -120,7 +138,7 @@ libloading = "0.9" zbus = "5" [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "301e4ac43970525764eb2f0c787884129db2847f", features = [ +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "302944f44e0a876b39a37e4f08741379202bbdd4", features = [ "push-notifications", ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } @@ -178,7 +196,7 @@ tauri-plugin-android-fs = { version = "=29.0.0", features = ["legacy-storage-per jni = "0.22" [features] -default = ["wry", "updater"] +default = ["wry", "updater", "matrix-crypto"] custom-protocol = ["tauri/custom-protocol"] wry = ["tauri/wry", "dep:webkit2gtk"] # Tauri auto-updater. Disable with --no-default-features --features wry,cef. @@ -188,6 +206,7 @@ cef = ["dep:tauri-runtime-cef", "dep:cef"] # CrabNebula devtools. Off by default: its aggregator retains every metadata record for the # session, which costs hundreds of MB per minute once tauri-plugin-log feeds it. devtools = ["dep:tauri-plugin-devtools"] +matrix-crypto = ["dep:matrix-sdk", "dep:matrix-sdk-crypto", "dep:matrix-sdk-sqlite", "dep:http"] [patch.crates-io] tauri-typegen = { git = "https://github.com/SableClient/tauri-typegen", branch = "fix/nondeterministic-generation-cache" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eed016b16f..d61f1eebd2 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"))] @@ -459,6 +461,16 @@ pub fn run() { network::media_protocol::set_media_encryption, network::media_protocol::prepare_loopback_media, sentry::set_native_sentry_enabled, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_invoke, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_open, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_close, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::engine_wipe, + #[cfg(feature = "matrix-crypto")] + matrix_crypto::push::engine_decrypt_push, share_inbox::share_inbox_drain, share_inbox::share_inbox_read, share_inbox::share_inbox_clear, diff --git a/src-tauri/src/matrix_crypto/args.rs b/src-tauri/src/matrix_crypto/args.rs new file mode 100644 index 0000000000..b1864dc5b8 --- /dev/null +++ b/src-tauri/src/matrix_crypto/args.rs @@ -0,0 +1,85 @@ +//! Argument parsing shared by the `OlmMachine` dispatch modules. + +use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId, RoomId, UserId}; +use matrix_sdk_crypto::{DecryptionSettings, TrustRequirement}; +use serde_json::Value; + +pub fn str_arg(args: &Value, method: &str, field: &str) -> Result { + args.get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("{method}: missing string argument `{field}`")) +} + +pub fn room_id(args: &Value, method: &str, field: &str) -> Result { + let raw = str_arg(args, method, field)?; + RoomId::parse(&raw).map_err(|e| format!("{method}: bad room id in `{field}`: {e}")) +} + +pub fn user_id(args: &Value, method: &str, field: &str) -> Result { + let raw = str_arg(args, method, field)?; + UserId::parse(&raw).map_err(|e| format!("{method}: bad user id in `{field}`: {e}")) +} + +pub fn decryption_settings() -> DecryptionSettings { + DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + } +} + +/// Codes are wasm's `TrustRequirement`; anything unrecognised stays permissive. +pub fn caller_decryption_settings(args: &Value) -> DecryptionSettings { + let requirement = args + .get("decryptionSettings") + .and_then(|settings| settings.get("senderDeviceTrustRequirement")) + .and_then(Value::as_u64); + + DecryptionSettings { + sender_device_trust_requirement: match requirement { + Some(1) => TrustRequirement::CrossSignedOrLegacy, + Some(2) => TrustRequirement::CrossSigned, + _ => TrustRequirement::Untrusted, + }, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn requirement(args: &Value) -> TrustRequirement { + caller_decryption_settings(args).sender_device_trust_requirement + } + + #[test] + fn reads_the_callers_trust_requirement() { + let args = json!({ "decryptionSettings": { "senderDeviceTrustRequirement": 1 } }); + assert!(matches!( + requirement(&args), + TrustRequirement::CrossSignedOrLegacy + )); + + let args = json!({ "decryptionSettings": { "senderDeviceTrustRequirement": 2 } }); + assert!(matches!(requirement(&args), TrustRequirement::CrossSigned)); + } + + #[test] + fn falls_back_to_untrusted_when_absent_or_unknown() { + assert!(matches!( + requirement(&json!({})), + TrustRequirement::Untrusted + )); + + let args = json!({ "decryptionSettings": { "senderDeviceTrustRequirement": 99 } }); + assert!(matches!(requirement(&args), TrustRequirement::Untrusted)); + } + + #[test] + fn rejects_a_missing_string_argument() { + let error = str_arg(&json!({}), "someMethod", "roomId").unwrap_err(); + assert!(error.contains("someMethod"), "{error}"); + assert!(error.contains("roomId"), "{error}"); + } +} diff --git a/src-tauri/src/matrix_crypto/backup.rs b/src-tauri/src/matrix_crypto/backup.rs new file mode 100644 index 0000000000..1654570fa7 --- /dev/null +++ b/src-tauri/src/matrix_crypto/backup.rs @@ -0,0 +1,253 @@ +//! Server-side key backup and room-key import/export for the `OlmMachine` IPC proxy. + +use matrix_sdk::ruma::RoomId; +use matrix_sdk_crypto::backups::{MegolmV1BackupKey, SignatureState}; +use matrix_sdk_crypto::olm::{BackedUpRoomKey, ExportedRoomKey}; +use matrix_sdk_crypto::store::types::BackupDecryptionKey; +use matrix_sdk_crypto::types::RoomKeyBackupInfo; +use matrix_sdk_crypto::{OlmMachine, RoomKeyImportResult}; +use serde_json::{json, Value}; + +use super::args::str_arg; +use super::wasm_enums::request_type::KEYS_BACKUP as REQUEST_TYPE_KEYS_BACKUP; + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + match handle(machine, method, args).await { + Ok(Some(value)) => Some(Ok(value)), + Ok(None) => None, + Err(error) => Some(Err(error)), + } +} + +fn signature_state(state: SignatureState) -> u8 { + match state { + SignatureState::Missing => 0, + SignatureState::Invalid => 1, + SignatureState::ValidButNotTrusted => 2, + SignatureState::ValidAndTrusted => 3, + } +} + +fn ignore_progress(_progress: usize, _total: usize) {} + +/// One entry of the flat array `CryptoApi::importBackedUpRoomKeys` receives. +#[derive(serde::Deserialize)] +struct BackedUpSession { + room_id: String, + session_id: String, + #[serde(flatten)] + key: Value, +} + +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: RoomKeyBackupInfo = + serde_json::from_str(&str_arg(args, method, "backupInfo")?) + .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 sessions: Vec = + serde_json::from_str(&str_arg(args, method, "keys")?) + .map_err(|e| format!("importBackedUpRoomKeys: bad keys: {e}"))?; + + let mut exported = Vec::with_capacity(sessions.len()); + for session in sessions { + let room = RoomId::parse(&session.room_id) + .map_err(|e| format!("importBackedUpRoomKeys: bad room id: {e}"))?; + let key: BackedUpRoomKey = serde_json::from_value(session.key) + .map_err(|e| format!("importBackedUpRoomKeys: bad key for room {room}: {e}"))?; + exported.push(ExportedRoomKey::from_backed_up_room_key( + room, + session.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)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// `backupInfo` arrives as a JSON string, like every other JSON argument. + #[test] + fn accepts_the_backup_info_shape_the_webview_sends() { + let info = json!({ + "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2", + "auth_data": { "public_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" }, + }); + let args = json!({ "backupInfo": info.to_string() }); + + let parsed: RoomKeyBackupInfo = + serde_json::from_str(&str_arg(&args, "verifyBackup", "backupInfo").unwrap()).unwrap(); + + assert!(matches!( + parsed, + RoomKeyBackupInfo::MegolmBackupV1Curve25519AesSha2(_) + )); + } + + #[test] + fn rejects_backup_info_passed_as_an_object() { + let args = + json!({ "backupInfo": { "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2" } }); + + assert!(str_arg(&args, "verifyBackup", "backupInfo").is_err()); + } + + /// CryptoApi sends a flat array of sessions, not a map grouped by room. + #[test] + fn accepts_the_flat_session_array_crypto_api_sends() { + let keys = json!([{ + "room_id": "!room:example.org", + "session_id": "session-1", + "algorithm": "m.megolm.v1.aes-sha2", + "sender_key": "sender", + "session_key": "key", + "sender_claimed_keys": {}, + "forwarding_curve25519_key_chain": [], + }]); + let args = json!({ "keys": keys.to_string(), "backupVersion": "1" }); + + let parsed: Vec = + serde_json::from_str(&str_arg(&args, "importBackedUpRoomKeys", "keys").unwrap()) + .unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].room_id, "!room:example.org"); + assert_eq!(parsed[0].session_id, "session-1"); + } +} diff --git a/src-tauri/src/matrix_crypto/bundles.rs b/src-tauri/src/matrix_crypto/bundles.rs new file mode 100644 index 0000000000..d28d76d941 --- /dev/null +++ b/src-tauri/src/matrix_crypto/bundles.rs @@ -0,0 +1,421 @@ +//! Room key bundles (MSC4268) and dehydrated devices for the `OlmMachine` IPC proxy. + +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::sync::{Arc, Mutex, OnceLock}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use matrix_sdk::ruma::events::room::EncryptedFile; +use matrix_sdk_crypto::dehydrated_devices::RehydratedDevice; +use matrix_sdk_crypto::store::types::{ + Changes, DehydratedDeviceKey, RoomPendingKeyBundleDetails, StoredRoomKeyBundleData, +}; +use matrix_sdk_crypto::types::events::room_key_bundle::RoomKeyBundleContent; +use matrix_sdk_crypto::types::room_history::RoomKeyBundle; +use matrix_sdk_crypto::{ + AttachmentDecryptor, AttachmentEncryptor, CollectStrategy, MediaEncryptionInfo, OlmMachine, +}; +use serde_json::{json, Map, Value}; +use zeroize::Zeroizing; + +use super::args::{decryption_settings, room_id, str_arg, user_id}; +use super::events::room_key_json; +use super::wasm_enums::request_type::TO_DEVICE as REQUEST_TYPE_TO_DEVICE; + +/// Never put the key in an error: it would end up in a log line. +fn dehydration_key(args: &Value, method: &str) -> Result { + let raw = str_arg(args, method, "dehydratedDeviceKey")?; + let bytes = BASE64 + .decode(raw) + .map_err(|_| format!("{method}: `dehydratedDeviceKey` is not valid base64"))?; + DehydratedDeviceKey::from_slice(&bytes) + .map_err(|e| format!("{method}: unusable dehydrated device key: {e}")) +} + +fn collect_strategy(args: &Value, method: &str) -> Result { + match args.get("sharingStrategy").and_then(Value::as_str) { + None | Some("identityBasedStrategy") => Ok(CollectStrategy::IdentityBasedStrategy), + Some("allDevices") => Ok(CollectStrategy::AllDevices), + Some("errorOnVerifiedUserProblem") => Ok(CollectStrategy::ErrorOnVerifiedUserProblem), + Some("onlyTrustedDevices") => Ok(CollectStrategy::OnlyTrustedDevices), + Some(other) => Err(format!("{method}: unknown sharing strategy `{other}`")), + } +} + +fn pending_details_json(details: &RoomPendingKeyBundleDetails) -> Value { + json!({ + "roomId": details.room_id.to_string(), + "inviterId": details.inviter.to_string(), + "inviteAcceptedAtMillis": u64::from(details.invite_accepted_at.0), + }) +} + +fn stored_bundle_json(data: &StoredRoomKeyBundleData) -> Result { + let encryption_info = + serde_json::to_string(&MediaEncryptionInfo::from(data.bundle_data.file.clone())) + .map_err(|e| format!("getReceivedRoomKeyBundleData: bad encryption info: {e}"))?; + Ok(json!({ + "senderUser": data.sender_user.to_string(), + "roomId": data.bundle_data.room_id.to_string(), + "url": data.bundle_data.file.url.to_string(), + "encryptionInfo": encryption_info, + })) +} + +/// Must persist across the paged `/dehydrated_device/{id}/events` fetches. +type RehydratedDevices = Mutex)>>; + +fn rehydrated_devices() -> &'static RehydratedDevices { + static DEVICES: OnceLock = OnceLock::new(); + DEVICES.get_or_init(Default::default) +} + +fn account_key(machine: &OlmMachine) -> String { + super::account_key(machine.user_id().as_str(), machine.device_id().as_str()) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getAllRoomsPendingKeyBundles" => machine + .store() + .get_all_rooms_pending_key_bundles() + .await + .map_err(|e| format!("getAllRoomsPendingKeyBundles failed: {e}")) + .map(|rooms| Value::Array(rooms.iter().map(pending_details_json).collect())), + + "getPendingKeyBundleDetailsForRoom" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .get_pending_key_bundle_details_for_room(&room) + .await + .map_err(|e| format!("getPendingKeyBundleDetailsForRoom failed: {e}")) + .map(|details| { + details + .as_ref() + .map(pending_details_json) + .unwrap_or(Value::Null) + }), + Err(e) => Err(e), + }, + + "storeRoomPendingKeyBundle" => store_room_pending_key_bundle(machine, method, args).await, + + "clearRoomPendingKeyBundle" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .clear_room_pending_key_bundle(&room) + .await + .map_err(|e| format!("clearRoomPendingKeyBundle failed: {e}")) + .map(|()| Value::Null), + Err(e) => Err(e), + }, + + "getReceivedRoomKeyBundleData" => received_bundle_data(machine, method, args).await, + "receiveRoomKeyBundle" => receive_bundle(machine, method, args).await, + + "hasDownloadedAllRoomKeys" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .has_downloaded_all_room_keys(&room) + .await + .map_err(|e| format!("hasDownloadedAllRoomKeys failed: {e}")) + .map(Value::Bool), + Err(e) => Err(e), + }, + "setHasDownloadedAllRoomKeys" => match room_id(args, method, "roomId") { + Ok(room) => machine + .store() + .save_changes(Changes { + room_key_backups_fully_downloaded: [room].into_iter().collect(), + ..Default::default() + }) + .await + .map_err(|e| format!("setHasDownloadedAllRoomKeys failed: {e}")) + .map(|()| Value::Null), + Err(e) => Err(e), + }, + "buildRoomKeyBundle" => build_bundle(machine, method, args).await, + "shareRoomKeyBundleData" => share_bundle_data(machine, method, args).await, + + "dehydratedDevices.getDehydratedDeviceKey" => machine + .dehydrated_devices() + .get_dehydrated_device_pickle_key() + .await + .map_err(|e| format!("{method} failed: {e}")) + .map(|key| match key { + Some(key) => Value::String(key.to_base64()), + None => Value::Null, + }), + "dehydratedDevices.saveDehydratedDeviceKey" => match dehydration_key(args, method) { + Ok(key) => machine + .dehydrated_devices() + .save_dehydrated_device_pickle_key(&key) + .await + .map_err(|e| format!("{method} failed: {e}")) + .map(|()| Value::Null), + Err(e) => Err(e), + }, + "dehydratedDevices.deleteDehydratedDeviceKey" => machine + .dehydrated_devices() + .delete_dehydrated_device_pickle_key() + .await + .map_err(|e| format!("{method} failed: {e}")) + .map(|()| Value::Null), + + "dehydratedDevices.create" => Ok(Value::Null), + "dehydratedDevices.keysForUpload" => keys_for_upload(machine, method, args).await, + + "dehydratedDevices.rehydrate" => rehydrate(machine, method, args).await, + "dehydratedDevices.receiveEvents" => receive_dehydrated_events(machine, method, args).await, + + _ => return None, + }) +} + +async fn store_room_pending_key_bundle( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let room = room_id(args, method, "roomId")?; + let inviter = user_id(args, method, "inviterId")?; + machine + .store() + .store_room_pending_key_bundle(&room, &inviter) + .await + .map_err(|e| format!("storeRoomPendingKeyBundle failed: {e}"))?; + Ok(Value::Null) +} + +async fn received_bundle_data( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let room = room_id(args, method, "roomId")?; + let inviter = user_id(args, method, "inviterId")?; + let data = machine + .store() + .get_received_room_key_bundle_data(&room, &inviter) + .await + .map_err(|e| format!("getReceivedRoomKeyBundleData failed: {e}"))?; + match data { + Some(data) => stored_bundle_json(&data), + None => Ok(Value::Null), + } +} + +async fn receive_bundle(machine: &OlmMachine, method: &str, args: &Value) -> Result { + let room = room_id(args, method, "roomId")?; + let inviter = user_id(args, method, "inviterId")?; + let encrypted = BASE64 + .decode(str_arg(args, method, "bundle")?) + .map_err(|e| format!("receiveRoomKeyBundle: `bundle` is not valid base64: {e}"))?; + + let info = machine + .store() + .get_received_room_key_bundle_data(&room, &inviter) + .await + .map_err(|e| format!("receiveRoomKeyBundle failed: {e}"))? + .ok_or_else(|| { + format!("receiveRoomKeyBundle: no stored bundle data for {room} from {inviter}") + })?; + + let bundle: RoomKeyBundle = { + let mut cursor = Cursor::new(encrypted.as_slice()); + let mut decryptor = AttachmentDecryptor::new( + &mut cursor, + MediaEncryptionInfo::from(info.bundle_data.file.clone()), + ) + .map_err(|e| format!("receiveRoomKeyBundle: bundle is not decryptable: {e}"))?; + + let mut decrypted = Zeroizing::new(Vec::new()); + decryptor + .read_to_end(&mut decrypted) + .map_err(|e| format!("receiveRoomKeyBundle: decrypting the bundle failed: {e}"))?; + serde_json::from_slice(&decrypted) + .map_err(|e| format!("receiveRoomKeyBundle: malformed bundle: {e}"))? + }; + + machine + .store() + .receive_room_key_bundle(&info, bundle, |_, _| {}) + .await + .map_err(|e| format!("receiveRoomKeyBundle failed: {e}"))?; + Ok(Value::Null) +} + +async fn build_bundle(machine: &OlmMachine, method: &str, args: &Value) -> Result { + let room = room_id(args, method, "roomId")?; + let bundle = machine + .store() + .build_room_key_bundle(&room) + .await + .map_err(|e| format!("buildRoomKeyBundle failed: {e}"))?; + + if bundle.is_empty() { + return Ok(Value::Null); + } + + let json = Zeroizing::new( + serde_json::to_vec(&bundle) + .map_err(|e| format!("buildRoomKeyBundle: serialising the bundle failed: {e}"))?, + ); + let mut cursor = Cursor::new(json.as_slice()); + let mut encryptor = AttachmentEncryptor::new(&mut cursor); + let mut encrypted = Vec::new(); + encryptor + .read_to_end(&mut encrypted) + .map_err(|e| format!("buildRoomKeyBundle: encrypting the bundle failed: {e}"))?; + let encryption_info = serde_json::to_string(&encryptor.finish()) + .map_err(|e| format!("buildRoomKeyBundle: bad encryption info: {e}"))?; + + Ok(json!({ + "encryptedData": BASE64.encode(&encrypted), + "mediaEncryptionInfo": encryption_info, + })) +} + +async fn share_bundle_data( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let user = user_id(args, method, "userId")?; + let room = room_id(args, method, "roomId")?; + let url = str_arg(args, method, "url")?; + let strategy = collect_strategy(args, method)?; + + let mut file_json: Value = serde_json::from_str(&str_arg(args, method, "mediaEncryptionInfo")?) + .map_err(|e| format!("shareRoomKeyBundleData: bad media encryption info: {e}"))?; + file_json + .as_object_mut() + .ok_or_else(|| { + "shareRoomKeyBundleData: `mediaEncryptionInfo` must be an object".to_owned() + })? + .insert("url".to_owned(), Value::String(url)); + let file: EncryptedFile = serde_json::from_value(file_json) + .map_err(|e| format!("shareRoomKeyBundleData: unusable bundle file: {e}"))?; + + let requests = machine + .share_room_key_bundle_data( + &user, + &strategy, + RoomKeyBundleContent { + room_id: room, + file, + }, + ) + .await + .map_err(|e| format!("shareRoomKeyBundleData failed: {e}"))?; + + Ok(Value::Array( + requests + .iter() + .map(|request| { + json!({ + "type": REQUEST_TYPE_TO_DEVICE, + "className": "ToDeviceRequest", + "id": request.txn_id.to_string(), + "event_type": request.event_type.to_string(), + "txn_id": request.txn_id.to_string(), + "body": json!({ "messages": request.messages }).to_string(), + }) + }) + .collect(), + )) +} + +async fn keys_for_upload( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let display_name = str_arg(args, method, "initialDeviceDisplayName")?; + let key = dehydration_key(args, method)?; + + let devices = machine.dehydrated_devices(); + let device = devices + .create() + .await + .map_err(|e| format!("{method}: creating the dehydrated device failed: {e}"))?; + let request = device + .keys_for_upload(display_name, &key) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + + let mut body = Map::new(); + body.insert("device_id".to_owned(), json!(request.device_id)); + body.insert("device_data".to_owned(), json!(request.device_data)); + body.insert("device_keys".to_owned(), json!(request.device_keys)); + if let Some(name) = &request.initial_device_display_name { + body.insert("initial_device_display_name".to_owned(), json!(name)); + } + if !request.one_time_keys.is_empty() { + body.insert("one_time_keys".to_owned(), json!(request.one_time_keys)); + } + if !request.fallback_keys.is_empty() { + body.insert("fallback_keys".to_owned(), json!(request.fallback_keys)); + } + let body = Value::Object(body); + + Ok(json!({ + "className": "PutDehydratedDeviceRequest", + "id": Value::Null, + "body": body.to_string(), + })) +} + +async fn rehydrate(machine: &OlmMachine, method: &str, args: &Value) -> Result { + let key = dehydration_key(args, method)?; + let device_id = str_arg(args, method, "deviceId")?; + let device_data = serde_json::from_str(&str_arg(args, method, "deviceData")?) + .map_err(|e| format!("{method}: bad device data json: {e}"))?; + + let device = machine + .dehydrated_devices() + .rehydrate(&key, device_id.as_str().into(), device_data) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + + rehydrated_devices() + .lock() + .map_err(|e| format!("{method}: rehydrated device registry poisoned: {e}"))? + .insert(account_key(machine), (device_id.clone(), Arc::new(device))); + + Ok(json!({ "deviceId": device_id })) +} + +async fn receive_dehydrated_events( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Result { + let device_id = str_arg(args, method, "deviceId")?; + let events = serde_json::from_str(&str_arg(args, method, "toDeviceEvents")?) + .map_err(|e| format!("{method}: bad to-device events json: {e}"))?; + + let device = { + let registry = rehydrated_devices() + .lock() + .map_err(|e| format!("{method}: rehydrated device registry poisoned: {e}"))?; + match registry.get(&account_key(machine)) { + Some((id, device)) if *id == device_id => Arc::clone(device), + _ => { + return Err(format!( + "{method}: device {device_id} has not been rehydrated" + )) + } + } + }; + + let room_keys = device + .receive_events(events, &decryption_settings()) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Array(room_keys.iter().map(room_key_json).collect())) +} diff --git a/src-tauri/src/matrix_crypto/cross_signing.rs b/src-tauri/src/matrix_crypto/cross_signing.rs new file mode 100644 index 0000000000..dadf827743 --- /dev/null +++ b/src-tauri/src/matrix_crypto/cross_signing.rs @@ -0,0 +1,205 @@ +//! Cross-signing bootstrap and private key transfer for the `OlmMachine` IPC proxy. + +use matrix_sdk_crypto::types::requests::AnyOutgoingRequest; +use matrix_sdk_crypto::types::SecretsBundle; +use matrix_sdk_crypto::{CrossSigningKeyExport, OlmMachine}; +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use super::wasm_enums::request_type::{ + KEYS_UPLOAD as KEYS_UPLOAD_REQUEST_TYPE, SIGNATURE_UPLOAD as SIGNATURE_UPLOAD_REQUEST_TYPE, +}; + +/// Absent keys are omitted, not null: js-sdk gates on `!== undefined` and would otherwise +/// store a null into 4S. The mixed casing is wasm's own, not a typo. +#[derive(Serialize)] +struct CrossSigningKeyExportSnapshot { + #[serde(rename = "masterKey", skip_serializing_if = "Option::is_none")] + master_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + self_signing_key: Option, + #[serde(rename = "userSigningKey", skip_serializing_if = "Option::is_none")] + user_signing_key: Option, +} + +fn opt_str_arg(args: &Value, field: &str) -> Option { + args.get(field).and_then(Value::as_str).map(str::to_owned) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "bootstrapCrossSigning" => bootstrap(machine, args).await, + "exportCrossSigningKeys" => export_keys(machine).await, + "importCrossSigningKeys" => import_keys(machine, args).await, + "exportSecretsBundle" => export_secrets_bundle(machine).await, + "importSecretsBundle" => import_secrets_bundle(machine, args).await, + + "pushSecretToVerifiedDevices" => push_secret(machine, args).await, + + _ => return None, + }) +} + +async fn bootstrap(machine: &OlmMachine, args: &Value) -> Result { + let reset = args + .get("reset") + .and_then(Value::as_bool) + .ok_or_else(|| "bootstrapCrossSigning: missing boolean argument `reset`".to_owned())?; + + let requests = machine + .bootstrap_cross_signing(reset) + .await + .map_err(|e| format!("bootstrapCrossSigning failed: {e}"))?; + + let upload_keys_request = match requests.upload_keys_req.as_ref() { + Some(request) => match request.request() { + AnyOutgoingRequest::KeysUpload(req) => json!({ + "id": request.request_id().to_string(), + "type": KEYS_UPLOAD_REQUEST_TYPE, + "className": "KeysUploadRequest", + "body": json!({ + "device_keys": req.device_keys, + "one_time_keys": req.one_time_keys, + "fallback_keys": req.fallback_keys, + }).to_string(), + }), + _ => { + return Err( + "bootstrapCrossSigning: upload_keys_req was not a /keys/upload request" + .to_owned(), + ) + } + }, + None => Value::Null, + }; + + let signing_keys = &requests.upload_signing_keys_req; + let mut signing_keys_body = Map::new(); + for (field, key) in [ + ("master_key", &signing_keys.master_key), + ("self_signing_key", &signing_keys.self_signing_key), + ("user_signing_key", &signing_keys.user_signing_key), + ] { + if let Some(key) = key { + signing_keys_body.insert(field.to_owned(), json!(key)); + } + } + + Ok(json!({ + "uploadKeysRequest": upload_keys_request, + "uploadSigningKeysRequest": { + "className": "UploadSigningKeysRequest", + "id": Value::Null, + "body": Value::Object(signing_keys_body).to_string(), + }, + "uploadSignaturesRequest": { + "type": SIGNATURE_UPLOAD_REQUEST_TYPE, + "className": "SignatureUploadRequest", + "body": json!(requests.upload_signatures_req.signed_keys).to_string(), + }, + })) +} + +/// wasm's `self_signing_key` is snake_case while its siblings are camelCase. +async fn export_keys(machine: &OlmMachine) -> Result { + let export = machine + .export_cross_signing_keys() + .await + .map_err(|e| format!("exportCrossSigningKeys failed: {e}"))?; + + Ok(match export { + Some(export) => serde_json::to_value(CrossSigningKeyExportSnapshot { + master_key: export.master_key.clone(), + self_signing_key: export.self_signing_key.clone(), + user_signing_key: export.user_signing_key.clone(), + }) + .map_err(|e| format!("exportCrossSigningKeys: serialising the export failed: {e}"))?, + None => Value::Null, + }) +} + +async fn import_keys(machine: &OlmMachine, args: &Value) -> Result { + let export = CrossSigningKeyExport { + master_key: opt_str_arg(args, "master_key"), + self_signing_key: opt_str_arg(args, "self_signing_key"), + user_signing_key: opt_str_arg(args, "user_signing_key"), + }; + + let status = machine + .import_cross_signing_keys(export) + .await + .map_err(|e| format!("importCrossSigningKeys failed: {e}"))?; + + Ok(json!({ + "hasMaster": status.has_master, + "hasSelfSigning": status.has_self_signing, + "hasUserSigning": status.has_user_signing, + })) +} + +async fn export_secrets_bundle(machine: &OlmMachine) -> Result { + let bundle = machine + .store() + .export_secrets_bundle() + .await + .map_err(|e| format!("exportSecretsBundle failed: {e}"))?; + + serde_json::to_value(&bundle) + .map_err(|e| format!("exportSecretsBundle: serializing the bundle failed: {e}")) +} + +async fn import_secrets_bundle(machine: &OlmMachine, args: &Value) -> Result { + let bundle = args + .get("bundle") + .ok_or_else(|| "importSecretsBundle: missing argument `bundle`".to_owned())?; + let bundle: SecretsBundle = serde_json::from_value(bundle.clone()) + .map_err(|e| format!("importSecretsBundle: bad bundle json: {e}"))?; + + machine + .store() + .import_secrets_bundle(&bundle) + .await + .map_err(|e| format!("importSecretsBundle failed: {e}"))?; + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use super::CrossSigningKeyExportSnapshot; + + #[test] + fn absent_keys_are_omitted_so_null_is_never_stored_in_secret_storage() { + let value = serde_json::to_value(CrossSigningKeyExportSnapshot { + master_key: Some("master".to_owned()), + self_signing_key: None, + user_signing_key: None, + }) + .unwrap(); + + let object = value.as_object().unwrap(); + assert_eq!(object.get("masterKey").unwrap(), "master"); + assert!(!object.contains_key("self_signing_key"), "{value}"); + assert!(!object.contains_key("userSigningKey"), "{value}"); + } +} + +async fn push_secret(machine: &OlmMachine, args: &Value) -> Result { + let name = opt_str_arg(args, "secretName") + .ok_or_else(|| "pushSecretToVerifiedDevices: missing `secretName`".to_owned())?; + + let failures = machine + .push_secret_to_verified_devices(name.as_str().into()) + .await + .map_err(|e| format!("pushSecretToVerifiedDevices failed: {e}"))?; + + Ok(Value::Array( + failures + .keys() + .map(|device| Value::String(device.to_string())) + .collect(), + )) +} diff --git a/src-tauri/src/matrix_crypto/devices.rs b/src-tauri/src/matrix_crypto/devices.rs new file mode 100644 index 0000000000..edfd3f7419 --- /dev/null +++ b/src-tauri/src/matrix_crypto/devices.rs @@ -0,0 +1,329 @@ +//! Devices, user identities and key queries for the `OlmMachine` IPC proxy. + +use std::collections::BTreeMap; +use std::time::Duration; + +use matrix_sdk::ruma::api::client::keys::upload_signatures::v3::Request as SignatureUploadRequest; +use matrix_sdk::ruma::{OwnedDeviceId, UserId}; +use matrix_sdk_crypto::types::Signatures; +use matrix_sdk_crypto::{ + CollectStrategy, Device, LocalTrust, OlmMachine, UserDevices, UserIdentity, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::args::{str_arg, user_id}; +use super::wasm_enums::{encryption_algorithm, request_type}; + +fn device_id(args: &Value, method: &str) -> Result { + Ok(str_arg(args, method, "deviceId")?.into()) +} + +fn timeout(args: &Value) -> Option { + args.get("timeoutSecs") + .and_then(Value::as_f64) + .map(Duration::from_secs_f64) +} + +fn signatures_json(signatures: &Signatures, method: &str) -> Result { + let json = serde_json::to_string(signatures) + .map_err(|e| format!("{method}: serializing signatures failed: {e}"))?; + Ok(json!({ "className": "Signatures", "json": json })) +} + +fn key_json(key: &T, method: &str) -> Result { + serde_json::to_string(key) + .map(Value::String) + .map_err(|e| format!("{method}: serializing cross-signing key failed: {e}")) +} + +/// No `id`: that is how js-sdk knows to skip `markRequestAsSent`. +fn signature_upload_json(request: &SignatureUploadRequest) -> Value { + json!({ + "id": Value::Null, + "type": request_type::SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "body": json!(request.signed_keys).to_string(), + }) +} + +fn device_json(device: &Device, method: &str) -> Result { + let keys: BTreeMap = device + .keys() + .iter() + .map(|(key_id, key)| (key_id.to_string(), key.to_base64())) + .collect(); + + Ok(json!({ + "className": "Device", + "userId": device.user_id(), + "deviceId": device.device_id(), + "displayName": device.display_name(), + "algorithms": device + .algorithms() + .iter() + .map(encryption_algorithm) + .collect::>(), + "keys": keys, + "signatures": signatures_json(device.signatures(), method)?, + "curve25519Key": device.curve25519_key().map(|key| key.to_base64()), + "ed25519Key": device.ed25519_key().map(|key| key.to_base64()), + "localTrustState": device.local_trust_state() as u8, + "isVerified": device.is_verified(), + "isCrossSigningTrusted": device.is_cross_signing_trusted(), + "isCrossSignedByOwner": device.is_cross_signed_by_owner(), + "isLocallyTrusted": device.is_locally_trusted(), + "isBlacklisted": device.is_blacklisted(), + "isDeleted": device.is_deleted(), + "isDehydrated": device.is_dehydrated(), + "firstTimeSeen": device.first_time_seen_ts().0, + })) +} + +fn user_devices_json(devices: &UserDevices, user: &UserId, method: &str) -> Result { + Ok(json!({ + "className": "UserDevices", + "userId": user, + "devices": devices + .devices() + .map(|device| device_json(&device, method)) + .collect::, String>>()?, + "keys": devices.keys().map(|id| id.to_string()).collect::>(), + "isAnyVerified": devices.is_any_verified(), + })) +} + +/// `className` is required: js-sdk tells own from other identities with `instanceof`. +fn identity_json(identity: &UserIdentity, method: &str) -> Result { + match identity { + UserIdentity::Own(own) => Ok(json!({ + "className": "OwnUserIdentity", + "userId": own.user_id(), + "isVerified": own.is_verified(), + "wasPreviouslyVerified": own.was_previously_verified(), + "hasVerificationViolation": own.has_verification_violation(), + "masterKey": key_json(own.master_key(), method)?, + "selfSigningKey": key_json(own.self_signing_key(), method)?, + "userSigningKey": key_json(own.user_signing_key(), method)?, + })), + UserIdentity::Other(other) => Ok(json!({ + "className": "OtherUserIdentity", + "userId": other.user_id(), + "isVerified": other.is_verified(), + "wasPreviouslyVerified": other.was_previously_verified(), + "hasVerificationViolation": other.has_verification_violation(), + "identityNeedsUserApproval": other.identity_needs_user_approval(), + "masterKey": key_json(other.master_key(), method)?, + "selfSigningKey": key_json(other.self_signing_key(), method)?, + })), + } +} + +async fn device_for( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result, String> { + let user = user_id(args, method, "userId")?; + let device = device_id(args, method)?; + machine + .get_device(&user, &device, timeout(args)) + .await + .map_err(|e| format!("{method} failed: {e}")) +} + +async fn identity_for( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result, String> { + let user = user_id(args, method, "userId")?; + machine + .get_identity(&user, timeout(args)) + .await + .map_err(|e| format!("{method} failed: {e}")) +} + +async fn get_device(machine: &OlmMachine, args: &Value, method: &str) -> Result { + match device_for(machine, args, method).await? { + Some(device) => device_json(&device, method), + None => Ok(Value::Null), + } +} + +async fn get_user_devices( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let user = user_id(args, method, "userId")?; + let devices = machine + .get_user_devices(&user, timeout(args)) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + user_devices_json(&devices, &user, method) +} + +async fn get_identity(machine: &OlmMachine, args: &Value, method: &str) -> Result { + match identity_for(machine, args, method).await? { + Some(identity) => identity_json(&identity, method), + None => Ok(Value::Null), + } +} + +fn query_keys_for_users(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let users = args + .get("users") + .and_then(Value::as_array) + .ok_or_else(|| format!("{method}: missing array argument `users`"))? + .iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect::>(); + + let (id, request) = machine.query_keys_for_users(users.iter().map(AsRef::as_ref)); + Ok(json!({ + "id": id.to_string(), + "type": request_type::KEYS_QUERY, + "className": "KeysQueryRequest", + "body": super::requests::keys_query_body( + &request.timeout, + &json!(request.device_keys), + ), + })) +} + +async fn verify_device(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let Some(device) = device_for(machine, args, method).await? else { + return Ok(Value::Null); + }; + let request = device + .verify() + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(signature_upload_json(&request)) +} + +async fn set_local_trust( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let trust = args + .get("trustState") + .and_then(Value::as_i64) + .ok_or_else(|| format!("{method}: missing numeric argument `trustState`"))?; + let Some(device) = device_for(machine, args, method).await? else { + return Ok(Value::Null); + }; + device + .set_local_trust(LocalTrust::from(trust)) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Null) +} + +async fn encrypt_to_device_event( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let event_type = str_arg(args, method, "eventType")?; + let content = args + .get("content") + .ok_or_else(|| format!("{method}: missing argument `content`"))?; + let Some(device) = device_for(machine, args, method).await? else { + return Err(format!("{method}: unknown device")); + }; + + let encrypted = device + .encrypt_event_raw(&event_type, content, CollectStrategy::AllDevices) + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::String(encrypted.json().get().to_owned())) +} + +async fn verify_identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let Some(identity) = identity_for(machine, args, method).await? else { + return Ok(Value::Null); + }; + let request = match identity { + UserIdentity::Own(own) => own.verify().await, + UserIdentity::Other(other) => other.verify().await, + } + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(signature_upload_json(&request)) +} + +async fn pin_identity(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let Some(identity) = identity_for(machine, args, method).await? else { + return Err(format!("{method}: unknown user identity")); + }; + identity + .pin() + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Null) +} + +async fn withdraw_identity_verification( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let Some(identity) = identity_for(machine, args, method).await? else { + return Err(format!("{method}: unknown user identity")); + }; + identity + .withdraw_verification() + .await + .map_err(|e| format!("{method} failed: {e}"))?; + Ok(Value::Null) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getDevice" => get_device(machine, args, method).await, + "getUserDevices" => get_user_devices(machine, args, method).await, + "getIdentity" => get_identity(machine, args, method).await, + "queryKeysForUsers" => query_keys_for_users(machine, args, method), + "trackedUsers" => machine + .tracked_users() + .await + .map(|users| { + Value::Array( + users + .into_iter() + .map(|user| Value::String(user.to_string())) + .collect(), + ) + }) + .map_err(|e| format!("trackedUsers failed: {e}")), + "sign" => match str_arg(args, method, "message") { + Ok(message) => match machine.sign(&message).await { + Ok(signatures) => signatures_json(&signatures, method), + Err(e) => Err(format!("sign failed: {e}")), + }, + Err(e) => Err(e), + }, + + "device.verify" => verify_device(machine, args, method).await, + "device.setLocalTrust" => set_local_trust(machine, args, method).await, + "device.encryptToDeviceEvent" => encrypt_to_device_event(machine, args, method).await, + "userIdentity.verify" => verify_identity(machine, args, method).await, + "userIdentity.pin" => pin_identity(machine, args, method).await, + "userIdentity.withdrawVerification" => { + withdraw_identity_verification(machine, args, method).await + } + + _ => return None, + }) +} diff --git a/src-tauri/src/matrix_crypto/dispatch.rs b/src-tauri/src/matrix_crypto/dispatch.rs new file mode 100644 index 0000000000..0d6ba3984c --- /dev/null +++ b/src-tauri/src/matrix_crypto/dispatch.rs @@ -0,0 +1,438 @@ +//! Generic `OlmMachine` dispatch for the IPC proxy: one Tauri command carries all +//! ~57 wasm `OlmMachine` members as a method name plus a JSON argument object. +//! Argument and return shapes mirror the wasm bindings, not the Rust API. + +use std::collections::BTreeMap; + +use matrix_sdk::deserialized_responses::{ + AlgorithmInfo, ProcessedToDeviceEvent, VerificationState, +}; +use matrix_sdk::ruma::api::client::sync::sync_events::DeviceLists; +use matrix_sdk::ruma::events::secret::request::SecretName; +use matrix_sdk::ruma::events::{AnyMessageLikeEventContent, AnySyncMessageLikeEvent}; +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::{DeviceKeyAlgorithm, OneTimeKeyAlgorithm, UInt, UserId}; +use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; +use matrix_sdk_crypto::types::events::ToDeviceEvents; +use matrix_sdk_crypto::{EncryptionSyncChanges, OlmMachine}; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::args::{caller_decryption_settings, decryption_settings, room_id, str_arg}; +use super::requests::{mark_request_sent, outgoing_requests}; +use super::wasm_enums::processed_to_device_event_type; + +#[derive(Serialize)] +#[serde(tag = "className")] +enum ProcessedToDeviceEventSnapshot<'a> { + DecryptedToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + #[serde(rename = "encryptionInfo")] + encryption_info: ToDeviceEncryptionInfoSnapshot, + }, + UTDToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + }, + PlainTextToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + }, + InvalidToDeviceEvent { + #[serde(rename = "type")] + event_type: u8, + #[serde(rename = "rawEvent")] + raw_event: &'a str, + }, +} + +#[derive(Serialize)] +#[serde(tag = "className")] +enum ToDeviceEncryptionInfoSnapshot { + ToDeviceEncryptionInfo { + sender: String, + #[serde(rename = "senderDevice")] + sender_device: Option, + #[serde(rename = "senderCurve25519Key")] + sender_curve25519_key: String, + #[serde(rename = "isSenderVerified")] + is_sender_verified: bool, + }, +} + +fn processed_to_device_event_json( + event: &ProcessedToDeviceEvent, + verification_request: Option, +) -> Result { + let raw_event = event.as_raw().json().get(); + + let snapshot = match event { + ProcessedToDeviceEvent::Decrypted { + encryption_info, .. + } => { + let sender_curve25519_key = match &encryption_info.algorithm_info { + AlgorithmInfo::OlmV1Curve25519AesSha2 { + curve25519_public_key_base64, + } => curve25519_public_key_base64.as_str(), + _ => { + return Err( + "receiveSyncChanges: decrypted to-device event did not use Olm v1" + .to_owned(), + ) + } + }; + + ProcessedToDeviceEventSnapshot::DecryptedToDeviceEvent { + event_type: processed_to_device_event_type::DECRYPTED, + raw_event, + encryption_info: ToDeviceEncryptionInfoSnapshot::ToDeviceEncryptionInfo { + sender: encryption_info.sender.to_string(), + sender_device: encryption_info + .sender_device + .as_ref() + .map(ToString::to_string), + sender_curve25519_key: sender_curve25519_key.to_owned(), + is_sender_verified: matches!( + encryption_info.verification_state, + VerificationState::Verified + ), + }, + } + } + ProcessedToDeviceEvent::UnableToDecrypt { .. } => { + ProcessedToDeviceEventSnapshot::UTDToDeviceEvent { + event_type: processed_to_device_event_type::UNABLE_TO_DECRYPT, + raw_event, + } + } + ProcessedToDeviceEvent::PlainText(_) => { + ProcessedToDeviceEventSnapshot::PlainTextToDeviceEvent { + event_type: processed_to_device_event_type::PLAIN_TEXT, + raw_event, + } + } + ProcessedToDeviceEvent::Invalid(_) => { + ProcessedToDeviceEventSnapshot::InvalidToDeviceEvent { + event_type: processed_to_device_event_type::INVALID, + raw_event, + } + } + }; + + let mut value = serde_json::to_value(snapshot) + .map_err(|e| format!("receiveSyncChanges: failed to serialize processed event: {e}"))?; + if let Some(request) = verification_request { + value["verificationRequest"] = request; + } + Ok(value) +} + +fn verification_request_snapshot( + machine: &OlmMachine, + event: &ProcessedToDeviceEvent, +) -> Option { + let ToDeviceEvents::KeyVerificationRequest(event) = + event.as_raw().deserialize_as::().ok()? + else { + return None; + }; + machine + .get_verification_request(&event.sender, &event.content.transaction_id) + .map(|request| super::verification::request_state(&request)) +} + +pub async fn invoke(machine: &OlmMachine, method: &str, args: Value) -> Result { + match method { + "identityKeys" => { + let keys = machine.identity_keys(); + Ok(json!({ + "ed25519": keys.ed25519.to_base64(), + "curve25519": keys.curve25519.to_base64(), + })) + } + "deviceCreationTimeMs" => Ok(json!(machine.device_creation_time().get())), + + "receiveSyncChanges" => { + let to_device_events = args + .get("toDeviceEvents") + .and_then(Value::as_str) + .map(|raw| { + serde_json::value::RawValue::from_string(raw.to_owned()) + .map(Raw::from_json) + .map_err(|e| format!("receiveSyncChanges: bad toDeviceEvents json: {e}")) + }) + .transpose()? + .map(|raw: Raw| { + serde_json::from_str::>>(raw.json().get()).map_err(|e| { + format!("receiveSyncChanges: toDeviceEvents not an array: {e}") + }) + }) + .transpose()? + .unwrap_or_default(); + + let mut device_lists = DeviceLists::new(); + device_lists.changed = args + .get("changedDevices") + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect() + }) + .unwrap_or_default(); + device_lists.left = args + .get("leftDevices") + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect() + }) + .unwrap_or_default(); + + let key_counts: BTreeMap = args + .get("oneTimeKeysCounts") + .and_then(Value::as_object) + .map(|counts| { + counts + .iter() + .filter_map(|(alg, count)| { + Some(( + OneTimeKeyAlgorithm::from(alg.as_str()), + UInt::try_from(count.as_u64()?).ok()?, + )) + }) + .collect() + }) + .unwrap_or_default(); + + let fallback_keys: Vec = args + .get("unusedFallbackKeys") + .and_then(Value::as_array) + .map(|keys| { + keys.iter() + .filter_map(Value::as_str) + .map(OneTimeKeyAlgorithm::from) + .collect() + }) + .unwrap_or_default(); + + let (processed, _room_keys) = machine + .receive_sync_changes( + EncryptionSyncChanges { + to_device_events, + changed_devices: &device_lists, + one_time_keys_counts: &key_counts, + unused_fallback_keys: Some(&fallback_keys), + next_batch_token: args + .get("nextBatchToken") + .and_then(Value::as_str) + .map(str::to_owned), + }, + &decryption_settings(), + ) + .await + .map_err(|e| format!("receiveSyncChanges failed: {e}"))?; + + processed + .iter() + .map(|event| { + processed_to_device_event_json( + event, + verification_request_snapshot(machine, event), + ) + }) + .collect::, _>>() + .map(Value::Array) + } + + "outgoingRequests" => outgoing_requests(machine).await, + "markRequestAsSent" => mark_request_sent(machine, &args).await, + + "receiveVerificationEvent" => { + let room = room_id(&args, method, "roomId")?; + let event_json = str_arg(&args, method, "event")?; + let event: AnySyncMessageLikeEvent = serde_json::from_str(&event_json) + .map_err(|e| format!("receiveVerificationEvent: bad event json: {e}"))?; + + machine + .receive_verification_event(&event.into_full_event(room)) + .await + .map_err(|e| format!("receiveVerificationEvent failed: {e}"))?; + Ok(Value::Null) + } + + "decryptRoomEvent" => { + let room = room_id(&args, method, "roomId")?; + let event_json = str_arg(&args, method, "event")?; + let event: Raw = serde_json::from_str(&event_json) + .map_err(|e| format!("decryptRoomEvent: bad event json: {e}"))?; + + let decrypted = machine + .decrypt_room_event(&event, &room, &caller_decryption_settings(&args)) + .await + .map_err(|e| format!("decryptRoomEvent failed: {e:?}"))?; + + let info = decrypted.encryption_info; + let (sender_curve25519_key, claimed_ed25519_key) = match &info.algorithm_info { + AlgorithmInfo::MegolmV1AesSha2 { + curve25519_key, + sender_claimed_keys, + .. + } => ( + Some(curve25519_key.clone()), + sender_claimed_keys + .get(&DeviceKeyAlgorithm::Ed25519) + .cloned(), + ), + _ => (None, None), + }; + + // Names follow wasm's `DecryptedRoomEvent`; `event` is a JSON string, not an object. + Ok(json!({ + "className": "DecryptedRoomEvent", + "event": decrypted.event.json().get(), + "sender": info.sender.to_string(), + "senderDevice": info.sender_device.as_ref().map(ToString::to_string), + "senderCurve25519Key": sender_curve25519_key, + "senderClaimedEd25519Key": claimed_ed25519_key, + "forwarder": Value::Null, + "forwarderDevice": Value::Null, + "forwardingCurve25519KeyChain": Vec::::new(), + })) + } + "encryptRoomEvent" => { + let room = room_id(&args, method, "roomId")?; + let event_type = str_arg(&args, method, "eventType")?; + let content_json = str_arg(&args, method, "content")?; + let content_box = serde_json::value::RawValue::from_string(content_json) + .map_err(|e| format!("encryptRoomEvent: bad content json: {e}"))?; + let content: Raw = Raw::from_json(content_box); + + let encrypted = machine + .encrypt_room_event_raw(&room, &event_type, &content) + .await + .map_err(|e| format!("encryptRoomEvent failed: {e:?}"))?; + let encrypted_json = encrypted.content.json().get(); + serde_json::from_str::(encrypted_json) + .map_err(|e| format!("encryptRoomEvent: bad encrypted content json: {e}"))?; + + // Match wasm's JSON-string return type. + Ok(Value::String(encrypted_json.to_owned())) + } + + "getSecretsFromInbox" => { + let name: SecretName = str_arg(&args, method, "secretName")?.as_str().into(); + let secrets = machine + .store() + .get_secrets_from_inbox(&name) + .await + .map_err(|e| format!("getSecretsFromInbox failed: {e}"))?; + Ok(Value::Array( + secrets + .into_iter() + .map(|secret| Value::String(secret.as_str().to_owned())) + .collect(), + )) + } + "deleteSecretsFromInbox" => { + let name: SecretName = str_arg(&args, method, "secretName")?.as_str().into(); + machine + .store() + .delete_secrets_from_inbox(&name) + .await + .map_err(|e| format!("deleteSecretsFromInbox failed: {e}"))?; + Ok(Value::Null) + } + + "crossSigningStatus" => { + let status = machine.cross_signing_status().await; + Ok(json!({ + "hasMaster": status.has_master, + "hasSelfSigning": status.has_self_signing, + "hasUserSigning": status.has_user_signing, + })) + } + + "updateTrackedUsers" => { + let users = args + .get("users") + .and_then(Value::as_array) + .ok_or_else(|| format!("{method}: missing array argument `users`"))? + .iter() + .filter_map(Value::as_str) + .filter_map(|id| UserId::parse(id).ok()) + .collect::>(); + machine + .update_tracked_users(users.iter().map(AsRef::as_ref)) + .await + .map_err(|e| format!("updateTrackedUsers failed: {e}"))?; + Ok(Value::Null) + } + "markAllTrackedUsersAsDirty" => { + machine + .mark_all_tracked_users_as_dirty() + .await + .map_err(|e| format!("markAllTrackedUsersAsDirty failed: {e}"))?; + Ok(Value::Null) + } + + other => { + if let Some(result) = super::devices::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::cross_signing::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::backup::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::rooms::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::verification::invoke(machine, other, &args).await { + return result; + } + if let Some(result) = super::bundles::invoke(machine, other, &args).await { + return result; + } + Err(format!( + "OlmMachine method not implemented by the Rust engine: {other}" + )) + } + } +} + +#[cfg(test)] +mod tests { + use matrix_sdk::ruma::events::AnyToDeviceEvent; + + use super::*; + + #[test] + fn plaintext_to_device_event_uses_the_wasm_wrapper_shape() { + let raw_json = r#"{"type":"m.test","sender":"@alice:example.org","content":{}}"#; + let raw: Raw = serde_json::from_str(raw_json).unwrap(); + + let value = + processed_to_device_event_json(&ProcessedToDeviceEvent::PlainText(raw), None).unwrap(); + + assert_eq!( + value, + json!({ + "className": "PlainTextToDeviceEvent", + "type": processed_to_device_event_type::PLAIN_TEXT, + "rawEvent": raw_json, + }) + ); + } +} diff --git a/src-tauri/src/matrix_crypto/events.rs b/src-tauri/src/matrix_crypto/events.rs new file mode 100644 index 0000000000..664b738a86 --- /dev/null +++ b/src-tauri/src/matrix_crypto/events.rs @@ -0,0 +1,114 @@ +//! Forwards the engine's change streams to the webview as Tauri events. + +use futures_util::StreamExt as _; +use matrix_sdk_crypto::store::types::{RoomKeyInfo, RoomKeyWithheldInfo}; +use matrix_sdk_crypto::OlmMachine; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter as _}; +use tokio::task::JoinHandle; + +use super::wasm_enums::encryption_algorithm; + +pub const ROOM_KEYS_RECEIVED: &str = "matrix-crypto://room-keys-received"; +pub const ROOM_KEYS_WITHHELD: &str = "matrix-crypto://room-keys-withheld"; +pub const IDENTITIES_UPDATED: &str = "matrix-crypto://identities-updated"; +pub const SECRET_RECEIVED: &str = "matrix-crypto://secret-received"; + +pub(super) fn room_key_json(info: &RoomKeyInfo) -> Value { + json!({ + "algorithm": encryption_algorithm(&info.algorithm), + "roomId": info.room_id.to_string(), + "senderKey": info.sender_key.to_base64(), + "sessionId": info.session_id, + }) +} + +fn withheld_json(info: &RoomKeyWithheldInfo) -> Value { + json!({ + "roomId": info.room_id.to_string(), + "sessionId": info.session_id, + }) +} + +/// One task per stream. The returned handles must be aborted when the engine is +/// closed: the streams outlive the machine, so they would otherwise keep the +/// listeners alive for an account that is no longer open. +pub fn spawn(app: &AppHandle, machine: &OlmMachine, account: String) -> Vec> { + let store = machine.store(); + + let mut room_keys = store.room_keys_received_stream(); + let mut withheld = store.room_keys_withheld_received_stream(); + let mut identities = store.identities_stream_raw(); + let mut secrets = store.secrets_stream(); + + let emit = |app: AppHandle, event: &'static str, account: String, payload: Value| { + // A failed emit means the webview is gone; the abort on close is what + // stops these tasks, so there is nothing to recover here. + let _ = app.emit(event, json!({ "account": account, "payload": payload })); + }; + + vec![ + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some(update) = room_keys.next().await { + // Lagging drops updates rather than ending the stream; js-sdk + // recovers on the next key or a retry, so keep listening. + if let Ok(keys) = update { + let payload = Value::Array(keys.iter().map(room_key_json).collect()); + emit(app.clone(), ROOM_KEYS_RECEIVED, account.clone(), payload); + } + } + } + }), + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some(sessions) = withheld.next().await { + let payload = Value::Array(sessions.iter().map(withheld_json).collect()); + emit(app.clone(), ROOM_KEYS_WITHHELD, account.clone(), payload); + } + } + }), + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some((identity_changes, device_changes)) = identities.next().await { + let identity_users: Vec = identity_changes + .new + .iter() + .chain(identity_changes.changed.iter()) + .map(|identity| identity.user_id().to_string()) + .collect(); + let device_users: Vec = device_changes + .new + .iter() + .chain(device_changes.changed.iter()) + .map(|device| device.user_id().to_string()) + .collect(); + emit( + app.clone(), + IDENTITIES_UPDATED, + account.clone(), + json!({ "identities": identity_users, "devices": device_users }), + ); + } + } + }), + tokio::spawn({ + let (app, account) = (app.clone(), account.clone()); + async move { + while let Some(secret) = secrets.next().await { + // Only the name travels: js-sdk re-reads the value from the + // inbox, and the value must not sit in an event payload. + emit( + app.clone(), + SECRET_RECEIVED, + account.clone(), + json!({ "name": secret.secret_name.to_string() }), + ); + } + } + }), + ] +} diff --git a/src-tauri/src/matrix_crypto/jni_push.rs b/src-tauri/src/matrix_crypto/jni_push.rs new file mode 100644 index 0000000000..abf1887bda --- /dev/null +++ b/src-tauri/src/matrix_crypto/jni_push.rs @@ -0,0 +1,50 @@ +//! Decrypting a push payload with no webview. +//! +//! A cold push starts the process for the delivery service alone, so there is no Tauri +//! command to invoke; the notifications plugin calls this symbol directly. + +use jni::objects::{JClass, JString}; +use jni::{Env, EnvUnowned}; + +use super::push::decrypt_push; + +/// The clear event as JSON, or an empty string when it cannot be decrypted. The symbol +/// name must match `PushPayloadDecryptor.nativeDecryptPush`, package included. +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_tauri_notification_PushPayloadDecryptor_nativeDecryptPush< + 'frame, +>( + mut unowned_env: EnvUnowned<'frame>, + _class: JClass<'frame>, + store_dir: JString<'frame>, + user_id: JString<'frame>, + device_id: JString<'frame>, + room_id: JString<'frame>, + event_json: JString<'frame>, +) -> JString<'frame> { + let outcome = unowned_env.with_env(|env: &mut Env<'frame>| -> Result<_, jni::errors::Error> { + let dir = store_dir.to_string(); + let user = user_id.to_string(); + let device = device_id.to_string(); + let room = room_id.to_string(); + let event = event_json.to_string(); + + // The delivery service has no runtime of its own. + let clear = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() + .and_then(|runtime| { + let store = std::path::Path::new(&dir).join(super::store_subpath(&user, &device)); + runtime + .block_on(decrypt_push(&store, None, &user, &device, &room, &event)) + .ok() + }) + .map(|decrypted| decrypted.clear_event) + .unwrap_or_default(); + + JString::from_str(env, clear) + }); + + outcome.resolve::() +} diff --git a/src-tauri/src/matrix_crypto/mod.rs b/src-tauri/src/matrix_crypto/mod.rs new file mode 100644 index 0000000000..3811225874 --- /dev/null +++ b/src-tauri/src/matrix_crypto/mod.rs @@ -0,0 +1,366 @@ +//! Rust crypto engine: `OlmMachine`s on passphrase-protected sqlite stores. + +pub mod args; +pub mod backup; +pub mod bundles; +pub mod cross_signing; +pub mod devices; +pub mod dispatch; +pub mod events; +#[cfg(target_os = "android")] +pub mod jni_push; +pub mod push; +pub mod requests; +pub mod rooms; +pub mod verification; +pub mod wasm_enums; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::sync::OnceLock; + +use matrix_sdk_crypto::OlmMachine; +use matrix_sdk_sqlite::SqliteCryptoStore; +use serde::Serialize; +use tauri::Manager as _; + +pub fn account_key(user_id: &str, device_id: &str) -> String { + format!("{user_id}|{device_id}") +} + +static ENGINES: OnceLock = OnceLock::new(); + +/// Process-global registry. Not Tauri-managed state: a push arriving while the app is +/// cold has to reach the same machines without an `AppHandle`. +pub fn engines() -> &'static CryptoEngineState { + ENGINES.get_or_init(CryptoEngineState::default) +} + +/// Owned, open OlmMachines keyed by [`account_key`]. +#[derive(Default)] +pub struct CryptoEngineState { + machines: StdMutex>>, + /// Stream-forwarding tasks per account; the streams outlive the machine, so + /// closing an account has to abort them explicitly. + listeners: StdMutex>>>, +} + +impl CryptoEngineState { + pub fn machine(&self, user_id: &str, device_id: &str) -> Result, String> { + self.machines + .lock() + .map_err(|e| e.to_string())? + .get(&account_key(user_id, device_id)) + .cloned() + .ok_or_else(|| format!("no open crypto engine for {user_id}|{device_id}")) + } + + pub fn close_account(&self, account: &str) -> Result { + if let Some(listeners) = self + .listeners + .lock() + .map_err(|e| e.to_string())? + .remove(account) + { + for listener in listeners { + listener.abort(); + } + } + + Ok(self + .machines + .lock() + .map_err(|e| e.to_string())? + .remove(account) + .is_some()) + } +} + +#[derive(Debug, Serialize)] +pub struct EngineInfo { + pub user_id: String, + pub device_id: String, + pub ed25519_key: String, + pub curve25519_key: String, + pub store_path: String, +} + +/// App Group that the iOS notification service extension shares with the app. An +/// extension is a separate process and can only reach the store through this container. +#[cfg(target_os = "ios")] +pub const APP_GROUP: &str = "group.moe.sable.client"; + +/// `None` until the App Group entitlement is present in the generated Xcode project, +/// in which case the caller falls back to the app-local directory. +#[cfg(target_os = "ios")] +fn app_group_dir() -> Option { + use objc2_foundation::{NSFileManager, NSString}; + + let identifier = NSString::from_str(APP_GROUP); + unsafe { + let manager = NSFileManager::defaultManager(); + let url = manager.containerURLForSecurityApplicationGroupIdentifier(&identifier)?; + url.path().map(|path| PathBuf::from(path.to_string())) + } +} + +#[cfg(not(target_os = "ios"))] +fn app_group_dir() -> Option { + None +} + +/// Per-account store directory, appended to whichever base directory the platform +/// exposes to background code. +pub fn store_subpath(user_id: &str, device_id: &str) -> PathBuf { + // `/` and `:` in a user id are not path-safe. + let account = account_key(user_id, device_id).replace(['/', ':'], "_"); + PathBuf::from("matrix-crypto").join(account) +} + +/// Per-account store directory. Resolved here rather than passed in so the +/// webview never has to know an absolute path, and so the native notification +/// handler can derive the same location independently. +fn store_dir(app: &tauri::AppHandle, user_id: &str, device_id: &str) -> Result { + let base = match app_group_dir() { + Some(shared) => shared, + None => app + .path() + .app_local_data_dir() + .map_err(|e| format!("resolving app data dir failed: {e}"))?, + }; + Ok(base.join(store_subpath(user_id, device_id))) +} + +/// Opens a store and registers its machine, replacing any machine already open for the +/// account. Tauri-free so a cold push can open the same store without an `AppHandle`. +pub async fn open_machine( + dir: &Path, + passphrase: Option<&str>, + user_id: &str, + device_id: &str, +) -> Result<(Arc, EngineInfo), String> { + let user: &matrix_sdk::ruma::UserId = user_id + .try_into() + .map_err(|e| format!("bad user id: {e}"))?; + let device: &matrix_sdk::ruma::DeviceId = device_id.into(); + + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| e.to_string())?; + let db_path = dir.join("matrix-sdk-crypto.sqlite3"); + + let account = account_key(user_id, device_id); + engines().close_account(&account)?; + + let store = SqliteCryptoStore::open(&db_path, passphrase) + .await + .map_err(|e| format!("opening crypto store failed: {e}"))?; + let machine = Arc::new( + OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .map_err(|e| format!("creating OlmMachine failed: {e}"))?, + ); + let keys = machine.identity_keys(); + + engines() + .machines + .lock() + .map_err(|e| e.to_string())? + .insert(account, Arc::clone(&machine)); + + let info = EngineInfo { + user_id: user_id.to_owned(), + device_id: device_id.to_owned(), + ed25519_key: keys.ed25519.to_base64(), + curve25519_key: keys.curve25519.to_base64(), + store_path: db_path.display().to_string(), + }; + Ok((machine, info)) +} + +#[tauri::command] +pub async fn engine_open( + app: tauri::AppHandle, + dir: Option, + passphrase: Option, + user_id: String, + device_id: String, +) -> Result { + let dir = match dir { + Some(dir) => PathBuf::from(dir), + None => store_dir(&app, &user_id, &device_id)?, + }; + + let (machine, info) = open_machine(&dir, passphrase.as_deref(), &user_id, &device_id).await?; + + let account = account_key(&user_id, &device_id); + let listeners = events::spawn(&app, &machine, account.clone()); + engines() + .listeners + .lock() + .map_err(|e| e.to_string())? + .insert(account, listeners); + + Ok(info) +} + +#[tauri::command] +pub async fn engine_close(user_id: String, device_id: String) -> Result { + engines().close_account(&account_key(&user_id, &device_id)) +} + +#[tauri::command] +pub async fn engine_wipe( + app: tauri::AppHandle, + user_id: String, + device_id: String, +) -> Result<(), String> { + let account = account_key(&user_id, &device_id); + let _ = engines().close_account(&account)?; + + let dir = store_dir(&app, &user_id, &device_id)?; + if dir.exists() { + tokio::fs::remove_dir_all(&dir) + .await + .map_err(|e| format!("deleting crypto store failed: {e}"))?; + } + Ok(()) +} + +#[tauri::command] +pub async fn engine_invoke( + user_id: String, + device_id: String, + method: String, + args_json: String, +) -> Result { + let machine = engines().machine(&user_id, &device_id)?; + let args: serde_json::Value = serde_json::from_str(&args_json) + .map_err(|e| format!("engine_invoke({method}): bad args json: {e}"))?; + + let result = dispatch::invoke(&machine, &method, args).await?; + serde_json::to_string(&result) + .map_err(|e| format!("engine_invoke({method}): serialising result failed: {e}")) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use matrix_sdk::ruma::api::client::sync::sync_events::DeviceLists; + use matrix_sdk::ruma::serde::Raw; + use matrix_sdk::ruma::{OneTimeKeyAlgorithm, UInt}; + use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; + use matrix_sdk_crypto::types::requests::AnyOutgoingRequest; + use matrix_sdk_crypto::{DecryptionSettings, EncryptionSyncChanges, TrustRequirement}; + + use super::*; + + #[tokio::test] + async fn engine_plumbing() { + let dir = std::env::temp_dir().join(format!("sable-engine-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let user: &matrix_sdk::ruma::UserId = "@engine:example.org".try_into().unwrap(); + let device: &matrix_sdk::ruma::DeviceId = "ENGINEDEVICE".into(); + let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), Some("pw")) + .await + .unwrap(); + let machine = OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .unwrap(); + + let mut device_lists = DeviceLists::new(); + device_lists.changed.push(user.to_owned()); + let counts: BTreeMap = BTreeMap::new(); + let settings = DecryptionSettings { + sender_device_trust_requirement: TrustRequirement::Untrusted, + }; + machine + .receive_sync_changes( + EncryptionSyncChanges { + to_device_events: vec![], + changed_devices: &device_lists, + one_time_keys_counts: &counts, + unused_fallback_keys: None, + next_batch_token: None, + }, + &settings, + ) + .await + .unwrap(); + + let out = machine.outgoing_requests().await.unwrap(); + assert!( + out.iter() + .any(|r| matches!(r.request(), AnyOutgoingRequest::KeysUpload(_))), + "expected at least one keys-upload request" + ); + + let bogus = serde_json::json!({ + "type": "m.room.encrypted", + "event_id": "$bogus:example.org", + "sender": "@someone:example.org", + "origin_server_ts": 0, + "room_id": "!room:example.org", + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "AAAAAAAA", + "sender_key": "AAAA", + "session_id": "AAAA", + "device_id": "X", + }, + }); + let raw: Raw = serde_json::from_value(bogus).unwrap(); + let room: &matrix_sdk::ruma::RoomId = "!room:example.org".try_into().unwrap(); + let result = machine.decrypt_room_event(&raw, room, &settings).await; + assert!(result.is_err(), "bogus megolm event must not decrypt"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn identity_reads_and_room_key_round_trip() { + let dir = std::env::temp_dir().join(format!("sable-engine-1c-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let user: &matrix_sdk::ruma::UserId = "@engine:example.org".try_into().unwrap(); + let device: &matrix_sdk::ruma::DeviceId = "ENGINEDEVICE".into(); + let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), Some("pw")) + .await + .unwrap(); + let machine = OlmMachine::with_store(user, device, Arc::new(store), None) + .await + .unwrap(); + + let status = machine.cross_signing_status().await; + assert!(!status.has_master); + assert!(!status.has_self_signing); + assert!(!status.has_user_signing); + + let other: &matrix_sdk::ruma::UserId = "@other:example.org".try_into().unwrap(); + assert!(machine + .get_device(other, "NODEVICE".into(), None) + .await + .unwrap() + .is_none()); + assert!(machine.get_identity(other, None).await.unwrap().is_none()); + + let exported = machine.store().export_room_keys(|_| true).await.unwrap(); + assert_eq!(serde_json::to_string(&exported).unwrap(), "[]"); + + let result = machine + .store() + .import_exported_room_keys(exported, |_, _| {}) + .await + .unwrap(); + assert_eq!(result.imported_count, 0); + assert_eq!(result.total_count, 0); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/matrix_crypto/push.rs b/src-tauri/src/matrix_crypto/push.rs new file mode 100644 index 0000000000..ba5357005a --- /dev/null +++ b/src-tauri/src/matrix_crypto/push.rs @@ -0,0 +1,284 @@ +//! Decryption for push notifications, reachable without a webview or an `AppHandle`. + +use std::path::Path; +use std::sync::Arc; + +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::RoomId; +use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; +use matrix_sdk_crypto::OlmMachine; +use serde_json::Value; +use tokio::sync::Mutex as AsyncMutex; + +use super::args::decryption_settings; +use super::{account_key, engines, open_machine}; + +/// Serialises open-if-absent. Two pushes racing here would otherwise build two +/// `OlmMachine`s over one sqlite store, which wedges Olm sessions. +static OPEN_GUARD: AsyncMutex<()> = AsyncMutex::const_new(()); + +/// Returns the machine already registered for the account, opening one if the process is +/// cold, and reports whether this call is what opened the store. Never evicts a machine +/// the webview is using; only the opener may close it again — see [`release_after_push`]. +pub async fn open_machine_for_push( + dir: &Path, + passphrase: Option<&str>, + user_id: &str, + device_id: &str, +) -> Result<(Arc, bool), String> { + if let Ok(machine) = engines().machine(user_id, device_id) { + return Ok((machine, false)); + } + + let _guard = OPEN_GUARD.lock().await; + // Another push may have opened it while we waited for the guard. + if let Ok(machine) = engines().machine(user_id, device_id) { + return Ok((machine, false)); + } + + let (machine, _) = open_machine(dir, passphrase, user_id, device_id).await?; + Ok((machine, true)) +} + +/// The decrypted event plus the fields a notification needs to render. +#[derive(Debug, serde::Serialize)] +pub struct DecryptedPush { + pub event_type: Option, + pub sender: Option, + pub body: Option, + /// JSON text: the TypeScript generator has no mapping for `serde_json::Value`. + pub clear_event: String, +} + +/// Decrypts one encrypted room event fetched for a push. +pub async fn decrypt_push_event( + machine: &OlmMachine, + room_id: &str, + event_json: &str, +) -> Result { + let room = RoomId::parse(room_id).map_err(|e| format!("bad room id `{room_id}`: {e}"))?; + let event: Raw = + serde_json::from_str(event_json).map_err(|e| format!("bad event json: {e}"))?; + + let decrypted = machine + .decrypt_room_event(&event, &room, &decryption_settings()) + .await + .map_err(|e| format!("decrypting push event failed: {e:?}"))?; + + let clear_event: Value = serde_json::from_str(decrypted.event.json().get()) + .map_err(|e| format!("bad clear event json: {e}"))?; + + Ok(DecryptedPush { + event_type: string_at(&clear_event, &["type"]), + sender: string_at(&clear_event, &["sender"]), + body: string_at(&clear_event, &["content", "body"]), + clear_event: clear_event.to_string(), + }) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + path.iter() + .try_fold(value, |current, key| current.get(key))? + .as_str() + .map(str::to_owned) +} + +/// Closes the machine this process opened for a push, leaving a webview-owned machine +/// alone. Callers on a cold path should release the store once the notification is shown. +pub fn release_after_push(user_id: &str, device_id: &str, was_cold: bool) -> Result<(), String> { + if was_cold { + engines().close_account(&account_key(user_id, device_id))?; + } + Ok(()) +} + +/// One-shot headless decrypt: opens the store if the process is cold, decrypts, then +/// releases whatever it opened. This is the entry point native push code uses when +/// there is no webview and no `AppHandle` to route through. +pub async fn decrypt_push( + dir: &Path, + passphrase: Option<&str>, + user_id: &str, + device_id: &str, + room_id: &str, + event_json: &str, +) -> Result { + let (machine, was_cold) = open_machine_for_push(dir, passphrase, user_id, device_id).await?; + let decrypted = decrypt_push_event(&machine, room_id, event_json).await; + + // The registry holds the other reference; drop ours so the close actually frees it. + drop(machine); + release_after_push(user_id, device_id, was_cold)?; + + decrypted +} + +/// Decrypts a push payload for the webview. The cold path cannot come through here: +/// with no webview alive there is nothing to invoke a command. +#[tauri::command] +pub async fn engine_decrypt_push( + app: tauri::AppHandle, + user_id: String, + device_id: String, + room_id: String, + event_json: String, + passphrase: Option, +) -> Result { + let dir = super::store_dir(&app, &user_id, &device_id)?; + decrypt_push( + &dir, + passphrase.as_deref(), + &user_id, + &device_id, + &room_id, + &event_json, + ) + .await +} + +#[cfg(test)] +mod tests { + use matrix_sdk_sqlite::SqliteCryptoStore; + use serde_json::json; + + use super::*; + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sable-push-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn reuses_an_already_open_machine() { + let dir = temp_dir("reuse"); + let (opened, _) = open_machine(&dir, None, "@push:example.org", "PUSHDEVICE") + .await + .unwrap(); + + let (reused, was_cold) = + open_machine_for_push(&dir, None, "@push:example.org", "PUSHDEVICE") + .await + .unwrap(); + assert!(!was_cold, "an already-open account must not report as cold"); + + assert!( + Arc::ptr_eq(&opened, &reused), + "push must not build a second OlmMachine over the same store" + ); + + engines() + .close_account(&account_key("@push:example.org", "PUSHDEVICE")) + .unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn opens_a_machine_when_the_process_is_cold() { + let dir = temp_dir("cold"); + let (machine, was_cold) = + open_machine_for_push(&dir, None, "@cold:example.org", "COLDDEVICE") + .await + .unwrap(); + + assert_eq!(machine.user_id().as_str(), "@cold:example.org"); + assert!( + was_cold, + "a cold process must report that it opened the store" + ); + + engines() + .close_account(&account_key("@cold:example.org", "COLDDEVICE")) + .unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn a_cold_one_shot_decrypt_releases_the_store_it_opened() { + let dir = temp_dir("oneshot-cold"); + let event = json!({ "not": "an encrypted event" }).to_string(); + + let result = decrypt_push( + &dir, + None, + "@oneshot:example.org", + "ONESHOTDEVICE", + "!room:example.org", + &event, + ) + .await; + + assert!(result.is_err(), "a bogus event must not decrypt"); + assert!( + engines() + .machine("@oneshot:example.org", "ONESHOTDEVICE") + .is_err(), + "a cold decrypt must not leave the crypto store open" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn a_warm_one_shot_decrypt_leaves_the_webviews_machine_open() { + let dir = temp_dir("oneshot-warm"); + let (_machine, _) = open_machine(&dir, None, "@warm:example.org", "WARMDEVICE") + .await + .unwrap(); + + let event = json!({ "not": "an encrypted event" }).to_string(); + let _ = decrypt_push( + &dir, + None, + "@warm:example.org", + "WARMDEVICE", + "!room:example.org", + &event, + ) + .await; + + assert!( + engines().machine("@warm:example.org", "WARMDEVICE").is_ok(), + "a push must never close a machine the webview owns" + ); + + engines() + .close_account(&account_key("@warm:example.org", "WARMDEVICE")) + .unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn reports_an_undecryptable_event_rather_than_panicking() { + let dir = temp_dir("undecryptable"); + let store = SqliteCryptoStore::open(dir.join("crypto.sqlite3"), None) + .await + .unwrap(); + let user: &matrix_sdk::ruma::UserId = "@bad:example.org".try_into().unwrap(); + let machine = OlmMachine::with_store(user, "BADDEVICE".into(), Arc::new(store), None) + .await + .unwrap(); + + let event = json!({ + "type": "m.room.encrypted", + "event_id": "$bogus:example.org", + "sender": "@someone:example.org", + "origin_server_ts": 0, + "room_id": "!room:example.org", + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "AAAAAAAA", + "sender_key": "AAAA", + "session_id": "AAAA", + "device_id": "X", + }, + }) + .to_string(); + + let result = decrypt_push_event(&machine, "!room:example.org", &event).await; + assert!(result.is_err(), "bogus megolm event must not decrypt"); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/matrix_crypto/requests.rs b/src-tauri/src/matrix_crypto/requests.rs new file mode 100644 index 0000000000..d80b8a905b --- /dev/null +++ b/src-tauri/src/matrix_crypto/requests.rs @@ -0,0 +1,212 @@ +//! Outgoing-request pump, shaped for matrix-js-sdk's `OutgoingRequestProcessor`. + +use matrix_sdk::ruma::api::client::{ + backup::add_backup_keys::v3::Response as KeysBackupResponse, + keys::{ + claim_keys::v3::Response as KeysClaimResponse, get_keys::v3::Response as KeysQueryResponse, + upload_keys::v3::Response as KeysUploadResponse, + upload_signatures::v3::Response as SignatureUploadResponse, + }, + message::send_message_event::v3::Response as RoomMessageResponse, + to_device::send_event_to_device::v3::Response as ToDeviceResponse, +}; +use matrix_sdk::ruma::api::IncomingResponse as _; +use matrix_sdk::ruma::events::MessageLikeEventContent as _; +use matrix_sdk_crypto::types::requests::AnyOutgoingRequest; +use matrix_sdk_crypto::OlmMachine; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::wasm_enums::request_type; + +/// Declared with ruma's serde attributes rather than built by hand: `json!` would emit +/// `{"secs":10,"nanos":0}` for a `Duration` where the wire format is milliseconds. +#[derive(Serialize)] +pub(super) struct KeysClaimBody<'a> { + #[serde(with = "matrix_sdk::ruma::serde::duration::opt_ms")] + #[serde(skip_serializing_if = "Option::is_none")] + timeout: &'a Option, + one_time_keys: &'a Value, +} + +pub(super) fn keys_claim_body( + timeout: &Option, + one_time_keys: &Value, +) -> String { + serde_json::to_string(&KeysClaimBody { + timeout, + one_time_keys, + }) + .unwrap_or_default() +} + +pub(super) fn keys_query_body( + timeout: &Option, + device_keys: &Value, +) -> String { + serde_json::to_string(&KeysQueryBody { + timeout, + device_keys, + }) + .unwrap_or_default() +} + +/// `device_keys` is omitted when absent; an explicit `null` is not the same thing. +fn keys_upload_body(req: &matrix_sdk::ruma::api::client::keys::upload_keys::v3::Request) -> String { + let mut body = json!({ + "one_time_keys": req.one_time_keys, + "fallback_keys": req.fallback_keys, + }); + if let Some(device_keys) = &req.device_keys { + body["device_keys"] = json!(device_keys); + } + body.to_string() +} + +#[derive(Serialize)] +pub(super) struct KeysQueryBody<'a> { + #[serde(with = "matrix_sdk::ruma::serde::duration::opt_ms")] + #[serde(skip_serializing_if = "Option::is_none")] + timeout: &'a Option, + device_keys: &'a Value, +} + +pub async fn outgoing_requests(machine: &OlmMachine) -> Result { + let requests = machine + .outgoing_requests() + .await + .map_err(|e| format!("outgoingRequests failed: {e}"))?; + + Ok(Value::Array( + requests + .into_iter() + .map(|request| { + let id = request.request_id().to_string(); + // `className` is required: js-sdk dispatches on `instanceof`. + // `body` must be a JSON string; js-sdk forwards it verbatim. + let mut entry = match request.request() { + AnyOutgoingRequest::KeysUpload(req) => json!({ + "type": request_type::KEYS_UPLOAD, + "className": "KeysUploadRequest", + "body": keys_upload_body(req), + }), + AnyOutgoingRequest::KeysQuery(req) => json!({ + "type": request_type::KEYS_QUERY, + "className": "KeysQueryRequest", + "body": keys_query_body(&req.timeout, &json!(req.device_keys)), + }), + AnyOutgoingRequest::KeysClaim(req) => json!({ + "type": request_type::KEYS_CLAIM, + "className": "KeysClaimRequest", + "body": keys_claim_body(&req.timeout, &json!(req.one_time_keys)), + }), + AnyOutgoingRequest::ToDeviceRequest(req) => json!({ + "type": request_type::TO_DEVICE, + "className": "ToDeviceRequest", + "body": json!({ "messages": req.messages }).to_string(), + "event_type": req.event_type.to_string(), + "txn_id": req.txn_id.to_string(), + }), + AnyOutgoingRequest::SignatureUpload(req) => json!({ + "type": request_type::SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "body": json!(req.signed_keys).to_string(), + }), + AnyOutgoingRequest::RoomMessage(req) => json!({ + "type": request_type::ROOM_MESSAGE, + "className": "RoomMessageRequest", + "body": json!(req.content).to_string(), + "room_id": req.room_id.to_string(), + "txn_id": req.txn_id.to_string(), + "event_type": req.content.as_ref().event_type().to_string(), + }), + }; + entry["id"] = Value::String(id); + entry + }) + .collect(), + )) +} + +pub async fn mark_request_sent(machine: &OlmMachine, args: &Value) -> Result { + let request_id = args + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| "markRequestAsSent: missing `requestId`".to_owned())?; + let request_type = args + .get("requestType") + .and_then(Value::as_u64) + .ok_or_else(|| "markRequestAsSent: missing numeric `requestType`".to_owned())? + as u8; + let response_body = args + .get("response") + .and_then(Value::as_str) + .ok_or_else(|| "markRequestAsSent: missing `response`".to_owned())?; + + let id: matrix_sdk::ruma::OwnedTransactionId = request_id.into(); + + let http_response = || { + http::Response::builder() + .status(http::StatusCode::OK) + .body(response_body.as_bytes().to_vec()) + .map_err(|e| e.to_string()) + }; + + macro_rules! mark { + ($resp_ty:ty) => {{ + let parsed = <$resp_ty>::try_from_http_response(http_response()?).map_err(|e| { + format!("markRequestAsSent: parsing type {request_type} response failed: {e}") + })?; + machine + .mark_request_as_sent(&id, &parsed) + .await + .map_err(|e| format!("markRequestAsSent failed: {e}"))?; + }}; + } + + match request_type { + request_type::KEYS_UPLOAD => mark!(KeysUploadResponse), + request_type::KEYS_QUERY => mark!(KeysQueryResponse), + request_type::KEYS_CLAIM => mark!(KeysClaimResponse), + request_type::TO_DEVICE => mark!(ToDeviceResponse), + request_type::SIGNATURE_UPLOAD => mark!(SignatureUploadResponse), + request_type::ROOM_MESSAGE => mark!(RoomMessageResponse), + // Without this ack the backup machine never clears the batch and re-uploads forever. + request_type::KEYS_BACKUP => mark!(KeysBackupResponse), + other => return Err(format!("markRequestAsSent: unknown request type {other}")), + } + + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serde_json::json; + + use super::{KeysClaimBody, KeysQueryBody}; + + #[test] + fn claim_keys_timeout_is_millis_not_a_duration_struct() { + let timeout = Some(Duration::from_secs(10)); + let body = serde_json::to_value(KeysClaimBody { + timeout: &timeout, + one_time_keys: &json!({}), + }) + .unwrap(); + + assert_eq!(body["timeout"], json!(10_000)); + } + + #[test] + fn absent_timeout_is_omitted_rather_than_null() { + let body = serde_json::to_value(KeysQueryBody { + timeout: &None, + device_keys: &json!({}), + }) + .unwrap(); + + assert!(!body.as_object().unwrap().contains_key("timeout"), "{body}"); + } +} diff --git a/src-tauri/src/matrix_crypto/rooms.rs b/src-tauri/src/matrix_crypto/rooms.rs new file mode 100644 index 0000000000..a35e931466 --- /dev/null +++ b/src-tauri/src/matrix_crypto/rooms.rs @@ -0,0 +1,420 @@ +//! Per-room encryption settings and megolm sessions for the `OlmMachine` IPC proxy. + +use std::time::Duration; + +use matrix_sdk::deserialized_responses::{ + AlgorithmInfo, ShieldState, ShieldStateCode, VerificationState, +}; +use matrix_sdk::ruma::events::room::history_visibility::HistoryVisibility; +use matrix_sdk::ruma::serde::Raw; +use matrix_sdk::ruma::{DeviceKeyAlgorithm, OwnedUserId, UserId}; +use matrix_sdk_crypto::olm::EncryptionSettings; +use matrix_sdk_crypto::store::types::RoomSettings; +use matrix_sdk_crypto::types::events::room::encrypted::EncryptedEvent; +use matrix_sdk_crypto::types::EventEncryptionAlgorithm; +use matrix_sdk_crypto::{CollectStrategy, OlmMachine}; +use serde_json::{json, Value}; + +use super::args::{room_id, str_arg}; +use super::wasm_enums::{encryption_algorithm as algorithm_to_wasm, request_type}; + +fn user_ids(args: &Value, method: &str, field: &str) -> Result, String> { + args.get(field) + .and_then(Value::as_array) + .ok_or_else(|| format!("{method}: missing array argument `{field}`"))? + .iter() + .map(|id| { + let id = id + .as_str() + .ok_or_else(|| format!("{method}: `{field}` must contain user id strings"))?; + UserId::parse(id).map_err(|e| format!("{method}: bad user id `{id}` in `{field}`: {e}")) + }) + .collect() +} + +fn as_u64(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_u64().or_else(|| { + let f = n.as_f64()?; + (f >= 0.0).then_some(f as u64) + }), + Value::String(s) => s.parse().ok(), + _ => None, + } +} + +fn algorithm(value: &Value, method: &str) -> Result { + match value { + Value::Number(_) => match as_u64(value) { + Some(0) => Ok(EventEncryptionAlgorithm::OlmV1Curve25519AesSha2), + Some(1) => Ok(EventEncryptionAlgorithm::MegolmV1AesSha2), + _ => Err(format!("{method}: unsupported algorithm {value}")), + }, + Value::String(name) => Ok(EventEncryptionAlgorithm::from(name.as_str())), + _ => Err(format!( + "{method}: `algorithm` must be a number or a string" + )), + } +} + +fn history_visibility(value: &Value, method: &str) -> Result { + match value { + Value::Number(_) => match as_u64(value) { + Some(0) => Ok(HistoryVisibility::Invited), + Some(1) => Ok(HistoryVisibility::Joined), + Some(2) => Ok(HistoryVisibility::Shared), + Some(3) => Ok(HistoryVisibility::WorldReadable), + _ => Err(format!("{method}: unknown history visibility {value}")), + }, + Value::String(name) => Ok(HistoryVisibility::from(name.as_str())), + _ => Err(format!( + "{method}: `historyVisibility` must be a number or a string" + )), + } +} + +fn collect_strategy(value: Option<&Value>, method: &str) -> Result { + match value { + None | Some(Value::Null) => Ok(CollectStrategy::AllDevices), + Some(Value::String(name)) => match name.as_str() { + "allDevices" => Ok(CollectStrategy::AllDevices), + "errorOnVerifiedUserProblem" => Ok(CollectStrategy::ErrorOnVerifiedUserProblem), + "identityBasedStrategy" => Ok(CollectStrategy::IdentityBasedStrategy), + "onlyTrustedDevices" => Ok(CollectStrategy::OnlyTrustedDevices), + other => Err(format!("{method}: unknown sharing strategy `{other}`")), + }, + Some(Value::Object(fields)) => { + let flag = |name: &str| fields.get(name).and_then(Value::as_bool).unwrap_or(false); + if flag("identityBasedStrategy") { + Ok(CollectStrategy::IdentityBasedStrategy) + } else if flag("onlyAllowTrustedDevices") { + Ok(CollectStrategy::OnlyTrustedDevices) + } else if flag("errorOnVerifiedUserProblem") { + Ok(CollectStrategy::ErrorOnVerifiedUserProblem) + } else { + Ok(CollectStrategy::AllDevices) + } + } + Some(_) => Err(format!( + "{method}: `sharingStrategy` must be a string or an object" + )), + } +} + +fn encryption_settings(args: &Value, method: &str) -> Result { + let settings = args + .get("encryptionSettings") + .ok_or_else(|| format!("{method}: missing object argument `encryptionSettings`"))?; + + let mut out = EncryptionSettings::default(); + if let Some(value) = settings.get("algorithm") { + out.algorithm = algorithm(value, method)?; + } + if let Some(value) = settings.get("historyVisibility") { + out.history_visibility = history_visibility(value, method)?; + } + if let Some(micros) = settings.get("rotationPeriod").and_then(as_u64) { + out.rotation_period = Duration::from_micros(micros); + } + if let Some(count) = settings.get("rotationPeriodMessages").and_then(as_u64) { + out.rotation_period_msgs = count; + } + out.sharing_strategy = collect_strategy(settings.get("sharingStrategy"), method)?; + Ok(out) +} + +fn room_settings(args: &Value, method: &str) -> Result { + let settings = args + .get("settings") + .ok_or_else(|| format!("{method}: missing object argument `settings`"))?; + + let mut out = RoomSettings::default(); + if let Some(value) = settings.get("algorithm") { + out.algorithm = algorithm(value, method)?; + } + out.only_allow_trusted_devices = settings + .get("onlyAllowTrustedDevices") + .and_then(Value::as_bool) + .unwrap_or(false); + out.session_rotation_period = settings + .get("sessionRotationPeriodMs") + .and_then(as_u64) + .map(Duration::from_millis); + out.session_rotation_period_messages = settings + .get("sessionRotationPeriodMessages") + .and_then(as_u64) + .map(|count| count as usize); + Ok(out) +} + +fn shield_state_json(state: ShieldState) -> Value { + let (color, code, message) = match state { + ShieldState::Red { code, message } => (0, Some(code), Some(message)), + ShieldState::Grey { code, message } => (1, Some(code), Some(message)), + ShieldState::None => (2, None, None), + }; + json!({ + "color": color, + "code": code.map(|code| match code { + ShieldStateCode::AuthenticityNotGuaranteed => 0, + ShieldStateCode::UnknownDevice => 1, + ShieldStateCode::UnsignedDevice => 2, + ShieldStateCode::UnverifiedIdentity => 3, + ShieldStateCode::VerificationViolation => 4, + ShieldStateCode::MismatchedSender => 5, + }), + "message": message, + }) +} + +fn shield_states_json(state: &VerificationState) -> (Value, Value) { + ( + shield_state_json(state.to_shield_state_lax()), + shield_state_json(state.to_shield_state_strict()), + ) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getRoomSettings" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + match machine.room_settings(&room).await { + Ok(Some(settings)) => Ok(json!({ + "algorithm": algorithm_to_wasm(&settings.algorithm), + "encryptStateEvents": false, + "onlyAllowTrustedDevices": settings.only_allow_trusted_devices, + "sessionRotationPeriodMs": settings + .session_rotation_period + .map(|period| period.as_millis() as u64), + "sessionRotationPeriodMessages": settings + .session_rotation_period_messages + .map(|count| count as u64), + })), + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("getRoomSettings failed: {e}")), + } + } + "setRoomSettings" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + let settings = match room_settings(args, method) { + Ok(settings) => settings, + Err(e) => return Some(Err(e)), + }; + machine + .set_room_settings(&room, &settings) + .await + .map(|()| Value::Null) + .map_err(|e| format!("setRoomSettings failed: {e}")) + } + + "shareRoomKey" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + let users = match user_ids(args, method, "users") { + Ok(users) => users, + Err(e) => return Some(Err(e)), + }; + let settings = match encryption_settings(args, method) { + Ok(settings) => settings, + Err(e) => return Some(Err(e)), + }; + + match machine + .share_room_key(&room, users.iter().map(AsRef::as_ref), settings) + .await + { + Ok(requests) => Ok(Value::Array( + requests + .into_iter() + .map(|request| { + json!({ + "type": request_type::TO_DEVICE, + "className": "ToDeviceRequest", + "id": request.txn_id.to_string(), + "event_type": request.event_type.to_string(), + "txn_id": request.txn_id.to_string(), + "body": json!({ "messages": request.messages }).to_string(), + }) + }) + .collect(), + )), + Err(e) => Err(format!("shareRoomKey failed: {e:?}")), + } + } + "getMissingSessions" => { + let users = match user_ids(args, method, "users") { + Ok(users) => users, + Err(e) => return Some(Err(e)), + }; + match machine + .get_missing_sessions(users.iter().map(AsRef::as_ref)) + .await + { + Ok(Some((txn_id, request))) => Ok(json!({ + "type": request_type::KEYS_CLAIM, + "className": "KeysClaimRequest", + "id": txn_id.to_string(), + "body": super::requests::keys_claim_body( + &request.timeout, + &json!(request.one_time_keys), + ), + })), + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("getMissingSessions failed: {e}")), + } + } + "invalidateGroupSession" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + machine + .discard_room_key(&room) + .await + .map(Value::Bool) + .map_err(|e| format!("invalidateGroupSession failed: {e}")) + } + + "getRoomEventEncryptionInfo" => { + let room = match room_id(args, method, "roomId") { + Ok(room) => room, + Err(e) => return Some(Err(e)), + }; + let event_json = match str_arg(args, method, "event") { + Ok(json) => json, + Err(e) => return Some(Err(e)), + }; + let event: Raw = match serde_json::from_str(&event_json) { + Ok(event) => event, + Err(e) => { + return Some(Err(format!( + "getRoomEventEncryptionInfo: bad event json: {e}" + ))) + } + }; + + match machine.get_room_event_encryption_info(&event, &room).await { + Ok(info) => { + let AlgorithmInfo::MegolmV1AesSha2 { + curve25519_key, + sender_claimed_keys, + .. + } = &info.algorithm_info + else { + return Some(Err( + "getRoomEventEncryptionInfo: event was not encrypted with megolm v1" + .to_owned(), + )); + }; + let (lax, strict) = shield_states_json(&info.verification_state); + Ok(json!({ + // Without this js-sdk's `shieldState()` call hits a plain object. + "className": "EncryptionInfo", + "sender": info.sender.to_string(), + "senderDevice": info.sender_device.as_ref().map(ToString::to_string), + "senderCurve25519Key": curve25519_key, + "senderClaimedEd25519Key": sender_claimed_keys + .get(&DeviceKeyAlgorithm::Ed25519), + "forwarder": info + .forwarder + .as_ref() + .map(|forwarder| forwarder.user_id.to_string()), + "forwarderDevice": info + .forwarder + .as_ref() + .map(|forwarder| forwarder.device_id.to_string()), + "shieldStateLax": lax, + "shieldStateStrict": strict, + })) + } + Err(e) => Err(format!("getRoomEventEncryptionInfo failed: {e:?}")), + } + } + + // Gated in matrix-sdk-crypto 0.18 behind `experimental-encrypted-state-events`. + "encryptStateEvent" => Err( + "encryptStateEvent: state-event encryption requires matrix-sdk-crypto's \ + `experimental-encrypted-state-events` feature, which this build does not enable" + .to_owned(), + ), + + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use matrix_sdk_crypto::CollectStrategy; + + use super::{collect_strategy, encryption_settings}; + + #[test] + fn parses_every_sharing_strategy_the_webview_can_send() { + let cases = [ + ( + "identityBasedStrategy", + CollectStrategy::IdentityBasedStrategy, + ), + ("onlyTrustedDevices", CollectStrategy::OnlyTrustedDevices), + ( + "errorOnVerifiedUserProblem", + CollectStrategy::ErrorOnVerifiedUserProblem, + ), + ("allDevices", CollectStrategy::AllDevices), + ]; + + for (name, expected) in cases { + let parsed = collect_strategy(Some(&json!(name)), "shareRoomKey") + .unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!( + std::mem::discriminant(&parsed), + std::mem::discriminant(&expected), + "{name} parsed to the wrong strategy" + ); + } + } + + #[test] + fn rejects_an_unknown_strategy_rather_than_silently_sharing_with_everyone() { + let error = collect_strategy(Some(&json!("somethingElse")), "shareRoomKey").unwrap_err(); + assert!(error.contains("somethingElse"), "{error}"); + } + + #[test] + fn reads_the_settings_shape_the_webview_encodes() { + let settings = encryption_settings( + &json!({ + "encryptionSettings": { + "algorithm": 1, + "historyVisibility": 2, + "rotationPeriod": 604_800_000_000u64, + "rotationPeriodMessages": 100, + "sharingStrategy": "onlyTrustedDevices", + } + }), + "shareRoomKey", + ) + .unwrap(); + + assert_eq!(settings.rotation_period_msgs, 100); + assert_eq!(settings.rotation_period.as_secs(), 604_800); + assert_eq!( + std::mem::discriminant(&settings.sharing_strategy), + std::mem::discriminant(&CollectStrategy::OnlyTrustedDevices), + ); + } +} diff --git a/src-tauri/src/matrix_crypto/verification.rs b/src-tauri/src/matrix_crypto/verification.rs new file mode 100644 index 0000000000..b909035f0d --- /dev/null +++ b/src-tauri/src/matrix_crypto/verification.rs @@ -0,0 +1,594 @@ +//! Interactive verification (SAS and QR) for the `OlmMachine` IPC proxy. + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use matrix_sdk::ruma::events::key::verification::VerificationMethod; +use matrix_sdk::ruma::events::room::message::RoomMessageEventContent; +use matrix_sdk::ruma::events::AnyMessageLikeEventContent; +use matrix_sdk::ruma::events::MessageLikeEventContent as _; +use matrix_sdk::ruma::{EventId, OwnedDeviceId, OwnedUserId, RoomId, TransactionId, UserId}; +use matrix_sdk_crypto::matrix_sdk_qrcode::QrVerificationData; +use matrix_sdk_crypto::types::requests::{OutgoingVerificationRequest, RoomMessageRequest}; +use matrix_sdk_crypto::{ + CancelInfo, Device, OlmMachine, OtherUserIdentity, OwnUserIdentity, QrVerification, + QrVerificationState, Sas, UserIdentity, Verification, VerificationRequest, + VerificationRequestState, +}; +use serde_json::{json, Value}; + +use super::args::{str_arg, user_id}; +use super::wasm_enums::request_type::{ + ROOM_MESSAGE as REQUEST_TYPE_ROOM_MESSAGE, SIGNATURE_UPLOAD as REQUEST_TYPE_SIGNATURE_UPLOAD, + TO_DEVICE as REQUEST_TYPE_TO_DEVICE, +}; + +fn flow(args: &Value, method: &str) -> Result<(OwnedUserId, String), String> { + let user = user_id(args, method, "userId")?; + Ok((user, str_arg(args, method, "flowId")?)) +} + +fn request( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let (user, flow_id) = flow(args, method)?; + machine + .get_verification_request(&user, &flow_id) + .ok_or_else(|| format!("{method}: no verification request for {user} / {flow_id}")) +} + +fn sas(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let (user, flow_id) = flow(args, method)?; + machine + .get_verification(&user, &flow_id) + .and_then(|verification| verification.sas_v1()) + .map(|sas| *sas) + .ok_or_else(|| format!("{method}: no SAS verification for {user} / {flow_id}")) +} + +fn qr(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let (user, flow_id) = flow(args, method)?; + machine + .get_verification(&user, &flow_id) + .and_then(|verification| verification.qr_v1()) + .map(|qr| *qr) + .ok_or_else(|| format!("{method}: no QR verification for {user} / {flow_id}")) +} + +fn user_arg(args: &Value, method: &str) -> Result { + let raw = str_arg(args, method, "userId")?; + UserId::parse(&raw).map_err(|e| format!("{method}: bad user id in `userId`: {e}")) +} + +async fn device(machine: &OlmMachine, args: &Value, method: &str) -> Result { + let user = user_arg(args, method)?; + let device_id: OwnedDeviceId = str_arg(args, method, "deviceId")?.into(); + machine + .get_device(&user, &device_id, None) + .await + .map_err(|e| format!("{method}: cannot load device {user} / {device_id}: {e}"))? + .ok_or_else(|| format!("{method}: unknown device {user} / {device_id}")) +} + +async fn identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let user = user_arg(args, method)?; + machine + .get_identity(&user, None) + .await + .map_err(|e| format!("{method}: cannot load the identity of {user}: {e}"))? + .ok_or_else(|| format!("{method}: no cross-signing identity for {user}")) +} + +async fn own_identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let identity = identity(machine, args, method).await?; + let user = identity.user_id().to_owned(); + identity.own().ok_or_else(|| { + format!("{method}: {user} is not our own user, use userIdentity.requestVerificationDm") + }) +} + +async fn other_identity( + machine: &OlmMachine, + args: &Value, + method: &str, +) -> Result { + let identity = identity(machine, args, method).await?; + let user = identity.user_id().to_owned(); + identity.other().ok_or_else(|| { + format!("{method}: {user} is our own user, use userIdentity.requestVerification") + }) +} + +/// Absent or empty means "let the crate pick its default method set". +fn methods_arg(args: &Value, method: &str) -> Result>, String> { + let Some(codes) = args.get("methods").and_then(Value::as_array) else { + return Ok(None); + }; + if codes.is_empty() { + return Ok(None); + } + codes + .iter() + .map(|code| { + code.as_u64() + .and_then(method_from_code) + .ok_or_else(|| format!("{method}: unknown verification method {code}")) + }) + .collect::, _>>() + .map(Some) +} + +fn started(request: &VerificationRequest, outgoing_request: Value) -> Value { + json!({ "request": request_state(request), "outgoingRequest": outgoing_request }) +} + +fn method_code(method: &VerificationMethod) -> Option { + match method { + VerificationMethod::SasV1 => Some(0), + VerificationMethod::QrCodeScanV1 => Some(1), + VerificationMethod::QrCodeShowV1 => Some(2), + VerificationMethod::ReciprocateV1 => Some(3), + _ => None, + } +} + +fn method_from_code(code: u64) -> Option { + match code { + 0 => Some(VerificationMethod::SasV1), + 1 => Some(VerificationMethod::QrCodeScanV1), + 2 => Some(VerificationMethod::QrCodeShowV1), + 3 => Some(VerificationMethod::ReciprocateV1), + _ => None, + } +} + +fn method_codes(methods: Option>) -> Value { + match methods { + Some(methods) => Value::Array( + methods + .iter() + .filter_map(method_code) + .map(|code| json!(code)) + .collect(), + ), + None => Value::Null, + } +} + +fn outgoing(request: OutgoingVerificationRequest) -> Value { + let id = request.request_id().to_string(); + let mut entry = match request { + OutgoingVerificationRequest::ToDevice(req) => json!({ + "type": REQUEST_TYPE_TO_DEVICE, + "className": "ToDeviceRequest", + "body": json!({ "messages": req.messages }).to_string(), + "event_type": req.event_type.to_string(), + "txn_id": req.txn_id.to_string(), + }), + OutgoingVerificationRequest::InRoom(req) => json!({ + "type": REQUEST_TYPE_ROOM_MESSAGE, + "className": "RoomMessageRequest", + "body": json!(req.content).to_string(), + "room_id": req.room_id.to_string(), + "txn_id": req.txn_id.to_string(), + "event_type": req.content.as_ref().event_type().to_string(), + }), + }; + entry["id"] = Value::String(id); + entry +} + +fn optional_outgoing(request: Option) -> Value { + request.map(outgoing).unwrap_or(Value::Null) +} + +fn cancel_info(info: Option) -> Value { + match info { + Some(info) => json!({ + "className": "CancelInfo", + "cancelCode": info.cancel_code().to_string(), + "cancelledbyUs": info.cancelled_by_us(), + "reason": info.reason(), + }), + None => Value::Null, + } +} + +fn emoji_list(sas: &Sas) -> Value { + match sas.emoji() { + Some(emojis) => Value::Array( + emojis + .iter() + .map(|emoji| json!({ "symbol": emoji.symbol, "description": emoji.description })) + .collect(), + ), + None => Value::Null, + } +} + +fn decimals(sas: &Sas) -> Value { + match sas.decimals() { + Some((a, b, c)) => json!([a, b, c]), + None => Value::Null, + } +} + +/// `className` is required: js-sdk dispatches on `instanceof RustSdkCryptoJs.Sas` / `.Qr`. +fn sas_state(sas: &Sas) -> Value { + json!({ + "className": "Sas", + "userId": sas.user_id().to_string(), + "deviceId": sas.device_id().to_string(), + "otherUserId": sas.other_user_id().to_string(), + "otherDeviceId": sas.other_device_id().to_string(), + "flowId": sas.flow_id().as_str(), + "roomId": sas.room_id().map(ToString::to_string), + "weStarted": sas.we_started(), + "isSelfVerification": sas.is_self_verification(), + "startedFromRequest": sas.started_from_request(), + "supportsEmoji": sas.supports_emoji(), + "haveWeConfirmed": sas.have_we_confirmed(), + "hasBeenAccepted": sas.has_been_accepted(), + "canBePresented": sas.can_be_presented(), + "timedOut": sas.timed_out(), + "isDone": sas.is_done(), + "isCancelled": sas.is_cancelled(), + "cancelInfo": cancel_info(sas.cancel_info()), + "emoji": emoji_list(sas), + "emojiIndex": sas.emoji_index().map(|indices| indices.to_vec()), + "decimals": decimals(sas), + }) +} + +fn qr_state_code(state: &QrVerificationState) -> u8 { + match state { + QrVerificationState::Started => 0, + QrVerificationState::Scanned => 1, + QrVerificationState::Confirmed => 2, + QrVerificationState::Reciprocated => 3, + QrVerificationState::Done { .. } => 4, + QrVerificationState::Cancelled(_) => 5, + } +} + +fn qr_state(qr: &QrVerification) -> Value { + json!({ + "className": "Qr", + "userId": qr.user_id().to_string(), + "otherUserId": qr.other_user_id().to_string(), + "otherDeviceId": qr.other_device_id().to_string(), + "flowId": qr.flow_id().as_str(), + "roomId": qr.room_id().map(ToString::to_string), + "weStarted": qr.we_started(), + "isSelfVerification": qr.is_self_verification(), + "hasBeenScanned": qr.has_been_scanned(), + "hasBeenConfirmed": qr.has_been_confirmed(), + "reciprocated": qr.reciprocated(), + "isDone": qr.is_done(), + "isCancelled": qr.is_cancelled(), + "cancelInfo": cancel_info(qr.cancel_info()), + "state": qr_state_code(&qr.state()), + "qrCodeBytes": qr.to_bytes().ok().map(|bytes| BASE64.encode(bytes)), + }) +} + +fn verification_state(verification: Verification) -> Value { + match verification { + Verification::SasV1(sas) => sas_state(&sas), + Verification::QrV1(qr) => qr_state(&qr), + _ => Value::Null, + } +} + +pub(crate) fn request_state(request: &VerificationRequest) -> Value { + let state = request.state(); + let phase = match &state { + VerificationRequestState::Created { .. } => 0, + VerificationRequestState::Requested { .. } => 1, + VerificationRequestState::Ready { .. } => 2, + VerificationRequestState::Transitioned { .. } => 3, + VerificationRequestState::Done => 4, + VerificationRequestState::Cancelled(_) => 5, + }; + + let verification = match &state { + VerificationRequestState::Transitioned { verification, .. } => match verification { + Verification::SasV1(sas) => sas_state(sas), + Verification::QrV1(qr) => qr_state(qr), + _ => Value::Null, + }, + _ => Value::Null, + }; + + json!({ + "className": "VerificationRequest", + "ownUserId": request.own_user_id().to_string(), + "otherUserId": request.other_user().to_string(), + "otherDeviceId": request.other_device_id().map(|id| id.to_string()), + "flowId": request.flow_id().as_str(), + "roomId": request.room_id().map(ToString::to_string), + "phase": phase, + "weStarted": request.we_started(), + "isSelfVerification": request.is_self_verification(), + "isPassive": request.is_passive(), + "isReady": request.is_ready(), + "isDone": request.is_done(), + "isCancelled": request.is_cancelled(), + "timedOut": request.timed_out(), + "timeRemainingMillis": request.time_remaining().as_millis() as f64, + "theirSupportedMethods": method_codes(request.their_supported_methods()), + "ourSupportedMethods": method_codes(request.our_supported_methods()), + "cancelInfo": cancel_info(request.cancel_info()), + "verification": verification, + }) +} + +pub async fn invoke( + machine: &OlmMachine, + method: &str, + args: &Value, +) -> Option> { + Some(match method { + "getVerificationRequest" => { + let (user, flow_id) = match flow(args, method) { + Ok(flow) => flow, + Err(e) => return Some(Err(e)), + }; + Ok(machine + .get_verification_request(&user, &flow_id) + .map(|request| request_state(&request)) + .unwrap_or(Value::Null)) + } + "getVerificationRequests" => { + let raw = match str_arg(args, method, "userId") { + Ok(raw) => raw, + Err(e) => return Some(Err(e)), + }; + match UserId::parse(&raw) { + Ok(user) => Ok(Value::Array( + machine + .get_verification_requests(&user) + .iter() + .map(request_state) + .collect(), + )), + Err(e) => Err(format!("{method}: bad user id in `userId`: {e}")), + } + } + + "device.requestVerification" => match device(machine, args, method).await { + Ok(device) => match methods_arg(args, method) { + Ok(Some(methods)) => { + let (request, out) = device.request_verification_with_methods(methods); + Ok(started(&request, outgoing(out))) + } + Ok(None) => { + let (request, out) = device.request_verification(); + Ok(started(&request, outgoing(out))) + } + Err(e) => Err(e), + }, + Err(e) => Err(e), + }, + "userIdentity.requestVerification" => match own_identity(machine, args, method).await { + Ok(identity) => match methods_arg(args, method) { + Ok(methods) => { + let result = match methods { + Some(methods) => identity.request_verification_with_methods(methods).await, + None => identity.request_verification().await, + }; + match result { + Ok((request, out)) => Ok(started(&request, outgoing(out))), + Err(e) => Err(format!("{method} failed: {e}")), + } + } + Err(e) => Err(e), + }, + Err(e) => Err(e), + }, + // The crate has no one-shot in-room start: the content is sent first, then the request is + // built from the event id it landed on. + "userIdentity.verificationRequestContent" => { + match other_identity(machine, args, method).await { + Ok(identity) => { + let room = str_arg(args, method, "roomId").and_then(|raw| { + RoomId::parse(&raw) + .map_err(|e| format!("{method}: bad room id in `roomId`: {e}")) + }); + match (room, methods_arg(args, method)) { + (Ok(room_id), Ok(methods)) => { + let content = identity.verification_request_content(methods); + let mut out = outgoing( + RoomMessageRequest { + room_id, + txn_id: TransactionId::new(), + content: Box::new(AnyMessageLikeEventContent::RoomMessage( + RoomMessageEventContent::new(content), + )), + } + .into(), + ); + // No `id`: the machine never issued this txn, so it cannot be acked. + if let Some(entry) = out.as_object_mut() { + entry.remove("id"); + } + Ok(json!({ "request": Value::Null, "outgoingRequest": out })) + } + (Err(e), _) | (_, Err(e)) => Err(e), + } + } + Err(e) => Err(e), + } + } + "userIdentity.requestVerificationDm" => match other_identity(machine, args, method).await { + Ok(identity) => { + let room = str_arg(args, method, "roomId").and_then(|raw| { + RoomId::parse(&raw) + .map_err(|e| format!("{method}: bad room id in `roomId`: {e}")) + }); + let event = str_arg(args, method, "requestEventId").and_then(|raw| { + EventId::parse(&raw) + .map_err(|e| format!("{method}: bad event id in `requestEventId`: {e}")) + }); + match (room, event, methods_arg(args, method)) { + (Ok(room_id), Ok(event_id), Ok(methods)) => { + let request = identity.request_verification(&room_id, &event_id, methods); + Ok(started(&request, Value::Null)) + } + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(e), + } + } + Err(e) => Err(e), + }, + + "verificationRequest.state" => request(machine, args, method).map(|r| request_state(&r)), + "verification.state" => { + let (user, flow_id) = match flow(args, method) { + Ok(flow) => flow, + Err(e) => return Some(Err(e)), + }; + Ok(machine + .get_verification(&user, &flow_id) + .map(verification_state) + .unwrap_or(Value::Null)) + } + "verificationRequest.accept" => { + request(machine, args, method).and_then(|request| { + match args.get("methods").and_then(Value::as_array) { + Some(codes) => { + let methods = codes + .iter() + .filter_map(Value::as_u64) + .map(|code| { + method_from_code(code).ok_or_else(|| { + format!("{method}: unknown verification method {code}") + }) + }) + .collect::, _>>()?; + Ok(optional_outgoing(request.accept_with_methods(methods))) + } + None => Ok(optional_outgoing(request.accept())), + } + }) + } + "verificationRequest.cancel" => { + request(machine, args, method).map(|request| optional_outgoing(request.cancel())) + } + "verificationRequest.startSas" => match request(machine, args, method) { + Ok(request) => match request.start_sas().await { + Ok(Some((sas, outgoing_request))) => { + Ok(json!([sas_state(&sas), outgoing(outgoing_request)])) + } + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("verificationRequest.startSas failed: {e}")), + }, + Err(e) => Err(e), + }, + "verificationRequest.generateQrCode" => match request(machine, args, method) { + Ok(request) => match request.generate_qr_code().await { + Ok(Some(qr)) => match qr.to_bytes() { + Ok(_) => Ok(qr_state(&qr)), + Err(e) => Err(format!("{method}: cannot encode the QR code payload: {e}")), + }, + Ok(None) => Ok(Value::Null), + Err(e) => Err(format!("{method} failed: {e}")), + }, + Err(e) => Err(e), + }, + "verificationRequest.scanQrCode" => match request(machine, args, method) { + Ok(request) => { + let decoded = str_arg(args, method, "qrCodeData") + .and_then(|data| { + BASE64 + .decode(data) + .map_err(|e| format!("{method}: `qrCodeData` is not valid base64: {e}")) + }) + .and_then(|bytes| { + QrVerificationData::from_bytes(bytes) + .map_err(|e| format!("{method}: undecodable QR code: {e}")) + }); + match decoded { + Ok(data) => match request.scan_qr_code(data).await { + Ok(Some(qr)) => Ok(qr_state(&qr)), + Ok(None) => Err(format!( + "{method}: the request cannot take a scanned QR code in its current state" + )), + Err(e) => Err(format!("{method} failed: {e}")), + }, + Err(e) => Err(e), + } + } + Err(e) => Err(e), + }, + + "sas.state" => sas(machine, args, method).map(|sas| sas_state(&sas)), + "sas.accept" => sas(machine, args, method).map(|sas| optional_outgoing(sas.accept())), + "sas.confirm" => match sas(machine, args, method) { + Ok(sas) => match sas.confirm().await { + Ok((requests, signature_upload)) => { + let mut out: Vec = requests.into_iter().map(outgoing).collect(); + // Null `id` tells js-sdk to skip `markRequestAsSent`. It must be present + // and null: an absent key reaches the wasm `get id()` with no pointer. + if let Some(upload) = signature_upload { + out.push(json!({ + "type": REQUEST_TYPE_SIGNATURE_UPLOAD, + "className": "SignatureUploadRequest", + "id": Value::Null, + "body": json!(upload.signed_keys).to_string(), + })); + } + Ok(Value::Array(out)) + } + Err(e) => Err(format!("sas.confirm failed: {e}")), + }, + Err(e) => Err(e), + }, + "sas.cancel" => { + sas(machine, args, method).map(|sas| match args.get("code").and_then(Value::as_str) { + Some(code) => optional_outgoing(sas.cancel_with_code(code.into())), + None => optional_outgoing(sas.cancel()), + }) + } + "sas.emoji" => sas(machine, args, method).map(|sas| emoji_list(&sas)), + "sas.decimals" => sas(machine, args, method).map(|sas| decimals(&sas)), + + "qr.state" => qr(machine, args, method).map(|qr| qr_state(&qr)), + "qr.confirm" => { + qr(machine, args, method).map(|qr| optional_outgoing(qr.confirm_scanning())) + } + "qr.reciprocate" => qr(machine, args, method).map(|qr| optional_outgoing(qr.reciprocate())), + "qr.cancel" => { + qr(machine, args, method).map(|qr| match args.get("code").and_then(Value::as_str) { + Some(code) => optional_outgoing(qr.cancel_with_code(code.into())), + None => optional_outgoing(qr.cancel()), + }) + } + + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The webview sends these codes from matrix-sdk-crypto-wasm's `VerificationMethod` + /// enum, which is versioned separately from this crate. If either side reorders them, + /// QR negotiation desyncs silently. Mirrored by `verificationInternals.test.ts`. + #[test] + fn method_codes_match_the_wasm_enum_the_webview_sends() { + assert_eq!(method_from_code(0), Some(VerificationMethod::SasV1)); + assert_eq!(method_from_code(1), Some(VerificationMethod::QrCodeScanV1)); + assert_eq!(method_from_code(2), Some(VerificationMethod::QrCodeShowV1)); + assert_eq!(method_from_code(3), Some(VerificationMethod::ReciprocateV1)); + assert_eq!(method_from_code(4), 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..02a93ec2ee --- /dev/null +++ b/src-tauri/src/matrix_crypto/wasm_enums.rs @@ -0,0 +1,31 @@ +//! The numeric values matrix-sdk-crypto-wasm's enums cross the IPC boundary as. + +use matrix_sdk_crypto::types::EventEncryptionAlgorithm; + +/// wasm's `RequestType`. +pub mod request_type { + pub const KEYS_UPLOAD: u8 = 0; + pub const KEYS_QUERY: u8 = 1; + pub const KEYS_CLAIM: u8 = 2; + pub const TO_DEVICE: u8 = 3; + pub const SIGNATURE_UPLOAD: u8 = 4; + pub const ROOM_MESSAGE: u8 = 5; + pub const KEYS_BACKUP: u8 = 6; +} + +/// wasm's `ProcessedToDeviceEventType`. +pub mod processed_to_device_event_type { + pub const DECRYPTED: u8 = 0; + pub const UNABLE_TO_DECRYPT: u8 = 1; + pub const PLAIN_TEXT: u8 = 2; + pub const INVALID: u8 = 3; +} + +/// wasm's `EncryptionAlgorithm`; anything the bindings do not name maps to `Unknown`. +pub fn encryption_algorithm(algorithm: &EventEncryptionAlgorithm) -> u8 { + match algorithm { + EventEncryptionAlgorithm::OlmV1Curve25519AesSha2 => 0, + EventEncryptionAlgorithm::MegolmV1AesSha2 => 1, + _ => 2, + } +} diff --git a/src/app/crypto/engineCrypto/EngineCrypto.ts b/src/app/crypto/engineCrypto/EngineCrypto.ts new file mode 100644 index 0000000000..e13e1da233 --- /dev/null +++ b/src/app/crypto/engineCrypto/EngineCrypto.ts @@ -0,0 +1,1266 @@ +import { + CrossSigningKey, + DeviceVerificationStatus, + deriveRecoveryKeyFromPassphrase, + EventShieldColour, + EventShieldReason, + encodeRecoveryKey, + EventType, + ImportRoomKeyStage, + UserVerificationStatus, + VerificationMethod, +} from '$types/matrix-sdk'; +import { Device, DeviceVerification } from 'matrix-js-sdk/lib/models/device'; +import { getHttpUriForMxc } from 'matrix-js-sdk/lib/content-repo'; +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { decodeBase64, encodeBase64 } from 'matrix-js-sdk/lib/base64'; +import { secureRandomString } from 'matrix-js-sdk/lib/randomstring'; +import type { KeyBackupSession } from 'matrix-js-sdk/lib/crypto-api/keybackup'; +import { + SECRET_STORAGE_ALGORITHM_V1_AES, + type SecretStorageKey, +} from 'matrix-js-sdk/lib/secret-storage'; +import { ClientPrefix, Method } from 'matrix-js-sdk/lib/http-api'; +import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'; +import { encodeUri } from 'matrix-js-sdk/lib/utils'; +import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; +import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; +import type { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api/CryptoEventHandlerMap'; +import { createDebugLogger } from '$utils/debugLogger'; +import { EngineVerificationRequest } from '../verification/request'; +import { + codeFromMethod, + type EngineVerificationState, +} from '../verification/state'; +import { engineInvoke, type EngineIdentity } from '../olmMachine/engineInvoke'; +import { sendOutgoingRequest, type OutgoingRequest } from './outgoing'; +import type { + BackupDecryptor, + CryptoBackend, + EventDecryptionResult, + OnSyncCompletedData, +} from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; +import type { DeviceMap } from 'matrix-js-sdk/lib/models/device'; +import type { + IDeviceLists, + IToDeviceEvent, + ReceivedToDeviceMessage, +} from 'matrix-js-sdk/lib/sync-accumulator'; +import type { IMegolmSessionData } from 'matrix-js-sdk/lib/@types/crypto'; +import type { ToDeviceBatch, ToDevicePayload } from 'matrix-js-sdk/lib/models/ToDeviceMessage'; +import type { UIAuthCallback } from 'matrix-js-sdk/lib/interactive-auth'; +import type { + BackupTrustInfo, + BootstrapCrossSigningOpts, + CreateSecretStorageOpts, + CrossSigningKeys, + CrossSigningKeyInfo, + CrossSigningStatus, + DeviceIsolationMode, + EventEncryptionInfo, + GeneratedSecretStorageKey, + ImportRoomKeysOpts, + KeyBackupCheck, + KeyBackupInfo, + KeyBackupRestoreOpts, + KeyBackupRestoreResult, + MatrixClient, + MatrixEvent, + OwnDeviceKeys, + Room, + SecretStorageStatus, + StartDehydrationOpts, + VerificationRequest, +} from '$types/matrix-sdk'; + +const engineCryptoLog = createDebugLogger('engine-crypto'); + +/** js-sdk keeps this union private to its own rust-crypto module; derived the same way. */ +type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent]; + +/** Matches matrix-js-sdk's own derivation cost so keys stay interchangeable. */ +const RECOVERY_KEY_DERIVATION_ITERATIONS = 500000; + +const SECRETS_IN_STORAGE = [ + 'm.cross_signing.master', + 'm.cross_signing.self_signing', + 'm.cross_signing.user_signing', +] as const satisfies readonly SecretStorageKey[]; + +const SUPPORTED_VERIFICATION_METHOD_CODES = [ + VerificationMethod.Sas, + VerificationMethod.ScanQrCode, + VerificationMethod.ShowQrCode, + VerificationMethod.Reciprocate, +] + .map(codeFromMethod) + .filter((code): code is number => code !== undefined); + +type EngineDevice = { + userId: string; + deviceId: string; + displayName?: string | null; + algorithms: number[]; + keys: Record; + isCrossSigningTrusted: boolean; + isCrossSignedByOwner: boolean; + isLocallyTrusted: boolean; + isDehydrated: boolean; +}; + +/** wasm's ProcessedToDeviceEventType, which the engine emits as bare numbers. */ +const ProcessedToDeviceEventType = { + Decrypted: 0, + UnableToDecrypt: 1, + PlainText: 2, + Invalid: 3, +} as const; + +type EngineProcessedToDeviceEvent = { + type: number; + rawEvent: string; + encryptionInfo?: { + sender: string; + senderDevice?: string; + senderCurve25519Key: string; + isSenderVerified: boolean; + }; +}; + +type EngineShieldState = { color: number; code?: number | null }; + +type EngineEncryptionInfo = { + shieldStateLax?: EngineShieldState; + shieldStateStrict?: EngineShieldState; +}; + +// Engine colour codes, per shield_state_json in matrix_crypto/rooms.rs. +const SHIELD_COLOUR: Record = { + 0: EventShieldColour.RED, + 1: EventShieldColour.GREY, + 2: EventShieldColour.NONE, +}; + +// Engine ShieldStateCode ordinals, per shield_state_json in matrix_crypto/rooms.rs. +const SHIELD_REASON: Record = { + 0: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + 1: EventShieldReason.UNKNOWN_DEVICE, + 2: EventShieldReason.UNSIGNED_DEVICE, + 3: EventShieldReason.UNVERIFIED_IDENTITY, + 4: EventShieldReason.VERIFICATION_VIOLATION, + 5: EventShieldReason.MISMATCHED_SENDER, +}; + +export const toEventEncryptionInfo = ( + info: EngineEncryptionInfo | null +): EventEncryptionInfo | null => { + if (!info) return null; + + // js-sdk reads the lax state; strict is only used behind its own setting. + const state = info.shieldStateLax; + if (!state) return null; + + const code = state.code; + return { + shieldColour: SHIELD_COLOUR[state.color] ?? EventShieldColour.RED, + shieldReason: + code === undefined || code === null + ? null + : (SHIELD_REASON[code] ?? EventShieldReason.UNKNOWN), + }; +}; + +type EngineDecryptedEvent = { + event: string; + senderCurve25519Key?: string | null; + senderClaimedEd25519Key?: string | null; + forwardingCurve25519KeyChain?: string[]; +}; + +const isOutgoingRequest = (value: unknown): value is OutgoingRequest => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return typeof candidate.type === 'number' && typeof candidate.body === 'string'; +}; + +type EngineRoomKeyBundle = { + encryptedData: string; + mediaEncryptionInfo: string; +}; + +type EngineBackupKeys = { + backupVersion?: string | null; + /** Base64 text, not raw bytes; the engine never hands the key over as an object. */ + decryptionKeyBase64?: string | null; +}; + +/** The engine serialises each cross-signing key as JSON text. */ +const parseCrossSigningKey = (raw: unknown): CrossSigningKeyInfo | undefined => { + if (typeof raw !== 'string') return undefined; + try { + return JSON.parse(raw) as CrossSigningKeyInfo; + } catch { + return undefined; + } +}; + +type EngineIdentityInfo = { + userId: string; + isVerified: boolean; + wasPreviouslyVerified: boolean; + identityNeedsUserApproval?: boolean; + masterKey?: unknown; + selfSigningKey?: unknown; + userSigningKey?: unknown; +}; + +const toSdkDevice = (device: EngineDevice): Device => + new Device({ + userId: device.userId, + deviceId: device.deviceId, + displayName: device.displayName ?? undefined, + algorithms: [], + keys: new Map(Object.entries(device.keys)), + verified: device.isLocallyTrusted + ? DeviceVerification.Verified + : DeviceVerification.Unverified, + signatures: new Map(), + dehydrated: device.isDehydrated, + }); + +export class EngineCrypto + extends TypedEventEmitter + implements CryptoBackend +{ + globalBlacklistUnverifiedDevices = false; + + globalErrorOnUnknownDevices = false; + + readonly #mx: MatrixClient; + + readonly #identity: EngineIdentity; + + #trustCrossSignedDevices = true; + + #stopped = false; + + #deviceIsolationMode: DeviceIsolationMode | undefined; + + /** Live requests, keyed by flow id, so the synchronous CryptoApi getters can answer. */ + readonly #verificationRequests = new Map(); + + constructor(mx: MatrixClient, identity: EngineIdentity) { + super(); + this.#mx = mx; + this.#identity = identity; + // Nothing else drives the backup connection. + void this.#connectKeyBackup(); + } + + /** The engine reports a backup version only once `enableBackupV1` has run. */ + async #connectKeyBackup(): Promise { + try { + await this.checkKeyBackupAndEnable(); + this.emit(CryptoEvent.KeyBackupStatus, (await this.getActiveSessionBackupVersion()) !== null); + } catch { + // Backup is optional; failing here must not break the session. + } + } + + onUserIdentityUpdated(userId: string): void { + this.emit(CryptoEvent.UserTrustStatusChanged, userId, new UserVerificationStatus(false, false, true)); + // Our own identity becoming trusted can make a backup we rejected trustworthy. + if (userId === this.#identity.userId) void this.#connectKeyBackup(); + } + + onDevicesUpdated(userIds: string[]): void { + this.emit(CryptoEvent.DevicesUpdated, userIds, false); + } + + onKeysChanged(): void { + this.emit(CryptoEvent.KeysChanged, {}); + } + + async #receiveSyncChanges(input: { + toDeviceEvents?: IToDeviceEvent[]; + deviceLists?: IDeviceLists; + oneTimeKeysCounts?: Record; + unusedFallbackKeys?: string[]; + }): Promise { + const processed = (await this.#call('receiveSyncChanges', { + toDeviceEvents: JSON.stringify(input.toDeviceEvents ?? []), + changedDevices: input.deviceLists?.changed ?? [], + leftDevices: input.deviceLists?.left ?? [], + oneTimeKeysCounts: input.oneTimeKeysCounts ?? {}, + unusedFallbackKeys: input.unusedFallbackKeys ?? null, + })) as EngineProcessedToDeviceEvent[] | null; + + void this.#flushOutgoingRequests(); + return processed ?? []; + } + + #call(method: string, args: Record = {}): Promise { + return engineInvoke(this.#identity, method, args); + } + + /** + * Verification actions return their outgoing request instead of queueing it, so the + * generic drain never sees it. Unsent, the flow stalls with no error. + */ + readonly #engineCall = async ( + method: string, + args: Record = {} + ): Promise => { + const result = await this.#call(method, args); + + // `sas.confirm` answers with several requests (the MAC plus a signature upload), and + // `startSas` answers with [state, request]. Sending only a lone object drops both. + if (Array.isArray(result)) { + for (const item of result) { + if (!isOutgoingRequest(item)) continue; + // Ordered: the peer rejects a MAC that arrives before the accept. + // eslint-disable-next-line no-await-in-loop + await sendOutgoingRequest(this.#mx, item); + } + return result; + } + + if (isOutgoingRequest(result)) { + await sendOutgoingRequest(this.#mx, result); + return null; + } + return result; + }; + + async #startVerification( + method: string, + args: Record + ): Promise { + const started = (await this.#call(method, args)) as { + request: EngineVerificationState; + outgoingRequest?: unknown; + }; + if (isOutgoingRequest(started.outgoingRequest)) { + await sendOutgoingRequest(this.#mx, started.outgoingRequest); + } + await this.#flushOutgoingRequests(); + + const request = new EngineVerificationRequest(this.#engineCall, started.request); + this.#verificationRequests.set(started.request.flowId, request); + return request; + } + + async onIncomingKeyVerificationRequest(sender: string, transactionId: string): Promise { + const state = (await this.#call('getVerificationRequest', { + userId: sender, + flowId: transactionId, + })) as EngineVerificationState | null; + if (!state) return; + + const existing = this.#verificationRequests.get(transactionId); + if (existing) { + existing.apply(state); + return; + } + const request = new EngineVerificationRequest(this.#engineCall, state); + this.#verificationRequests.set(transactionId, request); + this.emit(CryptoEvent.VerificationRequestReceived, request); + } + + /** matrix-sdk-crypto only clears a request once told it was sent, so a failure here + * leaves it queued for the next drain rather than losing it. */ + async #flushOutgoingRequests(): Promise { + if (this.#stopped) return; + const requests = ((await this.#call('outgoingRequests')) ?? []) as OutgoingRequest[]; + + for (const request of requests) { + if (this.#stopped) return; + try { + // Sequential: the engine's queue is ordered and later requests can depend on + // earlier ones having landed. + // eslint-disable-next-line no-await-in-loop + const response = await sendOutgoingRequest(this.#mx, request); + // eslint-disable-next-line no-await-in-loop + await this.#call('markRequestAsSent', { + requestId: request.id, + requestType: request.type, + response, + }); + } catch (error) { + // Loud: a request the engine never marks sent is retried on every sync forever. + engineCryptoLog.error('general', `Outgoing crypto request ${request.id} failed`, error); + } + } + } + + + async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise { + const processed = await this.#receiveSyncChanges({ toDeviceEvents: events }); + const received: ReceivedToDeviceMessage[] = []; + + for (const event of processed) { + const message = JSON.parse(event.rawEvent) as IToDeviceEvent; + + if (typeof message.type === 'string' && message.type.startsWith('m.key.verification.')) { + const transactionId = (message.content as { transaction_id?: string })?.transaction_id; + if (transactionId && message.sender) { + if (message.type === EventType.KeyVerificationRequest) { + // eslint-disable-next-line no-await-in-loop + await this.onIncomingKeyVerificationRequest(message.sender, transactionId); + } else { + // Without this the verifier never learns the SAS digits arrived. + // eslint-disable-next-line no-await-in-loop + await this.#verificationRequests.get(transactionId)?.refresh(); + } + } + } + + if (event.type === ProcessedToDeviceEventType.Decrypted && event.encryptionInfo) { + received.push({ + message, + encryptionInfo: { + sender: event.encryptionInfo.sender, + senderDevice: event.encryptionInfo.senderDevice, + senderCurve25519KeyBase64: event.encryptionInfo.senderCurve25519Key, + senderVerified: event.encryptionInfo.isSenderVerified, + }, + }); + } else if (event.type === ProcessedToDeviceEventType.PlainText) { + received.push({ message, encryptionInfo: null }); + } + // Undecryptable and invalid events are dropped, as js-sdk's own backend does. + } + + return received; + } + + async processKeyCounts( + oneTimeKeysCounts?: Record, + unusedFallbackKeys?: string[] + ): Promise { + await this.#receiveSyncChanges({ oneTimeKeysCounts, unusedFallbackKeys }); + } + + async processDeviceLists(deviceLists: IDeviceLists): Promise { + await this.#receiveSyncChanges({ deviceLists }); + } + + async onCryptoEvent(room: Room, event: MatrixEvent): Promise { + engineCryptoLog.debug('general', 'Room encryption configured', { + roomId: room.roomId, + algorithm: event.getContent().algorithm, + }); + } + + onSyncCompleted(syncState: OnSyncCompletedData): void { + // Working through a backlog: the next sync follows immediately, so batch the drain. + if (syncState.catchingUp) return; + void this.#flushOutgoingRequests(); + } + + async markAllTrackedUsersAsDirty(): Promise { + await this.#call('markAllTrackedUsersAsDirty'); + } + + + stop(): void { + this.#stopped = true; + } + + async encryptEvent(event: MatrixEvent, room: Room): Promise { + // The megolm session has to reach every device in the room before the event does. + const members = await room.getEncryptionTargetMembers(); + await this.#call('getMissingSessions', { users: members.map((member) => member.userId) }); + await this.#flushOutgoingRequests(); + await this.#call('shareRoomKey', { roomId: room.roomId, users: members.map((m) => m.userId) }); + await this.#flushOutgoingRequests(); + + const encrypted = (await this.#call('encryptRoomEvent', { + roomId: room.roomId, + eventType: event.getType(), + content: JSON.stringify(event.getContent()), + })) as string; + + event.makeEncrypted( + 'm.room.encrypted', + JSON.parse(encrypted) as Record, + '', + '' + ); + } + + async decryptEvent(event: MatrixEvent): Promise { + const roomId = event.getRoomId(); + if (!roomId) throw new Error('Cannot decrypt an event with no room id'); + + const decrypted = (await this.#call('decryptRoomEvent', { + event: JSON.stringify({ + event_id: event.getId(), + type: event.getWireType(), + sender: event.getSender(), + room_id: roomId, + origin_server_ts: event.getTs(), + content: event.getWireContent(), + }), + roomId, + decryptionSettings: { senderDeviceTrustRequirement: this.#deviceIsolationMode }, + })) as EngineDecryptedEvent; + + return { + clearEvent: JSON.parse(decrypted.event) as EventDecryptionResult['clearEvent'], + senderCurve25519Key: decrypted.senderCurve25519Key ?? undefined, + claimedEd25519Key: decrypted.senderClaimedEd25519Key ?? undefined, + forwardingCurve25519KeyChain: decrypted.forwardingCurve25519KeyChain ?? [], + }; + } + + /** Stateless: needs the backup key, not the crypto store, so it stays in-process. */ + async getBackupDecryptor( + backupInfo: KeyBackupInfo, + privKey: Uint8Array + ): Promise { + if (backupInfo.algorithm !== 'm.megolm_backup.v1.curve25519-aes-sha2') { + throw new Error(`Unsupported key backup algorithm ${backupInfo.algorithm}`); + } + + const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey)); + const authData = backupInfo.auth_data as { public_key?: string } | undefined; + if (authData?.public_key !== key.megolmV1PublicKey.publicKeyBase64) { + throw new Error('The backup key does not match this backup version'); + } + + return { + sourceTrusted: false, + async decryptSessions(ciphertexts) { + return Object.entries(ciphertexts).map(([sessionId, session]) => { + const decrypted = JSON.parse( + key.decryptV1( + session.session_data.ephemeral, + session.session_data.mac, + session.session_data.ciphertext + ) + ) as IMegolmSessionData; + decrypted.session_id = sessionId; + return decrypted; + }); + }, + free() { + key.free(); + }, + }; + } + + async importBackedUpRoomKeys( + keys: IMegolmSessionData[], + backupVersion: string, + opts?: ImportRoomKeysOpts + ): Promise { + const result = (await this.#call('importBackedUpRoomKeys', { + keys: JSON.stringify(keys), + backupVersion, + })) as { importedCount?: number; totalCount?: number } | null; + + const total = result?.totalCount ?? keys.length; + const successes = result?.importedCount ?? 0; + opts?.progressCallback?.({ + stage: ImportRoomKeyStage.LoadKeys, + successes, + failures: total - successes, + total, + }); + } + + /** MSC4268. The engine encrypts; we upload; only the mxc URL goes back. */ + async shareRoomHistoryWithUser(roomId: string, userId: string): Promise { + const own = await this.getUserVerificationStatus(this.#identity.userId); + if (!own.isCrossSigningVerified()) { + engineCryptoLog.warn( + 'general', + 'Not sharing message history: this device is not verified by our own identity' + ); + return; + } + + const bundle = (await this.#call('buildRoomKeyBundle', { roomId })) as EngineRoomKeyBundle | null; + if (!bundle) return; + + const { content_uri: url } = await this.#mx.uploadContent( + new Blob([decodeBase64(bundle.encryptedData) as BlobPart]), + { includeFilename: false } + ); + + await this.#call('queryKeysForUsers', { users: [userId] }); + await this.#flushOutgoingRequests(); + await this.#call('getMissingSessions', { users: [userId] }); + await this.#flushOutgoingRequests(); + + await this.#call('shareRoomKeyBundleData', { + userId, + roomId, + url, + mediaEncryptionInfo: bundle.mediaEncryptionInfo, + sharingStrategy: 'identityBasedStrategy', + }); + await this.#flushOutgoingRequests(); + } + + /** MSC4268. The engine stores the bundle metadata; we fetch the media it points at. */ + async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise { + const data = (await this.#call('getReceivedRoomKeyBundleData', { + roomId, + inviterId: inviter, + })) as { url?: string } | null; + if (!data?.url) return false; + + const httpUrl = new URL( + getHttpUriForMxc( + this.#mx.baseUrl, + data.url, + undefined, + undefined, + undefined, + false, + true, + true + ) + ); + const blob = await this.#mx.http.authedRequest( + Method.Get, + httpUrl.pathname + httpUrl.search, + {}, + undefined, + { rawResponseBody: true, prefix: '' } + ); + + await this.#call('receiveRoomKeyBundle', { + roomId, + inviterId: inviter, + bundle: encodeBase64(new Uint8Array(await blob.arrayBuffer())), + }); + await this.#call('clearRoomPendingKeyBundle', { roomId }); + return true; + } + + async markRoomAsPendingKeyBundle(roomId: string, inviterId: string): Promise { + await this.#call('storeRoomPendingKeyBundle', { roomId, inviterId }); + } + + + setDeviceIsolationMode(isolationMode: DeviceIsolationMode): void { + this.#deviceIsolationMode = isolationMode; + } + + getVersion(): string { + return 'Rust SDK (Sable engine over IPC)'; + } + + async getOwnDeviceKeys(): Promise { + const keys = (await this.#call('identityKeys')) as { ed25519: string; curve25519: string }; + return { ed25519: keys.ed25519, curve25519: keys.curve25519 }; + } + + async isEncryptionEnabledInRoom(roomId: string): Promise { + return this.#mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false; + } + + async isStateEncryptionEnabledInRoom(roomId: string): Promise { + const settings = (await this.#call('getRoomSettings', { roomId })) as { + encryptStateEvents?: boolean; + } | null; + return settings?.encryptStateEvents ?? false; + } + + prepareToEncrypt(room: Room): void { + void room + .getEncryptionTargetMembers() + .then((members) => + this.#call('getMissingSessions', { users: members.map((member) => member.userId) }) + ) + .then(() => this.#flushOutgoingRequests()) + .catch((error: unknown) => + engineCryptoLog.warn('general', 'prepareToEncrypt failed', error) + ); + } + + async forceDiscardSession(roomId: string): Promise { + await this.#call('invalidateGroupSession', { roomId }); + } + + async getEncryptionInfoForEvent(event: MatrixEvent): Promise { + if (!event.getClearContent() || event.isDecryptionFailure()) return null; + if (event.status !== null) { + return { shieldColour: EventShieldColour.NONE, shieldReason: null }; + } + + const roomId = event.getRoomId(); + if (!roomId) return null; + + const info = (await this.#call('getRoomEventEncryptionInfo', { + event: JSON.stringify({ + event_id: event.getId(), + type: event.getWireType(), + sender: event.getSender(), + room_id: roomId, + origin_server_ts: event.getTs(), + content: event.getWireContent(), + }), + roomId, + })) as EngineEncryptionInfo | null; + + return toEventEncryptionInfo(info); + } + + async encryptToDeviceMessages( + eventType: string, + devices: { userId: string; deviceId: string }[], + payload: ToDevicePayload + ): Promise { + const batch = await Promise.all( + devices.map(async ({ userId, deviceId }) => ({ + userId, + deviceId, + payload: JSON.parse( + (await this.#call('device.encryptToDeviceEvent', { + userId, + deviceId, + eventType, + content: JSON.stringify(payload), + })) as string + ) as ToDevicePayload, + })) + ); + + return { eventType: EventType.RoomMessageEncrypted, batch }; + } + + /** The outgoing-request queue has no interactive-auth path, so a server that challenges + * the signing-key upload will reject it. */ + async resetEncryption(authUploadDeviceSigningKeys: UIAuthCallback): Promise { + engineCryptoLog.info('general', 'Resetting encryption', { + interactiveAuthAvailable: typeof authUploadDeviceSigningKeys === 'function', + }); + await this.disableKeyStorage(); + await this.#call('bootstrapCrossSigning', { reset: true }); + await this.#flushOutgoingRequests(); + await this.resetKeyBackup(); + } + + + async exportRoomKeys(): Promise { + return JSON.parse(await this.exportRoomKeysAsJson()) as IMegolmSessionData[]; + } + + async exportRoomKeysAsJson(): Promise { + // Already JSON text; stringifying again would double-encode the export. + return (await this.#call('exportRoomKeys')) as string; + } + + async importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise { + await this.#call('importExportedRoomKeys', { keys: JSON.stringify(keys) }); + opts?.progressCallback?.({ + stage: ImportRoomKeyStage.LoadKeys, + successes: keys.length, + failures: 0, + total: keys.length, + }); + } + + async importRoomKeysAsJson(keys: string, opts?: ImportRoomKeysOpts): Promise { + await this.importRoomKeys(JSON.parse(keys) as IMegolmSessionData[], opts); + } + + + async userHasCrossSigningKeys( + userId: string = this.#identity.userId, + downloadUncached = false + ): Promise { + if (downloadUncached) await this.#call('queryKeysForUsers', { users: [userId] }); + const identity = (await this.#call('getIdentity', { userId })) as EngineIdentityInfo | null; + return identity !== null; + } + + async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise { + if (downloadUncached) await this.#call('queryKeysForUsers', { users: userIds }); + + const map: DeviceMap = new Map(); + await Promise.all( + userIds.map(async (userId) => { + const devices = ((await this.#call('getUserDevices', { + userId, + timeoutSecs: null, + })) ?? []) as EngineDevice[]; + + map.set(userId, new Map(devices.map((device) => [device.deviceId, toSdkDevice(device)]))); + }) + ); + return map; + } + + setTrustCrossSignedDevices(val: boolean): void { + this.#trustCrossSignedDevices = val; + } + + getTrustCrossSignedDevices(): boolean { + return this.#trustCrossSignedDevices; + } + + async getUserVerificationStatus(userId: string): Promise { + const identity = (await this.#call('getIdentity', { userId })) as EngineIdentityInfo | null; + if (!identity) return new UserVerificationStatus(false, false, false); + + return new UserVerificationStatus( + identity.isVerified, + identity.wasPreviouslyVerified, + true, + identity.identityNeedsUserApproval ?? false + ); + } + + async pinCurrentUserIdentity(userId: string): Promise { + await this.#call('userIdentity.pin', { userId }); + } + + async withdrawVerificationRequirement(userId: string): Promise { + await this.#call('userIdentity.withdrawVerification', { userId }); + } + + async getUserCrossSigningKeys(userId: string): Promise | null> { + const identity = (await this.#call('getIdentity', { userId })) as EngineIdentityInfo | null; + if (!identity) return null; + + return { + [CrossSigningKey.Master]: parseCrossSigningKey(identity.masterKey), + [CrossSigningKey.SelfSigning]: parseCrossSigningKey(identity.selfSigningKey), + [CrossSigningKey.UserSigning]: parseCrossSigningKey(identity.userSigningKey), + }; + } + + async getDeviceVerificationStatus( + userId: string, + deviceId: string + ): Promise { + const device = (await this.#call('getDevice', { + userId, + deviceId, + timeoutSecs: null, + })) as EngineDevice | null; + if (!device) return null; + + return new DeviceVerificationStatus({ + signedByOwner: device.isCrossSignedByOwner, + crossSigningVerified: device.isCrossSigningTrusted, + localVerified: device.isLocallyTrusted, + trustCrossSignedDevices: this.#trustCrossSignedDevices, + }); + } + + async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise { + await this.#call('device.setLocalTrust', { + userId, + deviceId, + trustState: verified + ? RustSdkCryptoJs.LocalTrust.Verified + : RustSdkCryptoJs.LocalTrust.Unset, + }); + } + + + async crossSignDevice(deviceId: string): Promise { + await this.#call('device.verify', { userId: this.#identity.userId, deviceId }); + await this.#flushOutgoingRequests(); + } + + async isCrossSigningReady(): Promise { + const status = await this.getCrossSigningStatus(); + const cached = status.privateKeysCachedLocally; + return ( + status.publicKeysOnDevice && cached.masterKey && cached.selfSigningKey && cached.userSigningKey + ); + } + + async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise { + const keys = await this.getUserCrossSigningKeys(this.#identity.userId); + const first = Object.values(keys?.[type]?.keys ?? {})[0]; + return first ?? null; + } + + async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise { + await this.#call('bootstrapCrossSigning', { reset: opts.setupNewCrossSigning ?? false }); + await this.#flushOutgoingRequests(); + } + + async isSecretStorageReady(): Promise { + return (await this.getSecretStorageStatus()).ready; + } + + async getSecretStorageStatus(): Promise { + const defaultKeyId = await this.#mx.secretStorage.getDefaultKeyId(); + if (!defaultKeyId) { + return { ready: false, defaultKeyId: null, secretStorageKeyValidityMap: {} }; + } + + const names: SecretStorageKey[] = [...SECRETS_IN_STORAGE]; + if (await this.getActiveSessionBackupVersion()) names.push('m.megolm_backup.v1'); + + const entries = await Promise.all( + names.map(async (name) => [name, Boolean(await this.#mx.secretStorage.isStored(name))] as const) + ); + const secretStorageKeyValidityMap = Object.fromEntries(entries); + + return { + ready: entries.every(([, stored]) => stored), + defaultKeyId, + secretStorageKeyValidityMap, + }; + } + + async bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise { + const existingKeyId = await this.#mx.secretStorage.getDefaultKeyId(); + const needsKey = opts.setupNewSecretStorage || !existingKeyId; + + if (needsKey) { + if (!opts.createSecretStorageKey) { + throw new Error('bootstrapSecretStorage needs createSecretStorageKey to make a new key'); + } + const key = await opts.createSecretStorageKey(); + const { keyId, keyInfo } = await this.#mx.secretStorage.addKey( + SECRET_STORAGE_ALGORITHM_V1_AES, + { ...key.keyInfo, key: key.privateKey } + ); + await this.#mx.secretStorage.setDefaultKeyId(keyId); + engineCryptoLog.info('general', 'Created a new secret storage key', { + keyId, + algorithm: keyInfo.algorithm, + }); + } + + const exported = (await this.#call('exportCrossSigningKeys')) as Record< + string, + string | undefined + > | null; + if (exported) { + const entries: [SecretStorageKey, string | undefined][] = [ + ['m.cross_signing.master', exported.masterKey], + ['m.cross_signing.self_signing', exported.self_signing_key ?? exported.selfSigningKey], + ['m.cross_signing.user_signing', exported.user_signing_key ?? exported.userSigningKey], + ]; + await Promise.all( + entries + .filter(([, value]) => Boolean(value)) + .map(([name, value]) => this.#mx.secretStorage.store(name, value as string)) + ); + } + + if (opts.setupNewKeyBackup) await this.resetKeyBackup(); + } + + async getCrossSigningStatus(): Promise { + const status = (await this.#call('crossSigningStatus')) as { + hasMaster: boolean; + hasSelfSigning: boolean; + hasUserSigning: boolean; + }; + + return { + publicKeysOnDevice: status.hasMaster && status.hasSelfSigning && status.hasUserSigning, + privateKeysInSecretStorage: false, + privateKeysCachedLocally: { + masterKey: status.hasMaster, + selfSigningKey: status.hasSelfSigning, + userSigningKey: status.hasUserSigning, + }, + }; + } + + async createRecoveryKeyFromPassphrase(password?: string): Promise { + if (!password) { + const key = new Uint8Array(32); + globalThis.crypto.getRandomValues(key); + return { privateKey: key, encodedPrivateKey: encodeRecoveryKey(key) }; + } + + const salt = secureRandomString(32); + const privateKey = await deriveRecoveryKeyFromPassphrase( + password, + salt, + RECOVERY_KEY_DERIVATION_ITERATIONS + ); + + return { + keyInfo: { + passphrase: { + algorithm: 'm.pbkdf2', + iterations: RECOVERY_KEY_DERIVATION_ITERATIONS, + salt, + }, + }, + privateKey, + encodedPrivateKey: encodeRecoveryKey(privateKey), + }; + } + + + getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { + return [...this.#verificationRequests.values()].filter( + (request) => request.otherUserId === userId && request.roomId === undefined + ); + } + + findVerificationRequestDMInProgress( + roomId: string, + userId?: string + ): VerificationRequest | undefined { + return [...this.#verificationRequests.values()].find( + (request) => + request.roomId === roomId && + request.pending && + (userId === undefined || request.otherUserId === userId) + ); + } + + /** The engine needs the event id of the request we send, so build, send, then register. */ + async requestVerificationDM(userId: string, roomId: string): Promise { + const content = (await this.#call('userIdentity.verificationRequestContent', { + userId, + roomId, + methods: SUPPORTED_VERIFICATION_METHOD_CODES, + })) as string; + + const { event_id: eventId } = await this.#mx.sendEvent( + roomId, + EventType.RoomMessage, + JSON.parse(content) as RoomMessageEventContent + ); + + const started = (await this.#call('userIdentity.requestVerificationDm', { + userId, + roomId, + eventId, + methods: SUPPORTED_VERIFICATION_METHOD_CODES, + })) as { request: EngineVerificationState; outgoingRequest?: unknown }; + + if (isOutgoingRequest(started.outgoingRequest)) { + await sendOutgoingRequest(this.#mx, started.outgoingRequest); + } + await this.#flushOutgoingRequests(); + return new EngineVerificationRequest(this.#engineCall, started.request); + } + + async requestOwnUserVerification(): Promise { + return this.#startVerification('userIdentity.requestVerification', { + userId: this.#identity.userId, + methods: SUPPORTED_VERIFICATION_METHOD_CODES, + }); + } + + async requestDeviceVerification(userId: string, deviceId: string): Promise { + return this.#startVerification('device.requestVerification', { + userId, + deviceId, + methods: SUPPORTED_VERIFICATION_METHOD_CODES, + }); + } + + + async getSessionBackupPrivateKey(): Promise { + const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; + if (!keys?.decryptionKeyBase64) return null; + return decodeBase64(keys.decryptionKeyBase64); + } + + async storeSessionBackupPrivateKey(key: Uint8Array, version: string): Promise { + await this.#call('saveBackupDecryptionKey', { decryptionKey: encodeBase64(key), version }); + } + + async loadSessionBackupPrivateKeyFromSecretStorage(): Promise { + const encoded = await this.#mx.secretStorage.get('m.megolm_backup.v1'); + if (!encoded) throw new Error('No session backup key in secret storage'); + + const backupInfo = await this.getKeyBackupInfo(); + if (!backupInfo?.version) throw new Error('No key backup version to attach the key to'); + + await this.storeSessionBackupPrivateKey(decodeBase64(encoded), backupInfo.version); + } + + async getActiveSessionBackupVersion(): Promise { + const enabled = (await this.#call('isBackupEnabled')) as boolean; + if (!enabled) return null; + const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; + return keys?.backupVersion ?? null; + } + + /** The engine reports signature trust only; whether our key opens it is separate. */ + async isKeyBackupTrusted(info: KeyBackupInfo): Promise { + const verification = (await this.#call('verifyBackup', { + backupInfo: JSON.stringify(info), + })) as { trusted?: boolean } | null; + + const stored = await this.getSessionBackupPrivateKey(); + const publicKey = (info.auth_data as { public_key?: string } | undefined)?.public_key; + let matchesDecryptionKey = false; + if (stored && publicKey) { + const key = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(stored)); + try { + matchesDecryptionKey = key.megolmV1PublicKey.publicKeyBase64 === publicKey; + } finally { + key.free(); + } + } + + return { trusted: verification?.trusted ?? false, matchesDecryptionKey }; + } + + async getKeyBackupInfo(): Promise { + try { + return await this.#mx.http.authedRequest( + Method.Get, + '/room_keys/version', + undefined, + undefined, + { prefix: ClientPrefix.V3 } + ); + } catch (error) { + if ((error as { errcode?: string }).errcode === 'M_NOT_FOUND') return null; + throw error; + } + } + + async checkKeyBackupAndEnable(): Promise { + const backupInfo = await this.getKeyBackupInfo(); + if (!backupInfo?.version) return null; + + const trustInfo = await this.isKeyBackupTrusted(backupInfo); + const authData = backupInfo.auth_data as { public_key?: string } | undefined; + if (trustInfo.trusted && authData?.public_key) { + await this.#call('enableBackupV1', { + publicKeyBase64: authData.public_key, + version: backupInfo.version, + }); + } + return { backupInfo, trustInfo }; + } + + async resetKeyBackup(): Promise { + const key = await this.createRecoveryKeyFromPassphrase(); + const publicKey = encodeBase64( + RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(key.privateKey)) + .megolmV1PublicKey.publicKeyBase64 as unknown as Uint8Array + ); + + const created = await this.#mx.http.authedRequest<{ version: string }>( + Method.Post, + '/room_keys/version', + undefined, + { + algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', + auth_data: { public_key: publicKey }, + }, + { prefix: ClientPrefix.V3 } + ); + + await this.#call('enableBackupV1', { publicKeyBase64: publicKey, version: created.version }); + await this.storeSessionBackupPrivateKey(key.privateKey, created.version); + await this.#mx.secretStorage.store('m.megolm_backup.v1', encodeBase64(key.privateKey)); + } + + async disableKeyStorage(): Promise { + const backupInfo = await this.getKeyBackupInfo(); + if (backupInfo?.version) await this.deleteKeyBackupVersion(backupInfo.version); + else await this.#call('disableBackup'); + } + + async deleteKeyBackupVersion(version: string): Promise { + await this.#mx.http.authedRequest( + Method.Delete, + encodeUri('/room_keys/version/$version', { $version: version }), + undefined, + undefined, + { prefix: ClientPrefix.V3 } + ); + await this.#call('disableBackup'); + } + + async restoreKeyBackup(opts?: KeyBackupRestoreOpts): Promise { + const keys = (await this.#call('getBackupKeys')) as EngineBackupKeys | null; + if (!keys?.decryptionKeyBase64 || !keys.backupVersion) { + throw new Error('No backup decryption key found in the crypto store'); + } + + const backupInfo = await this.getKeyBackupInfo(); + if (backupInfo?.version !== keys.backupVersion) { + throw new Error(`Backup version ${keys.backupVersion} is not the one on the server`); + } + + opts?.progressCallback?.({ stage: ImportRoomKeyStage.Fetch }); + + const decryptor = await this.getBackupDecryptor( + backupInfo, + decodeBase64(keys.decryptionKeyBase64) + ); + try { + const response = await this.#mx.http.authedRequest<{ + rooms: Record }>; + }>(Method.Get, '/room_keys/keys', { version: keys.backupVersion }, undefined, { + prefix: ClientPrefix.V3, + }); + + const sessions: KeyBackupSession[] = []; + const sessionIds: string[] = []; + for (const [roomId, room] of Object.entries(response.rooms ?? {})) { + for (const [sessionId, session] of Object.entries(room.sessions ?? {})) { + sessionIds.push(sessionId); + sessions.push({ ...session, room_id: roomId } as KeyBackupSession); + } + } + + const ciphertexts: Record = {}; + sessionIds.forEach((sessionId, index) => { + const session = sessions[index]; + if (session) ciphertexts[sessionId] = session; + }); + const decrypted = await decryptor.decryptSessions(ciphertexts); + const withRooms = decrypted.map((session, index) => ({ + ...session, + room_id: (sessions[index] as unknown as { room_id: string }).room_id, + })); + + await this.importBackedUpRoomKeys(withRooms, keys.backupVersion, opts); + return { total: withRooms.length, imported: withRooms.length }; + } finally { + decryptor.free(); + } + } + + async restoreKeyBackupWithPassphrase( + passphrase: string, + opts?: KeyBackupRestoreOpts + ): Promise { + const backupInfo = await this.getKeyBackupInfo(); + const passphraseInfo = backupInfo?.auth_data?.private_key_salt + ? backupInfo.auth_data + : undefined; + if (!passphraseInfo?.private_key_salt || !passphraseInfo.private_key_iterations) { + throw new Error('This backup was not created from a passphrase'); + } + + const privateKey = await deriveRecoveryKeyFromPassphrase( + passphrase, + passphraseInfo.private_key_salt, + passphraseInfo.private_key_iterations + ); + if (backupInfo?.version) await this.storeSessionBackupPrivateKey(privateKey, backupInfo.version); + return this.restoreKeyBackup(opts); + } + + + async isDehydrationSupported(): Promise { + return false; + } + + async startDehydration(opts?: StartDehydrationOpts | boolean): Promise { + // A dehydrated device is a second server-side device; Sable keeps one per session. + throw new Error( + `Device dehydration is not supported by the Sable crypto engine (opts: ${JSON.stringify(opts) ?? 'none'})` + ); + } +} diff --git a/src/app/crypto/engineCrypto/backupImport.test.ts b/src/app/crypto/engineCrypto/backupImport.test.ts new file mode 100644 index 0000000000..48b838de92 --- /dev/null +++ b/src/app/crypto/engineCrypto/backupImport.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ImportRoomKeyStage, type MatrixClient } from '$types/matrix-sdk'; +import type { IMegolmSessionData } from 'matrix-js-sdk/lib/@types/crypto'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(async () => ({ + importedCount: 0, + totalCount: 0, + })), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const crypto = () => + new EngineCrypto({} as MatrixClient, { userId: '@me:example.org', deviceId: 'DEVICE' }); + +const session = (id: string) => ({ session_id: id }) as IMegolmSessionData; + +describe('importBackedUpRoomKeys', () => { + beforeEach(() => mockInvoke.mockClear()); + + it('passes the backup version through alongside the keys', async () => { + await crypto().importBackedUpRoomKeys([session('a'), session('b')], '7'); + + const [, method, args] = mockInvoke.mock.calls[0] as [unknown, string, { backupVersion: string; keys: string }]; + expect(method).toBe('importBackedUpRoomKeys'); + expect(args.backupVersion).toBe('7'); + expect(JSON.parse(args.keys)).toHaveLength(2); + }); + + it('reports the counts the engine actually imported, not the counts requested', async () => { + mockInvoke.mockResolvedValueOnce({ importedCount: 1, totalCount: 2 }); + const progressCallback = vi.fn<(stage: unknown) => void>(); + + await crypto().importBackedUpRoomKeys([session('a'), session('b')], '7', { progressCallback }); + + expect(progressCallback).toHaveBeenCalledWith({ + stage: ImportRoomKeyStage.LoadKeys, + successes: 1, + failures: 1, + total: 2, + }); + }); + + it('falls back to the requested count when the engine reports nothing', async () => { + mockInvoke.mockResolvedValueOnce(null); + const progressCallback = vi.fn<(stage: unknown) => void>(); + + await crypto().importBackedUpRoomKeys([session('a')], '7', { progressCallback }); + + expect(progressCallback).toHaveBeenCalledWith( + expect.objectContaining({ successes: 0, failures: 1, total: 1 }) + ); + }); +}); diff --git a/src/app/crypto/engineCrypto/coverage.test.ts b/src/app/crypto/engineCrypto/coverage.test.ts new file mode 100644 index 0000000000..efb35d3bda --- /dev/null +++ b/src/app/crypto/engineCrypto/coverage.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { EngineCrypto } from './EngineCrypto'; + +const members = (): string[] => + Object.getOwnPropertyNames(EngineCrypto.prototype).filter((name) => name !== 'constructor'); + +describe('EngineCrypto surface', () => { + // Names one member per interface so the class cannot be hollowed out to satisfy tsc. + it.each([ + ['SyncCryptoCallbacks', 'preprocessToDeviceMessages'], + ['SyncCryptoCallbacks', 'onSyncCompleted'], + ['CryptoBackend', 'decryptEvent'], + ['CryptoBackend', 'encryptEvent'], + ['CryptoBackend', 'getBackupDecryptor'], + ['CryptoApi verification', 'requestDeviceVerification'], + ['CryptoApi trust', 'getDeviceVerificationStatus'], + ['CryptoApi backup', 'restoreKeyBackup'], + ['CryptoApi cross-signing', 'bootstrapCrossSigning'], + ['CryptoApi secret storage', 'bootstrapSecretStorage'], + ])('implements %s.%s', (_group, member) => { + expect(members()).toContain(member); + }); + + // js-sdk re-emits CryptoEvents off the backend; without this, prompts never arrive. + it('is an event emitter so js-sdk can re-emit crypto events', () => { + const crypto = new EngineCrypto({} as MatrixClient, { + userId: '@me:example.org', + deviceId: 'D', + }); + + expect(typeof crypto.on).toBe('function'); + expect(typeof crypto.emit).toBe('function'); + }); + + it('is the backend install.ts wires into the client', () => { + const install = readFileSync('src/app/crypto/install.ts', 'utf8'); + + expect(install).toMatch(/\bEngineCrypto\b/); + }); +}); diff --git a/src/app/crypto/engineCrypto/engineShapes.test.ts b/src/app/crypto/engineCrypto/engineShapes.test.ts new file mode 100644 index 0000000000..2ea1ba547c --- /dev/null +++ b/src/app/crypto/engineCrypto/engineShapes.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CrossSigningKey, type MatrixClient } from '$types/matrix-sdk'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const crypto = () => + new EngineCrypto({} as MatrixClient, { userId: '@me:example.org', deviceId: 'DEVICE' }); + +/** Mirrors the exact JSON the Rust side emits; a rename there breaks these loudly. */ +describe('engine payload shapes', () => { + beforeEach(() => mockInvoke.mockReset()); + + it('reads the backup key from decryptionKeyBase64, not decryptionKey', async () => { + mockInvoke.mockResolvedValue({ + className: 'BackupKeys', + backupVersion: '7', + decryptionKeyBase64: 'AAAA', + }); + + await expect(crypto().getSessionBackupPrivateKey()).resolves.not.toBeNull(); + await expect(crypto().getActiveSessionBackupVersion()).resolves.toBe('7'); + }); + + it('returns null when the engine holds no backup key', async () => { + mockInvoke.mockResolvedValue({ className: 'BackupKeys', backupVersion: null }); + + await expect(crypto().getSessionBackupPrivateKey()).resolves.toBeNull(); + }); + + // exportRoomKeys hands back JSON text, so it must be parsed, and the JSON variant + // must not stringify it a second time. + it('parses the room key export and does not re-encode the JSON variant', async () => { + const exported = JSON.stringify([{ session_id: 'a' }, { session_id: 'b' }]); + mockInvoke.mockResolvedValue(exported); + + await expect(crypto().exportRoomKeys()).resolves.toHaveLength(2); + await expect(crypto().exportRoomKeysAsJson()).resolves.toBe(exported); + }); + + it('keys cross-signing keys by the SDK enum and parses each JSON blob', async () => { + mockInvoke.mockResolvedValue({ + userId: '@me:example.org', + isVerified: true, + wasPreviouslyVerified: true, + masterKey: JSON.stringify({ keys: { 'ed25519:AAA': 'AAA' }, usage: ['master'] }), + selfSigningKey: JSON.stringify({ keys: { 'ed25519:BBB': 'BBB' }, usage: ['self_signing'] }), + userSigningKey: JSON.stringify({ keys: { 'ed25519:CCC': 'CCC' }, usage: ['user_signing'] }), + }); + + const keys = await crypto().getUserCrossSigningKeys('@me:example.org'); + + expect(Object.keys(keys ?? {})).toEqual([ + CrossSigningKey.Master, + CrossSigningKey.SelfSigning, + CrossSigningKey.UserSigning, + ]); + expect(keys?.[CrossSigningKey.Master]?.keys).toEqual({ 'ed25519:AAA': 'AAA' }); + }); + + it('resolves a cross-signing key id through that mapping', async () => { + mockInvoke.mockResolvedValue({ + userId: '@me:example.org', + isVerified: true, + wasPreviouslyVerified: true, + masterKey: JSON.stringify({ keys: { 'ed25519:AAA': 'AAA' }, usage: ['master'] }), + }); + + await expect(crypto().getCrossSigningKeyId()).resolves.toBe('AAA'); + }); + + it('reads own device keys from identityKeys', async () => { + mockInvoke.mockResolvedValue({ ed25519: 'ed', curve25519: 'curve' }); + + await expect(crypto().getOwnDeviceKeys()).resolves.toEqual({ + ed25519: 'ed', + curve25519: 'curve', + }); + }); + + it('reads cross-signing status from the hasX flags', async () => { + mockInvoke.mockResolvedValue({ + hasMaster: true, + hasSelfSigning: true, + hasUserSigning: false, + }); + + const status = await crypto().getCrossSigningStatus(); + expect(status.publicKeysOnDevice).toBe(false); + expect(status.privateKeysCachedLocally).toEqual({ + masterKey: true, + selfSigningKey: true, + userSigningKey: false, + }); + }); +}); diff --git a/src/app/crypto/engineCrypto/eventBridge.ts b/src/app/crypto/engineCrypto/eventBridge.ts new file mode 100644 index 0000000000..1f9090e0f9 --- /dev/null +++ b/src/app/crypto/engineCrypto/eventBridge.ts @@ -0,0 +1,38 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { EngineIdentity } from '../olmMachine/engineInvoke'; +import type { EngineCrypto } from './EngineCrypto'; + +const ROOM_KEYS_RECEIVED = 'matrix-crypto://room-keys-received'; +const IDENTITIES_UPDATED = 'matrix-crypto://identities-updated'; + +type Envelope = { account: string; payload: T }; + +/** Must be torn down with the client, or a re-login leaks a listener. */ +export const startCryptoEventBridge = async ( + crypto: EngineCrypto, + identity: EngineIdentity +): Promise => { + const account = `${identity.userId}|${identity.deviceId}`; + const forAccount = + (handle: (payload: T) => void) => + ({ payload: envelope }: { payload: Envelope }) => { + if (envelope.account !== account) return; + handle(envelope.payload); + }; + + const unlisten = await Promise.all([ + listen>( + ROOM_KEYS_RECEIVED, + forAccount(() => crypto.onKeysChanged()) + ), + listen>( + IDENTITIES_UPDATED, + forAccount<{ identities: string[]; devices: string[] }>(({ identities, devices }) => { + identities.forEach((userId) => crypto.onUserIdentityUpdated(userId)); + if (devices.length > 0) crypto.onDevicesUpdated(devices); + }) + ), + ]); + + return () => unlisten.forEach((stop) => stop()); +}; diff --git a/src/app/crypto/engineCrypto/keyBackupConnect.test.ts b/src/app/crypto/engineCrypto/keyBackupConnect.test.ts new file mode 100644 index 0000000000..2d744a1f3a --- /dev/null +++ b/src/app/crypto/engineCrypto/keyBackupConnect.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const BACKUP_INFO = { + version: '7', + algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2', + auth_data: { public_key: 'cHVibGlj' }, +}; + +const clientSpy = () => { + const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => BACKUP_INFO); + return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; +}; + +const settle = () => new Promise((resolve) => { + setTimeout(resolve, 0); +}); + +/** + * Nothing else calls checkKeyBackupAndEnable, so without this the engine never runs + * enableBackupV1 and the backup reads as disconnected on a fully verified device. + */ +describe('key backup connection', () => { + beforeEach(() => mockInvoke.mockReset()); + + it('enables a trusted backup on construction', async () => { + const { mx } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'verifyBackup') return { trusted: true }; + if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; + return null; + }); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + expect(crypto).toBeDefined(); + await settle(); + + const enabled = mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1'); + expect(enabled).toHaveLength(1); + expect(enabled[0]?.[2]).toMatchObject({ publicKeyBase64: 'cHVibGlj', version: '7' }); + }); + + it('leaves an untrusted backup alone', async () => { + const { mx } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'verifyBackup') return { trusted: false }; + if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; + return null; + }); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + expect(crypto).toBeDefined(); + await settle(); + + expect(mockInvoke.mock.calls.some(([, method]) => method === 'enableBackupV1')).toBe(false); + }); + + it('retries once our own identity becomes trusted', async () => { + const { mx } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'verifyBackup') return { trusted: true }; + if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; + return null; + }); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + await settle(); + crypto.onUserIdentityUpdated('@me:e.org'); + await settle(); + + expect( + mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1') + ).toHaveLength(2); + }); + + it('ignores another user becoming trusted', async () => { + const { mx } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'verifyBackup') return { trusted: true }; + if (method === 'getBackupKeys') return { backupVersion: null, decryptionKeyBase64: null }; + return null; + }); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + await settle(); + crypto.onUserIdentityUpdated('@them:e.org'); + await settle(); + + expect( + mockInvoke.mock.calls.filter(([, method]) => method === 'enableBackupV1') + ).toHaveLength(1); + }); +}); diff --git a/src/app/crypto/engineCrypto/outgoing.test.ts b/src/app/crypto/engineCrypto/outgoing.test.ts new file mode 100644 index 0000000000..7762dc05cd --- /dev/null +++ b/src/app/crypto/engineCrypto/outgoing.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { RequestType, sendOutgoingRequest } from './outgoing'; + +const clientReturning = (body: string) => { + const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => body); + return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; +}; + +describe('sendOutgoingRequest', () => { + /** + * `json: false` makes js-sdk return res.text(), so the response is already JSON text. + * Stringifying it again produced a double-encoded body that the engine could not parse, + * so it never cleared its queue and re-sent every request on every sync. + */ + it('returns the response body unchanged rather than re-encoding it', async () => { + const { mx } = clientReturning('{"one_time_key_counts":{"signed_curve25519":50}}'); + + const response = await sendOutgoingRequest(mx, { + id: 'req-1', + type: RequestType.KeysUpload, + body: '{"device_keys":{}}', + }); + + expect(response).toBe('{"one_time_key_counts":{"signed_curve25519":50}}'); + expect(JSON.parse(response)).toHaveProperty('one_time_key_counts'); + }); + + it('forwards the pre-signed body verbatim and does not let js-sdk re-serialise it', async () => { + const { mx, authedRequest } = clientReturning('{}'); + const body = '{"b":1,"a":2}'; + + await sendOutgoingRequest(mx, { id: 'r', type: RequestType.KeysQuery, body }); + + const [, , , sentBody, opts] = authedRequest.mock.calls[0] as unknown as [ + unknown, + unknown, + unknown, + string, + { json: boolean }, + ]; + expect(sentBody).toBe(body); + expect(opts.json).toBe(false); + }); + + it('routes each request type to its own endpoint', async () => { + const cases = [ + [RequestType.KeysUpload, '/_matrix/client/v3/keys/upload'], + [RequestType.KeysQuery, '/_matrix/client/v3/keys/query'], + [RequestType.KeysClaim, '/_matrix/client/v3/keys/claim'], + [RequestType.SignatureUpload, '/_matrix/client/v3/keys/signatures/upload'], + ] as const; + + await Promise.all( + cases.map(async ([type, expected]) => { + const { mx, authedRequest } = clientReturning('{}'); + await sendOutgoingRequest(mx, { id: 'r', type, body: '{}' }); + expect(authedRequest.mock.calls[0]?.[1]).toBe(expected); + }) + ); + }); + + it('puts to-device and room-message requests on their transaction-scoped paths', async () => { + const { mx, authedRequest } = clientReturning('{}'); + await sendOutgoingRequest(mx, { + id: 'r', + type: RequestType.ToDevice, + body: '{}', + event_type: 'm.key.verification.start', + txn_id: 'txn1', + }); + expect(authedRequest.mock.calls[0]?.[1]).toBe( + '/_matrix/client/v3/sendToDevice/m.key.verification.start/txn1' + ); + + const room = clientReturning('{}'); + await sendOutgoingRequest(room.mx, { + id: 'r', + type: RequestType.RoomMessage, + body: '{}', + room_id: '!r:e.org', + event_type: 'm.room.message', + txn_id: 'txn2', + }); + expect(room.authedRequest.mock.calls[0]?.[1]).toBe( + '/_matrix/client/v3/rooms/!r%3Ae.org/send/m.room.message/txn2' + ); + }); + + it('rejects a request type it cannot route instead of silently dropping it', async () => { + const { mx } = clientReturning('{}'); + await expect( + sendOutgoingRequest(mx, { id: 'r', type: 99, body: '{}' }) + ).rejects.toThrow('Unknown outgoing request type 99'); + }); +}); diff --git a/src/app/crypto/engineCrypto/outgoing.ts b/src/app/crypto/engineCrypto/outgoing.ts new file mode 100644 index 0000000000..71372e201b --- /dev/null +++ b/src/app/crypto/engineCrypto/outgoing.ts @@ -0,0 +1,75 @@ +import { Method } from 'matrix-js-sdk/lib/http-api'; +import type { MatrixClient } from '$types/matrix-sdk'; + +/** Numeric codes the engine tags outgoing requests with; see wasm_enums.rs. */ +export const RequestType = { + KeysUpload: 0, + KeysQuery: 1, + KeysClaim: 2, + ToDevice: 3, + SignatureUpload: 4, + RoomMessage: 5, + KeysBackup: 6, +} as const; + +export type OutgoingRequest = { + id: string; + type: number; + body: string; + event_type?: string; + txn_id?: string; + room_id?: string; + version?: string; +}; + +const path = { + keysUpload: '/_matrix/client/v3/keys/upload', + keysQuery: '/_matrix/client/v3/keys/query', + keysClaim: '/_matrix/client/v3/keys/claim', + signatures: '/_matrix/client/v3/keys/signatures/upload', + keysBackup: '/_matrix/client/v3/room_keys/keys', +} as const; + +/** + * `body` goes verbatim: these bodies are signed and re-serialising reorders keys. + * `json: false` also makes js-sdk return `res.text()`, so the response is already the + * JSON string `markRequestAsSent` wants — encoding it again wedges the engine's queue. + */ +export const sendOutgoingRequest = async ( + mx: MatrixClient, + request: OutgoingRequest +): Promise => { + const send = (method: Method, url: string, params: Record = {}) => + mx.http.authedRequest(method, url, params, request.body, { + prefix: '', + json: false, + headers: { 'Content-Type': 'application/json' }, + }); + + switch (request.type) { + case RequestType.KeysUpload: + return send(Method.Post, path.keysUpload); + case RequestType.KeysQuery: + return send(Method.Post, path.keysQuery); + case RequestType.KeysClaim: + return send(Method.Post, path.keysClaim); + case RequestType.SignatureUpload: + return send(Method.Post, path.signatures); + case RequestType.KeysBackup: + return send(Method.Put, path.keysBackup, { version: request.version ?? '' }); + case RequestType.ToDevice: { + const url = + `/_matrix/client/v3/sendToDevice/${encodeURIComponent(request.event_type ?? '')}` + + `/${encodeURIComponent(request.txn_id ?? '')}`; + return send(Method.Put, url); + } + case RequestType.RoomMessage: { + const url = + `/_matrix/client/v3/rooms/${encodeURIComponent(request.room_id ?? '')}/send` + + `/${encodeURIComponent(request.event_type ?? '')}/${encodeURIComponent(request.txn_id ?? '')}`; + return send(Method.Put, url); + } + default: + throw new Error(`Unknown outgoing request type ${request.type}`); + } +}; diff --git a/src/app/crypto/engineCrypto/outgoingDispatch.test.ts b/src/app/crypto/engineCrypto/outgoingDispatch.test.ts new file mode 100644 index 0000000000..733c81876d --- /dev/null +++ b/src/app/crypto/engineCrypto/outgoingDispatch.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const clientSpy = () => { + const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => '{}'); + return { mx: { http: { authedRequest } } as unknown as MatrixClient, authedRequest }; +}; + +/** Unsent, the peer never receives the ready/accept and nothing reports an error. */ +describe('verification outgoing requests', () => { + beforeEach(() => mockInvoke.mockReset()); + + it('sends the request that starting a device verification returns', async () => { + const { mx, authedRequest } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'device.requestVerification') { + return { + request: { flowId: '$f', otherUserId: '@them:e.org', phase: 1 }, + outgoingRequest: { + id: 'txn', + type: 3, + body: '{"messages":{}}', + event_type: 'm.key.verification.request', + txn_id: 'txn', + }, + }; + } + return []; + }); + + await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).requestDeviceVerification( + '@them:e.org', + 'THEIRS' + ); + + const sent = authedRequest.mock.calls.map((call) => call[1] as unknown as string); + expect(sent).toContain('/_matrix/client/v3/sendToDevice/m.key.verification.request/txn'); + }); + + /** + * sas.confirm answers with the MAC plus a signature upload. Sending only one leaves the + * peer waiting after both sides pressed "they match", with no error either side. + */ + it('sends every request when the engine answers with several', async () => { + const { mx, authedRequest } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'device.requestVerification') { + return { + request: { flowId: '$f', otherUserId: '@them:e.org', phase: 3, verification: { className: 'Sas', decimals: [1, 2, 3] } }, + }; + } + if (method === 'sas.confirm') { + return [ + { id: 'a', type: 3, body: '{}', event_type: 'm.key.verification.mac', txn_id: 'a' }, + { id: null, type: 4, body: '{}' }, + ]; + } + return []; + }); + + const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }); + const request = await crypto.requestDeviceVerification('@them:e.org', 'THEIRS'); + await request.verifier?.getShowSasCallbacks()?.confirm(); + + const sent = authedRequest.mock.calls.map((call) => call[1] as unknown as string); + expect(sent).toContain('/_matrix/client/v3/sendToDevice/m.key.verification.mac/a'); + expect(sent).toContain('/_matrix/client/v3/keys/signatures/upload'); + }); + + it('does not mistake a state snapshot for an outgoing request', async () => { + const { mx, authedRequest } = clientSpy(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'device.requestVerification') { + return { request: { flowId: '$f', otherUserId: '@them:e.org', phase: 1 } }; + } + return []; + }); + + await new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' }).requestDeviceVerification( + '@them:e.org', + 'THEIRS' + ); + + // The constructor probes the key backup; only verification traffic matters here. + const verificationCalls = authedRequest.mock.calls.filter( + ([, url]) => !String(url).startsWith('/room_keys/') + ); + expect(verificationCalls).toEqual([]); + }); +}); diff --git a/src/app/crypto/engineCrypto/shape.test.ts b/src/app/crypto/engineCrypto/shape.test.ts new file mode 100644 index 0000000000..e8f30de958 --- /dev/null +++ b/src/app/crypto/engineCrypto/shape.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { EventShieldColour, EventShieldReason } from '$types/matrix-sdk'; +import { toEventEncryptionInfo } from './EngineCrypto'; + +/** Engine payloads are cast from JSON, so only these pin the numeric encodings. */ +describe('toEventEncryptionInfo', () => { + it('maps engine colours onto the SDK enum', () => { + expect(toEventEncryptionInfo({ shieldStateLax: { color: 0 } })?.shieldColour).toBe( + EventShieldColour.RED + ); + expect(toEventEncryptionInfo({ shieldStateLax: { color: 1 } })?.shieldColour).toBe( + EventShieldColour.GREY + ); + expect(toEventEncryptionInfo({ shieldStateLax: { color: 2 } })?.shieldColour).toBe( + EventShieldColour.NONE + ); + }); + + it('maps every engine shield code onto the SDK reason', () => { + const expected: [number, EventShieldReason][] = [ + [0, EventShieldReason.AUTHENTICITY_NOT_GUARANTEED], + [1, EventShieldReason.UNKNOWN_DEVICE], + [2, EventShieldReason.UNSIGNED_DEVICE], + [3, EventShieldReason.UNVERIFIED_IDENTITY], + [4, EventShieldReason.VERIFICATION_VIOLATION], + [5, EventShieldReason.MISMATCHED_SENDER], + ]; + + expected.forEach(([code, reason]) => { + expect(toEventEncryptionInfo({ shieldStateLax: { color: 0, code } })?.shieldReason).toBe( + reason + ); + }); + }); + + it('has no reason when the engine sends none', () => { + expect(toEventEncryptionInfo({ shieldStateLax: { color: 2, code: null } })?.shieldReason).toBe( + null + ); + expect(toEventEncryptionInfo({ shieldStateLax: { color: 2 } })?.shieldReason).toBe(null); + }); + + // Fail safe: an unrecognised code must not silently read as "no warning". + it('falls back to a warning rather than clearing the shield', () => { + const unknown = toEventEncryptionInfo({ shieldStateLax: { color: 99, code: 99 } }); + expect(unknown?.shieldColour).toBe(EventShieldColour.RED); + expect(unknown?.shieldReason).toBe(EventShieldReason.UNKNOWN); + }); + + it('returns null when the engine has no info', () => { + expect(toEventEncryptionInfo(null)).toBeNull(); + expect(toEventEncryptionInfo({})).toBeNull(); + }); +}); diff --git a/src/app/crypto/install.test.ts b/src/app/crypto/install.test.ts new file mode 100644 index 0000000000..d72182da13 --- /dev/null +++ b/src/app/crypto/install.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isTauri } from '@tauri-apps/api/core'; +import { EventEmitter } from 'events'; +import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; +import { LegacyWasmCryptoStoreError, reEmitCryptoEvents, rustEngineEnabled } from './install'; + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn<() => boolean>() })); + +vi.mock('$generated/tauri/commands', () => ({ + engineOpen: vi.fn<(...args: never[]) => unknown>(), +})); + +const mockIsTauri = vi.mocked(isTauri); + +describe('rustEngineEnabled', () => { + beforeEach(() => { + mockIsTauri.mockReset(); + }); + + it('keeps WASM crypto for non-Tauri clients', async () => { + mockIsTauri.mockReturnValue(false); + + await expect(rustEngineEnabled('sync@alice:example.org')).resolves.toBe(false); + }); + + it('enables the native engine when no legacy crypto store exists', async () => { + mockIsTauri.mockReturnValue(true); + const databases = vi.fn<() => Promise>().mockResolvedValue([]); + vi.stubGlobal('indexedDB', { databases }); + + await expect(rustEngineEnabled('sync@alice:example.org')).resolves.toBe(true); + expect(databases).toHaveBeenCalledOnce(); + }); + + it('requires re-authentication instead of retaining a legacy WASM engine', async () => { + mockIsTauri.mockReturnValue(true); + vi.stubGlobal('indexedDB', { + databases: vi + .fn<() => Promise>() + .mockResolvedValue([{ name: 'sync@alice:example.org::matrix-sdk-crypto' }]), + }); + + await expect(rustEngineEnabled('sync@alice:example.org')).rejects.toBeInstanceOf( + LegacyWasmCryptoStoreError + ); + }); + + it('requires re-authentication when the legacy store cannot be inspected safely', async () => { + mockIsTauri.mockReturnValue(true); + vi.stubGlobal('indexedDB', {}); + + await expect(rustEngineEnabled('sync@alice:example.org')).rejects.toBeInstanceOf( + LegacyWasmCryptoStoreError + ); + }); +}); + +describe('reEmitCryptoEvents', () => { + it('forwards SDK crypto events to MatrixClient and detaches them on stop', () => { + const mx = new EventEmitter(); + const rustCrypto = new EventEmitter(); + const listener = vi.fn<(request: unknown) => void>(); + mx.on(CryptoEvent.VerificationRequestReceived, listener); + + const stop = reEmitCryptoEvents(mx as never, rustCrypto as never); + const request = { transactionId: 'verification-request' }; + rustCrypto.emit(CryptoEvent.VerificationRequestReceived, request); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(request, rustCrypto); + + stop(); + rustCrypto.emit(CryptoEvent.VerificationRequestReceived, request); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/crypto/install.ts b/src/app/crypto/install.ts new file mode 100644 index 0000000000..f76366f54f --- /dev/null +++ b/src/app/crypto/install.ts @@ -0,0 +1,143 @@ +import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api'; +import { ReEmitter } from 'matrix-js-sdk/lib/ReEmitter'; +import { isTauri } from '@tauri-apps/api/core'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; +import { engineOpen } from '$generated/tauri/commands'; +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; +import { engineInvoke } from './olmMachine/engineInvoke'; +import { EngineCrypto } from './engineCrypto/EngineCrypto'; +import { startCryptoEventBridge } from './engineCrypto/eventBridge'; + +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: EngineCrypto; +}; + +const MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE = 24 * 60 * 60 * 1000; + +const REEMITTED_CRYPTO_EVENTS = [ + CryptoEvent.VerificationRequestReceived, + CryptoEvent.UserTrustStatusChanged, + CryptoEvent.KeyBackupStatus, + CryptoEvent.KeyBackupSessionsRemaining, + CryptoEvent.KeyBackupFailed, + CryptoEvent.KeyBackupDecryptionKeyCached, + CryptoEvent.KeysChanged, + CryptoEvent.DevicesUpdated, + CryptoEvent.WillUpdateDevices, + CryptoEvent.DehydratedDeviceCreated, + CryptoEvent.DehydratedDeviceUploaded, + CryptoEvent.RehydrationStarted, + CryptoEvent.RehydrationProgress, + CryptoEvent.RehydrationCompleted, + CryptoEvent.RehydrationError, + CryptoEvent.DehydrationKeyCached, + CryptoEvent.DehydratedDeviceRotationError, +]; + +export const reEmitCryptoEvents = (mx: MatrixClient, crypto: EngineCrypto): (() => void) => { + const reEmitter = new ReEmitter(mx); + reEmitter.reEmit(crypto, REEMITTED_CRYPTO_EVENTS); + return () => reEmitter.stopReEmitting(crypto, REEMITTED_CRYPTO_EVENTS); +}; + +export const installRustCrypto = async ( + mx: MatrixClient, + options: { storeDir?: string; passphrase?: string } = {} +): Promise => { + // getBackupDecryptor uses the wasm primitive in-process, so the module must be ready. + await RustSdkCryptoJs.initAsync(); + + const userId = mx.getUserId(); + const deviceId = mx.getDeviceId(); + if (!userId || !deviceId) { + throw new Error('Cannot install the Rust crypto engine before the session has an identity'); + } + + await engineOpen({ + dir: options.storeDir ?? null, + passphrase: options.passphrase ?? null, + userId, + deviceId, + }); + + const identity = { userId, deviceId }; + const engineCrypto = new EngineCrypto(mx, identity); + + // `MatrixClient.initRustCrypto` normally wires these events to the client. The native + // engine is installed independently, so reproduce that SDK initialization step here. + const stopReEmittingCryptoEvents = reEmitCryptoEvents(mx, engineCrypto); + const stopEventBridge = await startCryptoEventBridge(engineCrypto, identity); + + const stopEngineCrypto = engineCrypto.stop.bind(engineCrypto); + engineCrypto.stop = () => { + stopReEmittingCryptoEvents(); + stopEventBridge(); + stopEngineCrypto(); + }; + + await acceptPendingKeyBundles(engineCrypto, identity); + + (mx as unknown as { cryptoBackend?: unknown }).cryptoBackend = engineCrypto; + cryptoLog.info('general', 'Installed the Rust IPC crypto engine', { userId, deviceId }); + + return { rustCrypto: engineCrypto }; +}; + +/** + * MSC4268: invites accepted before the key bundle arrived are marked pending by the + * engine. Import what is still in the window and forget the rest, or the list grows + * forever. + */ +const acceptPendingKeyBundles = async ( + crypto: EngineCrypto, + identity: { userId: string; deviceId: string } +): Promise => { + const pending = ((await engineInvoke(identity, 'getAllRoomsPendingKeyBundles')) ?? []) as { + roomId: string; + inviterId: string; + inviteAcceptedAtMillis: number; + }[]; + + for (const { roomId, inviterId, inviteAcceptedAtMillis } of pending) { + if (Date.now() - inviteAcceptedAtMillis <= MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE) { + // eslint-disable-next-line no-await-in-loop + await crypto.maybeAcceptKeyBundle(roomId, inviterId); + } else { + // eslint-disable-next-line no-await-in-loop + await engineInvoke(identity, '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/pushDecrypt.test.ts b/src/app/crypto/pushDecrypt.test.ts new file mode 100644 index 0000000000..cb578915f2 --- /dev/null +++ b/src/app/crypto/pushDecrypt.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type DecryptPushParams = { + userId: string; + deviceId: string; + roomId: string; + eventJson: string; + passphrase: string | null; +}; + +const engineDecryptPush = vi.fn<(params: DecryptPushParams) => Promise>(); +const isTauri = vi.fn<() => boolean>(() => true); + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => isTauri() })); +vi.mock('$generated/tauri/commands', () => ({ + engineDecryptPush: (params: DecryptPushParams) => engineDecryptPush(params), +})); + +const { decryptPushEventNatively } = await import('./pushDecrypt'); + +const event = { + roomId: '!room:example.org', + eventId: '$event:example.org', + sender: '@sender:example.org', + content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AAAA' }, +}; + +describe('decryptPushEventNatively', () => { + beforeEach(() => { + vi.clearAllMocks(); + isTauri.mockReturnValue(true); + }); + + it('returns the plaintext content and type from the Rust engine', async () => { + engineDecryptPush.mockResolvedValue({ + event_type: 'm.room.message', + sender: '@real:example.org', + body: 'hello', + clear_event: JSON.stringify({ + type: 'm.room.message', + sender: '@real:example.org', + content: { msgtype: 'm.text', body: 'hello' }, + }), + }); + + await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toEqual({ + eventType: 'm.room.message', + content: { msgtype: 'm.text', body: 'hello' }, + sender: '@real:example.org', + }); + }); + + it('passes the payload as a reconstructed m.room.encrypted event', async () => { + engineDecryptPush.mockResolvedValue({ + event_type: 'm.room.message', + clear_event: JSON.stringify({ content: { body: 'hi' } }), + }); + + await decryptPushEventNatively('@me:example.org', 'DEVICE', event); + + const params = engineDecryptPush.mock.calls[0]?.[0]; + if (!params) throw new Error('the engine command was never called'); + expect(params).toMatchObject({ + userId: '@me:example.org', + deviceId: 'DEVICE', + roomId: '!room:example.org', + }); + expect(JSON.parse(params.eventJson)).toMatchObject({ + type: 'm.room.encrypted', + room_id: '!room:example.org', + event_id: '$event:example.org', + sender: '@sender:example.org', + content: event.content, + }); + }); + + // Each of these is a case the js-sdk fallback must still get a chance to retry. + it('returns null when the Megolm key has not arrived, so the caller can fall back', async () => { + engineDecryptPush.mockRejectedValue(new Error('decrypting push event failed: UnknownMessageIndex')); + + await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); + }); + + it('returns null rather than throwing when isTauri itself throws', async () => { + isTauri.mockImplementation(() => { + throw new Error('not in a tauri context'); + }); + + await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); + expect(engineDecryptPush).not.toHaveBeenCalled(); + }); + + it('returns null off Tauri without calling the engine', async () => { + isTauri.mockReturnValue(false); + + await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); + expect(engineDecryptPush).not.toHaveBeenCalled(); + }); + + it('returns null without a session identity', async () => { + await expect(decryptPushEventNatively(null, 'DEVICE', event)).resolves.toBeNull(); + await expect(decryptPushEventNatively('@me:example.org', null, event)).resolves.toBeNull(); + expect(engineDecryptPush).not.toHaveBeenCalled(); + }); + + it('returns null when the clear event carries no content', async () => { + engineDecryptPush.mockResolvedValue({ + event_type: 'm.room.message', + clear_event: JSON.stringify({ type: 'm.room.message' }), + }); + + await expect(decryptPushEventNatively('@me:example.org', 'DEVICE', event)).resolves.toBeNull(); + }); +}); diff --git a/src/app/crypto/pushDecrypt.ts b/src/app/crypto/pushDecrypt.ts new file mode 100644 index 0000000000..8597a12b78 --- /dev/null +++ b/src/app/crypto/pushDecrypt.ts @@ -0,0 +1,70 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { engineDecryptPush } from '$generated/tauri/commands'; +import type { IContent } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; + +const pushDecryptLog = createDebugLogger('push-decrypt'); + +export type DecryptedPushEvent = { + eventType: string; + content: IContent; + sender?: string; +}; + +export type EncryptedPushEvent = { + roomId: string; + eventId: string; + sender?: string; + content: IContent; +}; + +/** + * Decrypts a push payload straight through the Rust `OlmMachine`. Null when the engine + * cannot answer — usually a late Megolm key — and the caller falls back to the js-sdk + * path, which retries as keys land. + */ +export const decryptPushEventNatively = async ( + userId: string | null, + deviceId: string | null, + event: EncryptedPushEvent +): Promise => { + if (!userId || !deviceId) return null; + + try { + if (!isTauri()) return null; + + const decrypted = await engineDecryptPush({ + userId, + deviceId, + roomId: event.roomId, + eventJson: JSON.stringify({ + type: 'm.room.encrypted', + content: event.content, + room_id: event.roomId, + event_id: event.eventId, + sender: event.sender, + origin_server_ts: Date.now(), + }), + passphrase: null, + }); + + // `engine_decrypt_push` reports snake_case, and hands the clear event over as JSON text. + const clearEvent = JSON.parse(decrypted.clear_event) as { content?: IContent }; + const content = clearEvent?.content; + if (!decrypted.event_type || !content) return null; + + return { + eventType: decrypted.event_type, + content, + sender: decrypted.sender ?? event.sender, + }; + } catch (error) { + // Expected while the to-device key is still in flight, so not a warning. + pushDecryptLog.info( + 'notification', + 'Native push decryption unavailable, falling back to the js-sdk path', + error + ); + return null; + } +}; diff --git a/src/app/crypto/verification/qr.test.ts b/src/app/crypto/verification/qr.test.ts new file mode 100644 index 0000000000..4ad5136501 --- /dev/null +++ b/src/app/crypto/verification/qr.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { encodeBase64 } from 'matrix-js-sdk/lib/base64'; +import { VerificationPhase } from '$types/matrix-sdk'; +import { EngineVerificationRequest } from './request'; +import { EnginePhase, type EngineVerificationState } from './state'; +import { EngineQrVerifier } from './verifier'; + +const state = (patch: Partial = {}): EngineVerificationState => ({ + ownUserId: '@me:e.org', + otherUserId: '@them:e.org', + otherDeviceId: 'THEIRS', + flowId: '$f', + roomId: null, + phase: EnginePhase.Ready, + weStarted: false, + isSelfVerification: true, + isPassive: false, + isReady: true, + isDone: false, + isCancelled: false, + timedOut: false, + timeRemainingMillis: 600000, + theirSupportedMethods: [1, 2, 3], + ourSupportedMethods: [1, 2, 3], + cancelInfo: null, + verification: null, + ...patch, +}); + +describe('QR: showing our code', () => { + // The payload is base64, not a byte array; reading it as an array yields a dead code. + it('decodes the base64 payload the engine returns', async () => { + const bytes = new Uint8Array([1, 2, 3, 250]); + const call = vi.fn<(m: string) => Promise>(async (method) => + method === 'verificationRequest.generateQrCode' + ? { className: 'Qr', qrCodeBytes: encodeBase64(bytes) } + : state() + ); + + const qr = await new EngineVerificationRequest(call, state()).generateQRCode(); + + expect(qr).toBeInstanceOf(Uint8ClampedArray); + expect(Array.from(qr ?? [])).toEqual([1, 2, 3, 250]); + }); + + it('returns undefined when the engine cannot produce a code', async () => { + const call = vi.fn<() => Promise>(async () => null); + + await expect(new EngineVerificationRequest(call, state()).generateQRCode()).resolves.toBeUndefined(); + }); +}); + +describe('QR: scanning their code', () => { + const scanning = () => { + const calls: { method: string; args?: Record }[] = []; + const call = vi.fn<(m: string, a?: Record) => Promise>( + async (method, args) => { + calls.push({ method, args }); + if (method === 'verificationRequest.state') { + return state({ + phase: EnginePhase.Transitioned, + verification: { className: 'Qr' }, + }); + } + return { className: 'Qr' }; + } + ); + return { call, calls }; + }; + + it('sends the scanned payload as base64', async () => { + const { call, calls } = scanning(); + await new EngineVerificationRequest(call, state()).scanQRCode( + new Uint8ClampedArray([9, 8, 7]) + ); + + const scan = calls.find((c) => c.method === 'verificationRequest.scanQrCode'); + expect(scan?.args?.qrCodeData).toBe(encodeBase64(new Uint8Array([9, 8, 7]))); + }); + + // Scanning alone tells them nothing; without this our side thinks it succeeded. + it('reciprocates after scanning', async () => { + const { call, calls } = scanning(); + const verifier = await new EngineVerificationRequest(call, state()).scanQRCode( + new Uint8ClampedArray([1]) + ); + + expect(calls.map((c) => c.method)).toContain('qr.reciprocate'); + expect(verifier).toBeInstanceOf(EngineQrVerifier); + }); +}); + +describe('QR verifier phase', () => { + it('maps the engine state ordinals as js-sdk does', () => { + const call = vi.fn<() => Promise>(async () => null); + const phases: [number, VerificationPhase][] = [ + [0, VerificationPhase.Ready], + [1, VerificationPhase.Started], + [2, VerificationPhase.Started], + [3, VerificationPhase.Started], + [4, VerificationPhase.Done], + [5, VerificationPhase.Cancelled], + ]; + + phases.forEach(([code, phase]) => { + const verifier = new EngineQrVerifier(call, { userId: '@t:e', flowId: '$f' }, {}, '@t:e'); + verifier.onChange({ state: code }); + expect(verifier.verificationPhase).toBe(phase); + }); + }); +}); diff --git a/src/app/crypto/verification/request.test.ts b/src/app/crypto/verification/request.test.ts new file mode 100644 index 0000000000..b200929123 --- /dev/null +++ b/src/app/crypto/verification/request.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { VerificationMethod, VerificationPhase } from '$types/matrix-sdk'; +import { EngineVerificationRequest } from './request'; +import { EnginePhase, type EngineVerificationState } from './state'; + +const state = (patch: Partial = {}): EngineVerificationState => ({ + ownUserId: '@me:example.org', + otherUserId: '@them:example.org', + otherDeviceId: 'THEIRS', + flowId: '$flow', + roomId: null, + phase: EnginePhase.Requested, + weStarted: false, + isSelfVerification: false, + isPassive: false, + isReady: false, + isDone: false, + isCancelled: false, + timedOut: false, + timeRemainingMillis: 600000, + theirSupportedMethods: [0], + ourSupportedMethods: [0], + cancelInfo: null, + verification: null, + ...patch, +}); + +describe('EngineVerificationRequest', () => { + it('accepts through the engine with our supported methods and refreshes', async () => { + const call = vi.fn<(m: string, a?: Record) => Promise>( + async (method) => (method === 'verificationRequest.state' ? state({ phase: EnginePhase.Ready }) : null) + ); + const request = new EngineVerificationRequest(call, state()); + + await request.accept(); + + expect(call).toHaveBeenCalledWith('verificationRequest.accept', { + userId: '@them:example.org', + flowId: '$flow', + methods: [0], + }); + expect(request.phase).toBe(VerificationPhase.Ready); + }); + + // js-sdk throws rather than silently doing nothing, and callers rely on that. + it('refuses to accept outside the Requested phase', async () => { + const call = vi.fn<() => Promise>(async () => null); + const request = new EngineVerificationRequest(call, state({ phase: EnginePhase.Ready })); + + await expect(request.accept()).rejects.toThrow('Cannot accept a verification request'); + expect(call).not.toHaveBeenCalled(); + }); + + it('refuses to start a method other than SAS', async () => { + const call = vi.fn<() => Promise>(async () => null); + const request = new EngineVerificationRequest(call, state()); + + await expect(request.startVerification(VerificationMethod.ShowQrCode)).rejects.toThrow( + 'Unsupported verification method' + ); + expect(call).not.toHaveBeenCalled(); + }); + + // js-sdk builds its verifier only on change, so one first seen here has none. + it('builds a verifier for a request that is already transitioned', () => { + const call = vi.fn<() => Promise>(async () => null); + const request = new EngineVerificationRequest( + call, + state({ phase: EnginePhase.Transitioned, verification: { className: 'Sas' } }) + ); + + expect(request.verifier).toBeDefined(); + expect(request.phase).toBe(VerificationPhase.Started); + }); + + it('reports the flow identity the app displays', () => { + const call = vi.fn<() => Promise>(async () => null); + const request = new EngineVerificationRequest(call, state({ roomId: '!r:e.org', weStarted: true })); + + expect(request.transactionId).toBe('$flow'); + expect(request.roomId).toBe('!r:e.org'); + expect(request.initiatedByMe).toBe(true); + expect(request.otherUserId).toBe('@them:example.org'); + expect(request.otherDeviceId).toBe('THEIRS'); + expect(request.pending).toBe(true); + expect(request.otherPartySupportsMethod(VerificationMethod.Sas)).toBe(true); + }); +}); diff --git a/src/app/crypto/verification/request.ts b/src/app/crypto/verification/request.ts new file mode 100644 index 0000000000..06b12bd8c7 --- /dev/null +++ b/src/app/crypto/verification/request.ts @@ -0,0 +1,246 @@ +import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; +import { decodeBase64, encodeBase64 } from 'matrix-js-sdk/lib/base64'; +import { + VerificationMethod, + VerificationRequestEvent, + type VerificationPhase, + type VerificationRequest, + type VerificationRequestEventHandlerMap, + type Verifier, +} from '$types/matrix-sdk'; +import { + cancellingUserId, + chosenMethod, + EnginePhase, + isPending, + methodsFromCodes, + otherPartySupportsMethod, + toVerificationPhase, + type EngineVerificationState, +} from './state'; +import { + EngineQrVerifier, + EngineSasVerifier, + type EngineCall, + type QrState, + type SasState, +} from './verifier'; + +/** The interface's getters are synchronous, so state is held as a refreshed snapshot. */ +export class EngineVerificationRequest + extends TypedEventEmitter + implements VerificationRequest +{ + readonly #call: EngineCall; + + #state: EngineVerificationState; + + #verifier: EngineSasVerifier | EngineQrVerifier | undefined; + + #accepting = false; + + #declining = false; + + constructor(call: EngineCall, state: EngineVerificationState) { + super(); + this.#call = call; + this.#state = state; + // js-sdk builds its verifier only on change, so an already-transitioned request has + // none and its `phase` throws. + this.#syncVerifier(); + } + + get #flow(): { userId: string; flowId: string } { + return { userId: this.#state.otherUserId, flowId: this.#state.flowId }; + } + + #syncVerifier(): void { + const verification = this.#state.verification; + if (!verification) { + this.#verifier = undefined; + return; + } + + if (!this.#verifier) { + if (verification.className === 'Sas') { + this.#verifier = new EngineSasVerifier( + this.#call, + this.#flow, + verification as SasState, + this.#state.otherUserId + ); + } else if (verification.className === 'Qr') { + this.#verifier = new EngineQrVerifier( + this.#call, + this.#flow, + verification as QrState, + this.#state.otherUserId + ); + } + } + + // Every snapshot, including the first: the verifier emits ShowSas off these. + if (this.#verifier instanceof EngineSasVerifier) { + this.#verifier.onChange(verification as SasState); + } else if (this.#verifier instanceof EngineQrVerifier) { + this.#verifier.onChange(verification as QrState); + } + } + + async refresh(): Promise { + const next = (await this.#call('verificationRequest.state', this.#flow)) as + | EngineVerificationState + | null; + if (!next) return; + + this.#state = next; + this.#syncVerifier(); + this.emit(VerificationRequestEvent.Change); + } + + apply(state: EngineVerificationState): void { + this.#state = state; + this.#syncVerifier(); + this.emit(VerificationRequestEvent.Change); + } + + get transactionId(): string | undefined { + return this.#state.flowId; + } + + get roomId(): string | undefined { + return this.#state.roomId ?? undefined; + } + + get initiatedByMe(): boolean { + return this.#state.weStarted; + } + + get otherUserId(): string { + return this.#state.otherUserId; + } + + get otherDeviceId(): string | undefined { + return this.#state.otherDeviceId ?? undefined; + } + + get isSelfVerification(): boolean { + return this.#state.isSelfVerification; + } + + get phase(): VerificationPhase { + return toVerificationPhase(this.#state, { + accepting: this.#accepting, + startedPhase: this.#verifier?.verificationPhase, + }); + } + + get pending(): boolean { + return isPending(this.#state, this.phase); + } + + get accepting(): boolean { + return this.#accepting; + } + + get declining(): boolean { + return this.#declining; + } + + get timeout(): number | null { + return this.#state.timeRemainingMillis; + } + + get methods(): string[] { + return methodsFromCodes(this.#state.theirSupportedMethods); + } + + get chosenMethod(): string | null { + return chosenMethod(this.#state, this.phase); + } + + get verifier(): Verifier | undefined { + return this.#verifier; + } + + get cancellationCode(): string | null { + return this.#state.cancelInfo?.cancelCode ?? null; + } + + get cancellingUserId(): string | undefined { + return cancellingUserId(this.#state); + } + + otherPartySupportsMethod(method: string): boolean { + return otherPartySupportsMethod(this.#state, method); + } + + async accept(): Promise { + // Matches js-sdk: accepting outside Requested is a caller error, not a no-op. + if (this.#state.phase !== EnginePhase.Requested || this.#accepting) { + throw new Error(`Cannot accept a verification request in phase ${this.phase}`); + } + this.#accepting = true; + try { + await this.#call('verificationRequest.accept', { + ...this.#flow, + methods: this.#state.ourSupportedMethods ?? undefined, + }); + await this.refresh(); + } finally { + this.#accepting = false; + } + } + + async cancel(params?: { reason?: string; code?: string }): Promise { + this.#declining = true; + try { + await this.#call('verificationRequest.cancel', { ...this.#flow, ...params }); + await this.refresh(); + } finally { + this.#declining = false; + } + } + + async startVerification(method: string): Promise { + // Only SAS can be started this way; QR is entered through scanQRCode. + if (method !== VerificationMethod.Sas) { + throw new Error(`Unsupported verification method ${method}`); + } + + await this.#call('verificationRequest.startSas', this.#flow); + await this.refresh(); + + if (!this.#verifier) throw new Error(`Starting ${method} produced no verifier`); + return this.#verifier; + } + + async scanQRCode(qrCodeData: Uint8ClampedArray): Promise { + await this.#call('verificationRequest.scanQrCode', { + ...this.#flow, + // The engine decodes base64; a number array is rejected as a missing string. + qrCodeData: encodeBase64(new Uint8Array(qrCodeData)), + }); + await this.refresh(); + + if (!(this.#verifier instanceof EngineQrVerifier)) { + throw new Error('Scanning the QR code produced no verifier'); + } + + // Scanning alone tells them nothing; reciprocating is what they see. + await this.#verifier.reciprocate(); + await this.refresh(); + return this.#verifier; + } + + async generateQRCode(): Promise { + // Returns the QR verification state, with the payload as base64 — not a byte array. + const qr = (await this.#call('verificationRequest.generateQrCode', this.#flow)) as + | { qrCodeBytes?: string | null } + | null; + if (!qr?.qrCodeBytes) return undefined; + + await this.refresh(); + return new Uint8ClampedArray(decodeBase64(qr.qrCodeBytes)); + } +} diff --git a/src/app/crypto/verification/state.test.ts b/src/app/crypto/verification/state.test.ts new file mode 100644 index 0000000000..73adf74e60 --- /dev/null +++ b/src/app/crypto/verification/state.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { VerificationPhase, VerificationMethod } from '$types/matrix-sdk'; +import { + EnginePhase, + chosenMethod, + cancellingUserId, + codeFromMethod, + isPending, + methodFromCode, + methodsFromCodes, + otherPartySupportsMethod, + toVerificationPhase, + type EngineVerificationState, +} from './state'; + +const state = (patch: Partial = {}): EngineVerificationState => ({ + ownUserId: '@me:example.org', + otherUserId: '@them:example.org', + otherDeviceId: 'THEIRDEVICE', + flowId: '$flow', + roomId: null, + phase: EnginePhase.Requested, + weStarted: false, + isSelfVerification: false, + isPassive: false, + isReady: false, + isDone: false, + isCancelled: false, + timedOut: false, + timeRemainingMillis: 600000, + theirSupportedMethods: null, + ourSupportedMethods: null, + cancelInfo: null, + verification: null, + ...patch, +}); + +describe('verification method codes', () => { + // These must track matrix-sdk-crypto's VerificationMethod ordering, which the Rust + // engine's method_from_code mirrors. A shift here silently breaks QR negotiation. + it('matches the engine ordering', () => { + expect(methodFromCode(0)).toBe(VerificationMethod.Sas); + expect(methodFromCode(1)).toBe(VerificationMethod.ScanQrCode); + expect(methodFromCode(2)).toBe(VerificationMethod.ShowQrCode); + expect(methodFromCode(3)).toBe(VerificationMethod.Reciprocate); + expect(methodFromCode(4)).toBeUndefined(); + }); + + it('round-trips every known method', () => { + [ + VerificationMethod.Sas, + VerificationMethod.ScanQrCode, + VerificationMethod.ShowQrCode, + VerificationMethod.Reciprocate, + ].forEach((method) => { + expect(methodFromCode(codeFromMethod(method) as number)).toBe(method); + }); + }); + + it('drops codes it cannot name rather than emitting undefined entries', () => { + expect(methodsFromCodes([0, 99, 3])).toEqual([ + VerificationMethod.Sas, + VerificationMethod.Reciprocate, + ]); + expect(methodsFromCodes(null)).toEqual([]); + }); +}); + +describe('toVerificationPhase', () => { + it('folds Created and Requested into Requested', () => { + expect(toVerificationPhase(state({ phase: EnginePhase.Created }), { accepting: false })).toBe( + VerificationPhase.Requested + ); + expect(toVerificationPhase(state({ phase: EnginePhase.Requested }), { accepting: false })).toBe( + VerificationPhase.Requested + ); + }); + + // The ready event is still in flight, so the request is not yet Ready to the caller. + it('keeps a locally-accepting request in Requested', () => { + const ready = state({ phase: EnginePhase.Ready }); + expect(toVerificationPhase(ready, { accepting: true })).toBe(VerificationPhase.Requested); + expect(toVerificationPhase(ready, { accepting: false })).toBe(VerificationPhase.Ready); + }); + + it('defers to the verifier once transitioned', () => { + const transitioned = state({ phase: EnginePhase.Transitioned }); + expect( + toVerificationPhase(transitioned, { accepting: false, startedPhase: VerificationPhase.Done }) + ).toBe(VerificationPhase.Done); + expect(toVerificationPhase(transitioned, { accepting: false })).toBe(VerificationPhase.Started); + }); + + it('maps the terminal phases', () => { + expect(toVerificationPhase(state({ phase: EnginePhase.Done }), { accepting: false })).toBe( + VerificationPhase.Done + ); + expect(toVerificationPhase(state({ phase: EnginePhase.Cancelled }), { accepting: false })).toBe( + VerificationPhase.Cancelled + ); + }); + + it('throws on a phase it does not know instead of guessing', () => { + expect(() => toVerificationPhase(state({ phase: 99 }), { accepting: false })).toThrow( + 'Unknown verification phase 99' + ); + }); +}); + +describe('isPending', () => { + it('is false for a passive request regardless of phase', () => { + expect(isPending(state({ isPassive: true }), VerificationPhase.Requested)).toBe(false); + }); + + it('is false once done or cancelled', () => { + expect(isPending(state(), VerificationPhase.Done)).toBe(false); + expect(isPending(state(), VerificationPhase.Cancelled)).toBe(false); + expect(isPending(state(), VerificationPhase.Ready)).toBe(true); + }); +}); + +describe('chosenMethod', () => { + it('names the method only once started', () => { + const sas = state({ verification: { className: 'Sas' } }); + expect(chosenMethod(sas, VerificationPhase.Started)).toBe(VerificationMethod.Sas); + expect(chosenMethod(sas, VerificationPhase.Ready)).toBeNull(); + + const qr = state({ verification: { className: 'Qr' } }); + expect(chosenMethod(qr, VerificationPhase.Started)).toBe(VerificationMethod.Reciprocate); + expect(chosenMethod(state(), VerificationPhase.Started)).toBeNull(); + }); +}); + +describe('otherPartySupportsMethod', () => { + // Absent is not the same as empty: before the other side speaks we must not claim support. + it('is false when the other side has not spoken', () => { + expect(otherPartySupportsMethod(state(), VerificationMethod.Sas)).toBe(false); + }); + + it('reads the advertised codes', () => { + const advertised = state({ theirSupportedMethods: [0, 3] }); + expect(otherPartySupportsMethod(advertised, VerificationMethod.Sas)).toBe(true); + expect(otherPartySupportsMethod(advertised, VerificationMethod.Reciprocate)).toBe(true); + expect(otherPartySupportsMethod(advertised, VerificationMethod.ShowQrCode)).toBe(false); + }); + + it('is false for a method it cannot map', () => { + expect(otherPartySupportsMethod(state({ theirSupportedMethods: [0] }), 'm.nonsense')).toBe( + false + ); + }); +}); + +describe('cancellingUserId', () => { + it('attributes the cancellation to the right side', () => { + expect(cancellingUserId(state())).toBeUndefined(); + expect(cancellingUserId(state({ cancelInfo: { cancelledbyUs: true } }))).toBe( + '@me:example.org' + ); + expect(cancellingUserId(state({ cancelInfo: { cancelledbyUs: false } }))).toBe( + '@them:example.org' + ); + }); +}); diff --git a/src/app/crypto/verification/state.ts b/src/app/crypto/verification/state.ts new file mode 100644 index 0000000000..92a6dd6731 --- /dev/null +++ b/src/app/crypto/verification/state.ts @@ -0,0 +1,114 @@ +import { VerificationPhase, VerificationMethod } from '$types/matrix-sdk'; + +/** Phase codes as `request_state` in the Rust engine emits them. */ +export const EnginePhase = { + Created: 0, + Requested: 1, + Ready: 2, + Transitioned: 3, + Done: 4, + Cancelled: 5, +} as const; + +/** Method codes as matrix-sdk-crypto's `VerificationMethod` orders them. */ +const METHOD_BY_CODE: Record = { + 0: VerificationMethod.Sas, + 1: VerificationMethod.ScanQrCode, + 2: VerificationMethod.ShowQrCode, + 3: VerificationMethod.Reciprocate, +}; + +const CODE_BY_METHOD: Record = Object.fromEntries( + Object.entries(METHOD_BY_CODE).map(([code, method]) => [method, Number(code)]) +); + +export type EngineCancelInfo = { + cancelCode?: string; + reason?: string; + cancelledbyUs?: boolean; +}; + +export type EngineVerificationState = { + className?: string; + ownUserId: string; + otherUserId: string; + otherDeviceId?: string | null; + flowId: string; + roomId?: string | null; + phase: number; + weStarted: boolean; + isSelfVerification: boolean; + isPassive: boolean; + isReady: boolean; + isDone: boolean; + isCancelled: boolean; + timedOut: boolean; + timeRemainingMillis: number; + theirSupportedMethods?: number[] | null; + ourSupportedMethods?: number[] | null; + cancelInfo?: EngineCancelInfo | null; + verification?: { className?: string } | null; +}; + +export const methodFromCode = (code: number): string | undefined => METHOD_BY_CODE[code]; + +export const codeFromMethod = (method: string): number | undefined => CODE_BY_METHOD[method]; + +export const methodsFromCodes = (codes: number[] | null | undefined): string[] => + (codes ?? []).map(methodFromCode).filter((method): method is string => method !== undefined); + +/** + * Mirrors matrix-js-sdk's phase mapping. `accepting` is local: while the ready event is + * in flight the request still reads as Requested though the engine says Ready. + */ +export const toVerificationPhase = ( + state: EngineVerificationState, + local: { accepting: boolean; startedPhase?: VerificationPhase } +): VerificationPhase => { + switch (state.phase) { + case EnginePhase.Created: + case EnginePhase.Requested: + return VerificationPhase.Requested; + case EnginePhase.Ready: + return local.accepting ? VerificationPhase.Requested : VerificationPhase.Ready; + case EnginePhase.Transitioned: + return local.startedPhase ?? VerificationPhase.Started; + case EnginePhase.Done: + return VerificationPhase.Done; + case EnginePhase.Cancelled: + return VerificationPhase.Cancelled; + default: + throw new Error(`Unknown verification phase ${state.phase}`); + } +}; + +export const isPending = (state: EngineVerificationState, phase: VerificationPhase): boolean => { + if (state.isPassive) return false; + return phase !== VerificationPhase.Done && phase !== VerificationPhase.Cancelled; +}; + +export const chosenMethod = ( + state: EngineVerificationState, + phase: VerificationPhase +): string | null => { + if (phase !== VerificationPhase.Started) return null; + if (state.verification?.className === 'Sas') return VerificationMethod.Sas; + if (state.verification?.className === 'Qr') return VerificationMethod.Reciprocate; + return null; +}; + +export const otherPartySupportsMethod = ( + state: EngineVerificationState, + method: string +): boolean => { + // Undefined rather than empty means the other side has not spoken yet. + if (!state.theirSupportedMethods) return false; + const code = codeFromMethod(method); + if (code === undefined) return false; + return state.theirSupportedMethods.includes(code); +}; + +export const cancellingUserId = (state: EngineVerificationState): string | undefined => { + if (!state.cancelInfo) return undefined; + return state.cancelInfo.cancelledbyUs ? state.ownUserId : state.otherUserId; +}; diff --git a/src/app/crypto/verification/verifier.test.ts b/src/app/crypto/verification/verifier.test.ts new file mode 100644 index 0000000000..6d1e0abe31 --- /dev/null +++ b/src/app/crypto/verification/verifier.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + VerificationPhase, + VerifierEvent, + type ShowQrCodeCallbacks, + type ShowSasCallbacks, +} from '$types/matrix-sdk'; +import { EngineQrVerifier, EngineSasVerifier } from './verifier'; + +const flow = { userId: '@them:e.org', flowId: '$f' }; + +describe('EngineSasVerifier', () => { + // Reading the digits straight after accepting yields nothing: they arrive later. + it('emits ShowSas when the digits arrive, not when accept is sent', async () => { + const call = vi.fn<() => Promise>(async () => null); + const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); + const shown = vi.fn<(sas: ShowSasCallbacks) => void>(); + verifier.on(VerifierEvent.ShowSas, shown); + + verifier.onChange({ canBePresented: false }); + expect(shown).not.toHaveBeenCalled(); + expect(verifier.getShowSasCallbacks()).toBeNull(); + + verifier.onChange({ + emoji: [{ symbol: '🐶', description: 'Dog' }], + decimals: [1, 2, 3], + }); + + expect(shown).toHaveBeenCalledOnce(); + expect(verifier.getShowSasCallbacks()?.sas).toEqual({ + emoji: [['🐶', 'Dog']], + decimal: [1, 2, 3], + }); + }); + + it('emits once even as further snapshots arrive', () => { + const call = vi.fn<() => Promise>(async () => null); + const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); + const shown = vi.fn<(sas: ShowSasCallbacks) => void>(); + verifier.on(VerifierEvent.ShowSas, shown); + + verifier.onChange({ decimals: [1, 2, 3] }); + verifier.onChange({ decimals: [1, 2, 3], haveWeConfirmed: true }); + + expect(shown).toHaveBeenCalledOnce(); + }); + + // Resolving early would let the UI close the prompt before the peer confirmed. + it('resolves verify only once the flow is done', async () => { + const call = vi.fn<() => Promise>(async () => null); + const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); + + let settled = false; + const verifying = verifier.verify().then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + verifier.onChange({ isDone: true }); + await verifying; + expect(settled).toBe(true); + }); + + it('confirms and cancels through the engine with the right codes', async () => { + const call = vi.fn<(m: string, a?: Record) => Promise>( + async () => null + ); + const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); + verifier.onChange({ decimals: [1, 2, 3] }); + + await verifier.getShowSasCallbacks()?.confirm(); + expect(call).toHaveBeenCalledWith('sas.confirm', flow); + + verifier.getShowSasCallbacks()?.mismatch(); + expect(call).toHaveBeenCalledWith('sas.cancel', { ...flow, code: 'm.mismatched_sas' }); + expect(verifier.hasBeenCancelled).toBe(true); + }); + + // js-sdk reports Started throughout SAS; Done is the request's phase, not the verifier's. + it('reports Started while running, leaving Done to the request', () => { + const call = vi.fn<() => Promise>(async () => null); + const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); + + expect(verifier.verificationPhase).toBe(VerificationPhase.Started); + verifier.onChange({ isDone: true }); + expect(verifier.verificationPhase).toBe(VerificationPhase.Started); + }); + + it('rejects verify when the flow is cancelled', async () => { + const call = vi.fn<() => Promise>(async () => null); + const verifier = new EngineSasVerifier(call, flow, {}, '@them:e.org'); + const verifying = verifier.verify(); + + verifier.onChange({ isCancelled: true }); + + await expect(verifying).rejects.toThrow('Verification cancelled'); + expect(verifier.hasBeenCancelled).toBe(true); + }); +}); + +describe('EngineQrVerifier', () => { + it('offers reciprocate callbacks only once our code has been scanned', () => { + const call = vi.fn<() => Promise>(async () => null); + const verifier = new EngineQrVerifier(call, flow, {}, '@them:e.org'); + const shown = vi.fn<(qr: ShowQrCodeCallbacks) => void>(); + verifier.on(VerifierEvent.ShowReciprocateQr, shown); + + verifier.onChange({ hasBeenScanned: false }); + expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); + + verifier.onChange({ hasBeenScanned: true }); + expect(shown).toHaveBeenCalledOnce(); + expect(verifier.getReciprocateQrCodeCallbacks()).not.toBeNull(); + }); +}); diff --git a/src/app/crypto/verification/verifier.ts b/src/app/crypto/verification/verifier.ts new file mode 100644 index 0000000000..535a9a7207 --- /dev/null +++ b/src/app/crypto/verification/verifier.ts @@ -0,0 +1,250 @@ +import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; +import { + VerificationPhase, + VerifierEvent, + type GeneratedSas, + type ShowQrCodeCallbacks, + type ShowSasCallbacks, + type Verifier, + type VerifierEventHandlerMap, +} from '$types/matrix-sdk'; + +export type EngineCall = (method: string, args?: Record) => Promise; + +export type SasState = { + className?: string; + canBePresented?: boolean; + haveWeConfirmed?: boolean; + isDone?: boolean; + isCancelled?: boolean; + emoji?: { symbol: string; description: string }[] | null; + decimals?: number[] | null; +}; + +export type QrState = { + className?: string; + hasBeenScanned?: boolean; + hasBeenConfirmed?: boolean; + reciprocated?: boolean; + isDone?: boolean; + isCancelled?: boolean; + /** QrVerificationState ordinal; see qr_state_code in matrix_crypto/verification.rs. */ + state?: number; +}; + +const QrVerificationState = { + Started: 0, + Scanned: 1, + Confirmed: 2, + Reciprocated: 3, + Done: 4, + Cancelled: 5, +} as const; + +type Flow = { userId: string; flowId: string }; + +const deferred = () => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +abstract class EngineVerifier + extends TypedEventEmitter + implements Verifier +{ + protected readonly call: EngineCall; + + protected readonly flow: Flow; + + protected state: TState; + + protected readonly completion = deferred(); + + #cancelled = false; + + readonly #userId: string; + + constructor(call: EngineCall, flow: Flow, state: TState, userId: string) { + super(); + this.call = call; + this.flow = flow; + this.state = state; + this.#userId = userId; + // Nothing awaits this until verify() does; an unhandled rejection would crash first. + this.completion.promise.catch(() => undefined); + } + + get hasBeenCancelled(): boolean { + return this.#cancelled; + } + + get userId(): string { + return this.#userId; + } + + protected markCancelled(): void { + this.#cancelled = true; + } + + abstract onChange(state: TState): void; + + abstract get verificationPhase(): VerificationPhase; + + abstract verify(): Promise; + + cancel(error: Error): void { + this.markCancelled(); + void this.call(this.cancelMethod, this.flow); + this.completion.reject(error); + this.emit(VerifierEvent.Cancel, error); + } + + protected abstract get cancelMethod(): string; + + getShowSasCallbacks(): ShowSasCallbacks | null { + return null; + } + + getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { + return null; + } +} + +/** The SAS digits only exist once the other side answers, hence the change handler. */ +export class EngineSasVerifier extends EngineVerifier { + #callbacks: ShowSasCallbacks | null = null; + + protected get cancelMethod(): string { + return 'sas.cancel'; + } + + get verificationPhase(): VerificationPhase { + return VerificationPhase.Started; + } + + async verify(): Promise { + await this.call('sas.accept', this.flow); + // Resolves only once both sides have confirmed, matching js-sdk. + await this.completion.promise; + } + + onChange(state: SasState): void { + this.state = state; + + if (state.isCancelled) { + this.markCancelled(); + this.completion.reject(new Error('Verification cancelled')); + return; + } + + if (!this.#callbacks) { + const sas = generatedSas(state); + if (sas) { + this.#callbacks = this.#buildCallbacks(sas); + this.emit(VerifierEvent.ShowSas, this.#callbacks); + } + } + + if (state.isDone) this.completion.resolve(); + } + + #buildCallbacks(sas: GeneratedSas): ShowSasCallbacks { + return { + sas, + confirm: async () => { + await this.call('sas.confirm', this.flow); + }, + mismatch: () => { + this.markCancelled(); + void this.call('sas.cancel', { ...this.flow, code: 'm.mismatched_sas' }); + }, + cancel: () => { + this.markCancelled(); + void this.call('sas.cancel', { ...this.flow, code: 'm.user' }); + }, + }; + } + + getShowSasCallbacks(): ShowSasCallbacks | null { + return this.#callbacks; + } +} + +export class EngineQrVerifier extends EngineVerifier { + #callbacks: ShowQrCodeCallbacks | null = null; + + protected get cancelMethod(): string { + return 'qr.cancel'; + } + + get verificationPhase(): VerificationPhase { + switch (this.state.state) { + case QrVerificationState.Started: + return VerificationPhase.Ready; + case QrVerificationState.Done: + return VerificationPhase.Done; + case QrVerificationState.Cancelled: + return VerificationPhase.Cancelled; + default: + return VerificationPhase.Started; + } + } + + async verify(): Promise { + // Already scanned: the user only has to confirm, so surface the prompt again. + if (this.#callbacks) this.emit(VerifierEvent.ShowReciprocateQr, this.#callbacks); + await this.completion.promise; + } + + async reciprocate(): Promise { + await this.call('qr.reciprocate', this.flow); + } + + onChange(state: QrState): void { + this.state = state; + + if (state.isCancelled) { + this.markCancelled(); + this.completion.reject(new Error('Verification cancelled')); + return; + } + + if (!this.#callbacks && state.hasBeenScanned) { + this.#callbacks = { + confirm: () => { + void this.call('qr.confirm', this.flow); + }, + cancel: () => { + this.markCancelled(); + void this.call('qr.cancel', { ...this.flow, code: 'm.user' }); + }, + }; + this.emit(VerifierEvent.ShowReciprocateQr, this.#callbacks); + } + + if (state.isDone) this.completion.resolve(); + } + + getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { + return this.#callbacks; + } +} + +const generatedSas = (state: SasState): GeneratedSas | null => { + const emoji = state.emoji?.map(({ symbol, description }) => [symbol, description]) as + | GeneratedSas['emoji'] + | undefined; + const decimals = state.decimals; + const decimal = + decimals && decimals.length >= 3 + ? ([decimals[0], decimals[1], decimals[2]] as [number, number, number]) + : undefined; + + if (!emoji?.length && !decimal) return null; + return { emoji, decimal }; +}; diff --git a/src/app/crypto/verificationInternals.test.ts b/src/app/crypto/verificationInternals.test.ts new file mode 100644 index 0000000000..7a1fe190a1 --- /dev/null +++ b/src/app/crypto/verificationInternals.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { describe, expect, it } from 'vitest'; +import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm'; + +/** + * The crypto backend implements js-sdk's public `CryptoBackend`, but js-sdk still offers no + * seam for supplying one, so `install.ts` assigns the private `cryptoBackend` field. These + * assertions run against the installed js-sdk so a version bump that moves it fails here + * rather than leaving the app running with no crypto at all. + */ +describe('matrix-js-sdk internals the crypto engine relies on', () => { + it('MatrixClient still exposes crypto through the cryptoBackend field', () => { + const require = createRequire(import.meta.url); + const source = readFileSync(require.resolve('matrix-js-sdk/lib/client.js'), 'utf8'); + + expect(source).toContain('this.cryptoBackend = '); + expect(source).toMatch(/getCrypto\(\)\s*\{\s*return this\.cryptoBackend;/); + }); + + /** + * Verification methods cross the IPC boundary as bare integers: the webview sends codes + * from this wasm enum, and the engine's `method_from_code` maps them back with hardcoded + * 0..3. The wasm package and the native matrix-sdk-crypto crate are versioned separately, + * so if either reorders this enum the two sides desync silently and QR verification stops + * negotiating. Keep these in step with `method_from_code` in matrix_crypto/verification.rs. + */ + it('pins the method codes the engine decodes by number', () => { + expect(RustSdkCryptoJs.VerificationMethod.SasV1).toBe(0); + expect(RustSdkCryptoJs.VerificationMethod.QrCodeScanV1).toBe(1); + expect(RustSdkCryptoJs.VerificationMethod.QrCodeShowV1).toBe(2); + expect(RustSdkCryptoJs.VerificationMethod.ReciprocateV1).toBe(3); + }); +}); diff --git a/src/app/features/settings/notifications/NativePushNotifications.test.ts b/src/app/features/settings/notifications/NativePushNotifications.test.ts index 96dae434d6..2c81dc63c6 100644 --- a/src/app/features/settings/notifications/NativePushNotifications.test.ts +++ b/src/app/features/settings/notifications/NativePushNotifications.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as WebPushSupportModule from './webPushSupport'; import { disableNativePush, enableNativePush, @@ -20,6 +21,10 @@ const getNativePushNotificationsApi = vi.hoisted(() => vi.fn<() => Promise>().mockResolvedValue(nativePushApi) ); +const getWebPushServerSupport = vi.hoisted(() => + vi.fn<() => Promise>() +); + const matrixClient = vi.hoisted(() => ({ setPusher: vi.fn<() => Promise>().mockResolvedValue(undefined), getDeviceId: vi.fn<() => string>(() => 'DEVICE'), @@ -29,6 +34,7 @@ const matrixClient = vi.hoisted(() => ({ getPushers: vi .fn<() => Promise<{ pushers: Array }>>() .mockResolvedValue({ pushers: [] }), + getSafeUserId: vi.fn<() => string>(() => '@user:example.com'), })); const nativePushClientConfig = { @@ -42,6 +48,15 @@ vi.mock('./NativePushNotificationsApiClient', () => ({ getNativePushNotificationsApi, })); +vi.mock('./webPushSupport', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getWebPushServerSupport }; +}); + +beforeEach(() => { + getWebPushServerSupport.mockResolvedValue({ supported: false }); +}); + afterEach(() => { vi.clearAllMocks(); localStorage.clear(); @@ -141,9 +156,9 @@ describe('native push pusher registration', () => { nativePushApi.isPermissionGranted.mockResolvedValue(true); nativePushApi.registerForPushNotifications.mockResolvedValue({ deviceToken: 'native-token' }); - await expect(enableNativePush(matrixClient as never, nativePushClientConfig)).resolves.toBe( - 'native-token' - ); + await expect( + enableNativePush(matrixClient as never, nativePushClientConfig) + ).resolves.toMatchObject({ pushkey: 'native-token' }); expect(localStorage.getItem('nativePushToken')).toBe('native-token'); expect(matrixClient.setPusher).toHaveBeenCalledWith( expect.objectContaining({ @@ -188,8 +203,14 @@ describe('native push pusher registration', () => { }, } as const; - await expect(enableNativePush(matrixClient as never, webPushConfig)).resolves.toBe('p256-key'); - expect(nativePushApi.registerForPushNotifications).toHaveBeenCalledWith('vapid-pub'); + await expect(enableNativePush(matrixClient as never, webPushConfig)).resolves.toMatchObject({ + pushkey: 'p256-key', + endpoint: 'https://fcm.googleapis.com/fcm/send/endpoint', + }); + expect(nativePushApi.registerForPushNotifications).toHaveBeenCalledWith('vapid-pub', { + userId: '@user:example.com', + deviceId: 'DEVICE', + }); expect(localStorage.getItem('nativePushAppId')).toBe('moe.sable.app.sygnal'); expect(localStorage.getItem('nativePushToken')).toBe('p256-key'); expect(matrixClient.setPusher).toHaveBeenCalledWith( @@ -208,6 +229,117 @@ describe('native push pusher registration', () => { ); }); + it('registers an MSC4174 webpush pusher with the homeserver VAPID key when supported', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: 'hs-vapid' }); + nativePushApi.isPermissionGranted.mockResolvedValue(true); + nativePushApi.registerForPushNotifications.mockResolvedValue({ + deviceToken: 'https://fcm.googleapis.com/fcm/send/endpoint', + p256dh: 'p256-key', + auth: 'auth-secret', + }); + + const webPushConfig = { + pushNotificationDetails: { + webPushAppID: 'moe.sable.app.sygnal', + nativePushAppID: 'moe.sable.mobile', + pushNotifyUrl: 'https://sygnal.example/_matrix/push/v1/notify', + vapidPublicKey: 'vapid-pub', + }, + } as const; + + await expect(enableNativePush(matrixClient as never, webPushConfig)).resolves.toMatchObject({ + pushkey: 'p256-key', + endpoint: 'https://fcm.googleapis.com/fcm/send/endpoint', + }); + expect(nativePushApi.registerForPushNotifications).toHaveBeenCalledWith('hs-vapid', { + userId: '@user:example.com', + deviceId: 'DEVICE', + }); + expect(localStorage.getItem('nativePushAppId')).toBe('moe.sable.app.sygnal'); + expect(localStorage.getItem('nativePushToken')).toBe('p256-key'); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'org.matrix.msc4174.webpush', + app_id: 'moe.sable.app.sygnal', + pushkey: 'p256-key', + data: expect.objectContaining({ + url: 'https://fcm.googleapis.com/fcm/send/endpoint', + auth: 'auth-secret', + format: 'event_id_only', + default_payload: { user_id: '@user:example.com' }, + }), + }) + ); + }); + + it('removes stale http gateway pushers when registering an MSC4174 webpush pusher', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: 'hs-vapid' }); + nativePushApi.isPermissionGranted.mockResolvedValue(true); + nativePushApi.registerForPushNotifications.mockResolvedValue({ + deviceToken: 'https://fcm.googleapis.com/fcm/send/endpoint', + p256dh: 'p256-key', + auth: 'auth-secret', + }); + matrixClient.getPushers.mockResolvedValue({ + pushers: [ + { + app_id: 'moe.sable.app.sygnal', + pushkey: 'old-p256-key', + device_display_name: 'Pixel', + kind: 'http', + }, + { + app_id: 'moe.sable.app.sygnal', + pushkey: 'other-device-key', + device_display_name: 'Other Phone', + kind: 'http', + }, + ], + }); + + const webPushConfig = { + pushNotificationDetails: { + webPushAppID: 'moe.sable.app.sygnal', + nativePushAppID: 'moe.sable.mobile', + vapidPublicKey: 'vapid-pub', + }, + } as const; + + await expect(enableNativePush(matrixClient as never, webPushConfig)).resolves.toMatchObject({ + pushkey: 'p256-key', + endpoint: 'https://fcm.googleapis.com/fcm/send/endpoint', + }); + // One webpush registration + one stale http pusher removal. + expect(matrixClient.setPusher).toHaveBeenCalledTimes(2); + expect(matrixClient.setPusher).toHaveBeenCalledWith({ + kind: null, + app_id: 'moe.sable.app.sygnal', + pushkey: 'old-p256-key', + }); + }); + + it('honors the gateway URL override for plain-token native push', async () => { + nativePushApi.isPermissionGranted.mockResolvedValue(true); + nativePushApi.registerForPushNotifications.mockResolvedValue({ deviceToken: 'native-token' }); + + await expect( + enableNativePush( + matrixClient as never, + nativePushClientConfig, + 'https://other-gateway.example/_matrix/push/v1/notify' + ) + ).resolves.toMatchObject({ pushkey: 'native-token' }); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'http', + pushkey: 'native-token', + data: expect.objectContaining({ + url: 'https://other-gateway.example/_matrix/push/v1/notify', + }), + }) + ); + }); + it('removes the stored pusher and unregisters the platform token', async () => { localStorage.setItem('nativePushAppId', 'moe.sable.mobile'); localStorage.setItem('nativePushToken', 'native-token'); diff --git a/src/app/features/settings/notifications/NativePushNotifications.ts b/src/app/features/settings/notifications/NativePushNotifications.ts index 3a43e0009b..81353b8a14 100644 --- a/src/app/features/settings/notifications/NativePushNotifications.ts +++ b/src/app/features/settings/notifications/NativePushNotifications.ts @@ -1,6 +1,10 @@ import type { IPusherRequest, MatrixClient } from '$types/matrix-sdk'; import type { ClientConfig } from '$hooks/useClientConfig'; +import { MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND } from '$unstable/prefixes'; import { getNativePushNotificationsApi } from './NativePushNotificationsApiClient'; +import { pushAccount, type PushAccount } from './pushAccount'; +import { resolvePushNotifyUrl } from './PushPusherConfig'; +import { getWebPushServerSupport, removeStaleHttpPushers } from './webPushSupport'; const NATIVE_PUSH_PUSHKEY_STORAGE_KEY = 'nativePushToken'; const NATIVE_PUSH_APP_ID_STORAGE_KEY = 'nativePushAppId'; @@ -61,12 +65,12 @@ function getWebPushAppId(clientConfig: ClientConfig): string { return appId; } -function getPushGatewayUrl(clientConfig: ClientConfig): string { +function getPushGatewayUrl(clientConfig: ClientConfig, pushNotifyUrlOverride?: string): string { const pushGatewayUrl = clientConfig.pushNotificationDetails?.pushNotifyUrl; - if (!pushGatewayUrl) { + if (!pushGatewayUrl && !pushNotifyUrlOverride?.trim()) { throw new Error('Native push requires pushNotificationDetails.pushNotifyUrl in config.json.'); } - return pushGatewayUrl; + return resolvePushNotifyUrl(pushGatewayUrl, pushNotifyUrlOverride); } export async function isNativePushPermissionGranted(): Promise { @@ -80,7 +84,8 @@ export async function requestNativePushPermission(): Promise { const permission = (await isNativePushPermissionGranted()) ? 'granted' @@ -96,7 +101,7 @@ export async function ensureNativePushRegistered( const api = await getNativePushNotificationsApi(); let registration; try { - registration = await api.registerForPushNotifications(vapid); + registration = await api.registerForPushNotifications(vapid, account); } catch (error) { throw new Error(describeNativePushRegistrationError(error), { cause: error }); } @@ -136,7 +141,7 @@ async function removeNativePushersForCurrentDevice(mx: MatrixClient, appId: stri (pusher) => pusher.app_id === appId && pusher.device_display_name === deviceDisplayName && - pusher.kind === 'http' + (pusher.kind === 'http' || pusher.kind === MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND) ); if (currentDevicePushers.length === 0) { @@ -154,12 +159,22 @@ async function removeNativePushersForCurrentDevice(mx: MatrixClient, appId: stri ); } +export type NativePushResult = { + pushkey: string; + endpoint?: string; + gatewayUrl?: string; +}; + export async function enableNativePush( mx: MatrixClient, - clientConfig: ClientConfig -): Promise { - const vapid = clientConfig.pushNotificationDetails?.vapidPublicKey; - const registration = await ensureNativePushRegistered(vapid); + clientConfig: ClientConfig, + pushNotifyUrlOverride?: string +): Promise { + const webPushSupport = await getWebPushServerSupport(mx); + const vapid = webPushSupport.supported + ? webPushSupport.vapidPublicKey + : clientConfig.pushNotificationDetails?.vapidPublicKey; + const registration = await ensureNativePushRegistered(vapid, pushAccount(mx)); if (registration.permission !== 'granted' || !registration.token) { throw new Error( @@ -169,12 +184,36 @@ export async function enableNativePush( ); } - const pushGatewayUrl = getPushGatewayUrl(clientConfig); const deviceDisplayName = (await mx.getDevice(mx.getDeviceId() ?? ''))?.display_name ?? 'Mobile Device'; if (registration.p256dh && registration.auth) { const appId = getWebPushAppId(clientConfig); + + if (webPushSupport.supported) { + // MSC4174: registration.token is the web push endpoint (e.g. FCM's). + await mx.setPusher({ + kind: MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND, + app_id: appId, + pushkey: registration.p256dh, + app_display_name: 'Sable (Native Push)', + device_display_name: deviceDisplayName, + lang: navigator.language || 'en', + data: { + url: registration.token, + format: 'event_id_only', + auth: registration.auth, + default_payload: { user_id: mx.getSafeUserId() }, + }, + append: false, + } as unknown as IPusherRequest); + + await removeStaleHttpPushers(mx, appId, [deviceDisplayName]); + storeNativePushRegistration(appId, registration.p256dh); + return { pushkey: registration.p256dh, endpoint: registration.token }; + } + + const pushGatewayUrl = getPushGatewayUrl(clientConfig, pushNotifyUrlOverride); await mx.setPusher({ kind: 'http', app_id: appId, @@ -193,9 +232,14 @@ export async function enableNativePush( } as unknown as IPusherRequest); storeNativePushRegistration(appId, registration.p256dh); - return registration.p256dh; + return { + pushkey: registration.p256dh, + endpoint: registration.token, + gatewayUrl: pushGatewayUrl, + }; } + const pushGatewayUrl = getPushGatewayUrl(clientConfig, pushNotifyUrlOverride); const appId = getNativePushAppId(clientConfig); await mx.setPusher({ kind: 'http', @@ -212,7 +256,7 @@ export async function enableNativePush( } as unknown as IPusherRequest); storeNativePushRegistration(appId, registration.token); - return registration.token; + return { pushkey: registration.token, gatewayUrl: pushGatewayUrl }; } export async function disableNativePush( diff --git a/src/app/features/settings/notifications/NativePushNotificationsApiClient.ts b/src/app/features/settings/notifications/NativePushNotificationsApiClient.ts index c5e2ad38ae..a3eb2bdea0 100644 --- a/src/app/features/settings/notifications/NativePushNotificationsApiClient.ts +++ b/src/app/features/settings/notifications/NativePushNotificationsApiClient.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { isAndroidTauri } from './TauriNotificationsApiClient'; +import type { PushAccount } from './pushAccount'; export type NativePushRegistration = { deviceToken: string; @@ -10,7 +11,10 @@ export type NativePushRegistration = { export type NativePushNotificationsApi = { isPermissionGranted: () => Promise; requestPermission: () => Promise; - registerForPushNotifications: (vapid?: string) => Promise; + registerForPushNotifications: ( + vapid?: string, + account?: PushAccount + ) => Promise; unregisterForPushNotifications: () => Promise; }; @@ -22,9 +26,11 @@ export async function getNativePushNotificationsApi(): Promise ({ isPermissionGranted: notificationsApi.isPermissionGranted, requestPermission: notificationsApi.requestPermission, - registerForPushNotifications: (vapid?: string) => + registerForPushNotifications: (vapid?: string, account?: PushAccount) => invoke('plugin:notifications|register_for_push_notifications', { vapid, + userId: account?.userId, + deviceId: account?.deviceId, ...(isAndroidTauri() ? { provider: 'fcm' } : {}), }), unregisterForPushNotifications: notificationsApi.unregisterForPushNotifications, diff --git a/src/app/features/settings/notifications/NotificationTransport.ts b/src/app/features/settings/notifications/NotificationTransport.ts index c4d840187a..f1f6e05986 100644 --- a/src/app/features/settings/notifications/NotificationTransport.ts +++ b/src/app/features/settings/notifications/NotificationTransport.ts @@ -15,6 +15,12 @@ export type PushTransportConfig = { unifiedPushGatewayUrl?: string; unifiedPushAppID?: string; unifiedPushDistributor?: string; + /** + * Endpoint server for the in-app distributor, e.g. `https://ntfy.sh`. Distinct from + * `unifiedPushGatewayUrl`, which is the Matrix push gateway that forwards *to* an + * endpoint. Set this to get push with no FCM and no distributor app installed. + */ + unifiedPushEmbeddedServerUrl?: string; }; export type PushTransportOverrides = Omit; @@ -112,6 +118,8 @@ export function mergePushConfig( unifiedPushGatewayUrl: overrides.unifiedPushGatewayUrl ?? defaults.unifiedPushGatewayUrl, unifiedPushAppID: overrides.unifiedPushAppID ?? defaults.unifiedPushAppID, unifiedPushDistributor: overrides.unifiedPushDistributor ?? defaults.unifiedPushDistributor, + unifiedPushEmbeddedServerUrl: + overrides.unifiedPushEmbeddedServerUrl ?? defaults.unifiedPushEmbeddedServerUrl, }; } diff --git a/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx b/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx index 32ce3c78cb..be58d2a8b7 100644 --- a/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx +++ b/src/app/features/settings/notifications/NotificationTransportRuntimeFeature.tsx @@ -6,7 +6,8 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useClientConfig } from '$hooks/useClientConfig'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { enableUnifiedPush } from './UnifiedPushNotifications'; +import { enableUnifiedPush, setEncryptedContentAllowed } from './UnifiedPushNotifications'; +import { healDormantWebPushPusher } from './webPushActivation'; import { enableNativePush, isNativePushPermissionGranted } from './NativePushNotifications'; import { isUnifiedPushPermissionGranted } from './UnifiedPushTransport'; import { @@ -61,6 +62,11 @@ export function NotificationTransportRuntimeFeature() { const [useRichPushPayloads] = useSetting(settingsAtom, 'useRichPushPayloads'); const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride'); + // The native cold path posts notifications without us. + useEffect(() => { + void setEncryptedContentAllowed(showMessageContent && showEncryptedMessageContent); + }, [showMessageContent, showEncryptedMessageContent]); + const runtimeRef = useRef(); if (!runtimeRef.current) runtimeRef.current = new NotificationTransportRuntime(); @@ -125,6 +131,9 @@ export function NotificationTransportRuntimeFeature() { const clientConfigRef = useRef(clientConfig); clientConfigRef.current = clientConfig; + const pushNotifyUrlOverrideRef = useRef(pushNotifyUrlOverride); + pushNotifyUrlOverrideRef.current = pushNotifyUrlOverride; + const lastEndpointRef = useRef(null); useEffect(() => { if (!mx) return undefined; @@ -141,9 +150,24 @@ export function NotificationTransportRuntimeFeature() { if (provider === 'unifiedpush') { const result = await enableUnifiedPush(mx, upConfigRef.current); lastEndpointRef.current = result.endpoint; + + // An MSC4174 pusher whose validation push arrived while the app was closed + // stays dormant forever, and the server never resends. Re-register so a fresh + // one is issued now, with the app running to ack it. + const appId = upConfigRef.current?.webPushAppID; + if (appId) { + const healed = await healDormantWebPushPusher(mx, appId, () => + enableUnifiedPush(mx, upConfigRef.current) + ); + if (healed) log.log('Re-registered a dormant MSC4174 pusher'); + } } else if (provider === 'native') { - const token = await enableNativePush(mx, clientConfigRef.current); - lastEndpointRef.current = token; + const native = await enableNativePush( + mx, + clientConfigRef.current, + pushNotifyUrlOverrideRef.current + ); + lastEndpointRef.current = native.pushkey; } } catch (error) { log.warn('Pusher registration failed at startup', error); diff --git a/src/app/features/settings/notifications/PushNotifications.test.ts b/src/app/features/settings/notifications/PushNotifications.test.ts index dfb86f7a30..8a65fb8e1b 100644 --- a/src/app/features/settings/notifications/PushNotifications.test.ts +++ b/src/app/features/settings/notifications/PushNotifications.test.ts @@ -1,8 +1,16 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type * as WebPushSupportModule from './webPushSupport'; const isTauri = vi.hoisted(() => vi.fn<() => boolean>()); +const getWebPushServerSupport = vi.hoisted(() => + vi.fn<() => Promise>() +); vi.mock('@tauri-apps/api/core', () => ({ isTauri })); +vi.mock('./webPushSupport', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getWebPushServerSupport }; +}); import { enablePushNotifications, @@ -10,13 +18,104 @@ import { togglePusher, } from './PushNotifications'; -const matrixClient = vi.hoisted(() => ({ - getAccessToken: vi.fn<() => string>(() => 'token'), -})); -const clientConfig = {} as never; +const MSC4174_KIND = 'org.matrix.msc4174.webpush'; +const VAPID = + 'BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg'; +const SERVER_VAPID = + 'BNbXV88MfMI0fSxB7cDngopoviZRTbxIS0qSS-O7BZCtG04khMOn-PP2uez_X7Aeci42n02kJ0-JJJ0uQ4ELRTs'; +const ENDPOINT = 'https://push.example.org/wpush/v2/subscription'; +const P256DH = + 'BLn9b-VR0ca83knDNZ32dCHGyjJp0000000000000000000000000000000000000000000000000000000000000000'; +const AUTH = '_ordMnz7uTCmrpBTeUV4Bw'; + +const setPushSubscription = vi.fn<(subscription: PushSubscription | null) => void>(); + +function base64UrlToArrayBuffer(value: string): ArrayBuffer { + const base64 = value.replaceAll('-', '+').replaceAll('_', '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +type MockSubscription = { + endpoint: string; + options: { applicationServerKey: ArrayBuffer | null }; + unsubscribe: Mock<() => Promise>; + toJSON: () => PushSubscriptionJSON; +}; + +function makeSubscription(applicationServerKey: string | null): MockSubscription { + return { + endpoint: ENDPOINT, + options: { + applicationServerKey: applicationServerKey + ? base64UrlToArrayBuffer(applicationServerKey) + : null, + }, + unsubscribe: vi.fn<() => Promise>(async () => {}), + toJSON: () => ({ endpoint: ENDPOINT, keys: { p256dh: P256DH, auth: AUTH } }), + }; +} + +const postMessage = vi.fn<(message: unknown) => void>(); +const getSubscription = vi.fn<() => Promise>(); +const subscribe = vi.fn<(options: unknown) => Promise>(); + +function createMatrixClient() { + return { + baseUrl: 'https://matrix.example.org', + getAccessToken: vi.fn<() => string>(() => 'token'), + getDeviceId: vi.fn<() => string>(() => 'DEVICE'), + getDevice: vi.fn<() => Promise<{ display_name: string }>>(async () => ({ + display_name: 'Test Device', + })), + getSafeUserId: vi.fn<() => string>(() => '@alice:example.org'), + getPushers: vi.fn<() => Promise<{ pushers: Array> }>>(async () => ({ + pushers: [], + })), + setPusher: vi.fn<() => Promise>>(async () => ({})), + }; +} + +const clientConfig = { + pushNotificationDetails: { + pushNotifyUrl: 'https://sygnal.example.org/_matrix/push/v1/notify', + vapidPublicKey: VAPID, + webPushAppID: 'moe.sable.app.sygnal', + }, +} as never; + +type TogglePushMessage = { + url: string; + type: string; + token: string; + pusherData: Record & { kind?: unknown; data: Record }; +}; + +function lastTogglePushMessage(): TogglePushMessage { + return postMessage.mock.calls.at(-1)?.[0] as unknown as TogglePushMessage; +} + +beforeEach(() => { + isTauri.mockReturnValue(false); + getWebPushServerSupport.mockResolvedValue({ supported: false }); + getSubscription.mockResolvedValue(null); + subscribe.mockImplementation(async () => makeSubscription(VAPID)); + vi.stubGlobal('PushManager', {}); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + ready: Promise.resolve({ pushManager: { getSubscription, subscribe } }), + controller: { postMessage }, + }, + }); +}); afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); describe('enablePushNotifications', () => { @@ -24,12 +123,147 @@ describe('enablePushNotifications', () => { isTauri.mockReturnValue(true); await expect( - enablePushNotifications(matrixClient as never, clientConfig, [ - null, - vi.fn<(sub: unknown) => void>(), - ]) + enablePushNotifications({} as never, clientConfig, [null, setPushSubscription]) ).resolves.toBeUndefined(); }); + + it('registers an MSC4174 webpush pusher when the server supports it', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: SERVER_VAPID }); + const mx = createMatrixClient(); + + await enablePushNotifications(mx as never, clientConfig, [null, setPushSubscription]); + + expect(subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: SERVER_VAPID, + }); + expect(postMessage).toHaveBeenCalledTimes(1); + const message = lastTogglePushMessage(); + expect(message.type).toBe('togglePush'); + expect(message.url).toBe('https://matrix.example.org'); + expect(message.token).toBe('token'); + expect(message.pusherData.kind).toBe(MSC4174_KIND); + expect(message.pusherData.pushkey).toBe(P256DH); + expect(message.pusherData.app_id).toBe('moe.sable.app.sygnal'); + expect(message.pusherData.data).toMatchObject({ + url: ENDPOINT, + auth: AUTH, + format: 'event_id_only', + }); + expect(message.pusherData.data).not.toHaveProperty('endpoint'); + expect(message.pusherData.data).not.toHaveProperty('p256dh'); + }); + + it('removes stale http gateway pushers for this device when switching to MSC4174', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: SERVER_VAPID }); + const mx = createMatrixClient(); + mx.getPushers.mockResolvedValue({ + pushers: [ + { + app_id: 'moe.sable.app.sygnal', + kind: 'http', + pushkey: 'old-p256dh', + device_display_name: 'Test Device', + }, + { + app_id: 'moe.sable.app.sygnal', + kind: 'http', + pushkey: 'other-device-p256dh', + device_display_name: 'Some Other Device', + }, + { app_id: 'm.email', kind: 'email', pushkey: 'a@b.c', device_display_name: 'a@b.c' }, + ], + }); + + await enablePushNotifications(mx as never, clientConfig, [null, setPushSubscription]); + + expect(mx.setPusher).toHaveBeenCalledTimes(1); + expect(mx.setPusher).toHaveBeenCalledWith({ + kind: null, + app_id: 'moe.sable.app.sygnal', + pushkey: 'old-p256dh', + }); + }); + + it('registers an http gateway pusher when the server does not support MSC4174', async () => { + const mx = createMatrixClient(); + + await enablePushNotifications(mx as never, clientConfig, [null, setPushSubscription]); + + expect(subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: VAPID, + }); + const message = lastTogglePushMessage(); + expect(message.pusherData.kind).toBe('http'); + expect(message.pusherData.data).toMatchObject({ + url: 'https://sygnal.example.org/_matrix/push/v1/notify', + format: 'event_id_only', + endpoint: ENDPOINT, + p256dh: P256DH, + auth: AUTH, + }); + expect(mx.setPusher).not.toHaveBeenCalled(); + }); + + it('honors the gateway URL override for the http fallback', async () => { + const mx = createMatrixClient(); + const override = 'https://other-gateway.example.org/_matrix/push/v1/notify'; + + await enablePushNotifications(mx as never, clientConfig, [null, setPushSubscription], override); + + const message = lastTogglePushMessage(); + expect(message.pusherData.kind).toBe('http'); + expect(message.pusherData.data.url).toBe(override); + }); + + it('rejects an invalid gateway URL override', async () => { + const mx = createMatrixClient(); + + await expect( + enablePushNotifications( + mx as never, + clientConfig, + [null, setPushSubscription], + 'not-a-gateway-url' + ) + ).rejects.toThrow(/Push gateway URL/); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('reuses the existing subscription when endpoint and VAPID key match', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: SERVER_VAPID }); + getSubscription.mockResolvedValue(makeSubscription(SERVER_VAPID)); + const mx = createMatrixClient(); + + await enablePushNotifications(mx as never, clientConfig, [ + { endpoint: ENDPOINT, keys: { p256dh: P256DH, auth: AUTH } }, + setPushSubscription, + ]); + + expect(subscribe).not.toHaveBeenCalled(); + const message = lastTogglePushMessage(); + expect(message.pusherData.kind).toBe(MSC4174_KIND); + }); + + it('re-subscribes when the stored subscription used a different VAPID key', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: SERVER_VAPID }); + const oldSubscription = makeSubscription(VAPID); + getSubscription.mockResolvedValue(oldSubscription); + subscribe.mockImplementation(async () => makeSubscription(SERVER_VAPID)); + const mx = createMatrixClient(); + + await enablePushNotifications(mx as never, clientConfig, [ + { endpoint: ENDPOINT, keys: { p256dh: P256DH, auth: AUTH } }, + setPushSubscription, + ]); + + expect(oldSubscription.unsubscribe).toHaveBeenCalled(); + expect(subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: SERVER_VAPID, + }); + }); }); describe('disablePushNotifications', () => { @@ -37,10 +271,7 @@ describe('disablePushNotifications', () => { isTauri.mockReturnValue(true); await expect( - disablePushNotifications(matrixClient as never, clientConfig, [ - null, - vi.fn<(sub: unknown) => void>(), - ]) + disablePushNotifications({} as never, clientConfig, [null, setPushSubscription]) ).resolves.toBeUndefined(); }); }); @@ -50,10 +281,7 @@ describe('togglePusher', () => { isTauri.mockReturnValue(true); await expect( - togglePusher(matrixClient as never, clientConfig, true, true, [ - null, - vi.fn<(sub: unknown) => void>(), - ]) + togglePusher({} as never, clientConfig, true, true, [null, setPushSubscription]) ).resolves.toBeUndefined(); }); }); diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 1d3dae30da..e0422884e4 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -1,10 +1,15 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { createDebugLogger } from '$utils/debugLogger'; import { isTauri } from '@tauri-apps/api/core'; +import { MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND } from '$unstable/prefixes'; import type { ClientConfig } from '../../../hooks/useClientConfig'; +import { resolvePushNotifyUrl } from './PushPusherConfig'; +import { getWebPushServerSupport, removeStaleHttpPushers } from './webPushSupport'; const debugLog = createDebugLogger('PushNotifications'); +const BROWSER_DEVICE_NAME = 'This Browser'; + type PushSubscriptionState = [ PushSubscriptionJSON | null, (subscription: PushSubscription | null) => void, @@ -28,10 +33,109 @@ export async function requestBrowserNotificationPermission(): Promise): void { + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); +} + export async function enablePushNotifications( mx: MatrixClient, clientConfig: ClientConfig, - pushSubscriptionAtom: PushSubscriptionState + pushSubscriptionAtom: PushSubscriptionState, + pushNotifyUrlOverride?: string ): Promise { if (isTauri()) return; if (!('serviceWorker' in navigator) || !('PushManager' in window)) { @@ -42,41 +146,52 @@ export async function enablePushNotifications( throw new Error('Push messaging is not supported in this browser.'); } debugLog.info('notification', 'Enabling push notifications'); + + const webPushSupport = await getWebPushServerSupport(mx); + const useServerWebPush = webPushSupport.supported; + const applicationServerKey = useServerWebPush + ? webPushSupport.vapidPublicKey + : clientConfig.pushNotificationDetails?.vapidPublicKey; + const deviceDisplayName = + (await mx.getDevice(mx.getDeviceId() ?? ''))?.display_name ?? 'Unknown Device'; + const [pushSubAtom, setPushSubscription] = pushSubscriptionAtom; const registration = await navigator.serviceWorker.ready; const currentBrowserSub = await registration.pushManager.getSubscription(); /* Self-Healing Check. Effectively checks if the browser has invalidated our subscription and recreates it only when necessary. This prevents us from needing an external call to get back the web push info. - */ - if (currentBrowserSub && pushSubAtom && currentBrowserSub.endpoint === pushSubAtom.endpoint) { + Also requires the VAPID key to match the active transport. */ + if ( + currentBrowserSub && + pushSubAtom && + currentBrowserSub.endpoint === pushSubAtom.endpoint && + applicationServerKeyMatches( + currentBrowserSub.options.applicationServerKey, + applicationServerKey + ) + ) { debugLog.info('notification', 'Push subscription already exists and is valid - reusing', { endpoint: pushSubAtom.endpoint, }); const { keys } = pushSubAtom; if (!keys?.p256dh || !keys.auth) return; - const pusherData = { - kind: 'http' as const, - app_id: clientConfig.pushNotificationDetails?.webPushAppID, - pushkey: keys.p256dh, - app_display_name: 'Cinny', - device_display_name: 'This Browser', - lang: navigator.language || 'en', - data: { - url: clientConfig.pushNotificationDetails?.pushNotifyUrl, - format: 'event_id_only' as const, + postPusherToServiceWorker( + mx, + buildWebPusherData(mx, clientConfig, useServerWebPush, { endpoint: pushSubAtom.endpoint, - p256dh: keys.p256dh, + pushkey: keys.p256dh, auth: keys.auth, - }, - append: false, - }; - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); + deviceDisplayName: BROWSER_DEVICE_NAME, + pushNotifyUrlOverride, + }) + ); + if (useServerWebPush) { + await removeStaleHttpPushers(mx, clientConfig.pushNotificationDetails?.webPushAppID, [ + deviceDisplayName, + BROWSER_DEVICE_NAME, + ]); + } return; } @@ -88,7 +203,7 @@ export async function enablePushNotifications( debugLog.info('notification', 'Creating new push subscription'); const newSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, + applicationServerKey, }); debugLog.info('notification', 'Push subscription created successfully', { @@ -102,30 +217,23 @@ export async function enablePushNotifications( debugLog.error('notification', 'Push subscription missing required keys'); throw new Error('Push subscription keys missing.'); } - const pusherData = { - kind: 'http' as const, - app_id: clientConfig.pushNotificationDetails?.webPushAppID, - pushkey: keys.p256dh, - app_display_name: 'Cinny', - device_display_name: - (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device', - lang: navigator.language || 'en', - data: { - url: clientConfig.pushNotificationDetails?.pushNotifyUrl, - format: 'event_id_only' as const, + + postPusherToServiceWorker( + mx, + buildWebPusherData(mx, clientConfig, useServerWebPush, { endpoint: newSubscription.endpoint, - p256dh: keys.p256dh, + pushkey: keys.p256dh, auth: keys.auth, - }, - append: false, - }; - - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); + deviceDisplayName, + pushNotifyUrlOverride, + }) + ); + if (useServerWebPush) { + await removeStaleHttpPushers(mx, clientConfig.pushNotificationDetails?.webPushAppID, [ + deviceDisplayName, + BROWSER_DEVICE_NAME, + ]); + } } /** @@ -179,13 +287,14 @@ export async function togglePusher( visible: boolean, usePushNotifications: boolean, pushSubscriptionAtom: PushSubscriptionState, - keepEnabledWhenVisible = false + keepEnabledWhenVisible = false, + pushNotifyUrlOverride?: string ): Promise { if (usePushNotifications) { if (visible && !keepEnabledWhenVisible) { await disablePushNotifications(mx, clientConfig, pushSubscriptionAtom); } else { - await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom); + await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom, pushNotifyUrlOverride); } } } diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 1ececb70d8..5373228928 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,4 +1,5 @@ import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { getIdentifier } from '@tauri-apps/api/app'; import { Box, Button, @@ -26,6 +27,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useClientConfig } from '$hooks/useClientConfig'; import { pushSubscriptionAtom } from '$state/pushSubscription'; import { unifiedPushEndpointAtom, type UnifiedPushState } from '$state/unifiedPushEndpoint'; + import { isMobileOrTablet } from '$utils/platform'; import { isIosTauri } from '$features/settings/notifications/TauriNotificationsApiClient'; import { isTauri } from '@tauri-apps/api/core'; @@ -55,6 +57,8 @@ import { tryEnableUnifiedPush, type UnifiedPushTransportConfigInput, } from './UnifiedPushNotifications'; +import { getWebPushServerSupport } from './webPushSupport'; +import { deliveryRouteDetail, deliveryRouteSummary, describeDeliveryRoute } from './deliveryRoute'; import { normalizeErrorMessage } from './UnifiedPushTransport'; import { ensureUnifiedPushDistributorSelection, @@ -104,9 +108,8 @@ function getNativePushConfigError(clientConfig: ReturnType segment.trim()) @@ -328,6 +344,7 @@ function BackgroundPushNotificationSetting() { clientConfig.pushTransport?.unifiedPushAppID ?? clientConfig.pushNotificationDetails?.unifiedPushAppID, unifiedPushDistributor: clientConfig.pushTransport?.unifiedPushDistributor, + unifiedPushEmbeddedServerUrl: clientConfig.pushTransport?.unifiedPushEmbeddedServerUrl, }; const [backgroundPushEnabled, setBackgroundPushEnabled] = useSetting( settingsAtom, @@ -343,7 +360,10 @@ function BackgroundPushNotificationSetting() { 'pushTransportOverride' ); const [useRichPushPayloads] = useSetting(settingsAtom, 'useRichPushPayloads'); - const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride'); + const [pushNotifyUrlOverride, setPushNotifyUrlOverride] = useSetting( + settingsAtom, + 'pushNotifyUrlOverride' + ); const pushSubAtom = useAtom(pushSubscriptionAtom); const [upEndpoint, setUpEndpoint] = useAtom(unifiedPushEndpointAtom); const unifiedPushStateRef = useRef(upEndpoint); @@ -353,6 +373,9 @@ function BackgroundPushNotificationSetting() { pushTransportOverride.unifiedPushDistributor ?? '' ); const [availableDistributors, setAvailableDistributors] = useState([]); + const [distributorScan, setDistributorScan] = useState(0); + const [serverSendsWebPush, setServerSendsWebPush] = useState(false); + const [appIdentifier, setAppIdentifier] = useState(); const browserPermission = usePermissionState('notifications', getNotificationState()); const isTauriRuntime = isTauri(); const runtimePlatform = getBackgroundPushPlatform(isTauriRuntime); @@ -387,13 +410,49 @@ function BackgroundPushNotificationSetting() { ) ).map((distributor) => ({ value: distributor, - label: labelUnifiedPushDistributorOption(distributor), + label: labelUnifiedPushDistributorOption(distributor, appIdentifier), })); useEffect(() => { unifiedPushStateRef.current = upEndpoint; }, [upEndpoint]); + // Read rather than guessed: the app's own package is indistinguishable from any other + // distributor by name alone. + useEffect(() => { + if (!isTauriRuntime) return; + void getIdentifier() + .then(setAppIdentifier) + .catch(() => undefined); + }, [isTauriRuntime]); + + // The gateway is only consulted when the homeserver cannot push WebPush itself. + useEffect(() => { + let cancelled = false; + getWebPushServerSupport(mx) + .then((support) => { + if (!cancelled) setServerSendsWebPush(support.supported); + }) + .catch(() => { + if (!cancelled) setServerSendsWebPush(false); + }); + return () => { + cancelled = true; + }; + }, [mx]); + + // Distributors are separate apps, so one can be installed or removed while this screen + // is open. Re-scan whenever the app comes back to the foreground. + useEffect(() => { + if (runtimePlatform !== 'android') return undefined; + + const rescanWhenVisible = () => { + if (document.visibilityState === 'visible') setDistributorScan((scan) => scan + 1); + }; + document.addEventListener('visibilitychange', rescanWhenVisible); + return () => document.removeEventListener('visibilitychange', rescanWhenVisible); + }, [runtimePlatform]); + useEffect(() => { if (runtimePlatform !== 'android') { setAvailableDistributors([]); @@ -433,7 +492,12 @@ function BackgroundPushNotificationSetting() { return () => { active = false; }; - }, [runtimePlatform, pushTransportOverride.unifiedPushDistributor, setPushTransportOverride]); + }, [ + runtimePlatform, + distributorScan, + pushTransportOverride.unifiedPushDistributor, + setPushTransportOverride, + ]); const updatePushTransportOverride = (patch: Partial) => { setPushTransportOverride((current) => @@ -447,6 +511,7 @@ function BackgroundPushNotificationSetting() { const buildUnifiedPushTransportConfig = (): UnifiedPushTransportConfigInput => ({ unifiedPushGatewayUrl: effectivePushTransport.unifiedPushGatewayUrl, unifiedPushAppID: effectivePushTransport.unifiedPushAppID, + unifiedPushEmbeddedServerUrl: effectivePushTransport.unifiedPushEmbeddedServerUrl, vapidPublicKey: clientConfig.pushNotificationDetails?.vapidPublicKey, webPushAppID: clientConfig.pushNotificationDetails?.webPushAppID, pushNotifyUrl: clientConfig.pushNotificationDetails?.pushNotifyUrl, @@ -503,7 +568,7 @@ function BackgroundPushNotificationSetting() { throw new Error('Browser notification permission was not granted.'); } } - await enablePushNotifications(mx, clientConfig, pushSubAtom); + await enablePushNotifications(mx, clientConfig, pushSubAtom, pushNotifyUrlOverride); return; } @@ -528,7 +593,14 @@ function BackgroundPushNotificationSetting() { throw new Error(nativePushConfigError); } - await enableNativePush(mx, clientConfig); + const native = await enableNativePush(mx, clientConfig, pushNotifyUrlOverride); + setUnifiedPushEndpointState({ + endpoint: native.endpoint ?? native.pushkey, + appId: clientConfig.pushNotificationDetails?.nativePushAppID ?? '', + gatewayUrl: native.gatewayUrl, + status: 'registered', + permissionState: 'granted', + }); }; const deactivateTransport = async (kind: BackgroundPushKind | null) => { @@ -700,6 +772,17 @@ function BackgroundPushNotificationSetting() { } }; + const deliveryRoute = + backgroundPushEnabled && effectiveKind + ? describeDeliveryRoute({ + homeserverUrl: mx.baseUrl, + serverSendsWebPush, + gatewayUrl: upEndpoint?.gatewayUrl, + endpoint: upEndpoint?.endpoint, + embedded: upEndpoint?.distributor === EMBEDDED_WEBSOCKET_DISTRIBUTOR, + }) + : undefined; + const transportDescription = (() => { if (error) { return ( @@ -776,13 +859,20 @@ function BackgroundPushNotificationSetting() { description={transportDescription} after={renderTransportToggle()} /> + {deliveryRoute && ( + + )} {supportedModes.length > 2 && ( + + updatePushTransportOverride({ unifiedPushEmbeddedServerUrl: nextValue }) + } + /> )} + {backgroundPushSupported && serverSendsWebPush && ( + + )} + {backgroundPushSupported && !serverSendsWebPush && ( + setPushNotifyUrlOverride(nextValue.trim() || undefined)} + /> + )} ); } diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts index 272242c066..fe6ddd5c99 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as WebPushSupportModule from './webPushSupport'; import { + DEFAULT_EMBEDDED_GATEWAY, + discoverPushGateway, DEFAULT_UNIFIED_PUSH_APP_ID, disableUnifiedPush, listenForUnifiedPushMessages, @@ -53,6 +56,13 @@ const invoke = vi.hoisted(() => vi.fn<(cmd: string, args?: Record) => Promise>() ); +const getWebPushServerSupport = vi.hoisted(() => + vi.fn<() => Promise>() +); +const acknowledgeWebPushPusher = vi.hoisted(() => + vi.fn<() => Promise>().mockResolvedValue(undefined) +); + const addPluginListener = vi.hoisted(() => vi.fn< ( @@ -73,12 +83,18 @@ vi.mock('./TauriNotificationsApiClient', () => ({ vi.mock('@tauri-apps/api/core', () => ({ addPluginListener, invoke, + isTauri: () => false, })); vi.mock('$utils/fetch', () => ({ fetch: (...args: Parameters) => globalThis.fetch(...args), })); +vi.mock('./webPushSupport', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getWebPushServerSupport, acknowledgeWebPushPusher }; +}); + function makeRoom() { return { name: 'Room', @@ -106,6 +122,8 @@ describe('UnifiedPushNotifications', () => { notificationsApi.createChannel.mockResolvedValue(undefined); notificationsApi.removeChannel.mockResolvedValue(undefined); getTauriNotificationsApi.mockResolvedValue(notificationsApi); + getWebPushServerSupport.mockResolvedValue({ supported: false }); + acknowledgeWebPushPusher.mockResolvedValue(undefined); unifiedPushTransport.registerUnifiedPushTransport.mockResolvedValue({ status: 'registered', permissionState: 'granted', @@ -630,6 +648,175 @@ describe('UnifiedPushNotifications', () => { ); }, 15_000); + it('registers an MSC4174 webpush pusher without a gateway when supported', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: 'hs-vapid' }); + unifiedPushTransport.registerUnifiedPushTransport.mockResolvedValue({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://fcm.googleapis.com/fcm/send/device', + distributor: 'embedded.fcm.distributor', + p256dh: 'p256-key', + auth: 'auth-secret', + }); + + await expect( + tryEnableUnifiedPush(matrixClient as never, { + webPushAppID: 'moe.sable.app', + vapidPublicKey: 'gateway-vapid', + }) + ).resolves.toMatchObject({ + status: 'registered', + endpoint: 'https://fcm.googleapis.com/fcm/send/device', + }); + + expect(unifiedPushTransport.registerUnifiedPushTransport).toHaveBeenCalledWith( + 'hs-vapid', + DEFAULT_EMBEDDED_GATEWAY, + { userId: '@user:example.com', deviceId: 'DEVICE' } + ); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'org.matrix.msc4174.webpush', + app_id: 'moe.sable.app', + pushkey: 'p256-key', + data: expect.objectContaining({ + url: 'https://fcm.googleapis.com/fcm/send/device', + auth: 'auth-secret', + format: 'event_id_only', + default_payload: { user_id: '@user:example.com' }, + }), + }) + ); + }, 15_000); + + // The two features meet here: MSC4174 chooses the VAPID key, the built-in distributor + // chooses the endpoint server, and both reach the same registration call. + it('passes the MSC4174 key and the built-in distributor server together', async () => { + getWebPushServerSupport.mockResolvedValue({ supported: true, vapidPublicKey: 'hs-vapid' }); + unifiedPushTransport.registerUnifiedPushTransport.mockResolvedValue({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://ntfy.sh/upabc123', + distributor: 'embedded-websocket', + }); + + await tryEnableUnifiedPush(matrixClient as never, { + webPushAppID: 'moe.sable.app', + vapidPublicKey: 'gateway-vapid', + unifiedPushEmbeddedServerUrl: 'https://ntfy.sh', + }); + + expect(unifiedPushTransport.registerUnifiedPushTransport).toHaveBeenCalledWith( + 'hs-vapid', + 'https://ntfy.sh', + { userId: '@user:example.com', deviceId: 'DEVICE' } + ); + }, 15_000); + + it('prefers the endpoint provider as gateway when it proxies matrix push', async () => { + const fetchMock = vi.fn<() => Promise>( + async () => + new Response(JSON.stringify({ unifiedpush: { gateway: 'matrix' } }), { status: 200 }) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(discoverPushGateway('https://ntfy.example/upabc')).resolves.toBe( + 'https://ntfy.example/_matrix/push/v1/notify' + ); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ntfy.example/_matrix/push/v1/notify', + expect.objectContaining({ method: 'GET' }) + ); + vi.unstubAllGlobals(); + }); + + it('falls back to the public gateway when the provider does not proxy', async () => { + vi.stubGlobal( + 'fetch', + vi.fn<() => Promise>(async () => new Response('{}', { status: 200 })) + ); + await expect(discoverPushGateway('https://up.example/dev')).resolves.toBe( + 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify' + ); + vi.unstubAllGlobals(); + }); + + it('falls back to the public gateway when discovery is unreachable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn<() => Promise>(async () => { + throw new Error('offline'); + }) + ); + await expect(discoverPushGateway('https://up.example/dev')).resolves.toBe( + 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify' + ); + vi.unstubAllGlobals(); + }); + + it('keeps the gateway webpush pusher when the homeserver does not support MSC4174', async () => { + unifiedPushTransport.registerUnifiedPushTransport.mockResolvedValue({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://up.example/device', + distributor: 'org.unifiedpush.distributor.ntfy', + p256dh: 'p256-key', + auth: 'auth-secret', + }); + + await tryEnableUnifiedPush(matrixClient as never, { + webPushAppID: 'moe.sable.app', + pushNotifyUrl: 'https://sygnal.example/_matrix/push/v1/notify', + vapidPublicKey: 'gateway-vapid', + }); + + expect(unifiedPushTransport.registerUnifiedPushTransport).toHaveBeenCalledWith( + 'gateway-vapid', + DEFAULT_EMBEDDED_GATEWAY, + { userId: '@user:example.com', deviceId: 'DEVICE' } + ); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'http', + app_id: 'moe.sable.app', + pushkey: 'p256-key', + data: expect.objectContaining({ + url: 'https://sygnal.example/_matrix/push/v1/notify', + endpoint: 'https://up.example/device', + p256dh: 'p256-key', + auth: 'auth-secret', + }), + }) + ); + }, 15_000); + + it('acknowledges MSC4174 activation pushes instead of notifying', async () => { + await listenForUnifiedPushMessages(() => makeSettings() as never); + + pushHandler({ + message: JSON.stringify({ app_id: 'moe.sable.app', ack_token: 'token-123' }), + }); + + await vi.waitFor(() => + expect(acknowledgeWebPushPusher).toHaveBeenCalledWith( + matrixClient, + 'moe.sable.app', + 'token-123' + ) + ); + expect(notificationsApi.sendNotification).not.toHaveBeenCalled(); + }); + + it('acknowledges MSC4174 activation pushes even without a user_id match', async () => { + await listenForUnifiedPushMessages(() => makeSettings() as never); + + pushHandler({ + message: JSON.stringify({ app_id: 'moe.sable.app', ack_token: 'token-456' }), + }); + + await vi.waitFor(() => expect(acknowledgeWebPushPusher).toHaveBeenCalledOnce()); + }); + it('clears the UnifiedPush registration timeout after successful registration', async () => { vi.useFakeTimers(); @@ -669,6 +856,12 @@ describe('UnifiedPushNotifications', () => { device_display_name: 'Pixel', kind: 'http', }, + { + app_id: 'com.example.up', + pushkey: 'stale-webpush-key', + device_display_name: 'Pixel', + kind: 'org.matrix.msc4174.webpush', + }, { app_id: 'com.example.up', pushkey: 'other-device-endpoint', @@ -684,7 +877,7 @@ describe('UnifiedPushNotifications', () => { }, }); - expect(matrixClient.setPusher).toHaveBeenCalledTimes(2); + expect(matrixClient.setPusher).toHaveBeenCalledTimes(3); expect(matrixClient.setPusher).toHaveBeenCalledWith( expect.objectContaining({ kind: null, @@ -699,6 +892,13 @@ describe('UnifiedPushNotifications', () => { pushkey: 'stale-endpoint-2', }) ); + expect(matrixClient.setPusher).toHaveBeenCalledWith( + expect.objectContaining({ + kind: null, + app_id: 'com.example.up', + pushkey: 'stale-webpush-key', + }) + ); expect(unifiedPushTransport.unregisterUnifiedPushTransport).toHaveBeenCalledOnce(); }); }); diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index 8c1846f940..aec5530e4f 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -13,6 +13,9 @@ import { import { getMxIdLocalPart } from '$utils/matrix'; import { getStateEvent } from '$utils/room/hierarchy'; import { createDebugLogger } from '$utils/debugLogger'; +import type { DecryptedPushEvent } from '$app/crypto/pushDecrypt'; +import { decryptPushEventNatively } from '$app/crypto/pushDecrypt'; +import { pushAccount, type PushAccount } from './pushAccount'; import { registerUnifiedPushTransport, type UnifiedPushRegistrationResult, @@ -22,7 +25,7 @@ import { createUnifiedPushMessageListener, parseUnifiedPushMessage, } from './UnifiedPushMessageListener'; -import { addPluginListener, invoke } from '@tauri-apps/api/core'; +import { addPluginListener, invoke, isTauri } from '@tauri-apps/api/core'; import type { PushTransportConfig } from './NotificationTransport'; import { getTauriNotificationsApi, isMobileTauri } from './TauriNotificationsApiClient'; import { @@ -30,8 +33,17 @@ import { withPushPayloadFormat, type PushPusherSettings, } from './PushPusherConfig'; +import { + acknowledgeWebPushPusher, + getWebPushServerSupport, + isWebPushActivationPayload, + removeStaleHttpPushers, +} from './webPushSupport'; +import { MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND } from '$unstable/prefixes'; const UP_PUBLIC_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; +/** The in-app distributor relays through a public gateway unless one is configured. */ +export const DEFAULT_EMBEDDED_GATEWAY = 'https://ntfy.sh'; export const DEFAULT_UNIFIED_PUSH_APP_ID = 'moe.sable.up'; const unifiedPushLog = createDebugLogger('unifiedpush'); @@ -86,7 +98,7 @@ async function ensureNotificationChannels( export type UnifiedPushTransportConfigInput = Pick< PushTransportConfig, - 'unifiedPushGatewayUrl' | 'unifiedPushAppID' + 'unifiedPushGatewayUrl' | 'unifiedPushAppID' | 'unifiedPushEmbeddedServerUrl' > & { vapidPublicKey?: string; webPushAppID?: string; @@ -122,7 +134,9 @@ export type EnableUnifiedPushResult = | Exclude; async function registerUnifiedPushWithTimeout( - vapid?: string + vapid?: string, + embeddedServerUrl?: string, + account?: PushAccount ): Promise { let timeoutId: ReturnType | undefined; const timeout = new Promise((_, reject) => { @@ -132,7 +146,10 @@ async function registerUnifiedPushWithTimeout( }); try { - return await Promise.race([registerUnifiedPushTransport(vapid), timeout]); + return await Promise.race([ + registerUnifiedPushTransport(vapid, embeddedServerUrl, account), + timeout, + ]); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); @@ -140,6 +157,30 @@ async function registerUnifiedPushWithTimeout( } } +/** + * A provider that speaks the Matrix push protocol answers `/_matrix/push/v1/notify` + * with `unifiedpush.gateway == "matrix"`. Preferring it keeps delivery on the provider + * the endpoint already belongs to. + */ +export async function discoverPushGateway(endpoint: string): Promise { + let candidate: string; + try { + candidate = new URL('/_matrix/push/v1/notify', endpoint).toString(); + } catch { + return UP_PUBLIC_GATEWAY; + } + + try { + const response = await fetch(candidate, { method: 'GET' }); + if (!response.ok) return UP_PUBLIC_GATEWAY; + const body = (await response.json()) as { unifiedpush?: { gateway?: unknown } }; + if (body?.unifiedpush?.gateway === 'matrix') return candidate; + } catch { + // Unreachable or not JSON: the provider does not proxy. + } + return UP_PUBLIC_GATEWAY; +} + export async function tryEnableUnifiedPush( mx: MatrixClient, config?: UnifiedPushTransportConfigInput @@ -147,7 +188,14 @@ export async function tryEnableUnifiedPush( const notificationsApi = await getTauriNotificationsApi(); await ensureNotificationChannels(notificationsApi); - const registration = await registerUnifiedPushWithTimeout(config?.vapidPublicKey); + // MSC4174: subscribe with the homeserver VAPID key when it pushes directly. + const webPushSupport = await getWebPushServerSupport(mx); + const vapid = webPushSupport.supported ? webPushSupport.vapidPublicKey : config?.vapidPublicKey; + const registration = await registerUnifiedPushWithTimeout( + vapid, + trimConfigValue(config?.unifiedPushEmbeddedServerUrl) ?? DEFAULT_EMBEDDED_GATEWAY, + pushAccount(mx) + ); if (registration.status !== 'registered') { return registration; @@ -157,38 +205,73 @@ export async function tryEnableUnifiedPush( const deviceDisplayName = (await mx.getDevice(mx.getDeviceId() ?? ''))?.display_name ?? 'Android Device'; - if (registration.p256dh && registration.auth && config?.webPushAppID && config?.pushNotifyUrl) { - const pushNotifyUrl = resolvePushNotifyUrl(config.pushNotifyUrl, config?.pushNotifyUrlOverride); - await mx.setPusher({ - kind: 'http', - app_id: config.webPushAppID, - pushkey: registration.p256dh, - app_display_name: 'Sable (UnifiedPush)', - device_display_name: deviceDisplayName, - lang: navigator.language || 'en', - data: withPushPayloadFormat( - { - url: pushNotifyUrl, - endpoint, - p256dh: registration.p256dh, - auth: registration.auth, - default_payload: { user_id: mx.getSafeUserId() }, - }, - config?.useRichPushPayloads - ), - append: false, - } as unknown as IPusherRequest); - - return { - status: 'registered', - endpoint, - gatewayUrl: pushNotifyUrl, - distributor: registration.distributor, - }; + if (registration.p256dh && registration.auth && config?.webPushAppID) { + if (webPushSupport.supported) { + // MSC4174: data.url is the distributor's push endpoint, not a gateway. + await mx.setPusher({ + kind: MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND, + app_id: config.webPushAppID, + pushkey: registration.p256dh, + app_display_name: 'Sable (UnifiedPush)', + device_display_name: deviceDisplayName, + lang: navigator.language || 'en', + data: withPushPayloadFormat( + { + url: endpoint, + auth: registration.auth, + default_payload: { user_id: mx.getSafeUserId() }, + }, + config?.useRichPushPayloads + ), + append: false, + } as unknown as IPusherRequest); + + await removeStaleHttpPushers(mx, config.webPushAppID, [deviceDisplayName]); + + return { + status: 'registered', + endpoint, + gatewayUrl: endpoint, + distributor: registration.distributor, + }; + } + + if (config?.pushNotifyUrl) { + const pushNotifyUrl = resolvePushNotifyUrl( + config.pushNotifyUrl, + config?.pushNotifyUrlOverride + ); + await mx.setPusher({ + kind: 'http', + app_id: config.webPushAppID, + pushkey: registration.p256dh, + app_display_name: 'Sable (UnifiedPush)', + device_display_name: deviceDisplayName, + lang: navigator.language || 'en', + data: withPushPayloadFormat( + { + url: pushNotifyUrl, + endpoint, + p256dh: registration.p256dh, + auth: registration.auth, + default_payload: { user_id: mx.getSafeUserId() }, + }, + config?.useRichPushPayloads + ), + append: false, + } as unknown as IPusherRequest); + + return { + status: 'registered', + endpoint, + gatewayUrl: pushNotifyUrl, + distributor: registration.distributor, + }; + } } const resolvedConfig = resolveUnifiedPushPusherConfig(config); - const gatewayUrl = resolvedConfig.gatewayUrl ?? UP_PUBLIC_GATEWAY; + const gatewayUrl = resolvedConfig.gatewayUrl ?? (await discoverPushGateway(endpoint)); await mx.setPusher({ kind: 'http', @@ -253,7 +336,7 @@ async function getCurrentDeviceUnifiedPushPushkeys( (pusher) => pusher.app_id === appId && pusher.device_display_name === deviceDisplayName && - pusher.kind === 'http' && + (pusher.kind === 'http' || pusher.kind === MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND) && isNonEmptyString(pusher.pushkey) ) .map((pusher) => pusher.pushkey); @@ -497,7 +580,6 @@ async function dismissLegacyGroupSummary(userId: string): Promise { } } -/** Clears accumulated messages for a room and dismisses its notification. */ export async function clearRoomNotification(userId: string, roomId: string) { const key = `${userId}\u0000${roomId}`; await enqueueRoomOperation(key, async () => { @@ -553,7 +635,6 @@ async function postRoomNotification( return true; } -/** Handles a rich push payload containing full event details (type, room_name, content, etc.). */ async function handleRichPushPayload( pushData: UnifiedPushPayload, settings: NotificationSettings, @@ -748,10 +829,11 @@ function scheduleEncryptedPreviewEnrichment( if (!initialSettings.showMessageContent || !initialSettings.showEncryptedMessageContent) return; const crypto = initialSettings.mx.getCrypto(); + const encryptedContent = pushData.content; const decrypted = buildEncryptedPreviewEvent(roomId, eventId, pushData); - if (!crypto || !decrypted) return; + if (!crypto || !decrypted || !encryptedContent) return; - const applyDecryptedPreview = async (): Promise => { + const applyDecryptedPreview = async (plaintext: DecryptedPushEvent): Promise => { await enqueueRoomOperation(cache.key, async () => { const liveSettings = getSettings(); const isAllowed = @@ -763,8 +845,8 @@ function scheduleEncryptedPreviewEnrichment( } const enrichedPreview = resolveNotificationPreviewText({ - content: decrypted.getContent(), - eventType: decrypted.getType(), + content: plaintext.content, + eventType: plaintext.eventType, isEncryptedRoom: true, showMessageContent: liveSettings.showMessageContent, showEncryptedMessageContent: liveSettings.showEncryptedMessageContent, @@ -772,7 +854,7 @@ function scheduleEncryptedPreviewEnrichment( if (!enrichedPreview || enrichedPreview === ENCRYPTED_MESSAGE_PREVIEW) return; const liveRoom = liveSettings.mx.getRoom(roomId); - const decryptedSender = decrypted.getSender(); + const decryptedSender = plaintext.sender; const senderName = decryptedSender ? (liveRoom?.getMember(decryptedSender)?.name ?? getMxIdLocalPart(decryptedSender) ?? @@ -817,16 +899,38 @@ function scheduleEncryptedPreviewEnrichment( }); }; - whenDecrypted(decrypted, applyDecryptedPreview); - void decrypted.attemptDecryption(crypto as CryptoBackend).catch(() => { - unifiedPushLog.warn('notification', 'Encrypted preview decryption failed'); - }); + const fallBackToSdkDecryption = (): void => { + whenDecrypted(decrypted, () => + applyDecryptedPreview({ + content: decrypted.getContent(), + eventType: decrypted.getType(), + sender: decrypted.getSender(), + }) + ); + void decrypted.attemptDecryption(crypto as CryptoBackend).catch(() => { + unifiedPushLog.warn('notification', 'Encrypted preview decryption failed'); + }); + }; + + // The engine reads the crypto store directly, so it answers without waiting on the SDK + // pipeline; it returns null exactly in the late-key case the SDK path retries. + void decryptPushEventNatively(initialSettings.mx.getUserId(), initialSettings.mx.getDeviceId(), { + roomId, + eventId, + sender: pushData.sender, + content: encryptedContent, + }) + .then((plaintext) => { + if (plaintext) { + void applyDecryptedPreview(plaintext); + return; + } + fallBackToSdkDecryption(); + }) + // Without this the preview would stay at its "Encrypted message" baseline forever. + .catch(fallBackToSdkDecryption); } -/** - * Handles a minimal push payload (event_id + room_id + counts) from - * the public UnifiedPush gateway, looking up context from local SDK state. - */ async function handleMinimalPushPayload( pushData: UnifiedPushPayload, settings: NotificationSettings, @@ -1027,12 +1131,26 @@ async function handleUnifiedPushPayload( ) { const settings = getSettings(); - // Skip system notification when in-app banners are active and visible. + const pushData = (raw.extra ?? raw) as UnifiedPushPayload; + + // MSC4174 validation push: not a notification, ack even while visible. + if (isWebPushActivationPayload(pushData)) { + try { + await acknowledgeWebPushPusher(settings.mx, pushData.app_id, pushData.ack_token); + } catch (error) { + unifiedPushLog.warn( + 'notification', + 'MSC4174 pusher activation failed', + error instanceof Error ? error : new Error(String(error)) + ); + } + return; + } + if (document.visibilityState === 'visible' && settings.useInAppNotifications) { return; } - const pushData = (raw.extra ?? raw) as UnifiedPushPayload; const eventType = pushData?.type as EventType | undefined; const userId = isNonEmptyString(pushData?.user_id) ? pushData.user_id.trim() : undefined; if (!userId || userId !== settings.mx.getUserId()) { @@ -1048,6 +1166,17 @@ async function handleUnifiedPushPayload( } const SET_PUSH_MESSAGE_LISTENER_ACTIVE = 'plugin:notifications|set_push_message_listener_active'; +const SET_ENCRYPTED_CONTENT_ALLOWED = 'plugin:notifications|set_encrypted_content_allowed'; + +/** A push handled with no webview cannot ask the app whether decryption is allowed. */ +export async function setEncryptedContentAllowed(allowed: boolean): Promise { + if (!isTauri()) return; + try { + await invoke(SET_ENCRYPTED_CONTENT_ALLOWED, { allowed }); + } catch { + // Older plugin builds lack the command; the native path then stays closed. + } +} export async function listenForUnifiedPushMessages(getSettings: () => NotificationSettings) { const dispatch = createUnifiedPushMessageListener( diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts index 72a726d254..ef513a962e 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts @@ -11,8 +11,17 @@ import { const unifiedPushApi = vi.hoisted(() => ({ isPermissionGranted: vi.fn<() => Promise>(), requestPermission: vi.fn<() => Promise>(), - registerForPushNotifications: - vi.fn<(vapid?: string) => Promise<{ deviceToken: string; p256dh?: string; auth?: string }>>(), + registerForPushNotifications: vi.fn< + ( + vapid?: string, + embeddedGatewayUrl?: string + ) => Promise<{ + deviceToken: string; + p256dh?: string; + auth?: string; + distributor?: string; + }> + >(), unregisterForPushNotifications: vi.fn<() => Promise>(), listDistributors: vi.fn<() => Promise>(), setDistributor: vi.fn<(name: string) => Promise>(), @@ -77,6 +86,41 @@ describe('registerUnifiedPushTransport', () => { expect(unifiedPushApi.registerForPushNotifications).not.toHaveBeenCalled(); }); + it('registers through the built-in distributor when none is installed', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + localStorage.removeItem('unifiedpush_distributor'); + unifiedPushApi.listDistributors.mockResolvedValue([]); + unifiedPushApi.registerForPushNotifications.mockResolvedValue({ + deviceToken: 'https://ntfy.sh/upabc123', + distributor: 'embedded-websocket', + }); + + await expect(registerUnifiedPushTransport(undefined, 'https://ntfy.sh')).resolves.toEqual({ + status: 'registered', + permissionState: 'granted', + endpoint: 'https://ntfy.sh/upabc123', + distributor: 'embedded-websocket', + p256dh: undefined, + auth: undefined, + }); + expect(unifiedPushApi.registerForPushNotifications).toHaveBeenCalledWith( + undefined, + 'https://ntfy.sh', + undefined + ); + }); + + it('still reports missing-distributor when no built-in server is configured', async () => { + unifiedPushApi.isPermissionGranted.mockResolvedValue(true); + localStorage.removeItem('unifiedpush_distributor'); + unifiedPushApi.listDistributors.mockResolvedValue([]); + + await expect(registerUnifiedPushTransport(undefined, ' ')).resolves.toMatchObject({ + status: 'missing-distributor', + }); + expect(unifiedPushApi.registerForPushNotifications).not.toHaveBeenCalled(); + }); + it('returns missing-distributor without registering when none are available', async () => { unifiedPushApi.isPermissionGranted.mockResolvedValue(true); localStorage.removeItem('unifiedpush_distributor'); diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.ts b/src/app/features/settings/notifications/UnifiedPushTransport.ts index 75952bcb1f..37278d8a5c 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransport.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransport.ts @@ -1,3 +1,4 @@ +import type { PushAccount } from './pushAccount'; import { getUnifiedPushTransportApi } from './UnifiedPushTransportApiClient'; export type UnifiedPushPermissionState = 'granted' | 'denied' | 'default'; @@ -199,7 +200,9 @@ export async function switchUnifiedPushDistributorSelection( } export async function registerUnifiedPushTransport( - vapid?: string + vapid?: string, + embeddedGatewayUrl?: string, + account?: PushAccount ): Promise { let permissionState: UnifiedPushPermissionState = 'default'; let selectedDistributor: string | undefined; @@ -220,7 +223,9 @@ export async function registerUnifiedPushTransport( const { distributors, selectedDistributor: distributor } = await loadUnifiedPushDistributorState(); selectedDistributor = distributor || undefined; - if (!distributor) { + // With a gateway configured the app is its own distributor, so an empty list is + // no longer a dead end. + if (!distributor && !embeddedGatewayUrl?.trim()) { return { status: 'missing-distributor', permissionState: 'granted', @@ -233,7 +238,11 @@ export async function registerUnifiedPushTransport( } const api = await getUnifiedPushTransportApi(); - const registration = await api.registerForPushNotifications(vapid); + const registration = await api.registerForPushNotifications( + vapid, + embeddedGatewayUrl?.trim(), + account + ); const endpoint = registration?.deviceToken; if (!endpoint || !endpoint.trim()) { return { @@ -248,7 +257,7 @@ export async function registerUnifiedPushTransport( status: 'registered', permissionState: 'granted', endpoint, - distributor, + distributor: registration.distributor ?? distributor, p256dh: registration.p256dh, auth: registration.auth, }; diff --git a/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts b/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts index d2776bee07..5a7e60b630 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransportApiClient.ts @@ -1,15 +1,22 @@ import { invoke } from '@tauri-apps/api/core'; +import type { PushAccount } from './pushAccount'; export type UnifiedPushRegistration = { deviceToken: string; p256dh?: string; auth?: string; + /** Set when the in-app websocket distributor answered instead of an installed one. */ + distributor?: string; }; export type UnifiedPushTransportApi = { isPermissionGranted: () => Promise; requestPermission: () => Promise; - registerForPushNotifications: (vapid?: string) => Promise; + registerForPushNotifications: ( + vapid?: string, + embeddedGatewayUrl?: string, + account?: PushAccount + ) => Promise; unregisterForPushNotifications: () => Promise; listDistributors: () => Promise; setDistributor: (name: string) => Promise; @@ -21,10 +28,17 @@ export async function getUnifiedPushTransportApi(): Promise + registerForPushNotifications: ( + vapid?: string, + embeddedGatewayUrl?: string, + account?: PushAccount + ) => invoke('plugin:notifications|register_for_push_notifications', { vapid, provider: 'unifiedpush', + embeddedGatewayUrl, + userId: account?.userId, + deviceId: account?.deviceId, }), unregisterForPushNotifications: notificationsApi.unregisterForPushNotifications, listDistributors: notificationsApi.listDistributors, diff --git a/src/app/features/settings/notifications/deliveryRoute.test.ts b/src/app/features/settings/notifications/deliveryRoute.test.ts new file mode 100644 index 0000000000..e4626bddd7 --- /dev/null +++ b/src/app/features/settings/notifications/deliveryRoute.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { deliveryRouteDetail, deliveryRouteSummary, describeDeliveryRoute } from './deliveryRoute'; + +const HOMESERVER = 'https://matrix.example.org'; + +describe('describeDeliveryRoute', () => { + it('names the homeserver as packager when it sends web push itself', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: true, + endpoint: 'https://ntfy.sh/upabc', + }); + + expect(deliveryRouteSummary(route)).toBe('Your homeserver → ntfy.sh'); + expect(route.encrypted).toBe(true); + expect(route.external).toEqual(['ntfy.sh']); + }); + + it('says a relay cannot read an encrypted push', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: true, + endpoint: 'https://ntfy.sh/upabc', + }); + + expect(deliveryRouteDetail(route)).toContain('cannot read it'); + }); + + it('warns that a gateway can read an unencrypted push', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: false, + gatewayUrl: 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify', + endpoint: 'https://ntfy.sh/upabc', + }); + + expect(deliveryRouteSummary(route)).toBe('matrix.gateway.unifiedpush.org → ntfy.sh'); + expect(route.external).toEqual(['matrix.gateway.unifiedpush.org', 'ntfy.sh']); + expect(deliveryRouteDetail(route)).toContain('can read what it contains'); + }); + + it('reports nothing external when every hop is the homeserver', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: false, + gatewayUrl: `${HOMESERVER}/_matrix/push/v1/notify`, + endpoint: `${HOMESERVER}/upabc`, + }); + + expect(route.external).toEqual([]); + expect(deliveryRouteDetail(route)).toBe('Every hop runs on your own infrastructure.'); + }); + + it('names Google rather than its hostname', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: true, + endpoint: 'https://fcm.googleapis.com/fcm/send/token', + }); + + expect(deliveryRouteSummary(route)).toBe('Your homeserver → Google'); + }); + + it('marks the in-app socket so the user knows no other app is involved', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: true, + endpoint: 'https://ntfy.sh/upabc', + embedded: true, + }); + + expect(deliveryRouteSummary(route)).toBe('Your homeserver → ntfy.sh (this app)'); + }); + + it('does not repeat a host that is both gateway and endpoint', () => { + const route = describeDeliveryRoute({ + homeserverUrl: HOMESERVER, + serverSendsWebPush: false, + gatewayUrl: 'https://ntfy.sh/_matrix/push/v1/notify', + endpoint: 'https://ntfy.sh/upabc', + }); + + expect(route.external).toEqual(['ntfy.sh']); + }); +}); diff --git a/src/app/features/settings/notifications/deliveryRoute.ts b/src/app/features/settings/notifications/deliveryRoute.ts new file mode 100644 index 0000000000..5136b3a310 --- /dev/null +++ b/src/app/features/settings/notifications/deliveryRoute.ts @@ -0,0 +1,69 @@ +const GOOGLE_PUSH_HOST = 'fcm.googleapis.com'; +const MOZILLA_PUSH_HOST = 'updates.push.services.mozilla.com'; + +export type DeliveryRoute = { + /** The homeserver itself, or the gateway that packages the push. */ + packagedBy: string; + deliveredVia: string; + /** Hosts outside the homeserver's own domain. */ + external: string[]; + encrypted: boolean; +}; + +const hostOf = (url: string | undefined): string | undefined => { + if (!url?.trim()) return undefined; + try { + return new URL(url).host; + } catch { + return undefined; + } +}; + +const labelHost = (host: string): string => { + if (host === GOOGLE_PUSH_HOST) return 'Google'; + if (host === MOZILLA_PUSH_HOST) return 'Mozilla'; + return host; +}; + +export type DeliveryRouteInput = { + homeserverUrl?: string; + serverSendsWebPush: boolean; + gatewayUrl?: string; + endpoint?: string; + /** The in-app websocket holds the connection rather than another app. */ + embedded?: boolean; +}; + +export function describeDeliveryRoute(input: DeliveryRouteInput): DeliveryRoute { + const ownHost = hostOf(input.homeserverUrl); + const gatewayHost = hostOf(input.gatewayUrl); + const endpointHost = hostOf(input.endpoint); + + const packagedBy = input.serverSendsWebPush + ? 'Your homeserver' + : (gatewayHost && labelHost(gatewayHost)) || 'A push gateway'; + + let deliveredVia = endpointHost ? labelHost(endpointHost) : 'Unknown'; + if (input.embedded && endpointHost) deliveredVia = `${endpointHost} (this app)`; + + const external = [input.serverSendsWebPush ? undefined : gatewayHost, endpointHost] + .filter((host): host is string => Boolean(host)) + .filter((host) => host !== ownHost) + .filter((host, index, all) => all.indexOf(host) === index); + + return { packagedBy, deliveredVia, external, encrypted: input.serverSendsWebPush }; +} + +export function deliveryRouteSummary(route: DeliveryRoute): string { + return `${route.packagedBy} → ${route.deliveredVia}`; +} + +export function deliveryRouteDetail(route: DeliveryRoute): string { + if (route.external.length === 0) { + return 'Every hop runs on your own infrastructure.'; + } + const who = route.external.join(', '); + return route.encrypted + ? `${who} relays it but cannot read it: your homeserver encrypts the push for this device.` + : `${who} handles the notification and can read what it contains.`; +} diff --git a/src/app/features/settings/notifications/distributorLabels.test.ts b/src/app/features/settings/notifications/distributorLabels.test.ts new file mode 100644 index 0000000000..0e1f9e9f93 --- /dev/null +++ b/src/app/features/settings/notifications/distributorLabels.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { labelUnifiedPushDistributorOption } from './SystemNotification'; + +const APP_ID = 'moe.sable.client'; + +describe('labelUnifiedPushDistributorOption', () => { + it('names the in-app websocket distributor plainly', () => { + expect(labelUnifiedPushDistributorOption('embedded-websocket', APP_ID)).toBe('Built-in'); + }); + + /** + * Embedded FCM registers under our own package, so it rendered as "client" — a label + * that says nothing about what it does or that it routes through Google. + */ + it('says what the old embedded-FCM entry actually is', () => { + expect(labelUnifiedPushDistributorOption(APP_ID, APP_ID)).toBe('Built-in (old, via Google)'); + }); + + it('names installed distributors by their last package segment', () => { + expect(labelUnifiedPushDistributorOption('io.heckel.ntfy', APP_ID)).toBe('ntfy'); + expect(labelUnifiedPushDistributorOption('org.unifiedpush.distributor.nextpush', APP_ID)).toBe( + 'nextpush' + ); + }); + + /** + * A previous version guessed our package by looking for a dot, which matched the first + * installed distributor and labelled ntfy as this app. + */ + it('never presents someone else as this app', () => { + ['io.heckel.ntfy', 'org.unifiedpush.distributor.nextpush', 'moe.sable.next.debug'].forEach( + (distributor) => { + expect(labelUnifiedPushDistributorOption(distributor, APP_ID)).not.toContain('Built-in'); + } + ); + }); + + it('falls back to the segment when the identifier is not known yet', () => { + expect(labelUnifiedPushDistributorOption(APP_ID)).toBe('client'); + }); +}); diff --git a/src/app/features/settings/notifications/pushAccount.ts b/src/app/features/settings/notifications/pushAccount.ts new file mode 100644 index 0000000000..8f6c0a11e1 --- /dev/null +++ b/src/app/features/settings/notifications/pushAccount.ts @@ -0,0 +1,13 @@ +import type { MatrixClient } from '$types/matrix-sdk'; + +/** Names the crypto store a cold push must open; the native side has no session. */ +export type PushAccount = { + userId: string; + deviceId: string; +}; + +export function pushAccount(mx: MatrixClient): PushAccount | undefined { + const deviceId = mx.getDeviceId(); + if (!deviceId) return undefined; + return { userId: mx.getSafeUserId(), deviceId }; +} diff --git a/src/app/features/settings/notifications/webPushActivation.test.ts b/src/app/features/settings/notifications/webPushActivation.test.ts new file mode 100644 index 0000000000..064729ce59 --- /dev/null +++ b/src/app/features/settings/notifications/webPushActivation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { getWebPushPusherState, healDormantWebPushPusher } from './webPushActivation'; + +const KIND = 'org.matrix.msc4174.webpush'; +const APP_ID = 'moe.sable.app.sygnal'; + +const clientWith = (pushers: unknown[]) => + ({ + getPushers: vi.fn<() => Promise>(async () => ({ pushers })), + }) as unknown as MatrixClient; + +describe('getWebPushPusherState', () => { + it('reports a pusher the server has not activated', async () => { + const mx = clientWith([{ kind: KIND, app_id: APP_ID, activated: false }]); + + await expect(getWebPushPusherState(mx, APP_ID)).resolves.toBe('dormant'); + }); + + it('reports an activated pusher', async () => { + const mx = clientWith([{ kind: KIND, app_id: APP_ID, activated: true }]); + + await expect(getWebPushPusherState(mx, APP_ID)).resolves.toBe('activated'); + }); + + it('reports absent when no webpush pusher exists for this app', async () => { + const mx = clientWith([ + { kind: 'http', app_id: APP_ID, activated: false }, + { kind: KIND, app_id: 'someone.else', activated: false }, + ]); + + await expect(getWebPushPusherState(mx, APP_ID)).resolves.toBe('absent'); + }); + + // Servers predating the field would otherwise be re-registered on every start. + it('does not treat a missing activated field as dormant', async () => { + const mx = clientWith([{ kind: KIND, app_id: APP_ID }]); + + await expect(getWebPushPusherState(mx, APP_ID)).resolves.toBe('activated'); + }); +}); + +describe('healDormantWebPushPusher', () => { + /** + * The validation push is one-shot and the server never resends, so a pusher that went + * dormant stays dormant until the client asks for a new one. + */ + it('re-registers a dormant pusher so a fresh validation push is sent', async () => { + const mx = clientWith([{ kind: KIND, app_id: APP_ID, activated: false }]); + const reRegister = vi.fn<() => Promise>(async () => undefined); + + await expect(healDormantWebPushPusher(mx, APP_ID, reRegister)).resolves.toBe(true); + expect(reRegister).toHaveBeenCalledOnce(); + }); + + it('leaves a working pusher alone', async () => { + const mx = clientWith([{ kind: KIND, app_id: APP_ID, activated: true }]); + const reRegister = vi.fn<() => Promise>(async () => undefined); + + await expect(healDormantWebPushPusher(mx, APP_ID, reRegister)).resolves.toBe(false); + expect(reRegister).not.toHaveBeenCalled(); + }); + + it('does not register when there is no pusher to heal', async () => { + const mx = clientWith([]); + const reRegister = vi.fn<() => Promise>(async () => undefined); + + await expect(healDormantWebPushPusher(mx, APP_ID, reRegister)).resolves.toBe(false); + expect(reRegister).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/settings/notifications/webPushActivation.ts b/src/app/features/settings/notifications/webPushActivation.ts new file mode 100644 index 0000000000..bc7d70583a --- /dev/null +++ b/src/app/features/settings/notifications/webPushActivation.ts @@ -0,0 +1,47 @@ +import type { MatrixClient } from '$types/matrix-sdk'; +import { MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND } from '$unstable/prefixes'; + +/** + * MSC4174 pushers start dormant: the homeserver sends one validation push carrying an + * `ack_token` and delivers nothing else until the client acks it. The ack is handled in + * the webview, so a validation push arriving while the app is closed is simply lost — and + * the server never resends, leaving the pusher dormant for good. + * + * `activated` is how the server reports that state, so it is worth reading rather than + * assuming registration succeeded. + */ +export type WebPushPusherState = 'activated' | 'dormant' | 'absent'; + +type PusherWithActivation = { kind?: string; app_id?: string; activated?: boolean }; + +export async function getWebPushPusherState( + mx: MatrixClient, + appId: string +): Promise { + const response = await mx.getPushers(); + const pusher = (response.pushers ?? []).find( + (candidate) => + (candidate as PusherWithActivation).kind === MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND && + candidate.app_id === appId + ) as PusherWithActivation | undefined; + + if (!pusher) return 'absent'; + // Absent `activated` means a server that predates the field; treat it as working + // rather than re-registering on every start. + return pusher.activated === false ? 'dormant' : 'activated'; +} + +/** + * Re-registers a dormant pusher so the homeserver issues a fresh validation push while + * the app is demonstrably running to ack it. Returns whether it re-registered. + */ +export async function healDormantWebPushPusher( + mx: MatrixClient, + appId: string, + reRegister: () => Promise +): Promise { + if ((await getWebPushPusherState(mx, appId)) !== 'dormant') return false; + + await reRegister(); + return true; +} diff --git a/src/app/features/settings/notifications/webPushSupport.test.ts b/src/app/features/settings/notifications/webPushSupport.test.ts new file mode 100644 index 0000000000..32a58ba54c --- /dev/null +++ b/src/app/features/settings/notifications/webPushSupport.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getWebPushServerSupport } from './webPushSupport'; + +const VAPID = + 'BNbXV88MfMI0fSxB7cDngopoviZRTbxIS0qSS-O7BZCtG04khMOn-PP2uez_X7Aeci42n02kJ0-JJJ0uQ4ELRTs'; + +type SupportMocks = { + versionsAdvertised?: boolean; + versionsError?: Error; + capabilities?: Record; + capabilitiesError?: Error; +}; + +function createMatrixClient({ + versionsAdvertised = false, + versionsError, + capabilities = {}, + capabilitiesError, +}: SupportMocks = {}) { + return { + doesServerSupportUnstableFeature: vi.fn<(feature: string) => Promise>( + async (feature: string) => { + if (feature !== 'org.matrix.msc4174') return false; + if (versionsError) throw versionsError; + return versionsAdvertised; + } + ), + getCapabilities: vi.fn<() => Promise>>(async () => { + if (capabilitiesError) throw capabilitiesError; + return capabilities; + }), + }; +} + +describe('getWebPushServerSupport', () => { + it('reports unsupported when /versions does not advertise MSC4174', async () => { + const mx = createMatrixClient({ versionsAdvertised: false }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ supported: false }); + expect(mx.getCapabilities).not.toHaveBeenCalled(); + }); + + it('reports unsupported when the /versions check fails', async () => { + const mx = createMatrixClient({ versionsError: new Error('network down') }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ supported: false }); + }); + + it('returns the VAPID key from the stable capability', async () => { + const mx = createMatrixClient({ + versionsAdvertised: true, + capabilities: { 'm.webpush': { enabled: true, vapid: VAPID } }, + }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ + supported: true, + vapidPublicKey: VAPID, + }); + }); + + it('returns the VAPID key from the unstable capability', async () => { + const mx = createMatrixClient({ + versionsAdvertised: true, + capabilities: { 'org.matrix.msc4174.webpush': { enabled: true, vapid: VAPID } }, + }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ + supported: true, + vapidPublicKey: VAPID, + }); + }); + + it('prefers the stable capability over the unstable one', async () => { + const mx = createMatrixClient({ + versionsAdvertised: true, + capabilities: { + 'm.webpush': { enabled: true, vapid: VAPID }, + 'org.matrix.msc4174.webpush': { enabled: true, vapid: 'unstable-key' }, + }, + }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ + supported: true, + vapidPublicKey: VAPID, + }); + }); + + it('reports unsupported when the capability is not enabled', async () => { + const mx = createMatrixClient({ + versionsAdvertised: true, + capabilities: { 'm.webpush': { enabled: false, vapid: VAPID } }, + }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ supported: false }); + }); + + it('reports unsupported when the capability has no VAPID key', async () => { + const mx = createMatrixClient({ + versionsAdvertised: true, + capabilities: { 'm.webpush': { enabled: true } }, + }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ supported: false }); + }); + + it('reports unsupported when fetching capabilities fails', async () => { + const mx = createMatrixClient({ + versionsAdvertised: true, + capabilitiesError: new Error('no capabilities'), + }); + + await expect(getWebPushServerSupport(mx as never)).resolves.toEqual({ supported: false }); + }); +}); diff --git a/src/app/features/settings/notifications/webPushSupport.ts b/src/app/features/settings/notifications/webPushSupport.ts new file mode 100644 index 0000000000..dd52f42d39 --- /dev/null +++ b/src/app/features/settings/notifications/webPushSupport.ts @@ -0,0 +1,121 @@ +import type { IPusherRequest, MatrixClient } from '$types/matrix-sdk'; +import { Method } from '$types/matrix-sdk'; +import { + MATRIX_STABLE_MSC4174_WEBPUSH_CAPABILITY_NAME, + MATRIX_UNSTABLE_MSC4174_FEATURE_NAME, + MATRIX_UNSTABLE_MSC4174_PUSHERS_ACK_PATH, + MATRIX_UNSTABLE_MSC4174_WEBPUSH_CAPABILITY_NAME, +} from '$unstable/prefixes'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('webPushSupport'); + +export type WebPushServerSupport = + | { supported: true; vapidPublicKey: string } + | { supported: false }; + +type WebPushCapability = { + enabled?: unknown; + vapid?: unknown; +}; + +/** + * MSC4174 support: advertised in /versions unstable features, VAPID key taken + * from the m.webpush capability in /capabilities. + */ +export async function getWebPushServerSupport(mx: MatrixClient): Promise { + let versionsAdvertised = false; + try { + versionsAdvertised = await mx.doesServerSupportUnstableFeature( + MATRIX_UNSTABLE_MSC4174_FEATURE_NAME + ); + } catch { + return { supported: false }; + } + if (!versionsAdvertised) return { supported: false }; + + try { + const capabilities = await mx.getCapabilities(); + const capability = (capabilities?.[MATRIX_STABLE_MSC4174_WEBPUSH_CAPABILITY_NAME] ?? + capabilities?.[MATRIX_UNSTABLE_MSC4174_WEBPUSH_CAPABILITY_NAME]) as + | WebPushCapability + | undefined; + + if ( + capability?.enabled === true && + typeof capability.vapid === 'string' && + capability.vapid.trim() + ) { + return { supported: true, vapidPublicKey: capability.vapid }; + } + + debugLog.warn( + 'notification', + 'Server advertises MSC4174 in /versions but no usable m.webpush capability' + ); + } catch { + // fall through to unsupported + } + + return { supported: false }; +} + +/** MSC4174 validation push: `{ app_id, ack_token }` and nothing else. */ +export function isWebPushActivationPayload( + data: unknown +): data is { app_id: string; ack_token: string } { + if (!data || typeof data !== 'object') return false; + const d = data as Record; + return ( + typeof d.app_id === 'string' && + typeof d.ack_token === 'string' && + d.room_id === undefined && + d.event_id === undefined && + d.type === undefined && + typeof d.unread !== 'number' + ); +} + +/** POSTs the ack_token received by validation push to activate the pusher. */ +export async function acknowledgeWebPushPusher( + mx: MatrixClient, + appId: string, + ackToken: string +): Promise { + await mx.http.authedRequest( + Method.Post, + MATRIX_UNSTABLE_MSC4174_PUSHERS_ACK_PATH, + undefined, + { app_id: appId, ack_token: ackToken }, + { prefix: '' } + ); +} + +/** Removes this device's http gateway pushers to avoid duplicate pushes (MSC4174). */ +export async function removeStaleHttpPushers( + mx: MatrixClient, + appId: string | undefined, + deviceDisplayNames: string[] +): Promise { + if (!appId || deviceDisplayNames.length === 0) return; + try { + const response = await mx.getPushers(); + const stalePushers = (response.pushers ?? []).filter( + (pusher) => + pusher.app_id === appId && + pusher.kind === 'http' && + deviceDisplayNames.includes(pusher.device_display_name) + ); + await Promise.allSettled( + stalePushers.map((pusher) => + mx.setPusher({ + kind: null, + app_id: pusher.app_id, + pushkey: pusher.pushkey, + } as unknown as IPusherRequest) + ) + ); + } catch { + // best effort cleanup + } +} diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 90bbb75229..5923271c36 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -211,7 +211,9 @@ export const settingsLinkFocusIdsBySection: Record { return invoke('deactivate_call_audio_session'); } +export async function engineClose(params: types.EngineCloseParams): Promise { + return invoke('engine_close', params); +} + +export async function engineDecryptPush(params: types.EngineDecryptPushParams): Promise { + return invoke('engine_decrypt_push', params); +} + +export async function engineInvoke(params: types.EngineInvokeParams): Promise { + return invoke('engine_invoke', params); +} + +export async function engineOpen(params: types.EngineOpenParams): Promise { + return invoke('engine_open', params); +} + +export async function engineWipe(params: types.EngineWipeParams): Promise { + return invoke('engine_wipe', params); +} + export async function exportDiagnostics(params: types.ExportDiagnosticsParams): Promise { return invoke('export_diagnostics', params); } diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 42a23c1f9e..13565cc6b3 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-18T09:43:40.541173302+00:00 + * Generated at: 2026-08-18T10:44:36.277685470+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -10,6 +10,13 @@ import type { Channel } from '@tauri-apps/api/core'; +export interface DecryptedPush { + event_type?: string | null; + sender?: string | null; + body?: string | null; + clear_event: string; +} + export interface DesktopRuntimeState { trayAvailable: boolean; } @@ -21,6 +28,14 @@ export interface DesktopSettings { spellcheck: 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; @@ -79,6 +94,43 @@ export interface BuildDiagnosticsArchiveParams { [key: string]: unknown; } +export interface EngineCloseParams { + userId: string; + deviceId: string; + [key: string]: unknown; +} + +export interface EngineDecryptPushParams { + userId: string; + deviceId: string; + roomId: string; + eventJson: string; + passphrase?: string | null; + [key: string]: unknown; +} + +export interface EngineInvokeParams { + userId: string; + deviceId: string; + method: string; + argsJson: string; + [key: string]: unknown; +} + +export interface EngineOpenParams { + dir?: string | null; + passphrase?: string | null; + userId: string; + deviceId: string; + [key: string]: unknown; +} + +export interface EngineWipeParams { + userId: string; + deviceId: string; + [key: string]: unknown; +} + export interface ExportDiagnosticsParams { frontendLogs?: string | null; [key: string]: unknown; diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 1b7fe399d4..cb17499d76 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -15,6 +15,7 @@ const debugLog = createDebugLogger('AppVisibility'); export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const [backgroundPushEnabled] = useSetting(settingsAtom, 'backgroundPushEnabled'); + const [pushNotifyUrlOverride] = useSetting(settingsAtom, 'pushNotifyUrlOverride'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = isMobileOrTablet(); @@ -43,12 +44,20 @@ export function useAppVisibility(mx: MatrixClient | undefined) { if (!mx) return undefined; const handleVisibilityForNotifications = (isVisible: boolean) => { - togglePusher(mx, clientConfig, isVisible, backgroundPushEnabled, pushSubAtom, isMobile); + togglePusher( + mx, + clientConfig, + isVisible, + backgroundPushEnabled, + pushSubAtom, + isMobile, + pushNotifyUrlOverride + ); }; appEvents.onVisibilityChange = handleVisibilityForNotifications; return () => { appEvents.onVisibilityChange = null; }; - }, [mx, clientConfig, backgroundPushEnabled, pushSubAtom, isMobile]); + }, [mx, clientConfig, backgroundPushEnabled, pushSubAtom, isMobile, pushNotifyUrlOverride]); } diff --git a/src/app/hooks/useVerificationRequest.test.tsx b/src/app/hooks/useVerificationRequest.test.tsx new file mode 100644 index 0000000000..571a5821ce --- /dev/null +++ b/src/app/hooks/useVerificationRequest.test.tsx @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { VerifierEvent } from '$types/matrix-sdk'; +import { useVerifierShowSas } from './useVerificationRequest'; + +describe('useVerifierShowSas', () => { + it('publishes SAS callbacks that were ready before the listener subscribed', () => { + const sasCallbacks = { sas: { emoji: [] } }; + const on = vi.fn<(event: string, handler: () => void) => void>(); + const removeListener = vi.fn<(event: string, handler: () => void) => void>(); + const getShowSasCallbacks = vi.fn<() => typeof sasCallbacks | null>(() => sasCallbacks); + const onCallback = vi.fn<(callbacks: unknown) => void>(); + + const { unmount } = renderHook(() => + useVerifierShowSas( + { + on, + removeListener, + getShowSasCallbacks, + } as never, + onCallback as never + ) + ); + + expect(on).toHaveBeenCalledWith(VerifierEvent.ShowSas, onCallback); + expect(getShowSasCallbacks).toHaveBeenCalledOnce(); + expect(onCallback).toHaveBeenCalledWith(sasCallbacks); + expect(on.mock.invocationCallOrder[0]).toBeLessThan( + getShowSasCallbacks.mock.invocationCallOrder[0] ?? 0 + ); + + unmount(); + expect(removeListener).toHaveBeenCalledWith(VerifierEvent.ShowSas, onCallback); + }); +}); diff --git a/src/app/hooks/useVerificationRequest.ts b/src/app/hooks/useVerificationRequest.ts index 65c2c2e4b1..187748da7c 100644 --- a/src/app/hooks/useVerificationRequest.ts +++ b/src/app/hooks/useVerificationRequest.ts @@ -66,6 +66,8 @@ export const useVerifierShowSas = ( ) => { useEffect(() => { verifier.on(VerifierEvent.ShowSas, onCallback); + const current = verifier.getShowSasCallbacks(); + if (current) onCallback(current); return () => { verifier.removeListener(VerifierEvent.ShowSas, onCallback); }; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0563fd59fb..02fb29335f 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -12,11 +12,14 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { clearCacheAndReload, clearLoginData, + discardSessionStores, initClient, logoutClient, startClient, stopClient, } from '$client/initMatrix'; +import { isLegacyWasmCryptoStoreError } from '$app/crypto/install'; +import { AsyncError } from '$components/AsyncError'; import { SplashScreen } from '$components/splash-screen'; import { ServerConfigsLoader } from '$components/ServerConfigsLoader'; import { CapabilitiesProvider } from '$hooks/useCapabilities'; @@ -339,6 +342,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); @@ -404,6 +419,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(() => { @@ -444,7 +461,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]); @@ -464,17 +481,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 8cb97147f4..045da799d5 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -15,6 +15,8 @@ import { import { fetch } from '$utils/fetch'; import { matrixFetch } from './matrixFetch'; import { clearMediaCache } from '$utils/mediaCache'; +import { isTauri } from '@tauri-apps/api/core'; +import { engineWipe } from '$generated/tauri/commands'; import { clearNavToActivePathStore } from '$state/navToActivePath'; import type { Session, Sessions, SessionStoreName } from '$state/sessions'; @@ -33,6 +35,7 @@ import { pushSessionToSW } from '../sw-session'; import { assertAuthMetadataIssuer, createSessionTokenRefresher } from './oidcTokenRefresher'; import { revokeOAuthToken } from './oauthTokenRevocation'; import { clearSecretStorageKeys, cryptoCallbacks } from './secretStorageKeys'; +import { installRustCrypto, rustEngineEnabled } from '$app/crypto/install'; import type { SlidingSyncDiagnostics } from './slidingSync'; import { prepareSlidingSyncTimelines, @@ -232,6 +235,29 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise = ]); }; +const clearSessionCaches = (session: Session): void => { + SlidingSyncSidebarCache.clear(session.userId); + clearCachedVersions(session.baseUrl, session.userId); + clearCachedUserProfiles(session.userId); + clearSecretStorageKeys(); +}; + +export const discardSessionStores = async (session: Session): Promise => { + clearSessionCaches(session); + const storeName = getSessionStoreName(session); + await deleteSessionStores(storeName); + await wipeNativeCryptoStore(session); +}; + +const wipeNativeCryptoStore = async (session: Session): Promise => { + if (!isTauri() || !session.deviceId) return; + try { + await engineWipe({ userId: session.userId, deviceId: session.deviceId }); + } catch (error) { + log.warn('wipeNativeCryptoStore failed', session.userId, error); + } +}; + const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -304,9 +330,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, @@ -665,17 +695,13 @@ 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); destroyLocalNotificationCache(session.userId); clearLocalNotificationCache(session.userId); const storeName: SessionStoreName = getSessionStoreName(session); await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); - await deleteDatabase(storeName.sync); - await deleteDatabase(storeName.crypto); - await deleteDatabase(`${storeName.rustCryptoPrefix}::matrix-sdk-crypto`); + await deleteSessionStores(storeName); + await wipeNativeCryptoStore(session); } else { await mx.clearStores(); window.localStorage.clear(); diff --git a/src/sw-webpush-activation.test.ts b/src/sw-webpush-activation.test.ts new file mode 100644 index 0000000000..c08b688c14 --- /dev/null +++ b/src/sw-webpush-activation.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { swTestHooks as swTestHooksHelper } from './sw'; + +vi.mock('workbox-precaching', () => ({ + cleanupOutdatedCaches: vi.fn<() => void>(), + precacheAndRoute: vi.fn<() => void>(), +})); + +type SwTestHooks = typeof swTestHooksHelper; + +type SwCacheStub = { + delete: () => Promise; + match: () => Promise; + put: (key: string, response: Response) => Promise; +}; + +const ACK_PATH = '/_matrix/client/unstable/org.matrix.msc4174/pushers/ack'; + +describe('service worker MSC4174 web push activation', () => { + let swTestHooks: SwTestHooks; + let clients: Map; + let addEventListener: Mock<(type: string, handler: (event: PushEvent) => void) => void>; + let persistedSessions: string | undefined; + let fetchMock: Mock; + + beforeEach(async () => { + vi.resetModules(); + clients = new Map(); + addEventListener = vi.fn<(type: string, handler: (event: PushEvent) => void) => void>(); + persistedSessions = undefined; + vi.stubGlobal('self', { + __WB_MANIFEST: [], + addEventListener, + caches: { + open: vi.fn<() => Promise>(async () => ({ + delete: vi.fn<() => Promise>(async () => true), + match: vi.fn<() => Promise>(async () => + persistedSessions ? new Response(persistedSessions) : undefined + ), + put: vi.fn<(key: string, response: Response) => Promise>( + async (_key: string, response: Response) => { + persistedSessions = await response.text(); + } + ), + })), + }, + clients: { + claim: vi.fn<() => void>(), + get: vi.fn<(id: string) => Promise>(async (id: string) => + clients.get(id) + ), + matchAll: vi.fn<() => Promise>(async () => Array.from(clients.values())), + }, + registration: {}, + }); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + swTestHooks = (await import('./sw')).swTestHooks; + }); + + const dispatchPush = async (payload: unknown): Promise => { + const pushHandler = addEventListener.mock.calls.find(([type]) => type === 'push')?.[1]; + expect(pushHandler).toBeTypeOf('function'); + + let pending: Promise | undefined; + pushHandler?.({ + data: { json: () => payload }, + waitUntil: (promise: Promise) => { + pending = promise; + }, + } as unknown as PushEvent); + await pending; + }; + + it('classifies activation payloads', () => { + expect(swTestHooks.isWebPushActivationPayload({ app_id: 'app', ack_token: 'token' })).toBe( + true + ); + expect( + swTestHooks.isWebPushActivationPayload({ + app_id: 'app', + ack_token: 'token', + room_id: '!room:example.org', + }) + ).toBe(false); + expect(swTestHooks.isWebPushActivationPayload({ app_id: 'app' })).toBe(false); + expect(swTestHooks.isWebPushActivationPayload({ room_id: '!room', event_id: '$event' })).toBe( + false + ); + expect(swTestHooks.isWebPushActivationPayload(undefined)).toBe(false); + }); + + it('acknowledges an activation push with the live session', async () => { + await swTestHooks.setSession( + 'alice-window', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + + await dispatchPush({ app_id: 'moe.sable.app', ack_token: '6fc76b70-5fad-4eb7-93ea' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`https://matrix.example.org${ACK_PATH}`); + expect(init.method).toBe('POST'); + expect((init.headers as Record).Authorization).toBe('Bearer alice-token'); + expect(JSON.parse(init.body as string)).toEqual({ + app_id: 'moe.sable.app', + ack_token: '6fc76b70-5fad-4eb7-93ea', + }); + }); + + it('acknowledges even when a visible client is open', async () => { + clients.set('visible-window', { visibilityState: 'visible' } as unknown as Client); + await swTestHooks.setSession( + 'alice-window', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + + await dispatchPush({ app_id: 'moe.sable.app', ack_token: 'token-1' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect((fetchMock.mock.calls[0] as unknown as [string])[0]).toContain(ACK_PATH); + }); + + it('falls back to the persisted session after an SW restart', async () => { + persistedSessions = JSON.stringify({ + '@bob:example.org': { + accessToken: 'bob-token', + baseUrl: 'https://other.example.org', + userId: '@bob:example.org', + }, + }); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + + await dispatchPush({ app_id: 'moe.sable.app', ack_token: 'token-2' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`https://other.example.org${ACK_PATH}`); + expect((init.headers as Record).Authorization).toBe('Bearer bob-token'); + }); + + it('tries the next account when the homeserver rejects the ack', async () => { + await swTestHooks.setSession( + 'alice-window', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + await swTestHooks.setSession( + 'bob-window', + 'bob-token', + 'https://matrix.example.org', + '@bob:example.org' + ); + fetchMock + .mockResolvedValueOnce(new Response('{"errcode":"M_NOT_FOUND"}', { status: 404 })) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + + await dispatchPush({ app_id: 'moe.sable.app', ack_token: 'token-3' }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstInit = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const secondInit = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect((firstInit[1].headers as Record).Authorization).toBe( + 'Bearer alice-token' + ); + expect((secondInit[1].headers as Record).Authorization).toBe( + 'Bearer bob-token' + ); + }); + + it('does not treat regular notifications as activation pushes', async () => { + clients.set('visible-window', { visibilityState: 'visible' } as unknown as Client); + await swTestHooks.setSession( + 'alice-window', + 'alice-token', + 'https://matrix.example.org', + '@alice:example.org' + ); + + // Visible client suppresses the notification before any network access. + await dispatchPush({ room_id: '!room:example.org', event_id: '$event:example.org' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/sw.ts b/src/sw.ts index 6de1c139c5..cde07216ce 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -4,6 +4,7 @@ import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'; import { EventType } from 'matrix-js-sdk/lib/@types/event'; +import { MATRIX_UNSTABLE_MSC4174_PUSHERS_ACK_PATH } from '$unstable/prefixes'; import { createPushNotifications } from './sw/pushNotification'; import { readPersistedSession } from './sw-session-persistence'; @@ -810,14 +811,12 @@ function respondWithInflightMedia( // mode: "no-cors", which prevents the Authorization header above from reaching the server. // Preserve Range header for streaming audio and video. const promise = fetch(request.url, { ...fetchConfig(token, request), redirect }) - .then( - async (res): Promise => ({ - status: res.status, - statusText: res.statusText, - headers: new Headers(res.headers), - body: await res.arrayBuffer(), - }) - ) + .then(async (res): Promise => ({ + status: res.status, + statusText: res.statusText, + headers: new Headers(res.headers), + body: await res.arrayBuffer(), + })) .finally(() => { inflightMediaFetches.delete(key); }); @@ -869,6 +868,8 @@ export const swTestHooks = { requestSessionWithTimeout, respondWithMediaAuthRecovery, setSession, + isWebPushActivationPayload, + acknowledgeWebPushActivation, }; self.addEventListener('message', (event: ExtendableMessageEvent) => { @@ -950,6 +951,69 @@ self.addEventListener('fetch', (event: FetchEvent) => { ); }); +// --------------------------------------------------------------------------- +// MSC4174 web push activation +// --------------------------------------------------------------------------- + +/** MSC4174 validation push: `{ app_id, ack_token }` and nothing else. */ +function isWebPushActivationPayload(data: unknown): data is { app_id: string; ack_token: string } { + if (!data || typeof data !== 'object') return false; + const d = data as Record; + return ( + typeof d.app_id === 'string' && + typeof d.ack_token === 'string' && + d.room_id === undefined && + d.event_id === undefined && + d.type === undefined && + typeof d.unread !== 'number' + ); +} + +async function postWebPushAck( + session: SessionInfo, + appId: string, + ackToken: string +): Promise { + try { + const res = await fetch(`${session.baseUrl}${MATRIX_UNSTABLE_MSC4174_PUSHERS_ACK_PATH}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ app_id: appId, ack_token: ackToken }), + }); + if (res.ok) return true; + console.warn('[SW push] MSC4174 ack rejected with HTTP', res.status); + } catch (err) { + console.warn('[SW push] MSC4174 ack failed', err); + } + return false; +} + +/** + * Acknowledges a MSC4174 validation push. The payload does not identify the + * account that registered the pusher, so each known session is tried in turn. + */ +async function acknowledgeWebPushActivation(appId: string, ackToken: string): Promise { + const candidates: SessionInfo[] = [...sessions.values()]; + const seen = new Set(candidates.map((session) => session.userId).filter(Boolean)); + const persisted = await loadPersistedSessions(); + Object.values(persisted).forEach((session) => { + if (!seen.has(session.userId)) { + candidates.push(session); + seen.add(session.userId); + } + }); + + // Chain sessions sequentially to avoid await-in-loop. + return candidates.reduce( + (prevPromise, session) => + prevPromise.then((acked) => acked || postWebPushAck(session, appId, ackToken)), + Promise.resolve(false) + ); +} + // Detect a minimal (event_id_only) payload: has room_id + event_id but no // event type field — meaning the homeserver stripped the event content. function isMinimalPushPayload( @@ -963,6 +1027,14 @@ function isMinimalPushPayload( const onPushNotification = async (event: PushEvent) => { if (!event?.data) return; + const pushData = event.data.json(); + + // MSC4174 validation push: not a notification, ack even while visible. + if (isWebPushActivationPayload(pushData)) { + await acknowledgeWebPushActivation(pushData.app_id, pushData.ack_token); + return; + } + // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. @@ -989,7 +1061,6 @@ const onPushNotification = async (event: PushEvent) => { return; } - const pushData = event.data.json(); console.debug('[SW push] raw payload:', JSON.stringify(pushData, null, 2)); try { diff --git a/src/unstable/prefixes/msc/index.ts b/src/unstable/prefixes/msc/index.ts index aa61f337f9..9a5bf88a57 100644 --- a/src/unstable/prefixes/msc/index.ts +++ b/src/unstable/prefixes/msc/index.ts @@ -5,3 +5,4 @@ export * from './event_misc'; export * from './profile'; export * from './accountdata'; export * from './state_events'; +export * from './push'; diff --git a/src/unstable/prefixes/msc/push.ts b/src/unstable/prefixes/msc/push.ts new file mode 100644 index 0000000000..cd573d6362 --- /dev/null +++ b/src/unstable/prefixes/msc/push.ts @@ -0,0 +1,19 @@ +// push related unstable prefixes +// don't import this file directly, import '$unstable/prefixes' +// all defined in MSC4174 https://github.com/matrix-org/matrix-spec-proposals/pull/4174 + +/** unstable /versions feature flag for MSC4174 web push support */ +export const MATRIX_UNSTABLE_MSC4174_FEATURE_NAME = 'org.matrix.msc4174'; + +/** stable capability name in /capabilities */ +export const MATRIX_STABLE_MSC4174_WEBPUSH_CAPABILITY_NAME = 'm.webpush'; + +/** unstable capability name in /capabilities */ +export const MATRIX_UNSTABLE_MSC4174_WEBPUSH_CAPABILITY_NAME = 'org.matrix.msc4174.webpush'; + +/** unstable pusher kind for /pushers/set */ +export const MATRIX_UNSTABLE_MSC4174_WEBPUSH_PUSHER_KIND = 'org.matrix.msc4174.webpush'; + +/** unstable endpoint to activate a webpush pusher with its ack_token */ +export const MATRIX_UNSTABLE_MSC4174_PUSHERS_ACK_PATH = + '/_matrix/client/unstable/org.matrix.msc4174/pushers/ack';