diff --git a/README.md b/README.md index 55e0f04..04d8b44 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ Join our public Matrix room for help: [#node-red-contrib-matrix-chat:skylar.tech Supported functionality in this package includes: -- **End-to-end encryption (E2EE)** - - [Work in progress](#end-to-end-encryption-notes) - - Alternative: Use [Pantalaimon](https://github.com/matrix-org/pantalaimon) for E2EE key synchronization across sessions +- **End-to-end encryption (E2EE)** — send and receive encrypted messages (see the [encryption notes](#end-to-end-encryption-notes)) +- **Cross-signing & secure backup** — interactive setup from the server config node so the bot's own device shows as verified +- **Device verification** — flow-driven SAS (emoji) verification via the `matrix-verification` and `matrix-verification-action` nodes - **Receive events** from rooms: Messages, reactions, images, audio, locations, files, encrypted or unencrypted - **Fetch/modify room state**: Update room settings - **Paginate room history** -- **Send files** (encryption support for files coming soon) +- **Send files** to rooms, encrypted or unencrypted - **Send/edit messages** (supports plain text and HTML formats) - **Send typing notifications** - **Delete events** (messages, reactions, etc.) @@ -33,6 +33,8 @@ These features allow you to easily build bots, set up chat relays, or even admin ### Installing +**Requires Node.js 22 or newer** (this is a requirement of the bundled `matrix-js-sdk`). + Install through Node-RED's UI by searching for `node-red-contrib-matrix-chat`, or use the following command inside your Node-RED directory: ```bash @@ -51,11 +53,12 @@ You're not limited to just the nodes we've created. Enable global access in your ### End-to-End Encryption Notes -- This module doesn't handle encryption key synchronization between devices. It’s recommended to use the bot exclusively in Node-RED to prevent issues with E2EE messages. -- **Storage:** Keys for E2EE are saved in a folder called `matrix-client-storage` within your Node-RED directory. Back up this folder regularly! If lost, you won’t be able to decrypt messages from E2EE rooms. +- E2EE uses the Rust crypto stack from `matrix-js-sdk`. The first time a bot starts after upgrading from an older version, any existing (legacy libolm) crypto state is migrated automatically. +- **Storage:** E2EE state is saved in a folder called `matrix-client-storage` within your Node-RED directory. Each account's Rust crypto store is persisted there as `rust-crypto-store.v8` (snapshotted on shutdown and every 5 minutes). Back up this folder regularly! If lost, you won’t be able to decrypt messages from E2EE rooms. - To move your bot to a different installation, migrate this folder and ensure the old and new clients don't run simultaneously. - -Interested in helping? Contributions to finalize E2EE support are welcome! +- It’s simplest to dedicate the account to the bot and run it only within Node-RED. The account can also be signed in elsewhere — if so, verify those sessions against the bot (see below) so they trust each other and share keys. +- **Cross-signing & secure backup:** open the server config node and use the **Set up secure backup & cross-signing** button. It checks the account and lets you unlock an existing secure backup with its recovery key, or create a fresh one — after which the bot's own device is cross-signed and shows as verified to others. +- **Device verification:** the `matrix-verification` node emits verification requests and phase changes, and `matrix-verification-action` accepts, starts, confirms, or cancels them — so you can build your own approval flow (e.g. emailing the SAS emoji for a human to confirm). See the [device verification example](https://github.com/Skylar-Tech/node-red-contrib-matrix-chat/tree/master/examples#device-verification). ### Registering a User diff --git a/examples/README.md b/examples/README.md index 8521c45..223d3d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -431,6 +431,27 @@ Downloads received files/images. If the file is encrypted, it will decrypt it fo +### Device Verification + +
+Handle device verification (SAS / emoji) + +[View JSON](device-verification-flow.json) + +An end-to-end example of interactive device verification. The `matrix-verification` node emits every verification request and phase change; the flow routes by phase, automatically **accepts** incoming requests and **starts SAS**, then surfaces the SAS emoji so a human can compare it. Inject nodes let you **confirm** or **reject** the match, and there are paths to have the bot **request** verification of a specific user's device, or a user in a room. + +Requires end-to-end encryption to be enabled on the server config node. For the bot's own device to be trusted by others, also set up cross-signing via the **Set up secure backup & cross-signing** button on the server config node. + +**Instructions:** + +1. Import the flow and set the Matrix server config on each matrix node. +2. Replace the `@CHANGE_ME:example.org` / `CHANGE_ME` placeholders in the "Verify a user" inject nodes if you want to use the bot-initiated paths. +3. To verify the bot from another client, start a verification with it, watch the debug sidebar for the `sas` event, compare the emoji, then click the **Confirm SAS match** inject. + +![device-verification-flow.png](device-verification-flow.png) + +
+ ### Deprecated
diff --git a/examples/device-verification-flow.json b/examples/device-verification-flow.json new file mode 100644 index 0000000..e916ed2 --- /dev/null +++ b/examples/device-verification-flow.json @@ -0,0 +1,548 @@ +[ + { + "id": "7158964bd67edc52", + "type": "group", + "z": "vtest", + "name": "Example verification flow", + "style": { + "label": true + }, + "nodes": [ + "40c105c38054d6db", + "83f785d52a61009a", + "d51bab8cbf5f247c", + "2e543533d49b467c" + ], + "x": 88, + "y": 73, + "w": 1044, + "h": 754 + }, + { + "id": "40c105c38054d6db", + "type": "group", + "z": "vtest", + "g": "7158964bd67edc52", + "name": "Verification request handling", + "style": { + "label": true + }, + "nodes": [ + "mv_all", + "dbg_events", + "sw_phase", + "act_accept", + "act_start", + "chg_savevid", + "dbg_sas", + "dbg_done", + "dbg_cancelled", + "dbg_err" + ], + "x": 114, + "y": 99, + "w": 992, + "h": 342 + }, + { + "id": "mv_all", + "type": "matrix-verification", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "All verifications", + "server": null, + "phaseRequested": true, + "phaseReady": true, + "phaseStarted": true, + "phaseSas": true, + "phaseDone": true, + "phaseCancelled": true, + "initiatedBy": "any", + "verificationType": "any", + "selfVerification": "any", + "userFilter": "", + "roomFilter": "", + "x": 220, + "y": 180, + "wires": [ + [ + "dbg_events", + "sw_phase" + ] + ] + }, + { + "id": "dbg_events", + "type": "debug", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "all verification events", + "active": true, + "tosidebar": true, + "complete": "true", + "x": 500, + "y": 140, + "wires": [] + }, + { + "id": "sw_phase", + "type": "switch", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "route by phase", + "property": "phase", + "propertyType": "msg", + "rules": [ + { + "t": "eq", + "v": "requested", + "vt": "str" + }, + { + "t": "eq", + "v": "ready", + "vt": "str" + }, + { + "t": "eq", + "v": "sas", + "vt": "str" + }, + { + "t": "eq", + "v": "done", + "vt": "str" + }, + { + "t": "eq", + "v": "cancelled", + "vt": "str" + } + ], + "checkall": "true", + "outputs": 5, + "x": 460, + "y": 220, + "wires": [ + [ + "act_accept" + ], + [ + "act_start" + ], + [ + "chg_savevid" + ], + [ + "dbg_done" + ], + [ + "dbg_cancelled" + ] + ] + }, + { + "id": "act_accept", + "type": "matrix-verification-action", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "Accept", + "server": null, + "mode": "accept", + "x": 700, + "y": 180, + "wires": [ + [], + [ + "dbg_err" + ] + ] + }, + { + "id": "act_start", + "type": "matrix-verification-action", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "Start SAS", + "server": null, + "mode": "start", + "x": 700, + "y": 230, + "wires": [ + [], + [ + "dbg_err" + ] + ] + }, + { + "id": "chg_savevid", + "type": "change", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "save verificationId", + "rules": [ + { + "t": "set", + "p": "verificationId", + "pt": "flow", + "to": "verificationId", + "tot": "msg" + } + ], + "x": 710, + "y": 290, + "wires": [ + [ + "dbg_sas" + ] + ] + }, + { + "id": "dbg_sas", + "type": "debug", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "SAS emoji (msg.sas)", + "active": true, + "tosidebar": true, + "complete": "true", + "x": 960, + "y": 290, + "wires": [] + }, + { + "id": "dbg_done", + "type": "debug", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "verification done", + "active": true, + "tosidebar": true, + "complete": "true", + "x": 710, + "y": 350, + "wires": [] + }, + { + "id": "dbg_cancelled", + "type": "debug", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "verification cancelled", + "active": true, + "tosidebar": true, + "complete": "true", + "x": 730, + "y": 400, + "wires": [] + }, + { + "id": "dbg_err", + "type": "debug", + "z": "vtest", + "g": "40c105c38054d6db", + "name": "action errors", + "active": true, + "tosidebar": true, + "complete": "true", + "x": 940, + "y": 200, + "wires": [] + }, + { + "id": "83f785d52a61009a", + "type": "group", + "z": "vtest", + "g": "7158964bd67edc52", + "name": "Confirm or reject last verification request", + "style": { + "label": true + }, + "nodes": [ + "inj_confirm", + "chg_vid_c", + "act_confirm", + "inj_reject", + "chg_vid_r", + "act_mismatch", + "dbg_result" + ], + "x": 114, + "y": 459, + "w": 982, + "h": 142 + }, + { + "id": "inj_confirm", + "type": "inject", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "Confirm SAS match", + "props": [], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 250, + "y": 500, + "wires": [ + [ + "chg_vid_c" + ] + ] + }, + { + "id": "chg_vid_c", + "type": "change", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "verificationId from flow", + "rules": [ + { + "t": "set", + "p": "verificationId", + "pt": "msg", + "to": "verificationId", + "tot": "flow" + } + ], + "x": 500, + "y": 500, + "wires": [ + [ + "act_confirm" + ] + ] + }, + { + "id": "act_confirm", + "type": "matrix-verification-action", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "Confirm SAS", + "server": null, + "mode": "confirm", + "x": 750, + "y": 500, + "wires": [ + [ + "dbg_result" + ], + [ + "dbg_err" + ] + ] + }, + { + "id": "inj_reject", + "type": "inject", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "Reject SAS (mismatch)", + "props": [], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 260, + "y": 560, + "wires": [ + [ + "chg_vid_r" + ] + ] + }, + { + "id": "chg_vid_r", + "type": "change", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "verificationId from flow", + "rules": [ + { + "t": "set", + "p": "verificationId", + "pt": "msg", + "to": "verificationId", + "tot": "flow" + } + ], + "x": 500, + "y": 560, + "wires": [ + [ + "act_mismatch" + ] + ] + }, + { + "id": "act_mismatch", + "type": "matrix-verification-action", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "Reject (mismatch)", + "server": null, + "mode": "mismatch", + "x": 760, + "y": 560, + "wires": [ + [ + "dbg_result" + ], + [ + "dbg_err" + ] + ] + }, + { + "id": "dbg_result", + "type": "debug", + "z": "vtest", + "g": "83f785d52a61009a", + "name": "action result", + "active": true, + "tosidebar": true, + "complete": "true", + "x": 980, + "y": 530, + "wires": [] + }, + { + "id": "d51bab8cbf5f247c", + "type": "group", + "z": "vtest", + "g": "7158964bd67edc52", + "name": "Request verification with specific user & device", + "style": { + "label": true + }, + "nodes": [ + "inj_request", + "act_request" + ], + "x": 114, + "y": 619, + "w": 512, + "h": 82 + }, + { + "id": "inj_request", + "type": "inject", + "z": "vtest", + "g": "d51bab8cbf5f247c", + "name": "Verify a user & device", + "props": [ + { + "p": "userId", + "v": "@CHANGE_ME:example.org", + "vt": "str" + }, + { + "p": "deviceId", + "v": "CHANGE_ME", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 260, + "y": 660, + "wires": [ + [ + "act_request" + ] + ] + }, + { + "id": "act_request", + "type": "matrix-verification-action", + "z": "vtest", + "g": "d51bab8cbf5f247c", + "name": "Request verification", + "server": null, + "mode": "request", + "x": 510, + "y": 660, + "wires": [ + [ + "dbg_result" + ], + [ + "dbg_err" + ] + ] + }, + { + "id": "2e543533d49b467c", + "type": "group", + "z": "vtest", + "g": "7158964bd67edc52", + "name": "Request verification with specific user & room", + "style": { + "label": true + }, + "nodes": [ + "f7c043d39780b9a4", + "b2807fd5125b56b4" + ], + "x": 114, + "y": 719, + "w": 512, + "h": 82 + }, + { + "id": "f7c043d39780b9a4", + "type": "inject", + "z": "vtest", + "g": "2e543533d49b467c", + "name": "Verify a user & room", + "props": [ + { + "p": "userId", + "v": "@CHANGE_ME:example.org", + "vt": "str" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "CHANGE_ME", + "x": 250, + "y": 760, + "wires": [ + [ + "b2807fd5125b56b4" + ] + ] + }, + { + "id": "b2807fd5125b56b4", + "type": "matrix-verification-action", + "z": "vtest", + "g": "2e543533d49b467c", + "name": "Request verification", + "server": null, + "mode": "request", + "x": 510, + "y": 760, + "wires": [ + [ + "dbg_result" + ], + [ + "dbg_err" + ] + ] + } +] \ No newline at end of file diff --git a/examples/device-verification-flow.png b/examples/device-verification-flow.png new file mode 100644 index 0000000..b167c92 Binary files /dev/null and b/examples/device-verification-flow.png differ diff --git a/package-lock.json b/package-lock.json index 198204c..4a18ee0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,22 +10,22 @@ "license": "SEE LICENSE FILE", "dependencies": { "abort-controller": "^3.0.0", + "fake-indexeddb": "^6.2.5", "fluent-ffmpeg": "^2.1.2", "fs-extra": "^11.1.0", "got": "^12.0.2", "image-size": "^1.0.2", "isomorphic-webcrypto": "^2.3.8", - "matrix-js-sdk": "^34.13.0", + "matrix-js-sdk": "^41.5.0", "mime": "^3.0.0", "node-fetch": "^3.3.0", "node-localstorage": "^2.2.1", - "olm": "https://gitlab.matrix.org/matrix-org/olm/-/package_files/2572/download", "sharp": "^0.33.4", "tmp": "^0.2.1", "utf8": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@ampproject/remapping": { @@ -4188,20 +4188,14 @@ } }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-9.1.0.tgz", - "integrity": "sha512-CtPoNcoRW6ehwxpRQAksG3tR+NJ7k4DV02nMFYTDwQtie1V4R8OTY77BjEIs97NOblhtS26jU8m1lWsOBEz0Og==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz", + "integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==", "license": "Apache-2.0", "engines": { - "node": ">= 10" + "node": ">= 18" } }, - "node_modules/@matrix-org/olm": { - "version": "3.2.15", - "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz", - "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==", - "license": "Apache-2.0" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6037,11 +6031,6 @@ "optional": true, "peer": true }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" - }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -8288,6 +8277,15 @@ } } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -9337,6 +9335,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -10717,42 +10727,27 @@ "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==" }, "node_modules/matrix-js-sdk": { - "version": "34.13.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-34.13.0.tgz", - "integrity": "sha512-AAU8ZdCawca+7ucQfdcC3LA85OtCTV7QeqcjvKt/ZZhU3xL9VoawuoRQ+4R6H8KZnqyJmT4j7bdeC0jG4qcqLg==", + "version": "41.5.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.5.0.tgz", + "integrity": "sha512-CK3h+qQJ4wkVEUgEWc5MdLjccXyiFqncCC53P+auqOhnX2U6tAFsRfnbML1QQiKIsFMzqTrAnF/4a5LUUOIeXg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^9.0.0", - "@matrix-org/olm": "3.2.15", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0", "another-json": "^0.2.0", "bs58": "^6.0.0", "content-type": "^1.0.4", "jwt-decode": "^4.0.0", - "loglevel": "^1.7.1", + "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.10.0", + "matrix-widget-api": "^1.16.1", "oidc-client-ts": "^3.0.1", - "p-retry": "4", - "sdp-transform": "^2.14.1", - "unhomoglyph": "^1.0.6", - "uuid": "11" + "p-retry": "8", + "sdp-transform": "^3.0.0", + "unhomoglyph": "^1.0.6" }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/matrix-js-sdk/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "node": ">=22.0.0" } }, "node_modules/matrix-widget-api": { @@ -12298,13 +12293,6 @@ "node": ">=18" } }, - "node_modules/olm": { - "name": "@matrix-org/olm", - "version": "3.2.15", - "resolved": "https://gitlab.matrix.org/matrix-org/olm/-/package_files/2572/download", - "integrity": "sha512-5j5CLplrEodeqGMHEzwDZz6UlqWHyR8c1+tCpCj/yrIJ3V4kYbQVySUhrPNIz2k6j7Ief8GiNxxArjXfuGLfgg==", - "license": "Apache-2.0" - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12559,15 +12547,18 @@ } }, "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz", + "integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==", + "license": "MIT", "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" + "is-network-error": "^1.3.0" }, "engines": { - "node": ">=8" + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-try": { @@ -13680,14 +13671,6 @@ "node": ">=4" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -13773,9 +13756,10 @@ } }, "node_modules/sdp-transform": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", - "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz", + "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==", + "license": "MIT", "bin": { "sdp-verify": "checker.js" } @@ -18417,14 +18401,9 @@ } }, "@matrix-org/matrix-sdk-crypto-wasm": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-9.1.0.tgz", - "integrity": "sha512-CtPoNcoRW6ehwxpRQAksG3tR+NJ7k4DV02nMFYTDwQtie1V4R8OTY77BjEIs97NOblhtS26jU8m1lWsOBEz0Og==" - }, - "@matrix-org/olm": { - "version": "3.2.15", - "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz", - "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==" + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz", + "integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==" }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -19841,11 +19820,6 @@ "optional": true, "peer": true }, - "@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" - }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -21631,6 +21605,11 @@ "base64-js": "^1.3.0" } }, + "fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==" + }, "fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -22427,6 +22406,11 @@ } } }, + "is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==" + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -23483,32 +23467,23 @@ "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==" }, "matrix-js-sdk": { - "version": "34.13.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-34.13.0.tgz", - "integrity": "sha512-AAU8ZdCawca+7ucQfdcC3LA85OtCTV7QeqcjvKt/ZZhU3xL9VoawuoRQ+4R6H8KZnqyJmT4j7bdeC0jG4qcqLg==", + "version": "41.5.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.5.0.tgz", + "integrity": "sha512-CK3h+qQJ4wkVEUgEWc5MdLjccXyiFqncCC53P+auqOhnX2U6tAFsRfnbML1QQiKIsFMzqTrAnF/4a5LUUOIeXg==", "requires": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^9.0.0", - "@matrix-org/olm": "3.2.15", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0", "another-json": "^0.2.0", "bs58": "^6.0.0", "content-type": "^1.0.4", "jwt-decode": "^4.0.0", - "loglevel": "^1.7.1", + "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.10.0", + "matrix-widget-api": "^1.16.1", "oidc-client-ts": "^3.0.1", - "p-retry": "4", - "sdp-transform": "^2.14.1", - "unhomoglyph": "^1.0.6", - "uuid": "11" - }, - "dependencies": { - "uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==" - } + "p-retry": "8", + "sdp-transform": "^3.0.0", + "unhomoglyph": "^1.0.6" } }, "matrix-widget-api": { @@ -24730,10 +24705,6 @@ "jwt-decode": "^4.0.0" } }, - "olm": { - "version": "https://gitlab.matrix.org/matrix-org/olm/-/package_files/2572/download", - "integrity": "sha512-5j5CLplrEodeqGMHEzwDZz6UlqWHyR8c1+tCpCj/yrIJ3V4kYbQVySUhrPNIz2k6j7Ief8GiNxxArjXfuGLfgg==" - }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -24927,12 +24898,11 @@ } }, "p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz", + "integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==", "requires": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" + "is-network-error": "^1.3.0" } }, "p-try": { @@ -25804,11 +25774,6 @@ "signal-exit": "^3.0.2" } }, - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" - }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -25873,9 +25838,9 @@ } }, "sdp-transform": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", - "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz", + "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==" }, "semver": { "version": "7.6.2", diff --git a/package.json b/package.json index 527815e..8bb2505 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "description": "Matrix chat server client for Node-RED", "dependencies": { "abort-controller": "^3.0.0", + "fake-indexeddb": "^6.2.5", "fluent-ffmpeg": "^2.1.2", "fs-extra": "^11.1.0", "got": "^12.0.2", "image-size": "^1.0.2", "isomorphic-webcrypto": "^2.3.8", - "matrix-js-sdk": "^34.13.0", + "matrix-js-sdk": "^41.5.0", "mime": "^3.0.0", "node-fetch": "^3.3.0", "node-localstorage": "^2.2.1", - "olm": "https://gitlab.matrix.org/matrix-org/olm/-/package_files/2572/download", "sharp": "^0.33.4", "tmp": "^0.2.1", "utf8": "^3.0.0" @@ -51,11 +51,13 @@ "matrix-whois-user": "src/matrix-whois-user.js", "matrix-paginate-room": "src/matrix-paginate-room.js", "matrix-get-event": "src/matrix-get-event.js", - "matrix-event-relations": "src/matrix-event-relations.js" + "matrix-event-relations": "src/matrix-event-relations.js", + "matrix-verification": "src/matrix-verification.js", + "matrix-verification-action": "src/matrix-verification-action.js" } }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "keywords": [ "node-red", @@ -72,8 +74,5 @@ "name": "Skylar Sadlier", "url": "https://skylar.tech" }, - "license": "SEE LICENSE FILE", - "engines": { - "node": ">=18.0.0" - } + "license": "SEE LICENSE FILE" } diff --git a/src/matrix-crypt-file.html b/src/matrix-crypt-file.html index ae73715..65a7a3b 100644 --- a/src/matrix-crypt-file.html +++ b/src/matrix-crypt-file.html @@ -43,6 +43,16 @@ string | null
the decoded mxc url.
+ +
msg.headers + object +
+
optional HTTP headers. If provided, they are used when downloading media (for example authenticated media bearer tokens).
+ +
msg.access_token + string +
+
optional Matrix access token. Used as a bearer token if msg.headers.Authorization is not present.

