mirror of
https://github.com/Skylar-Tech/node-red-contrib-matrix-chat.git
synced 2026-05-23 23:53:26 -06:00
Upgrade to matrix-js-sdk 41.5.0; add device verification
Upgrades matrix-js-sdk from 34.13.0 to 41.5.0. This crosses the v37 removal of the legacy libolm crypto stack, so E2EE is migrated to the Rust crypto implementation. Also adds device verification, cross-signing setup, and authenticated media support. Dependencies - Bump matrix-js-sdk ^34.13.0 -> ^41.5.0; require Node.js >= 22. - Drop the `olm` dependency (legacy crypto only); add `fake-indexeddb`. Rust crypto - Replace initCrypto() with initRustCrypto(); the legacy crypto stack was removed upstream in v37. - Add src/matrix-crypto-store.js: the Rust crypto store requires IndexedDB, absent in Node.js, so it is backed by fake-indexeddb and snapshotted to disk (rust-crypto-store.v8) to survive restarts. - Migrate existing libolm crypto state into the Rust store on first run, and discard the stored crypto state when the device ID changes. Homeserver discovery - Resolve the homeserver via .well-known, so a delegating domain (e.g. example.org) works as the configured server URL. Cross-signing & secure backup - Add a secured /matrix-chat/secure-backup admin endpoint and a modal dialog on the server config node: check status, unlock an existing secure backup with its recovery key, or reset and create a new one. Device verification (new nodes) - matrix-verification: event source emitting verification requests and phase changes, with on-node filters (phase, initiated by, type, self-verification, user allowlist, room). - matrix-verification-action: request, accept, start SAS, confirm, mismatch, or cancel an in-flight verification. Authenticated media - matrix-receive and matrix-crypt-file use the authenticated media endpoints, send a bearer token via msg.headers, and fall back between the v3 and v1 media endpoints on a 404. Fixes - Surface connection/auth errors in the log; node.error() calls were passed an empty msg object, which routed the error and suppressed console logging. - matrix-get-user: await getProfileInfo()/getPresence(). - matrix-invite-room: pass the reason as the third invite() argument (the removed callback parameter was shifting it out). - Guard the verification handlers so a throwing SDK getter cannot crash Node-RED. Docs - Add the device-verification example flow; update the READMEs and node help, correcting stale claims that device verification, secure backup, and encrypted file uploads were unsupported.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -431,6 +431,27 @@ Downloads received files/images. If the file is encrypted, it will decrypt it fo
|
||||
|
||||
</details>
|
||||
|
||||
### Device Verification
|
||||
|
||||
<details>
|
||||
<summary>Handle device verification (SAS / emoji)</summary>
|
||||
|
||||
[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.
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Deprecated
|
||||
|
||||
<details>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Generated
+80
-115
@@ -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",
|
||||
|
||||
+7
-8
@@ -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"
|
||||
}
|
||||
|
||||
@@ -43,6 +43,16 @@
|
||||
<span class="property-type">string | null</span>
|
||||
</dt>
|
||||
<dd> the decoded mxc url.</dd>
|
||||
|
||||
<dt class="optional">msg.headers
|
||||
<span class="property-type">object</span>
|
||||
</dt>
|
||||
<dd>optional HTTP headers. If provided, they are used when downloading media (for example authenticated media bearer tokens).</dd>
|
||||
|
||||
<dt class="optional">msg.access_token
|
||||
<span class="property-type">string</span>
|
||||
</dt>
|
||||
<dd>optional Matrix access token. Used as a bearer token if <code>msg.headers.Authorization</code> is not present.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Outputs</h3>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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<boolean>} 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<boolean>} 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -227,6 +227,11 @@
|
||||
<dt>msg.content <span class="property-type">object</span></dt>
|
||||
<dd>the message's content object</dd>
|
||||
</dl>
|
||||
|
||||
<dl class="message-properties">
|
||||
<dt>msg.headers <span class="property-type">object | null</span></dt>
|
||||
<dd>for media events, includes auth headers (for example <code>Authorization: Bearer ...</code>) used by authed media endpoints.</dd>
|
||||
</dl>
|
||||
</li>
|
||||
|
||||
<li><code>msg.type</code> == '<strong>m.text</strong>'
|
||||
|
||||
+32
-2
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+257
-72
@@ -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")) {
|
||||
$('<style>'
|
||||
+ '.matrix-sb-overlay{position:fixed;inset:0;z-index:3000;display:none;background:rgba(0,0,0,.45);}'
|
||||
+ '.matrix-sb-modal{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);'
|
||||
+ 'width:540px;max-width:92vw;max-height:88vh;display:flex;flex-direction:column;'
|
||||
+ 'border-radius:6px;overflow:hidden;box-shadow:0 12px 44px rgba(0,0,0,.45);'
|
||||
+ 'background:var(--red-ui-primary-background,#fff);color:var(--red-ui-primary-text-color,#2a2a2a);}'
|
||||
+ '.matrix-sb-head{display:flex;justify-content:space-between;align-items:center;'
|
||||
+ 'padding:12px 16px;font-size:15px;font-weight:bold;'
|
||||
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
|
||||
+ 'border-bottom:1px solid var(--red-ui-secondary-border-color,#ddd);}'
|
||||
+ '.matrix-sb-x{cursor:pointer;font-size:20px;line-height:1;opacity:.55;}'
|
||||
+ '.matrix-sb-x:hover{opacity:1;}'
|
||||
+ '.matrix-sb-body{padding:16px;overflow:auto;}'
|
||||
+ '.matrix-sb-state{display:flex;gap:10px;align-items:flex-start;font-size:14px;line-height:1.5;}'
|
||||
+ '.matrix-sb-section{margin-top:18px;}'
|
||||
+ '.matrix-sb-section label{display:block;font-weight:bold;margin-bottom:5px;}'
|
||||
+ '.matrix-sb-section input{width:100%;box-sizing:border-box;padding:7px 9px;border-radius:4px;'
|
||||
+ 'border:1px solid var(--red-ui-form-input-border-color,#ccc);'
|
||||
+ 'background:var(--red-ui-form-input-background,#fff);color:var(--red-ui-primary-text-color,#2a2a2a);}'
|
||||
+ '.matrix-sb-hint{color:var(--red-ui-secondary-text-color,#888);font-size:12px;margin-top:5px;}'
|
||||
+ '.matrix-sb-actions{margin-top:12px;text-align:right;}'
|
||||
+ '.matrix-sb-warn{background:#fdf3e7;border:1px solid #f0c36d;color:#7a5b16;'
|
||||
+ 'border-radius:4px;padding:9px 11px;font-size:13px;margin-bottom:12px;}'
|
||||
+ '.matrix-sb-result{margin-top:18px;padding:11px 13px;border-radius:4px;font-size:13px;white-space:pre-wrap;}'
|
||||
+ '.matrix-sb-result.ok{background:#e7f4ea;border:1px solid #8fcea5;color:#1e6b33;}'
|
||||
+ '.matrix-sb-result.err{background:#fde7e9;border:1px solid #e8a0a8;color:#8a1f2b;}'
|
||||
+ '.matrix-sb-key{margin-top:9px;padding:9px;border-radius:4px;font-family:monospace;'
|
||||
+ 'font-size:13px;word-break:break-all;background:rgba(127,127,127,.16);border:1px dashed #999;}'
|
||||
+ '.matrix-sb-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 16px;'
|
||||
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
|
||||
+ 'border-top:1px solid var(--red-ui-secondary-border-color,#ddd);}'
|
||||
+ '</style>').appendTo("head");
|
||||
|
||||
$('<div id="matrix-sb-overlay" class="matrix-sb-overlay"><div class="matrix-sb-modal">'
|
||||
+ '<div class="matrix-sb-head"><span><i class="fa fa-shield"></i> Secure Backup & Cross-signing</span>'
|
||||
+ '<span class="matrix-sb-x" id="matrix-sb-x" title="Close">×</span></div>'
|
||||
+ '<div class="matrix-sb-body">'
|
||||
+ '<div class="matrix-sb-state" id="matrix-sb-state"></div>'
|
||||
+ '<div class="matrix-sb-section" id="matrix-sb-unlock" style="display:none;">'
|
||||
+ '<label>Recovery key or passphrase</label>'
|
||||
+ '<input type="text" id="matrix-sb-recoverykey" placeholder="EsTx xxxx xxxx xxxx ...">'
|
||||
+ '<div class="matrix-sb-hint">The recovery key created when secure backup / key storage was first set up on this account.</div>'
|
||||
+ '<div class="matrix-sb-actions"><button type="button" class="red-ui-button" id="matrix-sb-unlock-btn">Unlock & set up cross-signing</button></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="matrix-sb-section" id="matrix-sb-reset" style="display:none;">'
|
||||
+ '<div class="matrix-sb-warn">Resetting creates new cross-signing keys and a new recovery key, replacing the existing ones. Other sessions that trusted the old identity will need to be re-verified.</div>'
|
||||
+ '<label>Account password</label>'
|
||||
+ '<input type="password" id="matrix-sb-password" placeholder="Account password">'
|
||||
+ '<div class="matrix-sb-actions"><button type="button" class="red-ui-button" id="matrix-sb-reset-btn">Reset cross-signing & secure backup</button></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="matrix-sb-result" id="matrix-sb-result" style="display:none;"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="matrix-sb-foot">'
|
||||
+ '<button type="button" class="red-ui-button" id="matrix-sb-reset-toggle" style="display:none;">Reset instead…</button>'
|
||||
+ '<button type="button" class="red-ui-button" id="matrix-sb-close">Close</button>'
|
||||
+ '</div></div></div>').appendTo(document.body);
|
||||
|
||||
var sbEsc = function(s) { return $("<div>").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('<i class="fa ' + icon + '" style="color:' + color + ';font-size:18px;"></i><span>' + html + '</span>');
|
||||
};
|
||||
var sbResult = function(ok, text, key) {
|
||||
var h = sbEsc(text);
|
||||
if (key) { h += '<div class="matrix-sb-key">' + sbEsc(key) + '</div>'; }
|
||||
$("#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", "<b>Cross-signing is set up.</b> 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", "<b>Done.</b>");
|
||||
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", "<b>Reset complete.</b>");
|
||||
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: <br />' + 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();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -72,7 +287,10 @@
|
||||
<input type="password" placeholder="" id="node-config-input-password">
|
||||
</div>
|
||||
<div class="form-tips" style="margin-bottom: 12px;">
|
||||
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.
|
||||
</div>
|
||||
<pre class="form-tips" id="matrix-chat-login-error" style="color: #721c24;background-color: #f8d7da;border-color: #f5c6cb;margin-bottom: 12px;display:none;"></pre>
|
||||
<pre class="form-tips" id="matrix-chat-login-success" style="color: #155724;background-color: #d4edda;border-color: #c3e6cb;margin-bottom: 12px;display:none;"></pre>
|
||||
@@ -145,88 +363,55 @@
|
||||
Allow sending messages to a room with unknown devices which have not been verified.
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$("#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: <br />' + 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();
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
<div class="form-row">
|
||||
<label><i class="fa fa-shield"></i> Secure Backup</label>
|
||||
<button type="button" class="red-ui-button" id="matrix-secure-backup-btn">Set up secure backup & cross-signing</button>
|
||||
</div>
|
||||
<div class="form-tips" style="margin-bottom: 12px;">
|
||||
Sets up cross-signing so the bot's own device shows as verified. The server
|
||||
configuration must be deployed and connected first.
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="matrix-server-config">
|
||||
<h3>Details</h3>
|
||||
<p>Matrix client connection configuration</p>
|
||||
|
||||
<h3>Server URL</h3>
|
||||
<p>
|
||||
The URL of your homeserver. You can enter either the homeserver URL directly
|
||||
(e.g. <code>https://matrix.example.org</code>) or the delegating domain
|
||||
(e.g. <code>https://example.org</code>) — in the latter case the real
|
||||
homeserver is resolved automatically via <code>.well-known</code> discovery.
|
||||
</p>
|
||||
|
||||
<h3>Setting up an account</h3>
|
||||
<div>
|
||||
<p>
|
||||
You need an account for your client to use. If you are going to be using End-to-End Encryption you should generate the bot and only use it within Node-RED otherwise if you have other clients connected on the same user it could cause problems with e2ee (key sharing is currently not supported).
|
||||
You need an account for your client to use. For End-to-End Encryption it is simplest to dedicate the account to the bot and run it only within Node-RED. The account may also be signed in on other clients — if so, verify those sessions against the bot (see <em>Cross-signing & secure backup</em> below) so they trust each other and share keys.
|
||||
</p>
|
||||
<p>If you have access to the server directly you can use Shared Secret Registration as described <a href="https://github.com/Skylar-Tech/node-red-contrib-matrix-chat/tree/master/examples#create-user-with-shared-secret-registration" target="_blank" style="text-decoration: underline;">here</a>.</p>
|
||||
<p>If this is a server you do not administrate/have access to follow these instructions:</p>
|
||||
<ol><li>In a private/incognito browser window, open Element.</li><li>Log in to the account you want to get the access token for, such as the bot's account. <strong>Do not setup key storage</strong>.</li><li>Click on the bot's name in the top left corner then "Settings".</li><li>(Optional) Set your bot's display name and avatar.</li><li>Click the "Help & About" tab (left side of the dialog).</li><li>Scroll to the bottom and click the <code><click to reveal></code> part of <code>Access Token: <click to reveal></code>.</li><li>Copy your access token to a safe place, like the bot's configuration file.</li><li><strong>Do not log out.</strong> Instead, just close the window. If you used a private browsing session, you should be able to still use Element for your own account. Logging out deletes the access token from the server, making the bot unable to use it.</li></ol>
|
||||
<ol><li>In a private/incognito browser window, open Element.</li><li>Log in to the account you want to get the access token for, such as the bot's account.</li><li>Click on the bot's name in the top left corner then "Settings".</li><li>(Optional) Set your bot's display name and avatar.</li><li>Click the "Help & About" tab (left side of the dialog).</li><li>Scroll to the bottom and click the <code><click to reveal></code> part of <code>Access Token: <click to reveal></code>.</li><li>Copy your access token to a safe place, like the bot's configuration file.</li><li><strong>Do not log out.</strong> Instead, just close the window. If you used a private browsing session, you should be able to still use Element for your own account. Logging out deletes the access token from the server, making the bot unable to use it.</li></ol>
|
||||
</div>
|
||||
|
||||
<h3>Cross-signing & secure backup</h3>
|
||||
<div>
|
||||
<p>
|
||||
Use the <strong>Set up secure backup & cross-signing</strong> button to set up
|
||||
cross-signing so the bot's own device is verified. The server configuration must be
|
||||
deployed and connected first; the button then checks the account and, interactively:
|
||||
</p>
|
||||
<ul>
|
||||
<li>if the account already has a secure backup, lets you <strong>unlock</strong> it with its recovery key (or passphrase) and set up cross-signing for the bot;</li>
|
||||
<li>otherwise (or on request) lets you <strong>reset</strong> cross-signing and secure backup, creating a new recovery key — this needs the account password.</li>
|
||||
</ul>
|
||||
<p>
|
||||
To verify other devices interactively (SAS emoji), use the <code>matrix-verification</code>
|
||||
node to receive verification requests and <code>matrix-verification-action</code> to
|
||||
accept, start, confirm or cancel them. This lets you build your own approval flow
|
||||
(for example emailing the emoji for a human to confirm).
|
||||
</p>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
+503
-79
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(); };
|
||||
});
|
||||
|
||||
node.debug(`hasLazyLoadMembersEnabled=${node.matrixClient.hasLazyLoadMembersEnabled()}`);
|
||||
|
||||
// set globally if configured to do so
|
||||
if(this.globalAccess) {
|
||||
this.context().global.set('matrixClient["'+this.userId+'"]', node.matrixClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
node.warn("Could not clear in-memory crypto database: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
function stopClient() {
|
||||
@@ -172,23 +269,195 @@ module.exports = function(RED) {
|
||||
if(retryStartTimeout) {
|
||||
clearTimeout(retryStartTimeout);
|
||||
}
|
||||
if(cryptoSnapshotInterval) {
|
||||
clearInterval(cryptoSnapshotInterval);
|
||||
cryptoSnapshotInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
node.on('close', function(done) {
|
||||
stopClient();
|
||||
persistCrypto().finally(function() {
|
||||
if(node.globalAccess) {
|
||||
try {
|
||||
node.context().global.set('matrixClient["'+node.userId+'"]', undefined);
|
||||
} catch(e){
|
||||
node.error(e.message, {});
|
||||
node.error(e.message);
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
node.isConnected = function() {
|
||||
return node.connected;
|
||||
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);
|
||||
};
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -207,7 +476,7 @@ module.exports = function(RED) {
|
||||
try {
|
||||
await node.matrixClient.decryptEventIfNeeded(event);
|
||||
} catch (error) {
|
||||
node.error(error, {});
|
||||
node.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,34 +522,7 @@ module.exports = function(RED) {
|
||||
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
|
||||
@@ -319,7 +561,7 @@ module.exports = function(RED) {
|
||||
} 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", {});
|
||||
node.error("Failed to connect to Matrix server");
|
||||
});
|
||||
} else if(prevState === "ERROR" && state === "PREPARED") {
|
||||
// Occurs when the initial sync succeeds
|
||||
@@ -336,18 +578,18 @@ module.exports = function(RED) {
|
||||
} else if(prevState === "SYNCING" && state === "RECONNECTING") {
|
||||
// Occurs when the live update fails.
|
||||
node.setConnected(false, function(){
|
||||
node.error("Connection to Matrix server lost", {});
|
||||
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", {});
|
||||
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", {});
|
||||
node.error("Connection to Matrix server lost");
|
||||
});
|
||||
} else if(prevState === "ERROR" && state === "SYNCING") {
|
||||
// Occurs when the client has performed a
|
||||
@@ -359,7 +601,7 @@ module.exports = function(RED) {
|
||||
// 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", {});
|
||||
node.error("Connection to Matrix server lost");
|
||||
});
|
||||
} else if(prevState === "SYNCING" && state === "SYNCING") {
|
||||
// Occurs when the client has performed a live update.
|
||||
@@ -371,7 +613,7 @@ module.exports = function(RED) {
|
||||
// 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.error("Connection to Matrix server lost");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -389,23 +631,57 @@ module.exports = function(RED) {
|
||||
// httpStatus: 401
|
||||
// }
|
||||
|
||||
node.error("Authentication failure: " + errorObj, {});
|
||||
node.error("Authentication failure: " + errorObj);
|
||||
stopClient();
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
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, {});
|
||||
node.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +702,7 @@ module.exports = function(RED) {
|
||||
.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.", {});
|
||||
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
|
||||
@@ -436,7 +712,7 @@ module.exports = function(RED) {
|
||||
|
||||
// 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']}`, {});
|
||||
node.error(`User ID provided is ${node.userId} but token belongs to ${data['user_id']}`);
|
||||
return;
|
||||
}
|
||||
run().catch((error) => node.error(error));
|
||||
@@ -445,13 +721,14 @@ module.exports = function(RED) {
|
||||
// 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, {});
|
||||
node.error("Auth check failed: " + err);
|
||||
}
|
||||
}
|
||||
)
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RED.nodes.registerType("matrix-server-config", MatrixServerNode, {
|
||||
credentials: {
|
||||
@@ -459,20 +736,25 @@ 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;
|
||||
|
||||
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,
|
||||
@@ -480,8 +762,6 @@ module.exports = function(RED) {
|
||||
localTimeoutMs: '30000'
|
||||
});
|
||||
|
||||
matrixClient.timelineSupport = true;
|
||||
|
||||
matrixClient.login(
|
||||
'm.login.password', {
|
||||
identifier: {
|
||||
@@ -507,6 +787,150 @@ module.exports = function(RED) {
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch(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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<script type="text/javascript">
|
||||
RED.nodes.registerType('matrix-verification-action',{
|
||||
category: 'matrix',
|
||||
color: '#00b7ca',
|
||||
icon: "matrix.png",
|
||||
inputs: 1,
|
||||
outputs: 2,
|
||||
outputLabels: ["success", "error"],
|
||||
align: 'right',
|
||||
defaults: {
|
||||
name: { value: null },
|
||||
server: { type: "matrix-server-config" },
|
||||
mode: { value: "accept" }
|
||||
},
|
||||
label: function() {
|
||||
const labels = {
|
||||
request: "Request verification",
|
||||
accept: "Accept verification",
|
||||
start: "Start SAS verification",
|
||||
confirm: "Confirm SAS",
|
||||
mismatch: "Reject SAS (mismatch)",
|
||||
cancel: "Cancel verification"
|
||||
};
|
||||
return this.name || labels[this.mode] || "Verification Action";
|
||||
},
|
||||
paletteLabel: 'Verification Action'
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="matrix-verification-action">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="Name">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-server"><i class="fa fa-user"></i> Matrix Server Config</label>
|
||||
<input type="text" id="node-input-server">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-mode"><i class="fa fa-shield"></i> Action</label>
|
||||
<select id="node-input-mode">
|
||||
<option value="request">Request verification</option>
|
||||
<option value="accept">Accept verification</option>
|
||||
<option value="start">Start SAS verification</option>
|
||||
<option value="confirm">Confirm SAS</option>
|
||||
<option value="mismatch">Reject SAS (mismatch)</option>
|
||||
<option value="cancel">Cancel verification</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-tips" style="margin-bottom: 12px;">
|
||||
The action can also be overridden per message with <code>msg.mode</code>.
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="matrix-verification-action">
|
||||
<h3>Details</h3>
|
||||
<p>Acts on a Matrix device verification: request, accept, start, confirm or cancel it.</p>
|
||||
|
||||
<p>
|
||||
This is the action counterpart to the <code>matrix-verification</code> node. The
|
||||
<b>Action</b> set on the node decides what it does; it can also be overridden per
|
||||
message with <code>msg.mode</code>. Every action except <b>Request verification</b>
|
||||
operates on an existing verification identified by <code>msg.verificationId</code>
|
||||
(as emitted by the <code>matrix-verification</code> node).
|
||||
</p>
|
||||
<p>
|
||||
A typical SAS (emoji) flow: receive a <code>requested</code> event →
|
||||
<b>Accept</b> → <b>Start SAS</b> → receive the <code>sas</code> event with
|
||||
the emoji → have a human confirm → <b>Confirm SAS</b>.
|
||||
</p>
|
||||
|
||||
<h3>Actions</h3>
|
||||
<dl class="message-properties">
|
||||
<dt>Request verification</dt>
|
||||
<dd>Starts a new verification. With <code>msg.userId</code> + <code>msg.deviceId</code> it verifies a specific device; with <code>msg.userId</code> + <code>msg.topic</code> it verifies a user in that DM room; with neither it verifies the bot's own other devices.</dd>
|
||||
|
||||
<dt>Accept verification</dt>
|
||||
<dd>Accepts an incoming verification request (moves it to the <code>ready</code> phase).</dd>
|
||||
|
||||
<dt>Start SAS verification</dt>
|
||||
<dd>Begins SAS (emoji) verification. The emoji/decimal are delivered through the <code>matrix-verification</code> node as a <code>sas</code> phase event.</dd>
|
||||
|
||||
<dt>Confirm SAS</dt>
|
||||
<dd>Confirms that the SAS emoji/decimal match. Call this once a human has verified the emoji.</dd>
|
||||
|
||||
<dt>Reject SAS (mismatch)</dt>
|
||||
<dd>Declares that the SAS does not match, cancelling the verification.</dd>
|
||||
|
||||
<dt>Cancel verification</dt>
|
||||
<dd>Cancels the verification request.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Inputs</h3>
|
||||
<dl class="message-properties">
|
||||
<dt class="optional">msg.mode <span class="property-type">string</span></dt>
|
||||
<dd>optional. Overrides the node's configured Action. One of <code>request</code>, <code>accept</code>, <code>start</code>, <code>confirm</code>, <code>mismatch</code>, <code>cancel</code>.</dd>
|
||||
|
||||
<dt>msg.verificationId <span class="property-type">string</span></dt>
|
||||
<dd>required for every action except <code>request</code>. The id of the verification to act on, from the <code>matrix-verification</code> node.</dd>
|
||||
|
||||
<dt class="optional">msg.userId <span class="property-type">string</span></dt>
|
||||
<dd>used by <code>request</code>: the user to verify.</dd>
|
||||
|
||||
<dt class="optional">msg.deviceId <span class="property-type">string</span></dt>
|
||||
<dd>used by <code>request</code>: the specific device to verify (to-device verification).</dd>
|
||||
|
||||
<dt class="optional">msg.topic <span class="property-type">string</span></dt>
|
||||
<dd>used by <code>request</code>: a DM room id, to verify a user in-room.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Outputs</h3>
|
||||
<ol class="node-ports">
|
||||
<li>Success
|
||||
<dl class="message-properties">
|
||||
<dt>msg.verificationId <span class="property-type">string</span></dt>
|
||||
<dd>the id of the verification that was acted on (or created, for <code>request</code>).</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li>Error
|
||||
<dl class="message-properties">
|
||||
<dt>msg.error <span class="property-type">string</span></dt>
|
||||
<dd>the error that occurred.</dd>
|
||||
</dl>
|
||||
</li>
|
||||
</ol>
|
||||
</script>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<script type="text/javascript">
|
||||
RED.nodes.registerType('matrix-verification',{
|
||||
category: 'matrix',
|
||||
color: '#00b7ca',
|
||||
icon: "matrix.png",
|
||||
inputs: 0,
|
||||
outputs: 1,
|
||||
defaults: {
|
||||
name: { value: null },
|
||||
server: { type: "matrix-server-config" },
|
||||
phaseRequested: { value: true },
|
||||
phaseReady: { value: true },
|
||||
phaseStarted: { value: true },
|
||||
phaseSas: { value: true },
|
||||
phaseDone: { value: true },
|
||||
phaseCancelled: { value: true },
|
||||
initiatedBy: { value: "any" },
|
||||
verificationType: { value: "any" },
|
||||
selfVerification: { value: "any" },
|
||||
userFilter: { value: "" },
|
||||
roomFilter: { value: "" }
|
||||
},
|
||||
label: function() {
|
||||
return this.name || "Verification";
|
||||
},
|
||||
paletteLabel: 'Verification'
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="matrix-verification">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="Name">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-server"><i class="fa fa-server"></i> Matrix Server</label>
|
||||
<input type="text" id="node-input-server">
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-left: 100px;margin-top:10px;font-weight:bold;">
|
||||
Phase filter
|
||||
</div>
|
||||
<div class="form-tips" style="margin-bottom:6px;">Emit only the ticked phases.</div>
|
||||
<div class="form-row" style="margin-bottom:0;">
|
||||
<input type="checkbox" id="node-input-phaseRequested" style="width:auto;margin-left:125px;vertical-align:top" />
|
||||
<label for="node-input-phaseRequested" style="width:auto">requested</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:0;">
|
||||
<input type="checkbox" id="node-input-phaseReady" style="width:auto;margin-left:125px;vertical-align:top" />
|
||||
<label for="node-input-phaseReady" style="width:auto">ready</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:0;">
|
||||
<input type="checkbox" id="node-input-phaseStarted" style="width:auto;margin-left:125px;vertical-align:top" />
|
||||
<label for="node-input-phaseStarted" style="width:auto">started</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:0;">
|
||||
<input type="checkbox" id="node-input-phaseSas" style="width:auto;margin-left:125px;vertical-align:top" />
|
||||
<label for="node-input-phaseSas" style="width:auto">sas (emoji ready to confirm)</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:0;">
|
||||
<input type="checkbox" id="node-input-phaseDone" style="width:auto;margin-left:125px;vertical-align:top" />
|
||||
<label for="node-input-phaseDone" style="width:auto">done</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="checkbox" id="node-input-phaseCancelled" style="width:auto;margin-left:125px;vertical-align:top" />
|
||||
<label for="node-input-phaseCancelled" style="width:auto">cancelled</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-initiatedBy"><i class="fa fa-filter"></i> Initiated by</label>
|
||||
<select id="node-input-initiatedBy">
|
||||
<option value="any">Any</option>
|
||||
<option value="me">Me (the bot started it)</option>
|
||||
<option value="notme">Not me (the other party)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-verificationType"><i class="fa fa-filter"></i> Type</label>
|
||||
<select id="node-input-verificationType">
|
||||
<option value="any">Any</option>
|
||||
<option value="room">Room (in-room / DM)</option>
|
||||
<option value="device">Device (to-device)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-selfVerification"><i class="fa fa-filter"></i> Self-verify</label>
|
||||
<select id="node-input-selfVerification">
|
||||
<option value="any">Any</option>
|
||||
<option value="self">Self only (bot's own devices)</option>
|
||||
<option value="others">Others only (other users)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-userFilter"><i class="fa fa-user"></i> User IDs</label>
|
||||
<input type="text" id="node-input-userFilter" placeholder="@alice:example.org, @bob:example.org">
|
||||
</div>
|
||||
<div class="form-tips" style="margin-bottom:12px;">User ID allowlist - only emit verifications involving these users. Comma separated, or blank for any.</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-roomFilter"><i class="fa fa-comments"></i> Room IDs</label>
|
||||
<input type="text" id="node-input-roomFilter" placeholder="!room:example.org, !other:example.org">
|
||||
</div>
|
||||
<div class="form-tips" style="margin-bottom:12px;">Restrict room verifications to these rooms. Comma separated, or blank for any. Device verifications are not affected.</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="matrix-verification">
|
||||
<h3>Details</h3>
|
||||
<p>Emits a message when a device verification request is received or changes phase.</p>
|
||||
|
||||
<p>
|
||||
This node is the event source for device verification. It outputs a message every
|
||||
time a verification request is created (either incoming, or started with the
|
||||
<code>matrix-verification-action</code> node) and again on every phase change. Use
|
||||
it together with <code>matrix-verification-action</code> to build your own approval
|
||||
flow — for example, emailing the SAS emoji to a human for confirmation.
|
||||
</p>
|
||||
<p>
|
||||
Each message carries a <code>msg.verificationId</code> which is the handle you pass
|
||||
to <code>matrix-verification-action</code> to act on that verification.
|
||||
</p>
|
||||
|
||||
<h3>Filters</h3>
|
||||
<p>
|
||||
All filters are applied on the node so you don't need <code>switch</code> nodes
|
||||
downstream. They AND-combine, and each defaults to passing everything.
|
||||
</p>
|
||||
<dl class="message-properties">
|
||||
<dt>Phase filter</dt>
|
||||
<dd>Emits only the ticked phases (<code>requested</code>, <code>ready</code>, <code>started</code>, <code>sas</code>, <code>done</code>, <code>cancelled</code>). All ticked = emit every phase.</dd>
|
||||
|
||||
<dt>Initiated by</dt>
|
||||
<dd><code>Any</code>, <code>Me</code> (only verifications the bot started), or <code>Not me</code> (only verifications started by the other party).</dd>
|
||||
|
||||
<dt>Type</dt>
|
||||
<dd><code>Any</code>, <code>Room</code> (in-room / DM verifications), or <code>Device</code> (to-device verifications).</dd>
|
||||
|
||||
<dt>Self-verify</dt>
|
||||
<dd><code>Any</code>, <code>Self only</code> (the other party is another of the bot's own devices), or <code>Others only</code> (a different user).</dd>
|
||||
|
||||
<dt>User IDs</dt>
|
||||
<dd>Allowlist of user IDs (comma separated). When set, only verifications involving one of these users are emitted. Blank = any user.</dd>
|
||||
|
||||
<dt>Room IDs</dt>
|
||||
<dd>Comma separated room IDs. When set, room verifications are restricted to these rooms. Device verifications have no room and are not affected by this filter. Blank = any room.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Outputs</h3>
|
||||
<dl class="message-properties">
|
||||
<dt>msg.verificationId <span class="property-type">string</span></dt>
|
||||
<dd>unique id of the verification. Pass this as <code>msg.verificationId</code> to the <code>matrix-verification-action</code> node.</dd>
|
||||
|
||||
<dt>msg.phase <span class="property-type">string</span></dt>
|
||||
<dd>
|
||||
current phase of the verification, one of:
|
||||
<code>requested</code> (a request was received/sent),
|
||||
<code>ready</code> (the request was accepted),
|
||||
<code>started</code> (a method was chosen),
|
||||
<code>sas</code> (SAS emoji/decimal are ready to confirm — see <code>msg.sas</code>),
|
||||
<code>done</code> (verification completed),
|
||||
<code>cancelled</code> (verification cancelled).
|
||||
</dd>
|
||||
|
||||
<dt>msg.payload <span class="property-type">string</span></dt>
|
||||
<dd>same value as <code>msg.phase</code>, for convenience.</dd>
|
||||
|
||||
<dt>msg.userId <span class="property-type">string</span></dt>
|
||||
<dd>the user id of the other party in the verification.</dd>
|
||||
|
||||
<dt>msg.deviceId <span class="property-type">string | null</span></dt>
|
||||
<dd>the other party's device id, for to-device verifications. <code>null</code> for in-room verifications.</dd>
|
||||
|
||||
<dt>msg.topic <span class="property-type">string | null</span></dt>
|
||||
<dd>the room id, for in-room (DM) verifications. <code>null</code> for to-device verifications.</dd>
|
||||
|
||||
<dt>msg.sas <span class="property-type">object</span></dt>
|
||||
<dd>
|
||||
present only when <code>msg.phase</code> is <code>sas</code>. Contains
|
||||
<code>emoji</code> (an array of <code>[emoji, name]</code> pairs) and
|
||||
<code>decimal</code> (an array of three numbers). One or both may be
|
||||
populated depending on what was negotiated.
|
||||
</dd>
|
||||
|
||||
<dt>msg.isSelfVerification <span class="property-type">boolean</span></dt>
|
||||
<dd><code>true</code> if the other party is another device of the bot's own account.</dd>
|
||||
|
||||
<dt>msg.initiatedByMe <span class="property-type">boolean</span></dt>
|
||||
<dd><code>true</code> if this verification was started by the bot.</dd>
|
||||
|
||||
<dt>msg.chosenMethod <span class="property-type">string | null</span></dt>
|
||||
<dd>the verification method that was chosen (e.g. <code>m.sas.v1</code>), once one has been picked; <code>null</code> before then.</dd>
|
||||
|
||||
<dt>msg.cancellationCode <span class="property-type">string | null</span></dt>
|
||||
<dd>present when <code>msg.phase</code> is <code>cancelled</code>; the reason code (e.g. <code>m.user</code>).</dd>
|
||||
</dl>
|
||||
|
||||
<h3>References</h3>
|
||||
<ul>
|
||||
<li><a href="https://spec.matrix.org/latest/client-server-api/#device-verification">Matrix spec</a> - device verification</li>
|
||||
</ul>
|
||||
</script>
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user