Merge pull request #103 from vector-im/bwindels/session-backup

Read from session backup
This commit is contained in:
Bruno Windels 2020-09-21 11:53:35 +00:00 committed by GitHub
commit 79ac939c8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 3567 additions and 84 deletions

View file

@ -47,7 +47,11 @@
"xxhashjs": "^0.2.2"
},
"dependencies": {
"aes-js": "^3.1.2",
"another-json": "^0.2.0",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz"
"base64-arraybuffer": "^0.2.0",
"bs58": "^4.0.1",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz",
"text-encoding": "^0.7.0"
}
}

View file

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script src="https://unpkg.com/text-encoding@0.6.4/lib/encoding-indexes.js"></script>
<script src="https://unpkg.com/text-encoding@0.6.4/lib/encoding.js"></script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script src="deps/jsSHA/dist/sha512.js"></script>
<script type="text/javascript" src="https://cdn.rawgit.com/ricmoo/aes-js/e27b99df/index.js"></script>
<script type="text/javascript" src="derive-keys-bundle.js"></script>
<script type="text/javascript">
if (!Math.imul) Math.imul = function(a, b) {
var aHi = (a >>> 16) & 0xffff;
var aLo = a & 0xffff;
var bHi = (b >>> 16) & 0xffff;
var bLo = b & 0xffff;
// the shift by 0 fixes the sign on the high part
// the final |0 converts the unsigned value into a signed value
return ((aLo * bLo) + (((aHi * bLo + aLo * bHi) << 16) >>> 0) | 0);
};
if (!Math.clz32) Math.clz32 = (function(log, LN2){
return function(x) {
// Let n be ToUint32(x).
// Let p be the number of leading zero bits in
// the 32-bit binary representation of n.
// Return p.
var asUint = x >>> 0;
if (asUint === 0) {
return 32;
}
return 31 - (log(asUint) / LN2 | 0) |0; // the "| 0" acts like math.floor
};
})(Math.log, Math.LN2);
</script>
<script type="text/javascript" src="../lib/olm/olm_legacy.js"></script>
<script type="text/javascript">
// sample data from account with recovery key
const ssssKeyAccountData = {
"type": "m.secret_storage.key.le4jDjlxrIMZDSKu1EudJL5Tc4U5qI0d",
"content": {
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "YPhwwArIUTwasbROMFd1PQ==",
"mac": "khWXeBzKtZi8SX6I7m/9yPoLB1yv1u9l+NNi6WF4+ek="
}
};
const megolmBackupKeyAccountData = {
"type": "m.megolm_backup.v1",
"content": {
"encrypted": {
"le4jDjlxrIMZDSKu1EudJL5Tc4U5qI0d": {
"iv": "PiqYdySj9s4RsaLc1oDF1w==",
"ciphertext": "62fjUs1xkF3BvqVEvAEoDH9jcYiotkcJHG/VNtzSrPBlrmOYQyPA93L2rKo=",
"mac": "vtq+kEg5XaRdw08aPiQi7+w9qUiDCQKo/jKNTvrN4ho="
}
}
}
};
const backupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"auth_data": {
"public_key": "tY/jSdfy2q1pS8Ux+LP8xr/RMn9NDElwofH+E5sFG38",
"signatures": {
"@bruno-test4s2:matrix.org": {
"ed25519:KTLGZUJCYZ": "YPuzpLo4OZL5+HQTkbDnUKpIuCmL50Q7RnMs9cRfKqyS+CMPm0RBU1ttPO6XOZ+TjZ4VThXU50LUkmpJiKM+Aw",
"ed25519:l17fdsfeS7qUKIYzgx3LxIcHnjPM00+Ge5dTk7Msy04": "epDo+d9foXXcnXChZaEOCKNYzofOMBXQF3FCMDJ52hxvxh9K1w+2zOOAwWEKOts88gubgIsdRQedkuhuIm2LCg"
}
}
},
"count": 1,
"etag": "1",
"version": "1"
};
const sessionResponse = {
"first_message_index": 0,
"forwarded_count": 0,
"is_verified": true,
"session_data": {
"ciphertext": "+a8OCF0v5U5GYTNAMwgNEqSItxy4hea073zlWCp+ocr4mUQDuUZyOo+DGHDPPvSOnhJA2waSV05wna/Jmig7NAzuJJy8eEd0dHmGiA16eUMFiUz0HYFseDXs0dDGF38shz1C6CXYRjTOS3S7JWLVzeeYy632BMGvGjWMvAuOpm4NgV9fLB5J6nYVb/wvU3Mf8mw/eT5k8AUJA/CAD6zM7T9skEJhuFoi5kdPfBoozUbScA5xcPVmE6aY08zZ6QpiZ7lsyWoIRDbRxaBxL82T2CnpcngE/SAHF+eJ9ZWK3txolYLT/KAfKlAVLV7yWXkYL7oxrW8DI/5ZQFXUqzqqqfAB7Qz2AIvCdUVqhDGwuDr5noCMlKYEwyYR0VC2i4ZyXdtLdOjKBS2eTqDcwdv2gcaOnbJJcIEuGMKVg89/rKqpWncY/+NOBTQhuts05+Wi+9wU+OlGlNFvhkOgp1BaP0Q7T4pkxgj4OSbf3t1UfthltJSX8TS9ZGd3DVDI8swQuMBvF9H+7kAeO2IWTMSe57MYvlk0aw/gPFdI06lcOvH2nAr9C2HNsuYhyO4XGZOAg8HHzkjLlzNU+zJk1MfRIXRoVgbIh1hApcK9HhyTBzg",
"ephemeral": "z0JE6swJZbrmRYOWGvEI6zhIzoJ57lhzp1uujVS2jUs",
"mac": "+AAASqA+4U8"
}
};
const keyId = "le4jDjlxrIMZDSKu1EudJL5Tc4U5qI0d";
// sample data with account with recovery passphrase
// const ssssKeyAccountData =
// {
// "type": "m.secret_storage.key.HB6AKfUD4avkZfPfyjcJ6iJPWDp4f9WM",
// "content": {
// "algorithm": "m.secret_storage.v1.aes-hmac-sha2",
// "passphrase": {
// "algorithm": "m.pbkdf2",
// "iterations": 500000,
// "salt": "tfY5mgvQBr3Gd5Dy1IBiKf7fLquL4Y9O"
// },
// "iv": "xitm4hxsqagkbyEmXj0tUw==",
// "mac": "nagOYz7FKrdlFEKM9ij78th0O2p7YVGgl+p0LHr4EBE="
// }
// };
// const megolmBackupKeyAccountData = {
// "type": "m.megolm_backup.v1",
// "content": {
// "encrypted": {
// "HB6AKfUD4avkZfPfyjcJ6iJPWDp4f9WM": {
// "iv": "HpzOY5DxYFJCxw5Vi6BBOQ==",
// "ciphertext": "u1TJjaaGKVDGExg9hu2fIUZ0gjToMcMReyhn4nsXgnhm7Dvz6E/4p+nSF3w=",
// "mac": "08ckDbQK9wB2jiE4n4sfp2sw83q/0C2/gEz2LuHMEPg="
// }
// }
// }
// };
// const backupInfo = {
// "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
// "auth_data": {
// "public_key": "Vw2cwhbxFg/GQ2rr4VRIQ+Oh74lP7IxY6oN4R9q992k",
// "signatures": {
// "@bruno-test4s:matrix.org": {
// "ed25519:XAIKJXBCNZ": "AFBp1T2x8hyPSi2hCHg6IzNy67RxULj3/7LYZgVT3Ruz49v5h1+jAScTxZj5jrItxo2LCzSORH+yABHjPIqOBQ",
// "ed25519:lukepZkTmPcJS6wCl12B0tIURIO8YbMd5QJLf8UOugI": "a1ZJa+1+p9Gm5Po1B619ZDy4xidHmLt82vXVPH7vWTjny1r3JI2iM4fB2qh8vEiASNlFyVrFx//gQrz9Y1IJBA"
// }
// }
// },
// "count": 1,
// "etag": "1",
// "version": "1"
// };
// const sessionResponse = {
// "first_message_index": 0,
// "forwarded_count": 0,
// "is_verified": true,
// "session_data": {
// "ciphertext": "1NoC8/GZWeGjneuoFDcqpbMYOJ8bjDFiw2O4/YOKC59x9RqSejLyM8qLL5FzlV+uW7anPVED8t9m+p2t1kKa15LxlcdzXjLPCv1QGYlhotbUhN8eRUobQuLqsD5Dl/QqNxv+Xl65tEaQhUeF30NmSesw6GHvP93vB3mTN8Yz9QyaQtvgoI/Q6c4d+yGmFVE2dlhXdOs7Hrylrg8UyM1QI+qpNJ3L9ETcqiXCG/FJIdM87LmNnHPX65TWK5xsu1JKWCI2BY1KFVDyxm40FyHHypUPYoT9RqPnygHtYoTiZzyaVxqUu2vg08Bv0t1VH2SNDGs5aZYQN5S1JNAHrXE+cWSg0rfVb160Z4FJC/89wO8fw/uXqJehqMVuC9BSU/zsKcZ797U92qDnIb6QQuMYKRgh9JrEugqJN9ocL7F8W9fW2oFfUYRyvOZRSf387hGrapEGBKx7Owb7UoXvWyb4C5hc5SFNvej+yg98+Fi4hzlGH26DqzJdLcxU5P/MWfZc222QqPFuFspe6f0Ts5jnJhjCQhXWoM4G6mtvGbOm2ESSJULj8U4JSDz8GsxrmojR/pBpywBvuy/mx//htnacnTRqYJz+PZVtV63rfaZlEtU",
// "ephemeral": "wXBeLoazggBmFS0eiVY9H/qq5o1yt2/NIKWcq384EHc",
// "mac": "w3IfO5vL9Bc"
// }
// };
//const keyId = "HB6AKfUD4avkZfPfyjcJ6iJPWDp4f9WM";
const cryptoDriver = new bundle.CryptoDriver((window.crypto || window.msCrypto).subtle);
window.Olm.init().then(function() {
bundle.deserializeSSSSKey("EsUH dBfj L7XF Kdej TNmK 2CdP R7NQ KnQH zA1o 8kDg piuJ QEZh", ssssKeyAccountData).then(function(ssssKey) {
//bundle.deriveSSSSKey(cryptoDriver, prompt("passphrase"), ssssKeyAccountData).then(function(ssssKey) {
// const ssssKey = new Uint8Array(32);
// const bytes = [123, 47, 138, 15, 190, 69, 224, 204, 88, 246, 203, 65, 243, 234, 91, 17, 250, 107, 104, 51, 211, 252, 81, 67, 80, 191, 105, 208, 127, 87, 107, 231];
// for (var i = bytes.length - 1; i >= 0; i--) {
// ssssKey[i] = bytes[i];
// }
console.log("ssssKey", ssssKey);
bundle.decryptSecret(cryptoDriver, keyId, ssssKey, megolmBackupKeyAccountData).then(function(backupKeyBase64) {
console.log("backupKeyBase64", backupKeyBase64);
bundle.decryptSession(backupKeyBase64, backupInfo, sessionResponse).then(function(session) {
console.log("session", session);
alert(session.session_key);
});
});
});
});
</script>
</body>
</html>