Outputs

@@ -74,4 +84,4 @@ - \ No newline at end of file + diff --git a/src/matrix-crypt-file.js b/src/matrix-crypt-file.js index a6236cf..c15f6db 100644 --- a/src/matrix-crypt-file.js +++ b/src/matrix-crypt-file.js @@ -32,7 +32,9 @@ module.exports = function(RED) { } try{ - let buffer = await got(msg.url).buffer(); + const requestOptions = getRequestOptions(msg); + + let buffer = await downloadBufferWithFallback(got, msg.url, requestOptions); msg.payload = Buffer.from(await decryptAttachment(buffer, msg.content.file)); // handle thumbnail decryption if necessary @@ -41,13 +43,14 @@ module.exports = function(RED) { && msg.thumbnail_url && msg.content.info.thumbnail_file ) { - let thumb_buffer = await got(msg.thumbnail_url).buffer(); + let thumb_buffer = await downloadBufferWithFallback(got, msg.thumbnail_url, requestOptions); msg.thumbnail_payload = Buffer.from(await decryptAttachment(thumb_buffer, msg.content.info.thumbnail_file)); } } catch(error){ node.error(error); msg.error = error; node.send([null, msg]); + return; } msg.filename = msg.content.filename || msg.content.body; @@ -57,6 +60,52 @@ module.exports = function(RED) { } RED.nodes.registerType("matrix-decrypt-file", MatrixDecryptFile); + function getRequestOptions(msg) { + const headers = { ...(msg.headers || {}) }; + if (!headers.Authorization && msg.access_token) { + headers.Authorization = `Bearer ${msg.access_token}`; + } + + return Object.keys(headers).length ? { headers } : {}; + } + + function getMediaEndpointFallbackUrl(url) { + if (typeof url !== "string") { + return null; + } + + if (url.includes("/_matrix/media/v3/download/")) { + return url.replace("/_matrix/media/v3/download/", "/_matrix/client/v1/media/download/"); + } + + if (url.includes("/_matrix/client/v1/media/download/")) { + return url.replace("/_matrix/client/v1/media/download/", "/_matrix/media/v3/download/"); + } + + if (url.includes("/_matrix/media/v3/thumbnail/")) { + return url.replace("/_matrix/media/v3/thumbnail/", "/_matrix/client/v1/media/thumbnail/"); + } + + if (url.includes("/_matrix/client/v1/media/thumbnail/")) { + return url.replace("/_matrix/client/v1/media/thumbnail/", "/_matrix/media/v3/thumbnail/"); + } + + return null; + } + + async function downloadBufferWithFallback(got, url, requestOptions) { + try { + return await got(url, requestOptions).buffer(); + } catch (error) { + const fallbackUrl = getMediaEndpointFallbackUrl(url); + if (error?.response?.statusCode === 404 && fallbackUrl && fallbackUrl !== url) { + return await got(fallbackUrl, requestOptions).buffer(); + } + + throw error; + } + } + function atob(a) { return Buffer.from(a, 'base64').toString('binary'); } @@ -200,4 +249,4 @@ module.exports = function(RED) { } return uint8Array; } -} \ No newline at end of file +} diff --git a/src/matrix-crypto-store.js b/src/matrix-crypto-store.js new file mode 100644 index 0000000..eb74cf3 --- /dev/null +++ b/src/matrix-crypto-store.js @@ -0,0 +1,175 @@ +/** + * Persistence helpers for the matrix-js-sdk Rust crypto store in Node.js. + * + * matrix-js-sdk v37+ removed the legacy (libolm) crypto stack. The Rust crypto + * replacement persists its state (device identity, Olm/megolm sessions, etc.) + * to IndexedDB, which does not exist in Node.js. We provide an in-memory + * IndexedDB via `fake-indexeddb` and snapshot the databases to/from disk so the + * crypto state survives Node-RED restarts. + * + * The `indexeddbshim` package (which can persist to disk directly) is not used + * because it is incompatible with the Rust crypto store migrations + * (see matrix-org/matrix-sdk-crypto-wasm#195). `fake-indexeddb` is spec + * compliant, so snapshotting it through the public IndexedDB API is reliable. + */ +const fs = require('fs-extra'); +const v8 = require('v8'); + +let shimInstalled = false; + +/** + * Install the in-memory IndexedDB shim onto globalThis. Idempotent. Must be + * called before MatrixClient.initRustCrypto(). + */ +function ensureIndexedDBShim() { + if (shimInstalled || globalThis.indexedDB) { + shimInstalled = true; + return; + } + // `fake-indexeddb/auto` assigns indexedDB / IDBKeyRange / etc. onto globalThis. + require('fake-indexeddb/auto'); + shimInstalled = true; +} + +function reqAsync(req) { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function txDone(tx) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted')); + }); +} + +/** + * Restore previously snapshotted IndexedDB databases from `filePath` into the + * in-memory store. No-op if the snapshot does not exist. Databases that are + * already present in memory (e.g. after a Node-RED redeploy that kept the + * process alive) are left untouched so the live state is not clobbered. + * + * Must be called before MatrixClient.initRustCrypto(). + * + * @returns {Promise} true if at least one database was restored. + */ +async function restoreCryptoStore(filePath) { + ensureIndexedDBShim(); + + if (!filePath || !fs.pathExistsSync(filePath)) { + return false; + } + + let databases; + try { + databases = v8.deserialize(fs.readFileSync(filePath)); + } catch (e) { + // Corrupt/unreadable snapshot - start fresh rather than crash. + return false; + } + if (!Array.isArray(databases) || !databases.length) { + return false; + } + + const existing = new Set((await indexedDB.databases()).map((d) => d.name)); + let restored = 0; + + for (const dbSpec of databases) { + if (existing.has(dbSpec.name)) { + continue; // already live in memory - don't overwrite + } + + const openReq = indexedDB.open(dbSpec.name, dbSpec.version); + openReq.onupgradeneeded = () => { + const db = openReq.result; + for (const store of dbSpec.stores) { + if (db.objectStoreNames.contains(store.name)) { + continue; + } + const os = db.createObjectStore(store.name, { + keyPath: store.keyPath || undefined, + autoIncrement: store.autoIncrement, + }); + for (const ix of store.indexes) { + os.createIndex(ix.name, ix.keyPath, { unique: ix.unique, multiEntry: ix.multiEntry }); + } + } + }; + const db = await reqAsync(openReq); + + for (const store of dbSpec.stores) { + if (!store.values.length) { + continue; + } + const tx = db.transaction(store.name, 'readwrite'); + const os = tx.objectStore(store.name); + for (let i = 0; i < store.values.length; i++) { + if (store.keyPath) { + os.put(store.values[i]); + } else { + os.put(store.values[i], store.keys[i]); + } + } + await txDone(tx); + } + db.close(); + restored++; + } + + return restored > 0; +} + +/** + * Snapshot IndexedDB databases to `filePath`. If `dbNamePrefix` is given only + * databases whose name starts with it are written, so multiple Matrix accounts + * sharing one process do not snapshot each other's data. + * + * The write is atomic (temp file + rename). Values are serialized with the V8 + * serializer so typed arrays / Maps inside the crypto store survive intact. + * + * @returns {Promise} true if a snapshot file was written. + */ +async function snapshotCryptoStore(filePath, dbNamePrefix) { + if (!filePath || !globalThis.indexedDB || typeof indexedDB.databases !== 'function') { + return false; + } + + let dbList = await indexedDB.databases(); + if (dbNamePrefix) { + dbList = dbList.filter((d) => typeof d.name === 'string' && d.name.startsWith(dbNamePrefix)); + } + + const out = []; + for (const { name, version } of dbList) { + const db = await reqAsync(indexedDB.open(name, version)); + const stores = []; + for (const storeName of Array.from(db.objectStoreNames)) { + const tx = db.transaction(storeName, 'readonly'); + const os = tx.objectStore(storeName); + const indexes = Array.from(os.indexNames).map((n) => { + const ix = os.index(n); + return { name: n, keyPath: ix.keyPath, unique: ix.unique, multiEntry: ix.multiEntry }; + }); + stores.push({ + name: storeName, + keyPath: os.keyPath, + autoIncrement: os.autoIncrement, + indexes, + values: await reqAsync(os.getAll()), + keys: await reqAsync(os.getAllKeys()), + }); + } + db.close(); + out.push({ name, version, stores }); + } + + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, v8.serialize(out)); + fs.renameSync(tmp, filePath); + return true; +} + +module.exports = { ensureIndexedDBShim, restoreCryptoStore, snapshotCryptoStore }; diff --git a/src/matrix-get-user.js b/src/matrix-get-user.js index 9afa8fc..0f8b595 100644 --- a/src/matrix-get-user.js +++ b/src/matrix-get-user.js @@ -106,14 +106,14 @@ module.exports = function(RED) { let user2 = {}; try { - let profileInfo = node.server.matrixClient.getProfileInfo(userId); - if(Object.keys(profileInfo).length > 0) { + let profileInfo = await node.server.matrixClient.getProfileInfo(userId); + if(profileInfo && Object.keys(profileInfo).length > 0) { user2.displayName = profileInfo.displayname; user2.avatarUrl = profileInfo.avatar_url; } - let presence = node.server.matrixClient.getPresence(userId); - if(Object.keys(presence).length > 0) { + let presence = await node.server.matrixClient.getPresence(userId); + if(presence && Object.keys(presence).length > 0) { user2.currentlyActive = presence.currently_active; user2.lastActiveAgo = presence.last_active_ago; user2.presenceStatusMsg = presence.presence_status_msg; diff --git a/src/matrix-invite-room.js b/src/matrix-invite-room.js index 61c7589..22a7f92 100644 --- a/src/matrix-invite-room.js +++ b/src/matrix-invite-room.js @@ -54,9 +54,10 @@ module.exports = function(RED) { return; } - // we need the status code, so set onlydata to false for this request + // invite(roomId, userId, opts|reason) - the SDK no longer accepts a + // callback argument, so the reason is passed as the 3rd parameter. node.server.matrixClient - .invite(msg.topic, msg.userId, undefined, msg.reason || undefined) + .invite(msg.topic, msg.userId, msg.reason || undefined) .then(function(e){ msg.payload = e; node.send([msg, null]); diff --git a/src/matrix-receive.html b/src/matrix-receive.html index 3274c4a..bfe4a50 100644 --- a/src/matrix-receive.html +++ b/src/matrix-receive.html @@ -227,6 +227,11 @@
msg.content object
the message's content object
+ +
+
msg.headers object | null
+
for media events, includes auth headers (for example Authorization: Bearer ...) used by authed media endpoints.
+
  • msg.type == 'm.text' @@ -358,4 +363,4 @@
  • - \ No newline at end of file + diff --git a/src/matrix-receive.js b/src/matrix-receive.js index 77059c8..45f1e76 100644 --- a/src/matrix-receive.js +++ b/src/matrix-receive.js @@ -47,11 +47,31 @@ module.exports = function(RED) { return; } + const setAuthHeaders = () => { + const accessToken = node.server.matrixClient.getAccessToken?.(); + if (accessToken) { + msg.headers = { + ...(msg.headers || {}), + Authorization: `Bearer ${accessToken}`, + }; + } + }; + const setUrls = (urlKey, encryptedKey) => { const url = msg.encrypted ? msg.content[encryptedKey]?.url : msg.content[urlKey]; if (url) { - msg.url = node.server.matrixClient.mxcUrlToHttp(url); + const authenticatedUrl = node.server.matrixClient.mxcUrlToHttp( + url, + undefined, + undefined, + undefined, + false, + true, + true, + ); + msg.url = authenticatedUrl || node.server.matrixClient.mxcUrlToHttp(url); msg.mxc_url = url; + setAuthHeaders(); } }; @@ -59,8 +79,18 @@ module.exports = function(RED) { const thumbnailFile = msg.content.info?.[infoKey]; const thumbnailUrl = thumbnailFile?.url; if (thumbnailUrl) { - msg.thumbnail_url = node.server.matrixClient.mxcUrlToHttp(thumbnailUrl); + const authenticatedThumbnailUrl = node.server.matrixClient.mxcUrlToHttp( + thumbnailUrl, + undefined, + undefined, + undefined, + false, + true, + true, + ); + msg.thumbnail_url = authenticatedThumbnailUrl || node.server.matrixClient.mxcUrlToHttp(thumbnailUrl); msg.thumbnail_mxc_url = thumbnailUrl; + setAuthHeaders(); } }; @@ -141,4 +171,4 @@ module.exports = function(RED) { }); } RED.nodes.registerType("matrix-receive", MatrixReceiveMessage); -} \ No newline at end of file +} diff --git a/src/matrix-server-config.html b/src/matrix-server-config.html index 60db2f7..7d9a29b 100644 --- a/src/matrix-server-config.html +++ b/src/matrix-server-config.html @@ -31,17 +31,232 @@ accessToken: { type: "password", required: true }, deviceId: { type: "text", required: false }, url: { type: "text", required: true }, + password: { type: "password", required: false }, }, defaults: { name: { value: null }, autoAcceptRoomInvites: { value: true }, enableE2ee: { type: "checkbox", value: true }, global: { type: "checkbox", value: true }, - allowUnknownDevices: { type: "checkbox", value: false } + allowUnknownDevices: { type: "checkbox", value: true } }, icon: "matrix.png", label: function() { return this.name || undefined; + }, + oneditprepare: function() { + const nodeId = this.id; + + // --- Secure backup / cross-signing setup (modal dialog) --- + // The modal is built once and reused across editor sessions; the + // current node id is stored on the overlay so its handlers target + // whichever server config node is being edited. + if (!document.getElementById("matrix-sb-overlay")) { + $('').appendTo("head"); + + $('
    ' + + '
    Secure Backup & Cross-signing' + + '×
    ' + + '
    ' + + '
    ' + + '' + + '' + + '' + + '
    ' + + '
    ' + + '' + + '' + + '
    ').appendTo(document.body); + + var sbEsc = function(s) { return $("
    ").text(s == null ? "" : String(s)).html(); }; + var sbClose = function() { $("#matrix-sb-overlay").fadeOut(120); }; + var sbId = function() { return $("#matrix-sb-overlay").data("matrixNodeId"); }; + var sbBtns = function(disabled) { $("#matrix-sb-unlock-btn,#matrix-sb-reset-btn").prop("disabled", disabled); }; + var sbCall = function(body) { + return $.ajax({ + url: "matrix-chat/secure-backup", type: "POST", + contentType: "application/json", data: JSON.stringify(body), + }); + }; + var sbState = function(icon, color, html) { + $("#matrix-sb-state").html('' + html + ''); + }; + var sbResult = function(ok, text, key) { + var h = sbEsc(text); + if (key) { h += '
    ' + sbEsc(key) + '
    '; } + $("#matrix-sb-result").removeClass("ok err").addClass(ok ? "ok" : "err").html(h).show(); + }; + var sbStatus = function() { + $("#matrix-sb-unlock,#matrix-sb-reset,#matrix-sb-result,#matrix-sb-reset-toggle").hide(); + sbState("fa-spinner fa-spin", "#888", "Checking the account…"); + sbCall({ id: sbId(), action: "status" }).done(function(data) { + if (data.result !== "ok") { + sbState("fa-exclamation-triangle", "#c9302c", "Could not check the account."); + sbResult(false, data.message || "Unknown error"); + return; + } + if (data.crossSigningReady) { + sbState("fa-check-circle", "#3a9a4e", "Cross-signing is set up. The bot's device is cross-signed."); + $("#matrix-sb-reset-toggle").show(); + } else if (data.secretStorageExists) { + sbState("fa-lock", "#d18a1b", "This account has an existing secure backup. Enter its recovery key to set up cross-signing for the bot."); + $("#matrix-sb-recoverykey").val(""); + $("#matrix-sb-unlock,#matrix-sb-reset-toggle").show(); + } else { + sbState("fa-shield", "#888", "No secure backup exists yet. Set one up to enable cross-signing."); + $("#matrix-sb-password").val(""); + $("#matrix-sb-reset").show(); + } + sbBtns(false); + }).fail(function() { + sbState("fa-exclamation-triangle", "#c9302c", "Request failed — is Node-RED still running?"); + }); + }; + + $("#matrix-sb-x,#matrix-sb-close").on("click", sbClose); + $("#matrix-sb-overlay").on("mousedown", function(e) { if (e.target === this) { sbClose(); } }); + $(document).on("keydown.matrixsb", function(e) { + if (e.key === "Escape" && $("#matrix-sb-overlay").is(":visible")) { sbClose(); } + }); + $("#matrix-sb-reset-toggle").on("click", function() { + $(this).hide(); + $("#matrix-sb-password").val(""); + $("#matrix-sb-reset").show(); + }); + $("#matrix-sb-unlock-btn").on("click", function() { + sbBtns(true); + sbState("fa-spinner fa-spin", "#888", "Unlocking secure backup…"); + sbCall({ id: sbId(), action: "unlock", recoveryKey: $("#matrix-sb-recoverykey").val() }) + .done(function(data) { + if (data.result !== "ok") { + sbState("fa-lock", "#d18a1b", "Enter the recovery key to set up cross-signing."); + sbResult(false, data.message); sbBtns(false); return; + } + $("#matrix-sb-unlock,#matrix-sb-reset,#matrix-sb-reset-toggle").hide(); + sbState("fa-check-circle", "#3a9a4e", "Done."); + sbResult(true, data.message); + }) + .fail(function() { sbResult(false, "Request failed — is Node-RED still running?"); sbBtns(false); }); + }); + $("#matrix-sb-reset-btn").on("click", function() { + sbBtns(true); + sbState("fa-spinner fa-spin", "#888", "Resetting cross-signing & secure backup…"); + sbCall({ id: sbId(), action: "reset", password: $("#matrix-sb-password").val() }) + .done(function(data) { + if (data.result !== "ok") { + sbState("fa-shield", "#d18a1b", "Enter the account password to reset."); + sbResult(false, data.message); sbBtns(false); return; + } + $("#matrix-sb-unlock,#matrix-sb-reset,#matrix-sb-reset-toggle").hide(); + sbState("fa-check-circle", "#3a9a4e", "Reset complete."); + sbResult(true, data.message, data.recoveryKey); + }) + .fail(function() { sbResult(false, "Request failed — is Node-RED still running?"); sbBtns(false); }); + }); + + // expose the status loader so per-session click handlers can call it + $("#matrix-sb-overlay").data("sbStatusFn", sbStatus); + } + + $("#matrix-secure-backup-btn").on("click", function() { + $("#matrix-sb-overlay").data("matrixNodeId", nodeId).fadeIn(120); + $("#matrix-sb-overlay").data("sbStatusFn")(); + }); + + // --- Login: fetch a fresh access token & device id --- + $("#matrix-login-btn").on("click", function() { + function prettyPrintJson(json) { + try { return typeof json === 'object' ? JSON.stringify(json, null, 2) : json; } + catch (error) { return json; } + } + let userId = $("#node-config-input-userId").val(), + userPassword = $("#node-config-input-password").val(), + serverUrl = $("#node-config-input-url").val(); + if (!userId) { alert("User ID is required to fetch access token."); return; } + if (!userPassword) { alert("Password is required to fetch access token."); return; } + if (!serverUrl) { alert("Server URL is required to fetch access token."); return; } + + $("#matrix-login-btn, #matrix-chat-login-error, #matrix-chat-login-success").hide(); + $("#matrix-access-token-loader").show(); + $.ajax({ + type: 'POST', + url: 'matrix-chat/login', + dataType: 'json', + data: { + 'userId': userId, + 'password': userPassword, + 'baseUrl': serverUrl, + 'displayName': $("#node-config-input-deviceLabel").val(), + } + }).then( + function(data) { + if (data.result && data.result === 'ok') { + $("#matrix-chat-login-error").hide(); + $("#matrix-chat-login-success") + .html("Login Successful! Auth Token and Device ID have been set below.") + .show(); + $("#node-config-input-accessToken").val(data.token); + $("#node-config-input-deviceId").val(data.device_id); + } else if (data.result && data.result === 'error') { + $("#matrix-chat-login-success").hide(); + $("#matrix-chat-login-error") + .html(data.message ? ('Failed to login:
    ' + prettyPrintJson(data.message)) : 'Failed to login') + .show(); + } + $("#matrix-login-btn").show(); + $("#matrix-access-token-loader").hide(); + }, + function() { + $("#matrix-chat-login-success").hide(); + $("#matrix-chat-login-error") + .html("Failed to login due to server error communicating with Node-RED") + .show(); + $("#matrix-login-btn").show(); + $("#matrix-access-token-loader").hide(); + } + ); + }); } }); @@ -72,7 +287,10 @@
    - Password is never saved and is only used to fetch an access token using the button below. + Optional. Used to fetch an access token with the button below, and — if you + enable cross-signing — as a fallback when the homeserver requires the account + password to upload signing keys. If set, it is stored (encrypted) with the node's + credentials. Leave blank if you only want to use an access token.
    @@ -145,88 +363,55 @@ Allow sending messages to a room with unknown devices which have not been verified. - +
    + + +
    +
    + Sets up cross-signing so the bot's own device shows as verified. The server + configuration must be deployed and connected first. +
    diff --git a/src/matrix-server-config.js b/src/matrix-server-config.js index d69cd49..234cbcd 100644 --- a/src/matrix-server-config.js +++ b/src/matrix-server-config.js @@ -1,12 +1,14 @@ -const {RelationType, TimelineWindow} = require("matrix-js-sdk"); +// matrix-js-sdk is an ES module; load it via dynamic import so this CommonJS +// node keeps working. All SDK-dependent setup awaits this promise. +const sdkPromise = import("matrix-js-sdk"); +// The crypto-api enums (CryptoEvent, VerificationPhase, ...) are not re-exported +// from the package root, so they are imported from the crypto-api subpath. +const cryptoApiPromise = import("matrix-js-sdk/lib/crypto-api/index.js"); -global.Olm = require('olm'); const fs = require("fs-extra"); -const sdk = require("matrix-js-sdk"); const { resolve } = require('path'); const { LocalStorage } = require('node-localstorage'); -const { LocalStorageCryptoStore } = require('matrix-js-sdk/lib/crypto/store/localStorage-crypto-store'); -const {RoomEvent, RoomMemberEvent, HttpApiEvent, ClientEvent, MemoryStore} = require("matrix-js-sdk"); +const { ensureIndexedDBShim, restoreCryptoStore, snapshotCryptoStore } = require('./matrix-crypto-store'); require("abort-controller/polyfill"); // polyfill abort-controller if we don't have it if (!globalThis.fetch) { // polyfill fetch if we don't have it @@ -17,6 +19,41 @@ if (!globalThis.fetch) { } } +/** + * Resolve the real homeserver base URL for a configured server name / URL. + * + * Uses matrix-js-sdk's built-in .well-known auto-discovery: given e.g. + * "https://example.org" it looks up https://example.org/.well-known/matrix/client + * and returns the homeserver it delegates to (e.g. https://matrix.example.org). + * If there is no .well-known delegation (or discovery fails), the original URL + * is returned unchanged, so explicitly-configured homeserver URLs still work. + */ +async function resolveHomeserverUrl(sdk, configuredUrl) { + if(!configuredUrl) { + return configuredUrl; + } + let domain; + try { + domain = new URL(configuredUrl).host; + } catch(e) { + // not a full URL - treat the value itself as a domain + domain = String(configuredUrl).replace(/^https?:\/\//i, '').replace(/\/.*$/, ''); + } + if(!domain) { + return configuredUrl; + } + try { + const discovery = await sdk.AutoDiscovery.findClientConfig(domain); + const homeserver = discovery['m.homeserver']; + if(homeserver && homeserver.state === sdk.AutoDiscovery.SUCCESS && homeserver.base_url) { + return homeserver.base_url; + } + } catch(e) { + // discovery failed unexpectedly - fall back to the configured URL + } + return configuredUrl; +} + module.exports = function(RED) { // disable logging if set to "off" let loggingSettings = RED.settings.get('logging'); @@ -25,8 +62,9 @@ module.exports = function(RED) { typeof loggingSettings.console.level !== 'undefined' && ['info','debug','trace'].indexOf(loggingSettings.console.level.toLowerCase()) >= 0 ) { - const { logger } = require('matrix-js-sdk/lib/logger'); - logger.disableAll(); + import('matrix-js-sdk/lib/logger.js') + .then(({ logger }) => logger.disableAll()) + .catch(() => { /* logger module path changed - ignore */ }); } function MatrixFolderNameFromUserId(name) { @@ -54,10 +92,25 @@ module.exports = function(RED) { this.url = this.credentials.url; this.autoAcceptRoomInvites = n.autoAcceptRoomInvites; this.e2ee = n.enableE2ee || false; + // Whether to send encrypted messages to devices that have not been + // verified. Undefined (config saved before this option existed) keeps + // the long-standing behaviour of allowing unverified devices. + this.allowUnknownDevices = n.allowUnknownDevices; + // Optional account password (used by the login helper, and as fallback + // user-interactive auth when resetting secure backup / cross-signing). + this.botPassword = this.credentials.password || null; this.globalAccess = n.global; this.initializedAt = new Date(); node.initialSyncLimit = 25; + // Live device-verification state, shared with the matrix-verification + // and matrix-verification-action nodes. Keyed by verification id. + node.verificationRequests = new Map(); // id -> VerificationRequest + node.verificationSas = new Map(); // id -> ShowSasCallbacks + // Cached Secure Secret Storage (4S) key as [keyId, Uint8Array], set by + // the /matrix-chat/secure-backup admin endpoint once unlocked. + node._secretStorageKeyCache = null; + // Keep track of all consumers of this node to be able to catch errors node.register = function(consumerNode) { node.users[consumerNode.id] = consumerNode; @@ -65,7 +118,7 @@ module.exports = function(RED) { node.deregister = function(consumerNode) { delete node.users[consumerNode.id]; }; - + if(!this.userId) { node.log("Matrix connection failed: missing user ID in configuration."); return; @@ -77,10 +130,17 @@ module.exports = function(RED) { let retryStartTimeout = null; + // Rust crypto persistence (see ./matrix-crypto-store.js). Each Matrix + // account gets its own IndexedDB name prefix and on-disk snapshot so + // multiple server-config nodes never collide. + let cryptoDbPrefix = 'mxjssdk-' + MatrixFolderNameFromUserId(this.userId), + cryptoSnapshotPath = null, + cryptoSnapshotInterval = null; + if(!this.credentials.accessToken) { - node.error("Matrix connection failed: missing access token in configuration.", {}); + node.error("Matrix connection failed: missing access token in configuration."); } else if(!this.url) { - node.error("Matrix connection failed: missing server URL in configuration.", {}); + node.error("Matrix connection failed: missing server URL in configuration."); } else { node.setConnected = async function(connected, cb) { if (node.connected !== connected) { @@ -98,7 +158,7 @@ module.exports = function(RED) { device_id = this.matrixClient.getDeviceId(); if(!device_id && node.enableE2ee) { - node.error("Failed to auto detect deviceId for this auth token. You will need to manually specify one. You may need to login to create a new deviceId.", {}) + node.error("Failed to auto detect deviceId for this auth token. You will need to manually specify one. You may need to login to create a new deviceId.") } else { if(!stored_device_id || stored_device_id !== device_id) { node.log(`Saving Device ID (old:${stored_device_id} new:${device_id})`); @@ -117,13 +177,13 @@ module.exports = function(RED) { }).then( function(response) {}, function(error) { - node.error("Failed to set device label: " + error, {}); + node.error("Failed to set device label: " + error); } ); } }, function(error) { - node.error("Failed to fetch device: " + error, {}); + node.error("Failed to fetch device: " + error); } ); } @@ -142,25 +202,62 @@ module.exports = function(RED) { }; node.setConnected(false); - fs.ensureDirSync(storageDir); // create storage directory if it doesn't exist - upgradeDirectoryIfNecessary(node, storageDir); - node.matrixClient = sdk.createClient({ - baseUrl: this.url, - accessToken: this.credentials.accessToken, - cryptoStore: new LocalStorageCryptoStore(localStorage), - store: new MemoryStore({ - localStorage: localStorage, - }), - userId: this.userId, - deviceId: (this.deviceId || getStoredDeviceId(localStorage)) || undefined - // verificationMethods: ["m.sas.v1"] - }); + node.isConnected = function() { + return node.connected; + }; - node.debug(`hasLazyLoadMembersEnabled=${node.matrixClient.hasLazyLoadMembersEnabled()}`); + // Snapshot the Rust crypto store to disk so E2EE state survives + // restarts. No-op when E2EE is disabled. + async function persistCrypto() { + if(!cryptoSnapshotPath) { + return; + } + try { + await snapshotCryptoStore(cryptoSnapshotPath, cryptoDbPrefix); + } catch(e) { + node.error("Failed to persist Matrix crypto store: " + e); + } + } - // set globally if configured to do so - if(this.globalAccess) { - this.context().global.set('matrixClient["'+this.userId+'"]', node.matrixClient); + // Discard all persisted crypto state for this account. Used when the + // device ID changes - the old crypto store belongs to a device that + // no longer exists and the Rust crypto stack refuses to load it. + async function discardCryptoStore() { + // remove the persisted Rust crypto snapshot + try { + if(cryptoSnapshotPath) { + fs.removeSync(cryptoSnapshotPath); + } + } catch(e) { + node.warn("Could not remove crypto snapshot: " + e); + } + // remove legacy (libolm) crypto data from local storage + try { + for(let i = localStorage.length - 1; i >= 0; i--) { + let key = localStorage.key(i); + if(key && key.indexOf('crypto') === 0) { + localStorage.removeItem(key); + } + } + } catch(e) { + node.warn("Could not clear legacy crypto store: " + e); + } + // drop any in-memory IndexedDB database for this account's crypto store + try { + if(globalThis.indexedDB && typeof indexedDB.databases === 'function') { + let dbs = await indexedDB.databases(); + for(let db of dbs) { + if(db.name && db.name.indexOf(cryptoDbPrefix) === 0) { + await new Promise(function(resolve) { + let req = indexedDB.deleteDatabase(db.name); + req.onsuccess = req.onerror = req.onblocked = function(){ resolve(); }; + }); + } + } + } + } catch(e) { + node.warn("Could not clear in-memory crypto database: " + e); + } } function stopClient() { @@ -172,284 +269,464 @@ module.exports = function(RED) { if(retryStartTimeout) { clearTimeout(retryStartTimeout); } + if(cryptoSnapshotInterval) { + clearInterval(cryptoSnapshotInterval); + cryptoSnapshotInterval = null; + } } node.on('close', function(done) { stopClient(); - if(node.globalAccess) { - try { - node.context().global.set('matrixClient["'+node.userId+'"]', undefined); - } catch(e){ - node.error(e.message, {}); - } - } - done(); - }); - - node.isConnected = function() { - return node.connected; - }; - - node.matrixClient.on(RoomEvent.Timeline, async function(event, room, toStartOfTimeline, removed, data) { - if (toStartOfTimeline) { - node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: paginated result"); - return; // ignore paginated results - } - if (!data || !data.liveEvent) { - node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message"); - return; // ignore old message (we only want live events) - } - if(node.initializedAt > event.getDate()) { - node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message before init"); - return; // skip events that occurred before our client initialized - } - - try { - await node.matrixClient.decryptEventIfNeeded(event); - } catch (error) { - node.error(error, {}); - return; - } - - const isDmRoom = (room) => { - // Find out if this is a direct message room. - let isDM = !!room.getDMInviter(); - const allMembers = room.currentState.getMembers(); - if (!isDM && allMembers.length <= 2) { - // if not a DM, but there are 2 users only - // double check DM (needed because getDMInviter works only if you were invited, not if you invite) - // hence why we check for each member - if (allMembers.some((m) => m.getDMInviter())) { - return true; + persistCrypto().finally(function() { + if(node.globalAccess) { + try { + node.context().global.set('matrixClient["'+node.userId+'"]', undefined); + } catch(e){ + node.error(e.message); } } - return allMembers.length <= 2 && isDM; + done(); + }); + }); + + fs.ensureDirSync(storageDir); // create storage directory if it doesn't exist + upgradeDirectoryIfNecessary(node, storageDir); + + if(node.e2ee) { + cryptoSnapshotPath = localStorageDir + '/rust-crypto-store.v8'; + } + + setupClient().catch(function(error) { + node.error(error); + }); + + async function setupClient() { + const sdk = await sdkPromise; + const { + RelationType, RoomEvent, RoomMemberEvent, HttpApiEvent, ClientEvent, + MemoryStore, LocalStorageCryptoStore, + } = sdk; + const { + CryptoEvent, VerificationRequestEvent, VerifierEvent, VerificationPhase, + } = await cryptoApiPromise; + + // ---- Device verification ---------------------------------- + // Surface a verification request (and every subsequent phase + // change) to the matrix-verification node as a "Verification.update" + // event. Live request objects are kept in node.verificationRequests + // so the matrix-verification-action node can act on them by id. + function buildVerificationMsg(request, sasShown) { + let phase = sasShown + ? 'sas' + : String(VerificationPhase[request.phase] || 'unknown').toLowerCase(); + let msg = { + verificationId : request.transactionId, + phase : phase, + payload : phase, + userId : request.otherUserId, + deviceId : request.otherDeviceId || null, + topic : request.roomId || null, + isSelfVerification: request.isSelfVerification, + initiatedByMe : request.initiatedByMe, + }; + // chosenMethod is null until a verification method is picked. + // (request.methods is intentionally not used - it is not + // implemented in the Rust crypto stack and always throws.) + try { + msg.chosenMethod = request.chosenMethod || null; + } catch(e) { + msg.chosenMethod = null; + } + let sas = node.verificationSas.get(request.transactionId); + if(sas && sas.sas) { + msg.sas = { + emoji : sas.sas.emoji || null, + decimal: sas.sas.decimal || null, + }; + } + if(request.phase === VerificationPhase.Cancelled) { + msg.cancellationCode = request.cancellationCode || null; + } + return msg; + } + + // Emit a verification update. Never lets an exception escape - + // this runs inside the SDK's synchronous event emission, where an + // uncaught throw would crash Node-RED. + function emitVerificationUpdate(request, sasShown) { + try { + node.emit("Verification.update", buildVerificationMsg(request, sasShown)); + } catch(e) { + node.error("Failed to process verification update: " + e); + } + } + + node.trackVerificationRequest = function(request) { + let id; + try { id = request.transactionId; } catch(e) { id = undefined; } + if(!id) { + // transactionId is only assigned once the first event is + // sent - wait for it before tracking. + const waitForId = function() { + let tid; + try { tid = request.transactionId; } catch(e) { tid = undefined; } + if(tid) { + request.off(VerificationRequestEvent.Change, waitForId); + node.trackVerificationRequest(request); + } + }; + request.on(VerificationRequestEvent.Change, waitForId); + return; + } + if(node.verificationRequests.has(id)) { + return; // already tracked + } + node.verificationRequests.set(id, request); + + let verifierHooked = false; + const onChange = function() { + try { + // Once a verifier exists, hook its SAS event so the + // emoji/decimal can be surfaced to the flow. + const verifier = request.verifier; + if(verifier && !verifierHooked) { + verifierHooked = true; + verifier.on(VerifierEvent.ShowSas, function(sasCallbacks) { + node.verificationSas.set(id, sasCallbacks); + emitVerificationUpdate(request, true); + }); + } + emitVerificationUpdate(request, false); + if(request.phase === VerificationPhase.Done || request.phase === VerificationPhase.Cancelled) { + request.off(VerificationRequestEvent.Change, onChange); + node.verificationRequests.delete(id); + node.verificationSas.delete(id); + } + } catch(e) { + node.error("Verification request handler error: " + e); + } + }; + request.on(VerificationRequestEvent.Change, onChange); + emitVerificationUpdate(request, false); }; - let msg = { - encrypted : event.isEncrypted(), - redacted : event.isRedacted(), - content : event.getContent(), - type : (event.getContent()['msgtype'] || event.getType()) || null, - payload : (event.getContent()['body'] || event.getContent()) || null, - isDM : isDmRoom(room), - isThread : event.getContent()?.['m.relates_to']?.rel_type === RelationType.Thread, - mentions : event.getContent()["m.mentions"] || null, - userId : event.getSender(), - user : node.matrixClient.getUser(event.getSender()), - topic : event.getRoomId(), - eventId : event.getId(), - event : event, - }; + // Resolve the real homeserver via .well-known discovery so a + // delegating domain (e.g. "example.org") works as the server URL. + const baseUrl = await resolveHomeserverUrl(sdk, node.url); + if(baseUrl !== node.url) { + node.log(`Discovered homeserver ${baseUrl} for ${node.url} via .well-known`); + } - // remove keys from user property that start with an underscore - Object.keys(msg.user).forEach(function (key) { - if (/^_/.test(key)) { - delete msg.user[key]; + let clientOpts = { + baseUrl: baseUrl, + accessToken: node.credentials.accessToken, + store: new MemoryStore({ + localStorage: localStorage, + }), + userId: node.userId, + deviceId: (node.deviceId || getStoredDeviceId(localStorage)) || undefined, + cryptoCallbacks: { + // Supplies the Secure Secret Storage (4S) key to the crypto + // stack once it has been unlocked via the secure-backup + // admin endpoint. Returns null when no key is available. + getSecretStorageKey: async function({ keys }) { + if(node._secretStorageKeyCache) { + const [cachedId, cachedKey] = node._secretStorageKeyCache; + if(keys[cachedId]) { + return [cachedId, cachedKey]; + } + } + return null; + }, + // Caches a newly created 4S key (e.g. after a reset). + cacheSecretStorageKey: function(keyId, keyInfo, key) { + node._secretStorageKeyCache = [keyId, key]; + }, + }, + }; + if(node.e2ee) { + // Provide the legacy (pre-v37 libolm) crypto store so that + // initRustCrypto() can perform a one-time migration of any + // existing crypto state into the Rust crypto store. + clientOpts.cryptoStore = new LocalStorageCryptoStore(localStorage); + } + node.matrixClient = sdk.createClient(clientOpts); + + node.debug(`hasLazyLoadMembersEnabled=${node.matrixClient.hasLazyLoadMembersEnabled()}`); + + // set globally if configured to do so + if(node.globalAccess) { + node.context().global.set('matrixClient["'+node.userId+'"]', node.matrixClient); + } + + node.matrixClient.on(RoomEvent.Timeline, async function(event, room, toStartOfTimeline, removed, data) { + if (toStartOfTimeline) { + node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: paginated result"); + return; // ignore paginated results + } + if (!data || !data.liveEvent) { + node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message"); + return; // ignore old message (we only want live events) + } + if(node.initializedAt > event.getDate()) { + node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message before init"); + return; // skip events that occurred before our client initialized + } + + try { + await node.matrixClient.decryptEventIfNeeded(event); + } catch (error) { + node.error(error); + return; + } + + const isDmRoom = (room) => { + // Find out if this is a direct message room. + let isDM = !!room.getDMInviter(); + const allMembers = room.currentState.getMembers(); + if (!isDM && allMembers.length <= 2) { + // if not a DM, but there are 2 users only + // double check DM (needed because getDMInviter works only if you were invited, not if you invite) + // hence why we check for each member + if (allMembers.some((m) => m.getDMInviter())) { + return true; + } + } + return allMembers.length <= 2 && isDM; + }; + + let msg = { + encrypted : event.isEncrypted(), + redacted : event.isRedacted(), + content : event.getContent(), + type : (event.getContent()['msgtype'] || event.getType()) || null, + payload : (event.getContent()['body'] || event.getContent()) || null, + isDM : isDmRoom(room), + isThread : event.getContent()?.['m.relates_to']?.rel_type === RelationType.Thread, + mentions : event.getContent()["m.mentions"] || null, + userId : event.getSender(), + user : node.matrixClient.getUser(event.getSender()), + topic : event.getRoomId(), + eventId : event.getId(), + event : event, + }; + + // remove keys from user property that start with an underscore + Object.keys(msg.user).forEach(function (key) { + if (/^_/.test(key)) { + delete msg.user[key]; + } + }); + + node.log(`Received ${msg.encrypted ? 'encrypted ' : ''}timeline event [${msg.type}]: (${room.name}) ${event.getSender()} :: ${msg.content.body} ${toStartOfTimeline ? ' [PAGINATED]' : ''}`); + node.emit("Room.timeline", event, room, toStartOfTimeline, removed, data, msg); + }); + + // handle auto-joining rooms + node.matrixClient.on(RoomMemberEvent.Membership, async function(event, member) { + if(node.initializedAt > event.getDate()) { + return; // skip events that occurred before our client initialized + } + + if (member.membership === "invite" && member.userId === node.userId) { + node.log("Got invite to join room " + member.roomId); + if(node.autoAcceptRoomInvites) { + node.matrixClient.joinRoom(member.roomId).then(function() { + node.log("Automatically accepted invitation to join room " + member.roomId); + }).catch(function(e) { + node.warn("Cannot join room (could be from being kicked/banned) " + member.roomId + ": " + e); + }); + } + + let room = node.matrixClient.getRoom(event.getRoomId()); + node.emit("Room.invite", { + type : 'm.room.member', + userId : event.getSender(), + topic : event.getRoomId(), + topicName : (room ? room.name : null) || null, + event : event, + eventId : event.getId(), + }); } }); - node.log(`Received ${msg.encrypted ? 'encrypted ' : ''}timeline event [${msg.type}]: (${room.name}) ${event.getSender()} :: ${msg.content.body} ${toStartOfTimeline ? ' [PAGINATED]' : ''}`); - node.emit("Room.timeline", event, room, toStartOfTimeline, removed, data, msg); - }); - - /** - * Fires when we want to suggest to the user that they restore their megolm keys - * from backup or by cross-signing the device. - * - * @event module:client~MatrixClient#"crypto.suggestKeyRestore" - */ - // node.matrixClient.on("crypto.suggestKeyRestore", function(){ - // - // }); - - // node.matrixClient.on("RoomMember.typing", async function(event, member) { - // let isTyping = member.typing; - // let roomId = member.roomId; - // }); - - // node.matrixClient.on("RoomMember.powerLevel", async function(event, member) { - // let newPowerLevel = member.powerLevel; - // let newNormPowerLevel = member.powerLevelNorm; - // let roomId = member.roomId; - // }); - - // node.matrixClient.on("RoomMember.name", async function(event, member) { - // let newName = member.name; - // let roomId = member.roomId; - // }); - - // handle auto-joining rooms - - node.matrixClient.on(RoomMemberEvent.Membership, async function(event, member) { - if(node.initializedAt > event.getDate()) { - return; // skip events that occurred before our client initialized - } - - if (member.membership === "invite" && member.userId === node.userId) { - node.log("Got invite to join room " + member.roomId); - if(node.autoAcceptRoomInvites) { - node.matrixClient.joinRoom(member.roomId).then(function() { - node.log("Automatically accepted invitation to join room " + member.roomId); - }).catch(function(e) { - node.warn("Cannot join room (could be from being kicked/banned) " + member.roomId + ": " + e); + node.matrixClient.on(ClientEvent.Sync, async function(state, prevState, data) { + node.debug("SYNC [STATE=" + state + "] [PREVSTATE=" + prevState + "]"); + if(prevState === null && state === "PREPARED" ) { + // Occurs when the initial sync is completed first time. + // This involves setting up filters and obtaining push rules. + node.setConnected(true, function(){ + node.log("Matrix client connected"); + }); + } else if(prevState === null && state === "ERROR") { + // Occurs when the initial sync failed first time. + node.setConnected(false, function(){ + node.error("Failed to connect to Matrix server"); + }); + } else if(prevState === "ERROR" && state === "PREPARED") { + // Occurs when the initial sync succeeds + // after previously failing. + node.setConnected(true, function(){ + node.log("Matrix client connected"); + }); + } else if(prevState === "PREPARED" && state === "SYNCING") { + // Occurs immediately after transitioning to PREPARED. + // Starts listening for live updates rather than catching up. + node.setConnected(true, function(){ + node.log("Matrix client connected"); + }); + } else if(prevState === "SYNCING" && state === "RECONNECTING") { + // Occurs when the live update fails. + node.setConnected(false, function(){ + node.error("Connection to Matrix server lost"); + }); + } else if(prevState === "RECONNECTING" && state === "RECONNECTING") { + // Can occur if the update calls continue to fail, + // but the keepalive calls (to /versions) succeed. + node.setConnected(false, function(){ + node.error("Connection to Matrix server lost"); + }); + } else if(prevState === "RECONNECTING" && state === "ERROR") { + // Occurs when the keepalive call also fails + node.setConnected(false, function(){ + node.error("Connection to Matrix server lost"); + }); + } else if(prevState === "ERROR" && state === "SYNCING") { + // Occurs when the client has performed a + // live update after having previously failed. + node.setConnected(true, function(){ + node.log("Matrix client connected"); + }); + } else if(prevState === "ERROR" && state === "ERROR") { + // Occurs when the client has failed to + // keepalive for a second time or more. + node.setConnected(false, function(){ + node.error("Connection to Matrix server lost"); + }); + } else if(prevState === "SYNCING" && state === "SYNCING") { + // Occurs when the client has performed a live update. + // This is called after processing. + node.setConnected(true, function(){ + node.log("Matrix client connected"); + }); + } else if(state === "STOPPED") { + // Occurs once the client has stopped syncing or + // trying to sync after stopClient has been called. + node.setConnected(false, function(){ + node.error("Connection to Matrix server lost"); }); } - - let room = node.matrixClient.getRoom(event.getRoomId()); - node.emit("Room.invite", { - type : 'm.room.member', - userId : event.getSender(), - topic : event.getRoomId(), - topicName : (room ? room.name : null) || null, - event : event, - eventId : event.getId(), - }); - } - }); - - node.matrixClient.on(ClientEvent.Sync, async function(state, prevState, data) { - node.debug("SYNC [STATE=" + state + "] [PREVSTATE=" + prevState + "]"); - if(prevState === null && state === "PREPARED" ) { - // Occurs when the initial sync is completed first time. - // This involves setting up filters and obtaining push rules. - node.setConnected(true, function(){ - node.log("Matrix client connected"); - }); - } else if(prevState === null && state === "ERROR") { - // Occurs when the initial sync failed first time. - node.setConnected(false, function(){ - node.error("Failed to connect to Matrix server", {}); - }); - } else if(prevState === "ERROR" && state === "PREPARED") { - // Occurs when the initial sync succeeds - // after previously failing. - node.setConnected(true, function(){ - node.log("Matrix client connected"); - }); - } else if(prevState === "PREPARED" && state === "SYNCING") { - // Occurs immediately after transitioning to PREPARED. - // Starts listening for live updates rather than catching up. - node.setConnected(true, function(){ - node.log("Matrix client connected"); - }); - } else if(prevState === "SYNCING" && state === "RECONNECTING") { - // Occurs when the live update fails. - node.setConnected(false, function(){ - node.error("Connection to Matrix server lost", {}); - }); - } else if(prevState === "RECONNECTING" && state === "RECONNECTING") { - // Can occur if the update calls continue to fail, - // but the keepalive calls (to /versions) succeed. - node.setConnected(false, function(){ - node.error("Connection to Matrix server lost", {}); - }); - } else if(prevState === "RECONNECTING" && state === "ERROR") { - // Occurs when the keepalive call also fails - node.setConnected(false, function(){ - node.error("Connection to Matrix server lost", {}); - }); - } else if(prevState === "ERROR" && state === "SYNCING") { - // Occurs when the client has performed a - // live update after having previously failed. - node.setConnected(true, function(){ - node.log("Matrix client connected"); - }); - } else if(prevState === "ERROR" && state === "ERROR") { - // Occurs when the client has failed to - // keepalive for a second time or more. - node.setConnected(false, function(){ - node.error("Connection to Matrix server lost", {}); - }); - } else if(prevState === "SYNCING" && state === "SYNCING") { - // Occurs when the client has performed a live update. - // This is called after processing. - node.setConnected(true, function(){ - node.log("Matrix client connected"); - }); - } else if(state === "STOPPED") { - // Occurs once the client has stopped syncing or - // trying to sync after stopClient has been called. - node.setConnected(false, function(){ - node.error("Connection to Matrix server lost", {}); - }); - } - }); + }); - node.matrixClient.on(HttpApiEvent.SessionLoggedOut, async function(errorObj){ - // Example if user auth token incorrect: - // { - // errcode: 'M_UNKNOWN_TOKEN', - // data: { - // errcode: 'M_UNKNOWN_TOKEN', - // error: 'Invalid macaroon passed.', - // soft_logout: false - // }, - // httpStatus: 401 - // } + node.matrixClient.on(HttpApiEvent.SessionLoggedOut, async function(errorObj){ + // Example if user auth token incorrect: + // { + // errcode: 'M_UNKNOWN_TOKEN', + // data: { + // errcode: 'M_UNKNOWN_TOKEN', + // error: 'Invalid macaroon passed.', + // soft_logout: false + // }, + // httpStatus: 401 + // } - node.error("Authentication failure: " + errorObj, {}); - stopClient(); - }); + node.error("Authentication failure: " + errorObj); + stopClient(); + }); - async function run() { - try { - if(node.e2ee){ - node.log("Initializing crypto..."); - await node.matrixClient.initCrypto(); - node.matrixClient.getCrypto().globalBlacklistUnverifiedDevices = false; // prevent errors from unverified devices + // incoming device-verification requests from other users/devices + node.matrixClient.on(CryptoEvent.VerificationRequestReceived, function(request) { + try { + node.log("Received device verification request from " + request.otherUserId); + node.trackVerificationRequest(request); + } catch(e) { + node.error("Failed to handle incoming verification request: " + e); } - node.log("Connecting to Matrix server..."); - await node.matrixClient.startClient({ - initialSyncLimit: node.initialSyncLimit - }); - } catch(error) { - node.error(error, {}); - } - } + }); - // do an authed request and only continue if we don't get an error - // this prevent the matrix client from crashing Node-RED on invalid auth token - (function checkAuthTokenThenStart() { - if(node.matrixClient.clientRunning) { - return; - } - - /** - * We do a /whoami request before starting for a few reasons: - * - validate our auth token - * - make sure auth token belongs to provided node.userId - * - fetch device_id if possible (only available on Synapse >= v1.40.0 under MSC2033) - */ - node.matrixClient.whoami() - .then( - function(data) { - if((typeof data['device_id'] === undefined || !data['device_id']) && !node.deviceId && !getStoredDeviceId(localStorage)) { - node.error("/whoami request did not return device_id. You will need to manually set one in your configuration because this cannot be automatically fetched.", {}); + async function run() { + try { + if(node.e2ee){ + node.log("Initializing crypto..."); + ensureIndexedDBShim(); + // If the device ID has changed (e.g. a new login), the + // persisted crypto store belongs to the old device and + // cannot be loaded - discard it and start fresh. + // Otherwise restore the previously persisted state. + let effectiveDeviceId = node.matrixClient.getDeviceId(), + storedDeviceId = getStoredDeviceId(localStorage); + if(storedDeviceId && effectiveDeviceId && storedDeviceId !== effectiveDeviceId) { + node.warn(`Device ID changed (${storedDeviceId} -> ${effectiveDeviceId}); discarding the encryption store from the old device.`); + await discardCryptoStore(); + } else { + await restoreCryptoStore(cryptoSnapshotPath); } - if('device_id' in data && data['device_id'] && !node.deviceId) { - // if we have no device_id configured lets use the one - // returned by /whoami for this access_token - node.matrixClient.deviceId = data['device_id']; - } - - // make sure our userId matches the access token's - if(data['user_id'].toLowerCase() !== node.userId.toLowerCase()) { - node.error(`User ID provided is ${node.userId} but token belongs to ${data['user_id']}`, {}); - return; - } - run().catch((error) => node.error(error)); - }, - function(err) { - // if the error isn't authentication related retry in a little bit - if(err.code !== "M_UNKNOWN_TOKEN") { - retryStartTimeout = setTimeout(checkAuthTokenThenStart, 15000); - node.error("Auth check failed: " + err, {}); + await node.matrixClient.initRustCrypto({ + useIndexedDB: true, + cryptoDatabasePrefix: cryptoDbPrefix, + }); + let crypto = node.matrixClient.getCrypto(); + if(crypto) { + // Blacklist (refuse to encrypt to) unverified devices only + // when the user has explicitly unticked "Allow unverified + // devices". Default/undefined allows them, as before. + crypto.globalBlacklistUnverifiedDevices = (node.allowUnknownDevices === false); } + // periodically persist crypto state so it survives an unclean shutdown + cryptoSnapshotInterval = setInterval(persistCrypto, 5 * 60 * 1000); } - ) - })(); + node.log("Connecting to Matrix server..."); + await node.matrixClient.startClient({ + initialSyncLimit: node.initialSyncLimit + }); + } catch(error) { + node.error(error); + } + } + + // do an authed request and only continue if we don't get an error + // this prevent the matrix client from crashing Node-RED on invalid auth token + (function checkAuthTokenThenStart() { + if(node.matrixClient.clientRunning) { + return; + } + + /** + * We do a /whoami request before starting for a few reasons: + * - validate our auth token + * - make sure auth token belongs to provided node.userId + * - fetch device_id if possible (only available on Synapse >= v1.40.0 under MSC2033) + */ + node.matrixClient.whoami() + .then( + function(data) { + if((typeof data['device_id'] === undefined || !data['device_id']) && !node.deviceId && !getStoredDeviceId(localStorage)) { + node.error("/whoami request did not return device_id. You will need to manually set one in your configuration because this cannot be automatically fetched."); + } + if('device_id' in data && data['device_id'] && !node.deviceId) { + // if we have no device_id configured lets use the one + // returned by /whoami for this access_token + node.matrixClient.deviceId = data['device_id']; + } + + // make sure our userId matches the access token's + if(data['user_id'].toLowerCase() !== node.userId.toLowerCase()) { + node.error(`User ID provided is ${node.userId} but token belongs to ${data['user_id']}`); + return; + } + run().catch((error) => node.error(error)); + }, + function(err) { + // if the error isn't authentication related retry in a little bit + if(err.code !== "M_UNKNOWN_TOKEN") { + retryStartTimeout = setTimeout(checkAuthTokenThenStart, 15000); + node.error("Auth check failed: " + err); + } + } + ) + })(); + } } } @@ -459,54 +736,201 @@ module.exports = function(RED) { userId: { type: "text", required: true }, accessToken: { type: "text", required: true }, deviceId: { type: "text", required: false }, - url: { type: "text", required: true } + url: { type: "text", required: true }, + password: { type: "password", required: false } } }); RED.httpAdmin.post( "/matrix-chat/login", RED.auth.needsPermission('flows.write'), - function(req, res) { + async function(req, res) { let userId = req.body.userId || undefined, password = req.body.password || undefined, baseUrl = req.body.baseUrl || undefined, deviceId = req.body.deviceId || undefined, displayName = req.body.displayName || undefined; - const matrixClient = sdk.createClient({ - baseUrl: baseUrl, - deviceId: deviceId, - timelineSupport: true, - localTimeoutMs: '30000' - }); + try { + const sdk = await sdkPromise; + // Resolve .well-known delegation so users can enter their domain. + baseUrl = await resolveHomeserverUrl(sdk, baseUrl); + const matrixClient = sdk.createClient({ + baseUrl: baseUrl, + deviceId: deviceId, + timelineSupport: true, + localTimeoutMs: '30000' + }); - matrixClient.timelineSupport = true; + matrixClient.login( + 'm.login.password', { + identifier: { + type: 'm.id.user', + user: userId, + }, + password: password, + initial_device_display_name: displayName + }) + .then( + function(response) { + res.json({ + 'result': 'ok', + 'token': response.access_token, + 'device_id': response.device_id, + 'user_id': response.user_id, + }); + }, + function(err) { + res.json({ + 'result': 'error', + 'message': err + }); + } + ); + } catch(err) { + res.json({ + 'result': 'error', + 'message': err + }); + } + }); - matrixClient.login( - 'm.login.password', { - identifier: { - type: 'm.id.user', - user: userId, - }, - password: password, - initial_device_display_name: displayName - }) - .then( - function(response) { - res.json({ - 'result': 'ok', - 'token': response.access_token, - 'device_id': response.device_id, - 'user_id': response.user_id, - }); - }, - function(err) { - res.json({ - 'result': 'error', - 'message': err - }); + /** + * Interactive Secure Secret Storage (4S) / cross-signing setup for the + * config editor's "Set up secure backup" button. + * + * Secured with the same flows.write permission as the login endpoint, so it + * is not publicly exposed. Operates on the live, connected client of an + * already-deployed server configuration node (identified by req.body.id). + * + * Actions: + * - status : report connection / cross-signing / secret-storage state + * - unlock : unlock existing 4S with a recovery key/passphrase, then set up + * cross-signing for this device + * - reset : create brand new cross-signing keys and secret storage + * (requires the account password); returns the new recovery key + */ + RED.httpAdmin.post( + "/matrix-chat/secure-backup", + RED.auth.needsPermission('flows.write'), + async function(req, res) { + try { + const serverNode = RED.nodes.getNode(req.body.id); + if(!serverNode || !serverNode.matrixClient) { + return res.json({ result: 'error', message: 'Server configuration not found. Save and deploy the server configuration node first.' }); + } + if(typeof serverNode.isConnected !== 'function' || !serverNode.isConnected()) { + return res.json({ result: 'error', message: 'The Matrix client is not connected. Deploy the server configuration and wait for it to connect, then try again.' }); + } + const crypto = serverNode.matrixClient.getCrypto(); + if(!crypto) { + return res.json({ result: 'error', message: 'End-to-end encryption is not enabled on this server configuration.' }); + } + const secretStorage = serverNode.matrixClient.secretStorage; + const action = req.body.action || 'status'; + + if(action === 'status') { + const defaultKeyId = await secretStorage.getDefaultKeyId(); + return res.json({ + result: 'ok', + crossSigningReady: await crypto.isCrossSigningReady(), + secretStorageReady: await crypto.isSecretStorageReady(), + secretStorageExists: !!defaultKeyId, + }); + } + + if(action === 'unlock') { + const cryptoApi = await cryptoApiPromise; + const recoveryInput = String(req.body.recoveryKey || '').trim(); + if(!recoveryInput) { + return res.json({ result: 'error', message: 'A recovery key or passphrase is required.' }); } - ); + const keyId = await secretStorage.getDefaultKeyId(); + if(!keyId) { + return res.json({ result: 'error', message: 'This account has no secure backup to unlock. Use Reset to create one.' }); + } + const stored = await secretStorage.getKey(keyId); + const keyInfo = stored && stored[1]; + if(!keyInfo) { + return res.json({ result: 'error', message: 'Could not read the secure backup key description from the account.' }); + } + + let keyBytes = null; + try { + keyBytes = cryptoApi.decodeRecoveryKey(recoveryInput.replace(/\s+/g, '')); + } catch(e) { /* not a recovery key - fall back to passphrase */ } + if(!keyBytes && keyInfo.passphrase) { + keyBytes = await cryptoApi.deriveRecoveryKeyFromPassphrase( + recoveryInput, keyInfo.passphrase.salt, keyInfo.passphrase.iterations); + } + if(!keyBytes) { + return res.json({ result: 'error', message: 'Could not read that value as a recovery key or passphrase.' }); + } + if(!(await secretStorage.checkKey(keyBytes, keyInfo))) { + return res.json({ result: 'error', message: 'That recovery key / passphrase is not correct.' }); + } + + serverNode._secretStorageKeyCache = [keyId, keyBytes]; + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async function(makeRequest) { + if(req.body.password) { + await makeRequest({ + type: 'm.login.password', + identifier: { type: 'm.id.user', user: serverNode.userId }, + password: req.body.password, + }); + } else { + await makeRequest(null); + } + }, + }); + try { await crypto.checkKeyBackupAndEnable(); } catch(e) { /* best effort */ } + serverNode.log("Secure backup unlocked; cross-signing set up."); + return res.json({ + result: 'ok', + message: 'Secure backup unlocked. Cross-signing is now set up for this bot.', + crossSigningReady: await crypto.isCrossSigningReady(), + }); + } + + if(action === 'reset') { + const password = req.body.password; + if(!password) { + return res.json({ result: 'error', message: 'The account password is required to reset secure backup.' }); + } + const newKey = await crypto.createRecoveryKeyFromPassphrase(); + // Replace secret storage FIRST. This makes the new 4S key + // (whose private key we hold and cache via cacheSecretStorageKey) + // the default before cross-signing is reset. bootstrapCrossSigning + // exports the new signing keys into whatever 4S is current, so if + // the old 4S were still default it would need the old (unknown) + // key and fail with "getSecretStorageKey callback returned falsey". + await crypto.bootstrapSecretStorage({ + setupNewSecretStorage: true, + createSecretStorageKey: async function() { return newKey; }, + }); + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: async function(makeRequest) { + await makeRequest({ + type: 'm.login.password', + identifier: { type: 'm.id.user', user: serverNode.userId }, + password: password, + }); + }, + }); + serverNode.log("Cross-signing and secure backup were reset."); + return res.json({ + result: 'ok', + message: 'Cross-signing and secure backup have been reset. Store the new recovery key somewhere safe - it is shown only once.', + recoveryKey: newKey.encodedPrivateKey, + }); + } + + return res.json({ result: 'error', message: 'Unknown action: ' + action }); + } catch(error) { + res.json({ result: 'error', message: String(error && error.message || error) }); + } }); function upgradeDirectoryIfNecessary(node, storageDir) { @@ -526,7 +950,7 @@ module.exports = function(RED) { fs.copySync(oldStorageDir, dir); } } catch (err) { - node.error(err, {}); + node.error(err); } }); diff --git a/src/matrix-verification-action.html b/src/matrix-verification-action.html new file mode 100644 index 0000000..7914136 --- /dev/null +++ b/src/matrix-verification-action.html @@ -0,0 +1,126 @@ + + + + + diff --git a/src/matrix-verification-action.js b/src/matrix-verification-action.js new file mode 100644 index 0000000..4688860 --- /dev/null +++ b/src/matrix-verification-action.js @@ -0,0 +1,139 @@ +module.exports = function(RED) { + function MatrixVerificationAction(n) { + RED.nodes.createNode(this, n); + + let node = this; + + this.name = n.name; + this.server = RED.nodes.getNode(n.server); + this.mode = n.mode || "accept"; + + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + + if (!node.server) { + node.error("No configuration node"); + return; + } + node.server.register(node); + + node.server.on("disconnected", function() { + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + }); + node.server.on("connected", function() { + node.status({ fill: "green", shape: "ring", text: "connected" }); + }); + + node.on("input", async function(msg) { + if (!node.server || !node.server.matrixClient) { + msg.error = "No matrix server selected"; + node.error(msg.error, msg); + node.send([null, msg]); + return; + } + + if (!node.server.isConnected()) { + msg.error = "Matrix server connection is currently closed"; + node.error(msg.error, msg); + node.send([null, msg]); + return; + } + + const crypto = node.server.matrixClient.getCrypto(); + if (!crypto) { + msg.error = "End-to-end encryption is not enabled on the Matrix server config"; + node.error(msg.error, msg); + node.send([null, msg]); + return; + } + + // msg.mode overrides the node's configured mode if provided + const mode = msg.mode || node.mode; + + try { + if (mode === "request") { + // Start a new verification request. + // - msg.userId + msg.deviceId : verify a specific device (to-device) + // - msg.userId + msg.topic : verify a user in a DM room + // - otherwise : verify our own other devices + let request; + if (msg.userId && msg.deviceId) { + request = await crypto.requestDeviceVerification(msg.userId, msg.deviceId); + } else if (msg.userId && msg.topic) { + request = await crypto.requestVerificationDM(msg.userId, msg.topic); + } else { + request = await crypto.requestOwnUserVerification(); + } + + if (typeof node.server.trackVerificationRequest === "function") { + node.server.trackVerificationRequest(request); + } + msg.verificationId = request.transactionId; + node.send([msg, null]); + return; + } + + // Every other mode acts on an existing tracked request. + const request = node.server.verificationRequests.get(msg.verificationId); + if (!request) { + throw new Error(`No active verification found for msg.verificationId '${msg.verificationId}'`); + } + + switch (mode) { + case "accept": + await request.accept(); + break; + + case "start": { + // Begin SAS (emoji) verification. The SAS emoji is delivered + // through the matrix-verification node when it becomes ready. + let verifier = request.verifier; + if (!verifier) { + verifier = await request.startVerification("m.sas.v1"); + } + verifier.verify().catch(function(e) { + node.warn("Verification ended: " + e); + }); + break; + } + + case "confirm": { + const sas = node.server.verificationSas.get(msg.verificationId); + if (!sas) { + throw new Error("This verification has no SAS awaiting confirmation"); + } + await sas.confirm(); + break; + } + + case "mismatch": { + const sas = node.server.verificationSas.get(msg.verificationId); + if (!sas) { + throw new Error("This verification has no SAS awaiting confirmation"); + } + sas.mismatch(); + break; + } + + case "cancel": + await request.cancel(); + break; + + default: + throw new Error("Unknown verification action mode: " + mode); + } + + msg.verificationId = request.transactionId; + node.send([msg, null]); + } catch (e) { + msg.error = String(e && e.message || e); + node.error("Verification action failed: " + msg.error, msg); + node.send([null, msg]); + } + }); + + node.on("close", function() { + node.server.deregister(node); + }); + } + RED.nodes.registerType("matrix-verification-action", MatrixVerificationAction); +} diff --git a/src/matrix-verification.html b/src/matrix-verification.html new file mode 100644 index 0000000..93f550e --- /dev/null +++ b/src/matrix-verification.html @@ -0,0 +1,199 @@ + + + + + diff --git a/src/matrix-verification.js b/src/matrix-verification.js new file mode 100644 index 0000000..839e6f2 --- /dev/null +++ b/src/matrix-verification.js @@ -0,0 +1,113 @@ +module.exports = function(RED) { + function MatrixVerification(n) { + RED.nodes.createNode(this, n); + + let node = this; + + this.name = n.name; + this.server = RED.nodes.getNode(n.server); + + // Phase filter - emit only the ticked phases. Undefined (config saved + // before these options existed) is treated as ticked, so old nodes + // keep emitting every phase. + this.phases = { + requested: n.phaseRequested !== false, + ready: n.phaseReady !== false, + started: n.phaseStarted !== false, + sas: n.phaseSas !== false, + done: n.phaseDone !== false, + cancelled: n.phaseCancelled !== false, + }; + this.initiatedBy = n.initiatedBy || 'any'; // any | me | notme + this.verificationType = n.verificationType || 'any'; // any | room | device + this.selfVerification = n.selfVerification || 'any'; // any | self | others + this.userFilter = (n.userFilter || '').split(',') + .map(function(s){ return s.trim().toLowerCase(); }) + .filter(Boolean); + this.roomFilter = (n.roomFilter || '').split(',') + .map(function(s){ return s.trim(); }) + .filter(Boolean); + + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + + if (!node.server) { + node.error("No configuration node"); + return; + } + node.server.register(node); + + // Returns true if a verification update message passes every configured + // filter. All filters AND-combine; each defaults to "pass everything". + function passesFilters(m) { + // phase + if ((m.phase in node.phases) && !node.phases[m.phase]) { + return false; + } + // initiated by + if (node.initiatedBy === 'me' && !m.initiatedByMe) { + return false; + } + if (node.initiatedBy === 'notme' && m.initiatedByMe) { + return false; + } + // verification type - room verifications carry a roomId (msg.topic), + // to-device verifications do not + if (node.verificationType === 'room' && !m.topic) { + return false; + } + if (node.verificationType === 'device' && m.topic) { + return false; + } + // self-verification (the other party is one of the bot's own devices) + if (node.selfVerification === 'self' && !m.isSelfVerification) { + return false; + } + if (node.selfVerification === 'others' && m.isSelfVerification) { + return false; + } + // user id allowlist + if (node.userFilter.length && + (!m.userId || node.userFilter.indexOf(m.userId.toLowerCase()) === -1)) { + return false; + } + // room id filter - only constrains room verifications; device + // verifications have no room and are not affected + if (node.roomFilter.length && m.topic && + node.roomFilter.indexOf(m.topic) === -1) { + return false; + } + return true; + } + + const onConnected = function() { + node.status({ fill: "green", shape: "ring", text: "connected" }); + }; + const onDisconnected = function() { + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + }; + const onVerificationUpdate = function(verificationMsg) { + if (!passesFilters(verificationMsg)) { + return; + } + node.status({ fill: "blue", shape: "dot", text: verificationMsg.phase }); + // clone so multiple verification nodes don't share/mutate one object + node.send(RED.util.cloneMessage(verificationMsg)); + }; + + node.server.on("connected", onConnected); + node.server.on("disconnected", onDisconnected); + node.server.on("Verification.update", onVerificationUpdate); + + if (node.server.isConnected && node.server.isConnected()) { + onConnected(); + } + + node.on("close", function() { + node.server.removeListener("connected", onConnected); + node.server.removeListener("disconnected", onDisconnected); + node.server.removeListener("Verification.update", onVerificationUpdate); + node.server.deregister(node); + }); + } + RED.nodes.registerType("matrix-verification", MatrixVerification); +}