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