458
prototypes/derive-keys.js vendored Normal file
View file

@ -0,0 +1,458 @@
import {base58} from "../src/utils/base-encoding.js";
function subtleCryptoResult(promiseOrOp, method) {
if (promiseOrOp instanceof Promise) {
return promiseOrOp;
} else {
return new Promise((resolve, reject) => {
promiseOrOp.oncomplete = e => resolve(e.target.result);
promiseOrOp.onerror = e => reject(new Error("Crypto error on " + method));
});
}
}
class CryptoHMACDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [hmac description]
* @param {BufferSource} key
* @param {BufferSource} mac
* @param {BufferSource} data
* @param {HashName} hash
* @return {boolean}
*/
async verify(key, mac, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hashName(hash)},
};
const hmacKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['verify'],
), "importKey");
const isVerified = await subtleCryptoResult(this._subtleCrypto.verify(
opts,
hmacKey,
mac,
data,
), "verify");
return isVerified;
}
async compute(key, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hashName(hash)},
};
const hmacKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['sign'],
), "importKey");
const buffer = await subtleCryptoResult(this._subtleCrypto.sign(
opts,
hmacKey,
data,
), "sign");
return new Uint8Array(buffer);
}
}
const nwbo = (num, len) => {
const arr = new Uint8Array(len);
for(let i=0; i<len; i++) arr[i] = 0xFF && (num >> ((len - i - 1)*8));
return arr;
};
class CryptoLegacyHMACDriver {
constructor(hmacDriver) {
this._hmacDriver = hmacDriver;
}
async verify(key, mac, data, hash) {
if (hash === "SHA-512") {
throw new Error("SHA-512 HMAC verification is not implemented yet");
} else {
return this._hmacDriver.verify(key, mac, data, hash)
}
}
async compute(key, data, hash) {
if (hash === "SHA-256") {
return await this._hmacDriver.compute(key, data, hash);
} else {
const shaObj = new window.jsSHA(hash, "UINT8ARRAY", {
"hmacKey": {
"value": key,
"format": "UINT8ARRAY"
}
});
shaObj.update(data);
return shaObj.getHash("UINT8ARRAY");
}
}
}
class CryptoLegacyDeriveDriver {
constructor(cryptoDriver) {
this._cryptoDriver = cryptoDriver;
}
// adapted from https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-pbkdf/src/pbkdf.ts#L21
// could also consider https://github.com/brix/crypto-js/blob/develop/src/pbkdf2.js although not async
async pbkdf2(password, iterations, salt, hash, length) {
const dkLen = length / 8;
if (iterations <= 0) {
throw new Error('InvalidIterationCount');
}
if (dkLen <= 0) {
throw new Error('InvalidDerivedKeyLength');
}
const hLen = this._cryptoDriver.digestSize(hash);
if(dkLen > (Math.pow(2, 32) - 1) * hLen) throw new Error('DerivedKeyTooLong');
const l = Math.ceil(dkLen/hLen);
const r = dkLen - (l-1)*hLen;
const funcF = async (i) => {
const seed = new Uint8Array(salt.length + 4);
seed.set(salt);
seed.set(nwbo(i+1, 4), salt.length);
let u = await this._cryptoDriver.hmac.compute(password, seed, hash);
let outputF = new Uint8Array(u);
for(let j = 1; j < iterations; j++){
if ((j % 1000) === 0) {
console.log(j, j/iterations);
}
u = await this._cryptoDriver.hmac.compute(password, u, hash);
outputF = u.map( (elem, idx) => elem ^ outputF[idx]);
}
return {index: i, value: outputF};
};
const Tis = [];
const DK = new Uint8Array(dkLen);
for(let i = 0; i < l; i++) {
Tis.push(funcF(i));
}
const TisResolved = await Promise.all(Tis);
TisResolved.forEach(elem => {
if (elem.index !== l - 1) {
DK.set(elem.value, elem.index*hLen);
}
else {
DK.set(elem.value.slice(0, r), elem.index*hLen);
}
});
return DK;
}
// based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-hkdf/src/hkdf.ts
async hkdf(key, salt, info, hash, length) {
length = length / 8;
const len = this._cryptoDriver.digestSize(hash);
// RFC5869 Step 1 (Extract)
const prk = await this._cryptoDriver.hmac.compute(salt, key, hash);
// RFC5869 Step 2 (Expand)
let t = new Uint8Array([]);
const okm = new Uint8Array(Math.ceil(length / len) * len);
for(let i = 0; i < Math.ceil(length / len); i++){
const concat = new Uint8Array(t.length + info.length + 1);
concat.set(t);
concat.set(info, t.length);
concat.set(new Uint8Array([i+1]), t.length + info.length);
t = await this._cryptoDriver.hmac.compute(prk, concat, hash);
okm.set(t, len * i);
}
return okm.slice(0, length);
}
}
class CryptoDeriveDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [pbkdf2 description]
* @param {BufferSource} password
* @param {Number} iterations
* @param {BufferSource} salt
* @param {HashName} hash
* @param {Number} length the desired length of the generated key, in bits (not bytes!)
* @return {BufferSource}
*/
async pbkdf2(password, iterations, salt, hash, length) {
// check for existance of deriveBits, which IE11 does not have
const key = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
password,
{name: 'PBKDF2'},
false,
['deriveBits'],
), "importKey");
const keybits = await subtleCryptoResult(this._subtleCrypto.deriveBits(
{
name: 'PBKDF2',
salt,
iterations,
hash: hashName(hash),
},
key,
length,
), "deriveBits");
return new Uint8Array(keybits);
}
/**
* [hkdf description]
* @param {BufferSource} key [description]
* @param {BufferSource} salt [description]
* @param {BufferSource} info [description]
* @param {HashName} hash the hash to use
* @param {Number} length desired length of the generated key in bits (not bytes!)
* @return {[type]} [description]
*/
async hkdf(key, salt, info, hash, length) {
const hkdfkey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
{name: "HKDF"},
false,
["deriveBits"],
), "importKey");
const keybits = await subtleCryptoResult(this._subtleCrypto.deriveBits({
name: "HKDF",
salt,
info,
hash: hashName(hash),
},
hkdfkey,
length,
), "deriveBits");
return new Uint8Array(keybits);
}
}
class CryptoAESDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description]
* @return {BufferSource} [description]
*/
async decrypt(key, iv, ciphertext) {
const opts = {
name: "AES-CTR",
counter: iv,
length: 64,
};
let aesKey;
try {
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['decrypt'],
), "importKey");
} catch (err) {
throw new Error(`Could not import key for AES-CTR decryption: ${err.message}`);
}
try {
const plaintext = await subtleCryptoResult(this._subtleCrypto.decrypt(
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts,
aesKey,
ciphertext,
), "decrypt");
return new Uint8Array(plaintext);
} catch (err) {
throw new Error(`Could not decrypt with AES-CTR: ${err.message}`);
}
}
}
class CryptoLegacyAESDriver {
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description]
* @return {BufferSource} [description]
*/
async decrypt(key, iv, ciphertext) {
const aesjs = window.aesjs;
var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv));
return aesCtr.decrypt(ciphertext);
}
}
function hashName(name) {
if (name !== "SHA-256" && name !== "SHA-512") {
throw new Error(`Invalid hash name: ${name}`);
}
return name;
}
export class CryptoDriver {
constructor(subtleCrypto) {
this.aes = new CryptoLegacyAESDriver();
// this.aes = new CryptoAESDriver(subtleCrypto);
//this.derive = new CryptoDeriveDriver(subtleCrypto);
this.derive = new CryptoLegacyDeriveDriver(this);
// subtleCrypto.deriveBits ?
// new CryptoDeriveDriver(subtleCrypto) :
// new CryptoLegacyDeriveDriver(this);
this.hmac = new CryptoLegacyHMACDriver(new CryptoHMACDriver(subtleCrypto));
this._subtleCrypto = subtleCrypto;
}
/**
* [digest description]
* @param {HashName} hash
* @param {BufferSource} data
* @return {BufferSource}
*/
async digest(hash, data) {
return await subtleCryptoResult(this._subtleCrypto.digest(hashName(hash), data));
}
digestSize(hash) {
switch (hashName(hash)) {
case "SHA-512": return 64;
case "SHA-256": return 32;
default: throw new Error(`Not implemented for ${hashName(hash)}`);
}
}
}
export function decodeBase64(base64) {
const binStr = window.atob(base64);
const len = binStr.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binStr.charCodeAt(i);
}
return bytes;
}
const DEFAULT_ITERATIONS = 500000;
const DEFAULT_BITSIZE = 256;
export async function deriveSSSSKey(cryptoDriver, passphrase, ssssKey) {
const textEncoder = new TextEncoder();
return await cryptoDriver.derive.pbkdf2(
textEncoder.encode(passphrase),
ssssKey.content.passphrase.iterations || DEFAULT_ITERATIONS,
textEncoder.encode(ssssKey.content.passphrase.salt),
"SHA-512",
ssssKey.content.passphrase.bits || DEFAULT_BITSIZE);
}
export async function decryptSecret(cryptoDriver, keyId, ssssKey, event) {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
// now derive the aes and mac key from the 4s key
const hkdfKey = await cryptoDriver.derive.hkdf(
ssssKey,
new Uint8Array(8).buffer, //salt
textEncoder.encode(event.type), // info
"SHA-256",
512 // 512 bits or 64 bytes
);
const aesKey = hkdfKey.slice(0, 32);
const hmacKey = hkdfKey.slice(32);
const data = event.content.encrypted[keyId];
const ciphertextBytes = decodeBase64(data.ciphertext);
const isVerified = await cryptoDriver.hmac.verify(
hmacKey, decodeBase64(data.mac),
ciphertextBytes, "SHA-256");
if (!isVerified) {
throw new Error("Bad MAC");
}
const plaintext = await cryptoDriver.aes.decrypt(aesKey, decodeBase64(data.iv), ciphertextBytes);
return textDecoder.decode(new Uint8Array(plaintext));
}
export async function decryptSession(backupKeyBase64, backupInfo, sessionResponse) {
const privKey = decodeBase64(backupKeyBase64);
console.log("privKey", privKey);
const decryption = new window.Olm.PkDecryption();
let backupPubKey;
try {
backupPubKey = decryption.init_with_private_key(privKey);
} catch (e) {
decryption.free();
throw e;
}
// If the pubkey computed from the private data we've been given
// doesn't match the one in the auth_data, the user has enetered
// a different recovery key / the wrong passphrase.
if (backupPubKey !== backupInfo.auth_data.public_key) {
console.log("backupPubKey", backupPubKey.length, backupPubKey);
throw new Error("bad backup key");
}
const sessionInfo = decryption.decrypt(
sessionResponse.session_data.ephemeral,
sessionResponse.session_data.mac,
sessionResponse.session_data.ciphertext,
);
return JSON.parse(sessionInfo);
}
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
export async function deserializeSSSSKey(recoverykey) {
const result = base58.decode(recoverykey.replace(/ /g, ''));
let parity = 0;
for (const b of result) {
parity ^= b;
}
if (parity !== 0) {
throw new Error("Incorrect parity");
}
for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
throw new Error("Incorrect prefix");
}
}
if (
result.length !==
OLM_RECOVERY_KEY_PREFIX.length + window.Olm.PRIVATE_KEY_LENGTH + 1
) {
throw new Error("Incorrect length");
}
return Uint8Array.from(result.slice(
OLM_RECOVERY_KEY_PREFIX.length,
OLM_RECOVERY_KEY_PREFIX.length + window.Olm.PRIVATE_KEY_LENGTH,
));
}

