mirror of
https://github.com/Skylar-Tech/node-red-contrib-matrix-chat.git
synced 2026-05-23 15:43:33 -06:00
ebcb1eab81
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.
253 lines
9.9 KiB
JavaScript
253 lines
9.9 KiB
JavaScript
module.exports = function(RED) {
|
|
const crypto = require('isomorphic-webcrypto');
|
|
|
|
function MatrixDecryptFile(n) {
|
|
RED.nodes.createNode(this, n);
|
|
|
|
var node = this;
|
|
|
|
this.name = n.name;
|
|
|
|
node.on("input", async function (msg) {
|
|
const { got } = await import('got');
|
|
|
|
if(!msg.type) {
|
|
node.error('msg.type is required.', msg);
|
|
return;
|
|
}
|
|
|
|
if(!msg.content) {
|
|
node.error('msg.content is required.', msg);
|
|
return;
|
|
}
|
|
|
|
if(!msg.content.file) {
|
|
node.error('msg.content.file is required.', msg);
|
|
return;
|
|
}
|
|
|
|
if(!msg.url) {
|
|
node.error('msg.url is required.', msg);
|
|
return;
|
|
}
|
|
|
|
try{
|
|
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
|
|
if(
|
|
msg.type.toLowerCase() === 'm.image'
|
|
&& msg.thumbnail_url
|
|
&& msg.content.info.thumbnail_file
|
|
) {
|
|
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;
|
|
|
|
node.send([msg, null]);
|
|
});
|
|
}
|
|
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');
|
|
}
|
|
|
|
function btoa(b) {
|
|
return Buffer.from(b).toString('base64');
|
|
}
|
|
|
|
// the following was taken & modified from https://github.com/matrix-org/browser-encrypt-attachment/blob/master/index.js
|
|
/**
|
|
* Encrypt an attachment.
|
|
* @param {ArrayBuffer} plaintextBuffer The attachment data buffer.
|
|
* @return {Promise} A promise that resolves with an object when the attachment is encrypted.
|
|
* The object has a "data" key with an ArrayBuffer of encrypted data and an "info" key
|
|
* with an object containing the info needed to decrypt the data.
|
|
*/
|
|
function encryptAttachment(plaintextBuffer) {
|
|
let cryptoKey; // The AES key object.
|
|
let exportedKey; // The AES key exported as JWK.
|
|
let ciphertextBuffer; // ArrayBuffer of encrypted data.
|
|
let sha256Buffer; // ArrayBuffer of digest.
|
|
let ivArray; // Uint8Array of AES IV
|
|
// Generate an IV where the first 8 bytes are random and the high 8 bytes
|
|
// are zero. We set the counter low bits to 0 since it makes it unlikely
|
|
// that the 64 bit counter will overflow.
|
|
ivArray = new Uint8Array(16);
|
|
crypto.getRandomValues(ivArray.subarray(0,8));
|
|
// Load the encryption key.
|
|
return crypto.subtle.generateKey(
|
|
{"name": "AES-CTR", length: 256}, true, ["encrypt", "decrypt"]
|
|
).then(function(generateKeyResult) {
|
|
cryptoKey = generateKeyResult;
|
|
// Export the Key as JWK.
|
|
return crypto.subtle.exportKey("jwk", cryptoKey);
|
|
}).then(function(exportKeyResult) {
|
|
exportedKey = exportKeyResult;
|
|
// Encrypt the input ArrayBuffer.
|
|
// Use half of the iv as the counter by setting the "length" to 64.
|
|
return crypto.subtle.encrypt(
|
|
{name: "AES-CTR", counter: ivArray, length: 64}, cryptoKey, plaintextBuffer
|
|
);
|
|
}).then(function(encryptResult) {
|
|
ciphertextBuffer = encryptResult;
|
|
// SHA-256 the encrypted data.
|
|
return crypto.subtle.digest("SHA-256", ciphertextBuffer);
|
|
}).then(function (digestResult) {
|
|
sha256Buffer = digestResult;
|
|
|
|
return {
|
|
data: ciphertextBuffer,
|
|
info: {
|
|
v: "v2",
|
|
key: exportedKey,
|
|
iv: encodeBase64(ivArray),
|
|
hashes: {
|
|
sha256: encodeBase64(new Uint8Array(sha256Buffer)),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decrypt an attachment.
|
|
* @param {ArrayBuffer} ciphertextBuffer The encrypted attachment data buffer.
|
|
* @param {Object} info The information needed to decrypt the attachment.
|
|
* @param {Object} info.key AES-CTR JWK key object.
|
|
* @param {string} info.iv Base64 encoded 16 byte AES-CTR IV.
|
|
* @param {string} info.hashes.sha256 Base64 encoded SHA-256 hash of the ciphertext.
|
|
* @return {Promise} A promise that resolves with an ArrayBuffer when the attachment is decrypted.
|
|
*/
|
|
function decryptAttachment(ciphertextBuffer, info) {
|
|
|
|
if (info === undefined || info.key === undefined || info.iv === undefined
|
|
|| info.hashes === undefined || info.hashes.sha256 === undefined) {
|
|
throw new Error("Invalid info. Missing info.key, info.iv or info.hashes.sha256 key");
|
|
}
|
|
|
|
let cryptoKey; // The AES key object.
|
|
let ivArray = decodeBase64(info.iv);
|
|
let expectedSha256base64 = info.hashes.sha256;
|
|
// Load the AES from the "key" key of the info object.
|
|
return crypto.subtle.importKey(
|
|
"jwk", info.key, {"name": "AES-CTR"}, false, ["encrypt", "decrypt"]
|
|
).then(function (importKeyResult) {
|
|
cryptoKey = importKeyResult;
|
|
// Check the sha256 hash
|
|
return crypto.subtle.digest("SHA-256", ciphertextBuffer);
|
|
}).then(function (digestResult) {
|
|
if (encodeBase64(new Uint8Array(digestResult)) !== expectedSha256base64) {
|
|
throw new Error("Mismatched SHA-256 digest (expected: " + encodeBase64(new Uint8Array(digestResult)) + ") got (" + expectedSha256base64 + ")");
|
|
}
|
|
let counterLength;
|
|
if (info.v.toLowerCase() === "v1" || info.v.toLowerCase() === "v2") {
|
|
// Version 1 and 2 use a 64 bit counter.
|
|
counterLength = 64;
|
|
} else {
|
|
// Version 0 uses a 128 bit counter.
|
|
counterLength = 128;
|
|
}
|
|
return crypto.subtle.decrypt(
|
|
{name: "AES-CTR", counter: ivArray, length: counterLength}, cryptoKey, ciphertextBuffer
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Encode a typed array of uint8 as base64.
|
|
* @param {Uint8Array} uint8Array The data to encode.
|
|
* @return {string} The base64 without padding.
|
|
*/
|
|
function encodeBase64(uint8Array) {
|
|
// Misinterpt the Uint8Array as Latin-1.
|
|
// window.btoa expects a unicode string with codepoints in the range 0-255.
|
|
// var latin1String = String.fromCharCode.apply(null, uint8Array);
|
|
// Use the builtin base64 encoder.
|
|
var paddedBase64 = btoa(uint8Array);
|
|
// Calculate the unpadded length.
|
|
var inputLength = uint8Array.length;
|
|
var outputLength = 4 * Math.floor((inputLength + 2) / 3) + (inputLength + 2) % 3 - 2;
|
|
// Return the unpadded base64.
|
|
return paddedBase64.slice(0, outputLength);
|
|
}
|
|
|
|
/**
|
|
* Decode a base64 string to a typed array of uint8.
|
|
* This will decode unpadded base64, but will also accept base64 with padding.
|
|
* @param {string} base64 The unpadded base64 to decode.
|
|
* @return {Uint8Array} The decoded data.
|
|
*/
|
|
function decodeBase64(base64) {
|
|
// Pad the base64 up to the next multiple of 4.
|
|
var paddedBase64 = base64 + "===".slice(0, (4 - base64.length % 4) % 4);
|
|
// Decode the base64 as a misinterpreted Latin-1 string.
|
|
// window.atob returns a unicode string with codepoints in the range 0-255.
|
|
var latin1String = atob(paddedBase64);
|
|
// Encode the string as a Uint8Array as Latin-1.
|
|
var uint8Array = new Uint8Array(latin1String.length);
|
|
for (var i = 0; i < latin1String.length; i++) {
|
|
uint8Array[i] = latin1String.charCodeAt(i);
|
|
}
|
|
return uint8Array;
|
|
}
|
|
}
|