diff --git a/src/util/decryptE2ERoomKeys.js b/src/util/decryptE2ERoomKeys.js new file mode 100644 index 0000000..2b00872 --- /dev/null +++ b/src/util/decryptE2ERoomKeys.js @@ -0,0 +1,225 @@ +// https://github.com/matrix-org/matrix-react-sdk/blob/e78a1adb6f1af2ea425b0bae9034fb7344a4b2e8/src/utils/MegolmExportEncryption.js + +const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle; + +/** + * Make an Error object which has a friendlyText property which is already + * translated and suitable for showing to the user. + * + * @param {string} msg message for the exception + * @param {string} friendlyText + * @returns {Error} + */ +function friendlyError(msg, friendlyText) { + const e = new Error(msg); + e.friendlyText = friendlyText; + return e; +} + +function cryptoFailMsg() { + return 'Your browser does not support the required cryptography extensions'; +} +/** + * Derive the AES and HMAC-SHA-256 keys for the file + * + * @param {Unit8Array} salt salt for pbkdf + * @param {Number} iterations number of pbkdf iterations + * @param {String} password password + * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key] + */ +async function deriveKeys(salt, iterations, password) { + const start = new Date(); + + let key; + try { + key = await subtleCrypto.importKey( + 'raw', + new TextEncoder().encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits'], + ); + } catch (e) { + throw friendlyError(`subtleCrypto.importKey failed: ${e}`, cryptoFailMsg()); + } + + let keybits; + try { + keybits = await subtleCrypto.deriveBits( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-512', + }, + key, + 512, + ); + } catch (e) { + throw friendlyError(`subtleCrypto.deriveBits failed: ${e}`, cryptoFailMsg()); + } + + const now = new Date(); + console.log(`E2e import/export: deriveKeys took ${(now - start)}ms`); + + const aesKey = keybits.slice(0, 32); + const hmacKey = keybits.slice(32); + + const aesProm = subtleCrypto.importKey( + 'raw', + aesKey, + { name: 'AES-CTR' }, + false, + ['encrypt', 'decrypt'], + ).catch((e) => { + throw friendlyError(`subtleCrypto.importKey failed for AES key: ${e}`, cryptoFailMsg()); + }); + + const hmacProm = subtleCrypto.importKey( + 'raw', + hmacKey, + { + name: 'HMAC', + hash: { name: 'SHA-256' }, + }, + false, + ['sign', 'verify'], + ).catch((e) => { + throw friendlyError(`subtleCrypto.importKey failed for HMAC key: ${e}`, cryptoFailMsg()); + }); + + // eslint-disable-next-line no-return-await + return await Promise.all([aesProm, hmacProm]); +} + +/** + * Decode a base64 string to a typed array of uint8. + * @param {string} base64 The base64 to decode. + * @return {Uint8Array} The decoded data. + */ +function decodeBase64(base64) { + // window.atob returns a unicode string with codepoints in the range 0-255. + const latin1String = window.atob(base64); + // Encode the string as a Uint8Array + const uint8Array = new Uint8Array(latin1String.length); + for (let i = 0; i < latin1String.length; i += 1) { + uint8Array[i] = latin1String.charCodeAt(i); + } + return uint8Array; +} + +const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----'; +const TRAILER_LINE = '-----END MEGOLM SESSION DATA-----'; + +/** + * Unbase64 an ascii-armoured megolm key file + * + * Strips the header and trailer lines, and unbase64s the content + * + * @param {ArrayBuffer} data input file + * @return {Uint8Array} unbase64ed content + */ +function unpackMegolmKeyFile(data) { + // parse the file as a great big String. This should be safe, because there + // should be no non-ASCII characters, and it means that we can do string + // comparisons to find the header and footer, and feed it into window.atob. + const fileStr = new TextDecoder().decode(new Uint8Array(data)); + + // look for the start line + let lineStart = 0; + while (1) { + const lineEnd = fileStr.indexOf('\n', lineStart); + if (lineEnd < 0) { + throw new Error('Header line not found'); + } + const line = fileStr.slice(lineStart, lineEnd).trim(); + + // start the next line after the newline + lineStart = lineEnd + 1; + + if (line === HEADER_LINE) { + break; + } + } + + const dataStart = lineStart; + + // look for the end line + while (1) { + const lineEnd = fileStr.indexOf('\n', lineStart); + const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim(); + if (line === TRAILER_LINE) { + break; + } + + if (lineEnd < 0) { + throw new Error('Trailer line not found'); + } + + // start the next line after the newline + lineStart = lineEnd + 1; + } + + const dataEnd = lineStart; + return decodeBase64(fileStr.slice(dataStart, dataEnd)); +} + +export default async function decryptMegolmKeyFile(data, password) { + const body = unpackMegolmKeyFile(data); + + // check we have a version byte + if (body.length < 1) { + throw friendlyError('Invalid file: too short', 'Not a valid keyfile'); + } + + const version = body[0]; + if (version !== 1) { + throw friendlyError('Unsupported version', 'Not a valid keyfile'); + } + + const ciphertextLength = body.length - (1 + 16 + 16 + 4 + 32); + if (ciphertextLength < 0) { + throw friendlyError('Invalid file: too short', 'Not a valid keyfile'); + } + + const salt = body.subarray(1, 1 + 16); + const iv = body.subarray(17, 17 + 16); + const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36]; + const ciphertext = body.subarray(37, 37 + ciphertextLength); + const hmac = body.subarray(-32); + + const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password); + const toVerify = body.subarray(0, -32); + + let isValid; + try { + isValid = await subtleCrypto.verify( + { name: 'HMAC' }, + hmacKey, + hmac, + toVerify, + ); + } catch (e) { + throw friendlyError(`subtleCrypto.verify failed: ${e}`, cryptoFailMsg()); + } + if (!isValid) { + throw friendlyError('hmac mismatch', 'Authentication check failed: Incorrect password?'); + } + + let plaintext; + try { + plaintext = await subtleCrypto.decrypt( + { + name: 'AES-CTR', + counter: iv, + length: 64, + }, + aesKey, + ciphertext, + ); + } catch (e) { + throw friendlyError(`subtleCrypto.decrypt failed: ${e}`, cryptoFailMsg()); + } + + return new TextDecoder().decode(new Uint8Array(plaintext)); +}