91
prototypes/ie11-hmac.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script src="https://dl.dropboxusercontent.com/s/r55397ld512etib/EncoderDecoderTogether.min.js?dl=0" nomodule="" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script src="deps/jsSHA/dist/sha512.js"></script>
<script type="text/javascript">
function decodeBase64(base64) {
const binStr = window.atob(base64);
const len = binStr.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binStr.charCodeAt(i);
}
return bytes;
}
function encodeBase64(bytes) {
let binStr = "";
for (let i = 0; i < bytes.length; i++) {
binStr += String.fromCharCode(bytes[i]);
}
return window.btoa(binStr);
}
function subtleCryptoResult(promiseOrOp, method) {
if (promiseOrOp instanceof Promise) {
return promiseOrOp;
} else {
return new Promise(function(resolve, reject) {
promiseOrOp.oncomplete = function(e) {resolve(e.target.result);}
promiseOrOp.onerror = function(e) {
reject(new Error("Crypto error on " + method));
}
});
}
}
const subtleCrypto = (window.crypto || window.msCrypto).subtle;
function computeFallback(key, data, hash) {
const shaObj = new jsSHA(hash, "UINT8ARRAY", {
"hmacKey": {
"value": key,
"format": "UINT8ARRAY"
}
});
shaObj.update(data);
return Promise.resolve(shaObj.getHash("UINT8ARRAY"));
}
function compute(key, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hash},
};
return subtleCryptoResult(subtleCrypto.importKey(
'raw',
key,
opts,
false,
['sign']
), "importKey").then(function (hmacKey) {
console.log("hmacKey", hmacKey);
return subtleCryptoResult(subtleCrypto.sign(
opts,
hmacKey,
data
), "sign");
}).then(function(buffer) {
return new Uint8Array(buffer);
});
}
const te = new TextEncoder();
computeFallback(
new Uint8Array(te.encode("I am a key!!")),
new Uint8Array(te.encode("I am some data!!")),
"SHA-512"
).then(function(mac) {
// should be 9bpJS7myNR/ttCfts+woXJSapVb19qqFRntGh17rHydOBB8+pplZFG8Cc4Qkxxznri4nWyzhFWcWnenY9vd5rA==
alert(encodeBase64(mac));
})
</script>
</body>
</html>

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="text/javascript">
if (!Math.imul) Math.imul = function(a, b) {
var aHi = (a >>> 16) & 0xffff;
var aLo = a & 0xffff;
var bHi = (b >>> 16) & 0xffff;
var bLo = b & 0xffff;
// the shift by 0 fixes the sign on the high part
// the final |0 converts the unsigned value into a signed value
return ((aLo * bLo) + (((aHi * bLo + aLo * bHi) << 16) >>> 0) | 0);
};
if (!Math.clz32) Math.clz32 = (function(log, LN2){
return function(x) {
// Let n be ToUint32(x).
// Let p be the number of leading zero bits in
// the 32-bit binary representation of n.
// Return p.
var asUint = x >>> 0;
if (asUint === 0) {
return 32;
}
return 31 - (log(asUint) / LN2 | 0) |0; // the "| 0" acts like math.floor
};
})(Math.log, Math.LN2);
</script>
<!-- removing this line will make it work -->
<script src="https://dl.dropboxusercontent.com/s/r55397ld512etib/EncoderDecoderTogether.min.js?dl=0" nomodule="" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script type="text/javascript" src="../lib/olm/olm_legacy.js"></script>
<script type="text/javascript">
window.Olm.init().then(function() {
const bytes = [
34, 123, 54, 9, 124, 89, 230, 120,
43, 232, 19, 78, 129, 170, 255, 5,
90, 143, 56, 99, 101, 140, 240, 3,
7, 121, 41, 22, 67, 231, 85, 32
];
var privKey = new Uint8Array(32);
for (var i = bytes.length - 1; i >= 0; i--) {
privKey[i] = bytes[i];
}
console.log("privKey", privKey);
const decryption = new window.Olm.PkDecryption();
let backupPubKey;
try {
backupPubKey = decryption.init_with_private_key(privKey);
console.log("backupPubKey", backupPubKey.length, backupPubKey);
} catch (e) {
decryption.free();
throw e;
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,23 @@
{
"name": "foo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.11.1",
"@babel/preset-env": "^7.11.0",
"@rollup/plugin-babel": "^5.1.0",
"@rollup/plugin-commonjs": "^15.0.0",
"@rollup/plugin-multi-entry": "^4.0.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"mdn-polyfills": "^5.20.0",
"regenerator-runtime": "^0.13.7",
"rollup": "^2.26.4",
"core-js": "^3.6.5"
}
}

View file

@ -0,0 +1,46 @@
import fsRoot from "fs";
const fs = fsRoot.promises;
import { rollup } from 'rollup';
// needed for legacy bundle
import babel from '@rollup/plugin-babel';
// needed to find the polyfill modules in the main-legacy.js bundle
import { nodeResolve } from '@rollup/plugin-node-resolve';
// needed because some of the polyfills are written as commonjs modules
import commonjs from '@rollup/plugin-commonjs';
// multi-entry plugin so we can add polyfill file to main
import multi from '@rollup/plugin-multi-entry';
import removeJsComments from 'rollup-plugin-cleanup';
// replace urls of asset names with content hashed version
async function build(inputFile, outputFile) {
// compile down to whatever IE 11 needs
const babelPlugin = babel.babel({
babelHelpers: 'bundled',
exclude: '../../node_modules/**',
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: "3",
targets: "IE 11"
}
]
]
});
const polyfillFile = '../../src/worker-polyfill.js';
// create js bundle
const rollupConfig = {
input: [polyfillFile, inputFile],
plugins: [multi(), commonjs(), nodeResolve(), babelPlugin, removeJsComments({comments: "none"})]
};
const bundle = await rollup(rollupConfig);
const {output} = await bundle.generate({
format: 'iife',
name: `bundle`
});
const code = output[0].code;
await fs.writeFile(outputFile, code, "utf8");
}
build(process.argv[2], process.argv[3]);

