Files
node-red-contrib-matrix-chat/src/matrix-server-config.html
T
skylord123 ebcb1eab81 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.
2026-05-22 14:40:00 -06:00

418 lines
25 KiB
HTML

<style>
.matrix-loader {
border: 8px solid #f3f3f3;
border-radius: 50%;
border-top: 8px solid #3498db;
border-bottom: 8px solid #3498db;
width: 20px;
height: 20px;
-webkit-animation: matrix-spin 2s linear infinite;
animation: matrix-spin 2s linear infinite;
}
@-webkit-keyframes matrix-spin {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes matrix-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<script type="text/javascript">
RED.nodes.registerType('matrix-server-config',{
category: 'config',
color: '#00b7ca',
credentials: {
userId: { type: "text", required: true },
deviceLabel: { type: "text", required: false },
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: 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 &amp; Cross-signing</span>'
+ '<span class="matrix-sb-x" id="matrix-sb-x" title="Close">&times;</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 &amp; 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 &amp; 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>
<script type="text/html" data-template-name="matrix-server-config">
<div class="form-row">
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-config-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-config-input-deviceLabel"><i class="fa fa-tag"></i> Device Label</label>
<input type="text" placeholder="Node-RED" id="node-config-input-deviceLabel">
</div>
<div class="form-row">
<label for="node-config-input-url"><i class="fa fa-server"></i> Server URL</label>
<input type="text" placeholder="https://matrix.org" id="node-config-input-url">
</div>
<div class="form-row">
<label for="node-config-input-userId"><i class="fa fa-user"></i> User ID</label>
<input type="text" placeholder="@example:matrix.org" id="node-config-input-userId">
</div>
<div class="form-row">
<label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
<input type="password" placeholder="" id="node-config-input-password">
</div>
<div class="form-tips" style="margin-bottom: 12px;">
Optional. Used to fetch an access token with the button below, and &mdash; if you
enable cross-signing &mdash; 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>
<button class="ui-button ui-corner-all ui-widget primary" id="matrix-login-btn">Update Access Token</button>
<div class="matrix-loader" id="matrix-access-token-loader" style="display:none;"></div>
<div class="form-row">
<label for="node-config-input-accessToken"><i class="fa fa-key"></i> Access Token</label>
<input type="text" id="node-config-input-accessToken">
</div>
<div class="form-tips" style="margin-bottom: 12px;">
You can either provide/generate an access token yourself or use the login button above to do it automatically. View the <a href="javascript:$('#red-ui-tab-help-link-button').click();">node docs</a> to figure out how to generate an Access Token manually. If you generated a user with shared secret registration you will already have an access token you can place here.
</div>
<div class="form-row">
<label for="node-config-input-deviceId"><i class="fa fa-desktop"></i> Device ID</label>
<input type="text" id="node-config-input-deviceId">
</div>
<div class="form-tips" style="margin-bottom: 12px;">
If you have a Device ID for this auth token you can place it here, or you can leave it blank to have one automatically generated (this field will always be blank if auto generated as it is saved internally). DO NOT manually set one, this must be generated by the server.<br />
WARNING: If you change this after the client has already initialized you will break encryption. Your Device ID is tied to your encryption keys.
</div>
<div class="form-row">
<input
type="checkbox"
id="node-config-input-autoAcceptRoomInvites"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-config-input-autoAcceptRoomInvites" style="width: auto;max-width:50%;">
Auto join invited rooms
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-config-input-enableE2ee"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-config-input-enableE2ee" style="width: auto;max-width:50%;">
Enable end-to-end encryption
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-config-input-global"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-config-input-global" style="width: auto">
Global access to Matrix Client
</label>
<div class="form-tips" style="margin-bottom: 12px;">
If global access is enabled you can access the client directly within a Function node. This way you can do <a href="https://github.com/Skylar-Tech/node-red-contrib-matrix-chat/tree/master/examples#use-function-node-to-run-any-command" target="_blank">whatever you want</a> with the client. Example:<br>
<code style="white-space: normal;">let client = global.get("matrixClient['@bot:example.com']");</code>
</div>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-config-input-allowUnknownDevices"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-config-input-allowUnknownDevices" style="width: auto">
Allow unverified devices in rooms
</label>
<div class="form-tips" style="margin-bottom: 12px;">
Allow sending messages to a room with unknown devices which have not been verified.
</div>
</div>
<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 &amp; 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>) &mdash; 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. 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 &mdash; if so, verify those sessions against the bot (see <em>Cross-signing &amp; 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.</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 &amp; About" tab (left side of the dialog).</li><li>Scroll to the bottom and click the <code>&lt;click to reveal&gt;</code> part of <code>Access Token: &lt;click to reveal&gt;</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 &amp; secure backup</h3>
<div>
<p>
Use the <strong>Set up secure backup &amp; 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 &mdash; 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>