1485
prototypes/tools/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -88,7 +88,7 @@ async function build() {
// so do it first
const themeAssets = await copyThemeAssets(themes, legacy);
const jsBundlePath = await buildJs("src/main.js", `${PROJECT_ID}.js`);
const jsLegacyBundlePath = await buildJsLegacy("src/main.js", `${PROJECT_ID}-legacy.js`);
const jsLegacyBundlePath = await buildJsLegacy("src/main.js", `${PROJECT_ID}-legacy.js`, 'src/legacy-extras.js');
const jsWorkerPath = await buildWorkerJsLegacy("src/worker.js", `worker.js`);
const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets);
@ -185,7 +185,7 @@ async function buildHtml(doc, version, assetPaths, manifestPath) {
doc("script#main").replaceWith(
`<script type="module">import {main} from "./${assetPaths.jsBundle()}"; main(document.body, ${pathsJSON});</script>` +
`<script type="text/javascript" nomodule src="${assetPaths.jsLegacyBundle()}"></script>` +
`<script type="text/javascript" nomodule>${PROJECT_ID}Bundle.main(document.body, ${pathsJSON});</script>`);
`<script type="text/javascript" nomodule>${PROJECT_ID}Bundle.main(document.body, ${pathsJSON}, ${PROJECT_ID}Bundle.legacyExtras);</script>`);
removeOrEnableScript(doc("script#service-worker"), offline);
const versionScript = doc("script#version");
@ -218,7 +218,7 @@ async function buildJs(inputFile, outputName) {
return bundlePath;
}
async function buildJsLegacy(inputFile, outputName, polyfillFile = null) {
async function buildJsLegacy(inputFile, outputName, extraFile, polyfillFile) {
// compile down to whatever IE 11 needs
const babelPlugin = babel.babel({
babelHelpers: 'bundled',
@ -237,10 +237,14 @@ async function buildJsLegacy(inputFile, outputName, polyfillFile = null) {
if (!polyfillFile) {
polyfillFile = 'src/legacy-polyfill.js';
}
const inputFiles = [polyfillFile, inputFile];
if (extraFile) {
inputFiles.push(extraFile);
}
// create js bundle
const rollupConfig = {
input: [polyfillFile, inputFile],
plugins: [multi(), commonjs(), nodeResolve(), babelPlugin, removeJsComments({comments: "none"})]
input: inputFiles,
plugins: [multi(), commonjs(), nodeResolve(), babelPlugin]
};
const bundle = await rollup(rollupConfig);
const {output} = await bundle.generate({
@ -255,7 +259,7 @@ async function buildJsLegacy(inputFile, outputName, polyfillFile = null) {
function buildWorkerJsLegacy(inputFile, outputName) {
const polyfillFile = 'src/worker-polyfill.js';
return buildJsLegacy(inputFile, outputName, polyfillFile);
return buildJsLegacy(inputFile, outputName, null, polyfillFile);
}
async function buildOffline(version, assetPaths) {

View file

@ -0,0 +1,6 @@
module.exports = class Buffer {
static isBuffer(array) {return array instanceof Uint8Array;}
static from(arrayBuffer) {return arrayBuffer;}
static allocUnsafe(size) {return Buffer.alloc(size);}
static alloc(size) {return new Uint8Array(size);}
};

View file

@ -0,0 +1 @@
module.exports.Buffer = require("buffer");

View file

@ -22,18 +22,34 @@ import { fileURLToPath } from 'url';
import { dirname } from 'path';
// needed to translate commonjs modules to esm
import commonjs from '@rollup/plugin-commonjs';
// multi-entry plugin so we can add polyfill file to main
import { nodeResolve } from '@rollup/plugin-node-resolve';
import {removeDirIfExists} from "./common.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectDir = path.join(__dirname, "../");
/** function used to resolve common-js require calls below. */
function packageIterator(request, start, defaultIterator) {
// this is just working for bs58, would need to tune it further for other dependencies
if (request === "safe-buffer") {
return [path.join(projectDir, "/scripts/package-overrides/safe-buffer")];
} else if (request === "buffer/") {
return [path.join(projectDir, "/scripts/package-overrides/buffer")];
} else {
return defaultIterator();
}
}
async function commonjsToESM(src, dst) {
// create js bundle
const bundle = await rollup({
input: src,
plugins: [commonjs()]
plugins: [commonjs(), nodeResolve({
browser: true,
preferBuiltins: false,
customResolveOptions: {packageIterator}
})]
});
const {output} = await bundle.generate({
format: 'es'
@ -59,6 +75,27 @@ async function populateLib() {
path.join(modulesDir, 'another-json/another-json.js'),
path.join(libDir, "another-json/index.js")
);
// transpile bs58 to esm
await fs.mkdir(path.join(libDir, "bs58/"));
await commonjsToESM(
path.join(modulesDir, 'bs58/index.js'),
path.join(libDir, "bs58/index.js")
);
// transpile base64-arraybuffer to esm
await fs.mkdir(path.join(libDir, "base64-arraybuffer/"));
await commonjsToESM(
path.join(modulesDir, 'base64-arraybuffer/lib/base64-arraybuffer.js'),
path.join(libDir, "base64-arraybuffer/index.js")
);
// this probably should no go in here, we can just import "aes-js" from legacy-extras.js
// as that file is never loaded from a browser
// transpile aesjs to esm
await fs.mkdir(path.join(libDir, "aes-js/"));
await commonjsToESM(
path.join(modulesDir, 'aes-js/index.js'),
path.join(libDir, "aes-js/index.js")
);
}
populateLib();

View file

@ -80,7 +80,7 @@ export class SessionLoadViewModel extends ViewModel {
async cancel() {
try {
if (this._sessionContainer) {
this._sessionContainer.stop();
this._sessionContainer.dispose();
if (this._deleteSessionOnCancel) {
await this._sessionContainer.deleteSession();
}

View file

@ -31,21 +31,24 @@ const SessionStatus = createEnum(
export class SessionStatusViewModel extends ViewModel {
constructor(options) {
super(options);
const {sync, reconnector} = options;
const {sync, reconnector, session} = options;
this._sync = sync;
this._reconnector = reconnector;
this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get());
this._session = session;
}
start() {
const update = () => this._updateStatus();
this.track(this._sync.status.subscribe(update));
this.track(this._reconnector.connectionStatus.subscribe(update));
this.track(this._session.needsSessionBackup.subscribe(() => {
this.emitChange();
}));
}
get isShown() {
return this._status !== SessionStatus.Syncing;
return this._session.needsSessionBackup.get() || this._status !== SessionStatus.Syncing;
}
get statusLabel() {
@ -61,6 +64,9 @@ export class SessionStatusViewModel extends ViewModel {
case SessionStatus.SyncError:
return this.i18n`Sync failed because of ${this._sync.error}`;
}
if (this._session.needsSessionBackup.get()) {
return this.i18n`Set up secret storage to decrypt older messages.`;
}
return "";
}
@ -122,9 +128,25 @@ export class SessionStatusViewModel extends ViewModel {
return this._status === SessionStatus.Disconnected;
}
get isSecretStorageShown() {
// TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other.
return this._status === SessionStatus.Syncing && this._session.needsSessionBackup.get();
}
connectNow() {
if (this.isConnectNowShown) {
this._reconnector.tryNow();
}
}
async enterPassphrase(passphrase) {
if (passphrase) {
try {
await this._session.enableSecretStorage("recoverykey", passphrase);
} catch (err) {
console.error(err);
alert(`Could not set up secret storage: ${err.message}`);
}
}
}
}

View file

@ -26,7 +26,8 @@ export class SessionViewModel extends ViewModel {
this._session = sessionContainer.session;
this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({
sync: sessionContainer.sync,
reconnector: sessionContainer.reconnector
reconnector: sessionContainer.reconnector,
session: sessionContainer.session,
})));
this._currentRoomTileViewModel = null;
this._currentRoomViewModel = null;

View file

@ -145,7 +145,12 @@ export class TilesCollection extends BaseObservableList {
if (tile) {
const action = tile.updateEntry(entry, params);
if (action.shouldReplace) {
this._replaceTile(tileIdx, tile, this._tileCreator(entry));
const newTile = this._tileCreator(entry);
if (newTile) {
this._replaceTile(tileIdx, tile, newTile);
} else {
this._removeTile(tileIdx, tile);
}
}
if (action.shouldRemove) {
this._removeTile(tileIdx, tile);

6
src/legacy-extras.js Normal file
View file

@ -0,0 +1,6 @@
import aesjs from "../lib/aes-js/index.js";
import {hkdf} from "./utils/crypto/hkdf.js";
// these are run-time dependencies that are only needed for the legacy bundle.
// they are exported here and passed into main to make them available to the app.
export const legacyExtras = {crypto:{aesjs, hkdf}};

View file

@ -18,6 +18,13 @@ limitations under the License.
import "core-js/stable";
import "regenerator-runtime/runtime";
import "mdn-polyfills/Element.prototype.closest";
// olm.init needs utf-16le, and this polyfill was
// the only one I could find supporting it.
// TODO: because the library sees a commonjs environment,
// it will also include the file supporting *all* the encodings,
// weighing a good extra 500kb :-(
import "text-encoding";
// TODO: contribute this to mdn-polyfills
if (!Element.prototype.remove) {
Element.prototype.remove = function remove() {

View file

@ -25,6 +25,7 @@ import {BrawlViewModel} from "./domain/BrawlViewModel.js";
import {BrawlView} from "./ui/web/BrawlView.js";
import {Clock} from "./ui/web/dom/Clock.js";
import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js";
import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js";
import {WorkerPool} from "./utils/WorkerPool.js";
import {OlmWorker} from "./matrix/e2ee/OlmWorker.js";
@ -78,8 +79,9 @@ async function loadOlmWorker(paths) {
// Don't use a default export here, as we use multiple entries during legacy build,
// which does not support default exports,
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
export async function main(container, paths) {
export async function main(container, paths, legacyExtras) {
try {
// TODO: add .legacy to body in (legacy)platform.createAndMountRootView; and use body:not(.legacy) if needed for modern stuff
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
if (isIE11) {
document.body.className += " ie11";
@ -122,6 +124,7 @@ export async function main(container, paths) {
sessionInfoStorage,
request,
clock,
cryptoDriver: new CryptoDriver(legacyExtras?.crypto),
olmPromise,
workerPromise,
});
@ -132,6 +135,7 @@ export async function main(container, paths) {
});
window.__brawlViewModel = vm;
await vm.load();
// TODO: replace with platform.createAndMountRootView(vm, container);
const view = new BrawlView(vm);
container.appendChild(view.mount());
} catch(err) {

View file

@ -23,18 +23,26 @@ import {Account as E2EEAccount} from "./e2ee/Account.js";
import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js";
import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js";
import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption.js";
import {SessionBackup} from "./e2ee/megolm/SessionBackup.js";
import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js";
import {MEGOLM_ALGORITHM} from "./e2ee/common.js";
import {RoomEncryption} from "./e2ee/RoomEncryption.js";
import {DeviceTracker} from "./e2ee/DeviceTracker.js";
import {LockMap} from "../utils/LockMap.js";
import {groupBy} from "../utils/groupBy.js";
import {
keyFromCredential as ssssKeyFromCredential,
readKey as ssssReadKey,
writeKey as ssssWriteKey,
} from "./ssss/index.js";
import {SecretStorage} from "./ssss/SecretStorage.js";
import {ObservableValue} from "../observable/ObservableValue.js";
const PICKLE_KEY = "DEFAULT_KEY";
export class Session {
// sessionInfo contains deviceId, userId and homeServer
constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker}) {
constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker, cryptoDriver}) {
this._clock = clock;
this._storage = storage;
this._hsApi = hsApi;
@ -54,6 +62,7 @@ export class Session {
this._megolmDecryption = null;
this._getSyncToken = () => this.syncToken;
this._olmWorker = olmWorker;
this._cryptoDriver = cryptoDriver;
if (olm) {
this._olmUtil = new olm.Utility();
@ -66,6 +75,7 @@ export class Session {
});
}
this._createRoomEncryption = this._createRoomEncryption.bind(this);
this.needsSessionBackup = new ObservableValue(false);
}
// called once this._e2eeAccount is assigned
@ -130,10 +140,62 @@ export class Session {
megolmEncryption: this._megolmEncryption,
megolmDecryption: this._megolmDecryption,
storage: this._storage,
encryptionParams
sessionBackup: this._sessionBackup,
encryptionParams,
notifyMissingMegolmSession: () => {
if (!this._sessionBackup) {
this.needsSessionBackup.set(true)
}
},
clock: this._clock
});
}
/**
* Enable secret storage by providing the secret storage credential.
* This will also see if there is a megolm session backup and try to enable that if so.
*
* @param {string} type either "passphrase" or "recoverykey"
* @param {string} credential either the passphrase or the recovery key, depending on the type
* @return {Promise} resolves or rejects after having tried to enable secret storage
*/
async enableSecretStorage(type, credential) {
if (!this._olm) {
throw new Error("olm required");
}
const key = await ssssKeyFromCredential(type, credential, this._storage, this._cryptoDriver, this._olm);
// and create session backup, which needs to read from accountData
const readTxn = await this._storage.readTxn([
this._storage.storeNames.accountData,
]);
await this._createSessionBackup(key, readTxn);
// only after having read a secret, write the key
// as we only find out if it was good if the MAC verification succeeds
const writeTxn = await this._storage.readWriteTxn([
this._storage.storeNames.session,
]);
try {
ssssWriteKey(key, writeTxn);
} catch (err) {
writeTxn.abort();
throw err;
}
await writeTxn.complete();
}
async _createSessionBackup(ssssKey, txn) {
const secretStorage = new SecretStorage({key: ssssKey, cryptoDriver: this._cryptoDriver});
this._sessionBackup = await SessionBackup.fromSecretStorage({olm: this._olm, secretStorage, hsApi: this._hsApi, txn});
if (this._sessionBackup) {
for (const room of this._rooms.values()) {
if (room.isEncrypted) {
room.enableSessionBackup(this._sessionBackup);
}
}
}
this.needsSessionBackup.set(false);
}
// called after load
async beforeFirstSync(isNewLogin) {
if (this._olm) {
@ -155,6 +217,17 @@ export class Session {
await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
await this._e2eeAccount.uploadKeys(this._storage);
await this._deviceMessageHandler.decryptPending(this.rooms);
const txn = await this._storage.readTxn([
this._storage.storeNames.session,
this._storage.storeNames.accountData,
]);
// try set up session backup if we stored the ssss key
const ssssKey = await ssssReadKey(txn);
if (ssssKey) {
// txn will end here as this does a network request
await this._createSessionBackup(ssssKey, txn);
}
}
}
@ -197,9 +270,13 @@ export class Session {
return this._sendScheduler.isStarted;
}
stop() {
dispose() {
this._olmWorker?.dispose();
this._sendScheduler.stop();
this._sessionBackup?.dispose();
for (const room of this._rooms.values()) {
room.dispose();
}
}
async start(lastVersionResponse) {
@ -289,6 +366,16 @@ export class Session {
if (Array.isArray(toDeviceEvents)) {
this._deviceMessageHandler.writeSync(toDeviceEvents, txn);
}
// store account data
const accountData = syncResponse["account_data"];
if (Array.isArray(accountData?.events)) {
for (const event of accountData.events) {
if (typeof event.type === "string") {
txn.accountData.set(event);
}
}
}
return changes;
}
@ -306,8 +393,6 @@ export class Session {
const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
const promises = [this._deviceMessageHandler.decryptPending(this.rooms)];
if (needsToUploadOTKs) {
// TODO: we could do this in parallel with sync if it proves to be too slow
// but I'm not sure how to not swallow errors in that case
promises.push(this._e2eeAccount.uploadKeys(this._storage));
}
// run key upload and decryption in parallel

View file

@ -42,7 +42,7 @@ export const LoginFailure = createEnum(
);
export class SessionContainer {
constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage, olmPromise, workerPromise}) {
constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage, olmPromise, workerPromise, cryptoDriver}) {
this._random = random;
this._clock = clock;
this._onlineStatus = onlineStatus;
@ -60,6 +60,7 @@ export class SessionContainer {
this._storage = null;
this._olmPromise = olmPromise;
this._workerPromise = workerPromise;
this._cryptoDriver = cryptoDriver;
}
createNewSessionId() {
@ -159,7 +160,7 @@ export class SessionContainer {
}
this._session = new Session({storage: this._storage,
sessionInfo: filteredSessionInfo, hsApi, olm,
clock: this._clock, olmWorker});
clock: this._clock, olmWorker, cryptoDriver: this._cryptoDriver});
await this._session.load();
this._status.set(LoadStatus.SessionSetup);
await this._session.beforeFirstSync(isNewLogin);
@ -245,7 +246,7 @@ export class SessionContainer {
return this._reconnector;
}
stop() {
dispose() {
if (this._reconnectSubscription) {
this._reconnectSubscription();
this._reconnectSubscription = null;
@ -254,7 +255,7 @@ export class SessionContainer {
this._sync.stop();
}
if (this._session) {
this._session.stop();
this._session.dispose();
}
if (this._waitForFirstSyncHandle) {
this._waitForFirstSyncHandle.dispose();

View file

@ -105,6 +105,8 @@ export class Sync {
this._status.set(SyncStatus.Syncing);
} catch (err) {
if (!(err instanceof AbortError)) {
console.warn("stopping sync because of error");
console.error(err);
this._error = err;
this._status.set(SyncStatus.Stopped);
}
@ -168,8 +170,6 @@ export class Sync {
}));
sessionChanges = await this._session.writeSync(response, syncFilterId, syncTxn);
} catch(err) {
console.warn("aborting syncTxn because of error");
console.error(err);
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
@ -228,7 +228,8 @@ export class Sync {
// to discard outbound session when somebody leaves a room
// and to create room key messages when somebody leaves
storeNames.outboundGroupSessions,
storeNames.operations
storeNames.operations,
storeNames.accountData,
]);
}

View file

@ -21,8 +21,17 @@ import {makeTxnId} from "../common.js";
const ENCRYPTED_TYPE = "m.room.encrypted";
function encodeMissingSessionKey(senderKey, sessionId) {
return `${senderKey}|${sessionId}`;
}
function decodeMissingSessionKey(key) {
const [senderKey, sessionId] = key.split("|");
return {senderKey, sessionId};
}
export class RoomEncryption {
constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage}) {
constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, sessionBackup, notifyMissingMegolmSession, clock}) {
this._room = room;
this._deviceTracker = deviceTracker;
this._olmEncryption = olmEncryption;
@ -37,6 +46,21 @@ export class RoomEncryption {
this._eventIdsByMissingSession = new Map();
this._senderDeviceCache = new Map();
this._storage = storage;
this._sessionBackup = sessionBackup;
this._notifyMissingMegolmSession = notifyMissingMegolmSession;
this._clock = clock;
this._disposed = false;
}
async enableSessionBackup(sessionBackup) {
if (this._sessionBackup) {
return;
}
this._sessionBackup = sessionBackup;
for(const key of this._eventIdsByMissingSession.keys()) {
const {senderKey, sessionId} = decodeMissingSessionKey(key);
await this._requestMissingSessionFromBackup(senderKey, sessionId, null);
}
}
notifyTimelineClosed() {
@ -81,7 +105,7 @@ export class RoomEncryption {
} else if (source === DecryptionSource.Retry) {
// when retrying, we could have mixed events from at the bottom of the timeline (sync)
// and somewhere else, so create a custom cache we use just for this operation.
customCache = this._megolmEncryption.createSessionCache();
customCache = this._megolmDecryption.createSessionCache();
sessionCache = customCache;
} else {
throw new Error("Unknown source: " + source);
@ -91,13 +115,13 @@ export class RoomEncryption {
if (customCache) {
customCache.dispose();
}
return new DecryptionPreparation(preparation, errors, {isTimelineOpen}, this);
return new DecryptionPreparation(preparation, errors, {isTimelineOpen, source}, this);
}
async _processDecryptionResults(results, errors, flags, txn) {
for (const error of errors.values()) {
if (error.code === "MEGOLM_NO_SESSION") {
this._addMissingSessionEvent(error.event);
this._addMissingSessionEvent(error.event, flags.source);
}
}
if (flags.isTimelineOpen) {
@ -120,23 +144,78 @@ export class RoomEncryption {
}
}
_addMissingSessionEvent(event) {
_addMissingSessionEvent(event, source) {
const senderKey = event.content?.["sender_key"];
const sessionId = event.content?.["session_id"];
const key = `${senderKey}|${sessionId}`;
const key = encodeMissingSessionKey(senderKey, sessionId);
let eventIds = this._eventIdsByMissingSession.get(key);
// new missing session
if (!eventIds) {
this._requestMissingSessionFromBackup(senderKey, sessionId, source);
eventIds = new Set();
this._eventIdsByMissingSession.set(key, eventIds);
}
eventIds.add(event.event_id);
}
async _requestMissingSessionFromBackup(senderKey, sessionId, source) {
if (!this._sessionBackup) {
this._notifyMissingMegolmSession();
return;
}
// if the message came from sync, wait 10s to see if the room key arrives,
// and only after that proceed to request from backup
if (source === DecryptionSource.Sync) {
await this._clock.createTimeout(10000).elapsed();
if (this._disposed || !this._eventIdsByMissingSession.has(encodeMissingSessionKey(senderKey, sessionId))) {
return;
}
}
try {
const session = await this._sessionBackup.getSession(this._room.id, sessionId);
if (session?.algorithm === MEGOLM_ALGORITHM) {
if (session["sender_key"] !== senderKey) {
console.warn("Got session key back from backup with different sender key, ignoring", {session, senderKey});
return;
}
const txn = await this._storage.readWriteTxn([this._storage.storeNames.inboundGroupSessions]);
let roomKey;
try {
roomKey = await this._megolmDecryption.addRoomKeyFromBackup(
this._room.id, sessionId, session, txn);
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
if (roomKey) {
// this will call into applyRoomKeys below
await this._room.notifyRoomKeys([roomKey]);
}
} else if (session?.algorithm) {
console.info(`Backed-up session of unknown algorithm: ${session.algorithm}`);
}
} catch (err) {
console.error(`Could not get session ${sessionId} from backup`, err);
}
}
/**
* @type {RoomKeyDescription}
* @property {RoomKeyDescription} senderKey the curve25519 key of the sender
* @property {RoomKeyDescription} sessionId
*
*
* @param {Array<RoomKeyDescription>} roomKeys
* @return {Array<string>} the event ids that should be retried to decrypt
*/
applyRoomKeys(roomKeys) {
// retry decryption with the new sessions
const retryEventIds = [];
for (const roomKey of roomKeys) {
const key = `${roomKey.senderKey}|${roomKey.sessionId}`;
const key = encodeMissingSessionKey(roomKey.senderKey, roomKey.sessionId);
const entriesForSession = this._eventIdsByMissingSession.get(key);
if (entriesForSession) {
this._eventIdsByMissingSession.delete(key);
@ -263,6 +342,10 @@ export class RoomEncryption {
const txnId = makeTxnId();
await hsApi.sendToDevice(type, payload, txnId).response();
}
dispose() {
this._disposed = true;
}
}
/**

View file

@ -17,7 +17,7 @@ limitations under the License.
import anotherjson from "../../../lib/another-json/index.js";
import {createEnum} from "../../utils/enum.js";
export const DecryptionSource = createEnum(["Sync", "Timeline", "Retry"]);
export const DecryptionSource = createEnum("Sync", "Timeline", "Retry");
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
export const SESSION_KEY_PREFIX = "e2ee:";

View file

@ -141,37 +141,76 @@ export class Decryption {
const session = new this._olm.InboundGroupSession();
try {
session.create(sessionKey);
let incomingSessionIsBetter = true;
const existingSessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
if (existingSessionEntry) {
const existingSession = new this._olm.InboundGroupSession();
try {
existingSession.unpickle(this._pickleKey, existingSessionEntry.session);
incomingSessionIsBetter = session.first_known_index() < existingSession.first_known_index();
} finally {
existingSession.free();
}
}
if (incomingSessionIsBetter) {
const sessionEntry = {
roomId,
senderKey,
sessionId,
session: session.pickle(this._pickleKey),
claimedKeys: {ed25519: claimedEd25519Key},
};
txn.inboundGroupSessions.set(sessionEntry);
const sessionEntry = await this._writeInboundSession(
session, roomId, senderKey, claimedEd25519Key, sessionId, txn);
if (sessionEntry) {
newSessions.push(sessionEntry);
}
} finally {
session.free();
}
}
// this will be passed to the Room in notifyRoomKeys
return newSessions;
}
/*
sessionInfo is a response from key backup and has the following keys:
algorithm
forwarding_curve25519_key_chain
sender_claimed_keys
sender_key
session_key
*/
async addRoomKeyFromBackup(roomId, sessionId, sessionInfo, txn) {
const sessionKey = sessionInfo["session_key"];
const senderKey = sessionInfo["sender_key"];
// TODO: can we just trust this?
const claimedEd25519Key = sessionInfo["sender_claimed_keys"]?.["ed25519"];
if (
typeof roomId !== "string" ||
typeof sessionId !== "string" ||
typeof senderKey !== "string" ||
typeof sessionKey !== "string" ||
typeof claimedEd25519Key !== "string"
) {
return;
}
const session = new this._olm.InboundGroupSession();
try {
session.import_session(sessionKey);
return await this._writeInboundSession(
session, roomId, senderKey, claimedEd25519Key, sessionId, txn);
} finally {
session.free();
}
}
async _writeInboundSession(session, roomId, senderKey, claimedEd25519Key, sessionId, txn) {
let incomingSessionIsBetter = true;
const existingSessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
if (existingSessionEntry) {
const existingSession = new this._olm.InboundGroupSession();
try {
existingSession.unpickle(this._pickleKey, existingSessionEntry.session);
incomingSessionIsBetter = session.first_known_index() < existingSession.first_known_index();
} finally {
existingSession.free();
}
}
if (incomingSessionIsBetter) {
const sessionEntry = {
roomId,
senderKey,
sessionId,
session: session.pickle(this._pickleKey),
claimedKeys: {ed25519: claimedEd25519Key},
};
txn.inboundGroupSessions.set(sessionEntry);
return sessionEntry;
}
}
}

View file

@ -0,0 +1,60 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import base64 from "../../../../lib/base64-arraybuffer/index.js";
export class SessionBackup {
constructor({backupInfo, decryption, hsApi}) {
this._backupInfo = backupInfo;
this._decryption = decryption;
this._hsApi = hsApi;
}
async getSession(roomId, sessionId) {
const sessionResponse = await this._hsApi.roomKeyForRoomAndSession(this._backupInfo.version, roomId, sessionId).response();
const sessionInfo = this._decryption.decrypt(
sessionResponse.session_data.ephemeral,
sessionResponse.session_data.mac,
sessionResponse.session_data.ciphertext,
);
return JSON.parse(sessionInfo);
}
dispose() {
this._decryption.free();
}
static async fromSecretStorage({olm, secretStorage, hsApi, txn}) {
const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn);
if (base64PrivateKey) {
const privateKey = new Uint8Array(base64.decode(base64PrivateKey));
const backupInfo = await hsApi.roomKeysVersion().response();
const expectedPubKey = backupInfo.auth_data.public_key;
const decryption = new olm.PkDecryption();
try {
const pubKey = decryption.init_with_private_key(privateKey);
if (pubKey !== expectedPubKey) {
throw new Error(`Bad backup key, public key does not match. Calculated ${pubKey} but expected ${expectedPubKey}`);
}
} catch(err) {
decryption.free();
throw err;
}
return new SessionBackup({backupInfo, decryption, hsApi});
}
}
}

View file

@ -73,13 +73,13 @@ export class HomeServerApi {
return `${this._homeserver}/_matrix/client/r0${csPath}`;
}
_request(method, url, queryParams, body, options) {
_baseRequest(method, url, queryParams, body, options, accessToken) {
const queryString = encodeQueryParams(queryParams);
url = `${url}?${queryString}`;
let bodyString;
const headers = new Map();
if (this._accessToken) {
headers.set("Authorization", `Bearer ${this._accessToken}`);
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
headers.set("Accept", "application/json");
if (body) {
@ -106,16 +106,24 @@ export class HomeServerApi {
return wrapper;
}
_unauthedRequest(method, url, queryParams, body, options) {
return this._baseRequest(method, url, queryParams, body, options, null);
}
_authedRequest(method, url, queryParams, body, options) {
return this._baseRequest(method, url, queryParams, body, options, this._accessToken);
}
_post(csPath, queryParams, body, options) {
return this._request("POST", this._url(csPath), queryParams, body, options);
return this._authedRequest("POST", this._url(csPath), queryParams, body, options);
}
_put(csPath, queryParams, body, options) {
return this._request("PUT", this._url(csPath), queryParams, body, options);
return this._authedRequest("PUT", this._url(csPath), queryParams, body, options);
}
_get(csPath, queryParams, body, options) {
return this._request("GET", this._url(csPath), queryParams, body, options);
return this._authedRequest("GET", this._url(csPath), queryParams, body, options);
}
sync(since, filter, timeout, options = null) {
@ -142,7 +150,7 @@ export class HomeServerApi {
}
passwordLogin(username, password, initialDeviceDisplayName, options = null) {
return this._post("/login", null, {
return this._unauthedRequest("POST", this._url("/login"), null, {
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
@ -158,7 +166,7 @@ export class HomeServerApi {
}
versions(options = null) {
return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
}
uploadKeys(payload, options = null) {
@ -176,6 +184,18 @@ export class HomeServerApi {
sendToDevice(type, payload, txnId, options = null) {
return this._put(`/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, payload, options);
}
roomKeysVersion(version = null, options = null) {
let versionPart = "";
if (version) {
versionPart = `/${encodeURIComponent(version)}`;
}
return this._get(`/room_keys/version${versionPart}`, null, null, options);
}
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options);
}
get mediaRepository() {
return this._mediaRepository;

View file

@ -429,6 +429,10 @@ export class Room extends EventEmitter {
return !!this._summary.encryption;
}
enableSessionBackup(sessionBackup) {
this._roomEncryption?.enableSessionBackup(sessionBackup);
}
get isTrackingMembers() {
return this._summary.isTrackingMembers;
}
@ -525,6 +529,11 @@ export class Room extends EventEmitter {
applyIsTrackingMembersChanges(changes) {
this._summary.applyChanges(changes);
}
dispose() {
this._roomEncryption?.dispose();
this._timeline?.dispose();
}
}
class DecryptionRequest {

View file

@ -326,7 +326,7 @@ export class RoomSummary {
* writeIsTrackingMembers, ... take a txn directly.
*/
async writeAndApplyChanges(data, storage) {
const txn = await storage.readTxn([
const txn = await storage.readWriteTxn([
storage.storeNames.roomSummary,
]);
try {

View file

@ -0,0 +1,72 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import base64 from "../../../lib/base64-arraybuffer/index.js";
export class SecretStorage {
constructor({key, cryptoDriver}) {
this._key = key;
this._cryptoDriver = cryptoDriver;
}
async readSecret(name, txn) {
const accountData = await txn.accountData.get(name);
if (!accountData) {
return;
}
const encryptedData = accountData?.content?.encrypted?.[this._key.id];
if (!encryptedData) {
throw new Error(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`);
}
if (this._key.algorithm === "m.secret_storage.v1.aes-hmac-sha2") {
return await this._decryptAESSecret(accountData.type, encryptedData);
} else {
throw new Error(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`);
}
}
async _decryptAESSecret(type, encryptedData) {
// TODO: we should we move this to platform specific code
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
// now derive the aes and mac key from the 4s key
const hkdfKey = await this._cryptoDriver.derive.hkdf(
this._key.binaryKey,
new Uint8Array(8).buffer, //zero salt
textEncoder.encode(type), // info
"SHA-256",
512 // 512 bits or 64 bytes
);
const aesKey = hkdfKey.slice(0, 32);
const hmacKey = hkdfKey.slice(32);
const ciphertextBytes = base64.decode(encryptedData.ciphertext);
const isVerified = await this._cryptoDriver.hmac.verify(
hmacKey, base64.decode(encryptedData.mac),
ciphertextBytes, "SHA-256");
if (!isVerified) {
throw new Error("Bad MAC");
}
const plaintextBytes = await this._cryptoDriver.aes.decrypt(
aesKey, base64.decode(encryptedData.iv), ciphertextBytes);
return textDecoder.decode(plaintextBytes);
}
}

53
src/matrix/ssss/common.js Normal file
View file

@ -0,0 +1,53 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export class KeyDescription {
constructor(id, keyAccountData) {
this._id = id;
this._keyAccountData = keyAccountData;
}
get id() {
return this._id;
}
get passphraseParams() {
return this._keyAccountData?.content?.passphrase;
}
get algorithm() {
return this._keyAccountData?.content?.algorithm;
}
}
export class Key {
constructor(keyDescription, binaryKey) {
this._keyDescription = keyDescription;
this._binaryKey = binaryKey;
}
get id() {
return this._keyDescription.id;
}
get binaryKey() {
return this._binaryKey;
}
get algorithm() {
return this._keyDescription.algorithm;
}
}

64
src/matrix/ssss/index.js Normal file
View file

@ -0,0 +1,64 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {KeyDescription, Key} from "./common.js";
import {keyFromPassphrase} from "./passphrase.js";
import {keyFromRecoveryKey} from "./recoveryKey.js";
async function readDefaultKeyDescription(storage) {
const txn = await storage.readTxn([
storage.storeNames.accountData
]);
const defaultKeyEvent = await txn.accountData.get("m.secret_storage.default_key");
const id = defaultKeyEvent?.content?.key;
if (!id) {
return;
}
const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${id}`);
if (!keyAccountData) {
return;
}
return new KeyDescription(id, keyAccountData);
}
export async function writeKey(key, txn) {
txn.session.set("ssssKey", {id: key.id, binaryKey: key.binaryKey});
}
export async function readKey(txn) {
const keyData = await txn.session.get("ssssKey");
if (!keyData) {
return;
}
const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${keyData.id}`);
return new Key(new KeyDescription(keyData.id, keyAccountData), keyData.binaryKey);
}
export async function keyFromCredential(type, credential, storage, cryptoDriver, olm) {
const keyDescription = await readDefaultKeyDescription(storage);
if (!keyDescription) {
throw new Error("Could not find a default secret storage key in account data");
}
let key;
if (type === "passphrase") {
key = await keyFromPassphrase(keyDescription, credential, cryptoDriver);
} else if (type === "recoverykey") {
key = keyFromRecoveryKey(olm, keyDescription, credential);
} else {
throw new Error(`Invalid type: ${type}`);
}
return key;
}

View file

@ -0,0 +1,46 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {Key} from "./common.js";
const DEFAULT_ITERATIONS = 500000;
const DEFAULT_BITSIZE = 256;
/**
* @param {KeyDescription} keyDescription
* @param {string} passphrase
* @param {CryptoDriver} cryptoDriver
* @return {Key}
*/
export async function keyFromPassphrase(keyDescription, passphrase, cryptoDriver) {
const {passphraseParams} = keyDescription;
if (!passphraseParams) {
throw new Error("not a passphrase key");
}
if (passphraseParams.algorithm !== "m.pbkdf2") {
throw new Error(`Unsupported passphrase algorithm: ${passphraseParams.algorithm}`);
}
// TODO: we should we move this to platform specific code
const textEncoder = new TextEncoder();
const keyBits = await cryptoDriver.derive.pbkdf2(
textEncoder.encode(passphrase),
passphraseParams.iterations || DEFAULT_ITERATIONS,
// salt is just a random string, not encoded in any way
textEncoder.encode(passphraseParams.salt),
"SHA-512",
passphraseParams.bits || DEFAULT_BITSIZE);
return new Key(keyDescription, keyBits);
}

View file

@ -0,0 +1,57 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import bs58 from "../../../lib/bs58/index.js";
import {Key} from "./common.js";
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
/**
* @param {Olm} olm
* @param {KeyDescription} keyDescription
* @param {string} recoveryKey
* @return {Key}
*/
export function keyFromRecoveryKey(olm, keyDescription, recoveryKey) {
const result = bs58.decode(recoveryKey.replace(/ /g, ''));
let parity = 0;
for (const b of result) {
parity ^= b;
}
if (parity !== 0) {
throw new Error("Incorrect parity");
}
for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
throw new Error("Incorrect prefix");
}
}
if (
result.length !==
OLM_RECOVERY_KEY_PREFIX.length + olm.PRIVATE_KEY_LENGTH + 1
) {
throw new Error("Incorrect length");
}
const keyBits = Uint8Array.from(result.slice(
OLM_RECOVERY_KEY_PREFIX.length,
OLM_RECOVERY_KEY_PREFIX.length + olm.PRIVATE_KEY_LENGTH,
));
return new Key(keyDescription, keyBits);
}

View file

@ -28,7 +28,8 @@ export const STORE_NAMES = Object.freeze([
"inboundGroupSessions",
"outboundGroupSessions",
"groupSessionDecryptions",
"operations"
"operations",
"accountData",
]);
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {

View file

@ -19,7 +19,7 @@ import { openDatabase, reqAsPromise } from "./utils.js";
import { exportSession, importSession } from "./export.js";
import { schema } from "./schema.js";
const sessionName = sessionId => `brawl_session_${sessionId}`;
const sessionName = sessionId => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, schema.length);
export class StorageFactory {

View file

@ -31,6 +31,7 @@ import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore.js";
import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore.js";
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore.js";
import {OperationStore} from "./stores/OperationStore.js";
import {AccountDataStore} from "./stores/AccountDataStore.js";
export class Transaction {
constructor(txn, allowedStoreNames) {
@ -111,6 +112,10 @@ export class Transaction {
return this._store("operations", idbStore => new OperationStore(idbStore));
}
get accountData() {
return this._store("accountData", idbStore => new AccountDataStore(idbStore));
}
complete() {
return txnAsPromise(this._txn);
}

View file

@ -10,7 +10,8 @@ export const schema = [
createMemberStore,
migrateSession,
createE2EEStores,
migrateEncryptionFlag
migrateEncryptionFlag,
createAccountDataStore
];
// TODO: how to deal with git merge conflicts of this array?
@ -97,3 +98,8 @@ async function migrateEncryptionFlag(db, txn) {
}
}
}
// v6
function createAccountDataStore(db) {
db.createObjectStore("accountData", {keyPath: "type"});
}

View file

@ -0,0 +1,29 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export class AccountDataStore {
constructor(store) {
this._store = store;
}
async get(type) {
return await this._store.get(type);
}
set(event) {
return this._store.put(event);
}
}

View file

@ -0,0 +1,257 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// turn IE11 result into promise
function subtleCryptoResult(promiseOrOp, method) {
if (promiseOrOp instanceof Promise) {
return promiseOrOp;
} else {
return new Promise((resolve, reject) => {
promiseOrOp.oncomplete = e => resolve(e.target.result);
promiseOrOp.onerror = () => reject(new Error("Crypto error on " + method));
});
}
}
class CryptoHMACDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [hmac description]
* @param {BufferSource} key
* @param {BufferSource} mac
* @param {BufferSource} data
* @param {HashName} hash
* @return {boolean}
*/
async verify(key, mac, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hashName(hash)},
};
const hmacKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['verify'],
), "importKey");
const isVerified = await subtleCryptoResult(this._subtleCrypto.verify(
opts,
hmacKey,
mac,
data,
), "verify");
return isVerified;
}
async compute(key, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hashName(hash)},
};
const hmacKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['sign'],
), "importKey");
const buffer = await subtleCryptoResult(this._subtleCrypto.sign(
opts,
hmacKey,
data,
), "sign");
return new Uint8Array(buffer);
}
}
class CryptoDeriveDriver {
constructor(subtleCrypto, cryptoDriver, cryptoExtras) {
this._subtleCrypto = subtleCrypto;
this._cryptoDriver = cryptoDriver;
this._cryptoExtras = cryptoExtras;
}
/**
* [pbkdf2 description]
* @param {BufferSource} password
* @param {Number} iterations
* @param {BufferSource} salt
* @param {HashName} hash
* @param {Number} length the desired length of the generated key, in bits (not bytes!)
* @return {BufferSource}
*/
async pbkdf2(password, iterations, salt, hash, length) {
if (!this._subtleCrypto.deriveBits) {
throw new Error("PBKDF2 is not supported");
}
const key = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
password,
{name: 'PBKDF2'},
false,
['deriveBits'],
), "importKey");
const keybits = await subtleCryptoResult(this._subtleCrypto.deriveBits(
{
name: 'PBKDF2',
salt,
iterations,
hash: hashName(hash),
},
key,
length,
), "deriveBits");
return new Uint8Array(keybits);
}
/**
* [hkdf description]
* @param {BufferSource} key [description]
* @param {BufferSource} salt [description]
* @param {BufferSource} info [description]
* @param {HashName} hash the hash to use
* @param {Number} length desired length of the generated key in bits (not bytes!)
* @return {[type]} [description]
*/
async hkdf(key, salt, info, hash, length) {
if (!this._subtleCrypto.deriveBits) {
return this._cryptoExtras.hkdf(this._cryptoDriver, key, salt, info, hash, length);
}
const hkdfkey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
{name: "HKDF"},
false,
["deriveBits"],
), "importKey");
const keybits = await subtleCryptoResult(this._subtleCrypto.deriveBits({
name: "HKDF",
salt,
info,
hash: hashName(hash),
},
hkdfkey,
length,
), "deriveBits");
return new Uint8Array(keybits);
}
}
class CryptoAESDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description]
* @return {BufferSource} [description]
*/
async decrypt(key, iv, ciphertext) {
const opts = {
name: "AES-CTR",
counter: iv,
length: 64,
};
let aesKey;
try {
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['decrypt'],
), "importKey");
} catch (err) {
throw new Error(`Could not import key for AES-CTR decryption: ${err.message}`);
}
try {
const plaintext = await subtleCryptoResult(this._subtleCrypto.decrypt(
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts,
aesKey,
ciphertext,
), "decrypt");
return new Uint8Array(plaintext);
} catch (err) {
throw new Error(`Could not decrypt with AES-CTR: ${err.message}`);
}
}
}
class CryptoLegacyAESDriver {
constructor(aesjs) {
this._aesjs = aesjs;
}
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description]
* @return {BufferSource} [description]
*/
async decrypt(key, iv, ciphertext) {
const aesjs = this._aesjs;
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
return aesCtr.decrypt(new Uint8Array(ciphertext));
}
}
function hashName(name) {
if (name !== "SHA-256" && name !== "SHA-512") {
throw new Error(`Invalid hash name: ${name}`);
}
return name;
}
export class CryptoDriver {
constructor(cryptoExtras) {
const crypto = window.crypto || window.msCrypto;
const subtleCrypto = crypto.subtle || crypto.webkitSubtle;
this._subtleCrypto = subtleCrypto;
// not exactly guaranteeing AES-CTR support
// but in practice IE11 doesn't have this
if (!subtleCrypto.deriveBits && cryptoExtras.aesjs) {
this.aes = new CryptoLegacyAESDriver(cryptoExtras.aesjs);
} else {
this.aes = new CryptoAESDriver(subtleCrypto);
}
this.hmac = new CryptoHMACDriver(subtleCrypto);
this.derive = new CryptoDeriveDriver(subtleCrypto, this, cryptoExtras);
}
/**
* [digest description]
* @param {HashName} hash
* @param {BufferSource} data
* @return {BufferSource}
*/
async digest(hash, data) {
return await subtleCryptoResult(this._subtleCrypto.digest(hashName(hash), data));
}
digestSize(hash) {
switch (hashName(hash)) {
case "SHA-512": return 64;
case "SHA-256": return 32;
default: throw new Error(`Not implemented for ${hashName(hash)}`);
}
}
}

View file

@ -26,6 +26,7 @@ export class SessionStatusView extends TemplateView {
spinner(t, {hidden: vm => !vm.isWaiting}),
t.p(vm => vm.statusLabel),
t.if(vm => vm.isConnectNowShown, t.createTemplate(t => t.button({onClick: () => vm.connectNow()}, "Retry now"))),
t.if(vm => vm.isSecretStorageShown, t.createTemplate(t => t.button({onClick: () => vm.enterPassphrase(prompt("Security key"))}, "Enter security key"))),
window.DEBUG ? t.button({id: "showlogs"}, "Show logs") : ""
]);
}

29
src/utils/crypto/hkdf.js Normal file
View file

@ -0,0 +1,29 @@
/**
* Copyright (c) 2018 Jun Kurihara
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* MIT LICENSE, See https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-hkdf/LICENSE
* Based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-hkdf/src/hkdf.ts
*/
// forked this code to make it use the cryptoDriver for HMAC that is more backwards-compatible
export async function hkdf(cryptoDriver, key, salt, info, hash, length) {
length = length / 8;
const len = cryptoDriver.digestSize(hash);
// RFC5869 Step 1 (Extract)
const prk = await cryptoDriver.hmac.compute(salt, key, hash);
// RFC5869 Step 2 (Expand)
let t = new Uint8Array([]);
const okm = new Uint8Array(Math.ceil(length / len) * len);
for(let i = 0; i < Math.ceil(length / len); i++){
const concat = new Uint8Array(t.length + info.length + 1);
concat.set(t);
concat.set(info, t.length);
concat.set(new Uint8Array([i+1]), t.length + info.length);
t = await cryptoDriver.hmac.compute(prk, concat, hash);
okm.set(t, len * i);
}
return okm.slice(0, length);
}

View file

@ -0,0 +1,65 @@
/**
* Copyright (c) 2018 Jun Kurihara
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* MIT LICENSE, See https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-pbkdf/LICENSE
* Based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-pbkdf/src/pbkdf.ts
*/
// not used atm, but might in the future
// forked this code to make it use the cryptoDriver for HMAC that is more backwards-compatible
const nwbo = (num, len) => {
const arr = new Uint8Array(len);
for(let i=0; i<len; i++) arr[i] = 0xFF && (num >> ((len - i - 1)*8));
return arr;
};
export async function pbkdf2(cryptoDriver, password, iterations, salt, hash, length) {
const dkLen = length / 8;
if (iterations <= 0) {
throw new Error('InvalidIterationCount');
}
if (dkLen <= 0) {
throw new Error('InvalidDerivedKeyLength');
}
const hLen = cryptoDriver.digestSize(hash);
if(dkLen > (Math.pow(2, 32) - 1) * hLen) throw new Error('DerivedKeyTooLong');
const l = Math.ceil(dkLen/hLen);
const r = dkLen - (l-1)*hLen;
const funcF = async (i) => {
const seed = new Uint8Array(salt.length + 4);
seed.set(salt);
seed.set(nwbo(i+1, 4), salt.length);
let u = await cryptoDriver.hmac.compute(password, seed, hash);
let outputF = new Uint8Array(u);
for(let j = 1; j < iterations; j++){
if ((j % 1000) === 0) {
console.log(j, j/iterations);
}
u = await cryptoDriver.hmac.compute(password, u, hash);
outputF = u.map( (elem, idx) => elem ^ outputF[idx]);
}
return {index: i, value: outputF};
};
const Tis = [];
const DK = new Uint8Array(dkLen);
for(let i = 0; i < l; i++) {
Tis.push(funcF(i));
}
const TisResolved = await Promise.all(Tis);
TisResolved.forEach(elem => {
if (elem.index !== l - 1) {
DK.set(elem.value, elem.index*hLen);
}
else {
DK.set(elem.value.slice(0, r), elem.index*hLen);
}
});
return DK;
}

View file

@ -17,6 +17,9 @@ limitations under the License.
export function createEnum(...values) {
const obj = {};
for (const value of values) {
if (typeof value !== "string") {
throw new Error("Invalid enum value name" + value?.toString());
}
obj[value] = value;
}
return Object.freeze(obj);

View file

@ -21,3 +21,30 @@ import "regenerator-runtime/runtime";
import "core-js/modules/es.promise";
import "core-js/modules/es.math.imul";
import "core-js/modules/es.math.clz32";
import "core-js/modules/es.typed-array.from";
import "core-js/modules/es.typed-array.of";
import "core-js/modules/es.typed-array.copy-within";
import "core-js/modules/es.typed-array.every";
import "core-js/modules/es.typed-array.fill";
import "core-js/modules/es.typed-array.filter";
import "core-js/modules/es.typed-array.find";
import "core-js/modules/es.typed-array.find-index";
import "core-js/modules/es.typed-array.for-each";
import "core-js/modules/es.typed-array.includes";
import "core-js/modules/es.typed-array.index-of";
import "core-js/modules/es.typed-array.join";
import "core-js/modules/es.typed-array.last-index-of";
import "core-js/modules/es.typed-array.map";
import "core-js/modules/es.typed-array.reduce";
import "core-js/modules/es.typed-array.reduce-right";
import "core-js/modules/es.typed-array.reverse";
import "core-js/modules/es.typed-array.set";
import "core-js/modules/es.typed-array.slice";
import "core-js/modules/es.typed-array.some";
import "core-js/modules/es.typed-array.sort";
import "core-js/modules/es.typed-array.subarray";
import "core-js/modules/es.typed-array.to-locale-string";
import "core-js/modules/es.typed-array.to-string";
import "core-js/modules/es.typed-array.iterator";
import "core-js/modules/es.object.to-string";

View file

@ -821,7 +821,16 @@
globals "^11.1.0"
lodash "^4.17.19"
"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.4.4":
"@babel/types@^7.10.4":
version "7.11.5"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d"
integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==
dependencies:
"@babel/helper-validator-identifier" "^7.10.4"
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.4.4":
version "7.11.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.0.tgz#2ae6bf1ba9ae8c3c43824e5861269871b206e90d"
integrity sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==
@ -831,9 +840,9 @@
to-fast-properties "^2.0.0"
"@rollup/plugin-babel@^5.1.0":
version "5.2.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.0.tgz#b87556d61ed108b4eaf9d18b5323965adf8d9bee"
integrity sha512-CPABsajaKjINgBQ3it+yMnfVO3ibsrMBxRzbUOUw2cL1hsZJ7aogU8mgglQm3S2hHJgjnAmxPz0Rq7DVdmHsTw==
version "5.2.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.1.tgz#20fc8f8864dc0eaa1c5578408459606808f72924"
integrity sha512-Jd7oqFR2dzZJ3NWANDyBjwTtX/lYbZpVcmkHrfQcpvawHs9E4c0nYk5U2mfZ6I/DZcIvy506KZJi54XK/jxH7A==
dependencies:
"@babel/helper-module-imports" "^7.10.4"
"@rollup/pluginutils" "^3.1.0"
@ -886,9 +895,9 @@
picomatch "^2.2.2"
"@types/estree@*":
version "0.0.42"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.42.tgz#8d0c1f480339efedb3e46070e22dd63e0430dd11"
integrity sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==
version "0.0.45"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884"
integrity sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g==
"@types/estree@0.0.39":
version "0.0.39"
@ -896,9 +905,9 @@
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/node@*":
version "13.9.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.9.1.tgz#96f606f8cd67fb018847d9b61e93997dabdefc72"
integrity sha512-E6M6N0blf/jiZx8Q3nb0vNaswQeEyn0XlupO+xN6DtJ6r6IT4nXrTry7zhIfYvFCl3/8Cu6WIysmUBKiqV0bqQ==
version "14.10.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.10.3.tgz#5ae1f119c96643fc9b19b2d1a83bfa2ec3dbb7ea"
integrity sha512-zdN0hor7TLkjAdKTnYW+Y22oIhUUpil5ZD1V1OFq0CR0CLKw+NdR6dkziTfkWRLo6sKzisayoj/GNpNbe4LY9Q==
"@types/resolve@1.17.1":
version "1.17.1"
@ -907,6 +916,11 @@
dependencies:
"@types/node" "*"
aes-js@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a"
integrity sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==
another-json@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/another-json/-/another-json-0.2.0.tgz#b5f4019c973b6dd5c6506a2d93469cb6d32aeedc"
@ -931,6 +945,18 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base-x@^3.0.2:
version "3.0.8"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d"
integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA==
dependencies:
safe-buffer "^5.0.1"
base64-arraybuffer@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==
boolbase@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
@ -954,6 +980,13 @@ browserslist@^4.12.0, browserslist@^4.8.5:
escalade "^3.0.2"
node-releases "^1.1.60"
bs58@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a"
integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo=
dependencies:
base-x "^3.0.2"
builtin-modules@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484"
@ -1720,12 +1753,17 @@ rollup-pluginutils@^2.3.3:
estree-walker "^0.6.1"
rollup@^2.26.4:
version "2.26.4"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.26.4.tgz#a8350fd6bd56fce9873a7db2bd9547d40de3992b"
integrity sha512-6+qsGuP0MXGd7vlYmk72utm1MrgZj5GfXibGL+cRkKQ9+ZL/BnFThDl0D5bcl7AqlzMjAQXRAwZX1HVm22M/4Q==
version "2.27.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.27.1.tgz#372744e1d36eba0fd942d997600c2fc2ca266305"
integrity sha512-GiWHQvnmMgBktSpY/1+nrGpwPsTw4b9P28og2uedfeq4JZ16rzAmnQ5Pm/E0/BEmDNia1ZbY7+qu3nBgNa19Hg==
optionalDependencies:
fsevents "~2.1.2"
safe-buffer@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@ -1826,6 +1864,11 @@ supports-color@^6.1.0:
dependencies:
has-flag "^3.0.0"
text-encoding@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643"
integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==
to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"