From 753ec9e45ac3a7283deeec5b80de24ac18a5b084 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Wed, 9 Dec 2020 18:40:31 -0500 Subject: [PATCH 1/2] use random pickle key on all platforms, and store access token encrypted in IDB --- src/BasePlatform.ts | 68 +++++++++++++- src/IndexedDB.ts | 92 +++++++++++++++++++ src/Lifecycle.ts | 82 ++++++++++++----- .../structures/auth/Registration.tsx | 3 +- 4 files changed, 219 insertions(+), 26 deletions(-) create mode 100644 src/IndexedDB.ts diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 4f7c7126e9..46fa998f15 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -18,6 +18,7 @@ limitations under the License. */ import {MatrixClient} from "matrix-js-sdk/src/client"; +import {encodeUnpaddedBase64} from "matrix-js-sdk/src/crypto/olmlib"; import dis from './dispatcher/dispatcher'; import BaseEventIndexManager from './indexing/BaseEventIndexManager'; import {ActionPayload} from "./dispatcher/payloads"; @@ -25,6 +26,7 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; import {Action} from "./dispatcher/actions"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; import {MatrixClientPeg} from "./MatrixClientPeg"; +import {idbLoad, idbSave, idbDelete} from "./IndexedDB"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -273,7 +275,40 @@ export default abstract class BasePlatform { * pickle key has been stored. */ async getPickleKey(userId: string, deviceId: string): Promise { - return null; + if (!window.crypto || !window.crypto.subtle) { + return null; + } + let data; + try { + data = await idbLoad("pickleKey", [userId, deviceId]); + } catch (e) {} + if (!data) { + return null; + } + if (!data.encrypted || !data.iv || !data.cryptoKey) { + console.error("Badly formatted pickle key"); + return null; + } + + const additionalData = new Uint8Array(userId.length + deviceId.length + 1); + for (let i = 0; i < userId.length; i++) { + additionalData[i] = userId.charCodeAt(i); + } + additionalData[userId.length] = 124; // "|" + for (let i = 0; i < deviceId.length; i++) { + additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i); + } + + try { + const key = await crypto.subtle.decrypt( + {name: "AES-GCM", iv: data.iv, additionalData}, data.cryptoKey, + data.encrypted, + ); + return encodeUnpaddedBase64(key); + } catch (e) { + console.error("Error decrypting pickle key"); + return null; + } } /** @@ -284,7 +319,33 @@ export default abstract class BasePlatform { * support storing pickle keys. */ async createPickleKey(userId: string, deviceId: string): Promise { - return null; + if (!window.crypto || !window.crypto.subtle) { + return null; + } + const crypto = window.crypto; + const randomArray = new Uint8Array(32); + crypto.getRandomValues(randomArray); + const cryptoKey = await crypto.subtle.generateKey( + {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"], + ); + const iv = new Uint8Array(32); + crypto.getRandomValues(iv); + + const additionalData = new Uint8Array(userId.length + deviceId.length + 1); + for (let i = 0; i < userId.length; i++) { + additionalData[i] = userId.charCodeAt(i); + } + additionalData[userId.length] = 124; // "|" + for (let i = 0; i < deviceId.length; i++) { + additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i); + } + + const encrypted = await crypto.subtle.encrypt( + {name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray, + ); + + await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey}); + return encodeUnpaddedBase64(randomArray); } /** @@ -293,5 +354,8 @@ export default abstract class BasePlatform { * @param {string} userId the device ID that the pickle key is for. */ async destroyPickleKey(userId: string, deviceId: string): Promise { + try { + await idbDelete("pickleKey", [userId, deviceId]); + } catch (e) {} } } diff --git a/src/IndexedDB.ts b/src/IndexedDB.ts new file mode 100644 index 0000000000..bf03ffccb7 --- /dev/null +++ b/src/IndexedDB.ts @@ -0,0 +1,92 @@ +/* +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. +*/ + +/* Simple wrapper around IndexedDB. + */ + +let idb = null; + +async function idbInit(): Promise { + idb = await new Promise((resolve, reject) => { + const request = window.indexedDB.open("element", 1); + request.onerror = reject; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: TS thinks target.result doesn't exist + request.onsuccess = (event) => { resolve(event.target.result); }; + request.onupgradeneeded = (event) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: TS thinks target.result doesn't exist + const db = event.target.result; + db.createObjectStore("pickleKey"); + db.createObjectStore("account"); + }; + }); +} + +export async function idbLoad( + table: string, + key: string | string[], +): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readonly"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.get(key); + request.onerror = reject; + request.onsuccess = (event) => { resolve(request.result); }; + }); +} + +export async function idbSave( + table: string, + key: string | string[], + data: any, +): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.put(data, key); + request.onerror = reject; + request.onsuccess = (event) => { resolve(); }; + }); +} + +export async function idbDelete( + table: string, + key: string | string[], +): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.delete(key); + request.onerror = reject; + request.onsuccess = (event) => { resolve(); }; + }); +} diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index ac96d59b09..2f6309415f 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -21,6 +21,7 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes"; import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; import SecurityCustomisations from "./customisations/Security"; @@ -50,6 +51,7 @@ import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; +import {idbLoad, idbSave, idbDelete} from "./IndexedDB"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -147,20 +149,13 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise * Gets the user ID of the persisted session, if one exists. This does not validate * that the user's credentials still work, just that they exist and that a user ID * is associated with them. The session is not loaded. - * @returns {String} The persisted session's owner, if an owner exists. Null otherwise. + * @returns {[String, bool]} The persisted session's owner and whether the stored + * session is for a guest user, if an owner exists. If there is no stored session, + * return [null, null]. */ -export function getStoredSessionOwner(): string { - const {hsUrl, userId, accessToken} = getLocalStorageSessionVars(); - return hsUrl && userId && accessToken ? userId : null; -} - -/** - * @returns {bool} True if the stored session is for a guest user or false if it is - * for a real user. If there is no stored session, return null. - */ -export function getStoredSessionIsGuest(): boolean { - const sessVars = getLocalStorageSessionVars(); - return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; +export async function getStoredSessionOwner(): Promise<[string, boolean]> { + const {hsUrl, userId, accessToken, isGuest} = await getLocalStorageSessionVars(); + return hsUrl && userId && accessToken ? [userId, isGuest] : [null, null]; } /** @@ -197,8 +192,8 @@ export function attemptTokenLogin( }, ).then(function(creds) { console.log("Logged in with token"); - return clearStorage().then(() => { - persistCredentialsToLocalStorage(creds); + return clearStorage().then(async () => { + await persistCredentialsToLocalStorage(creds); // remember that we just logged in sessionStorage.setItem("mx_fresh_login", String(true)); return true; @@ -290,10 +285,17 @@ export interface ILocalStorageSession { * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export function getLocalStorageSessionVars(): ILocalStorageSession { +export async function getLocalStorageSessionVars(): Promise { const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); - const accessToken = localStorage.getItem("mx_access_token"); + let accessToken = await idbLoad("account", "mx_access_token"); + if (!accessToken) { + accessToken = localStorage.getItem("mx_access_token"); + if (accessToken) { + await idbSave("account", "mx_access_token", accessToken); + localStorage.removeItem("mx_access_token"); + } + } const userId = localStorage.getItem("mx_user_id"); const deviceId = localStorage.getItem("mx_device_id"); @@ -308,6 +310,30 @@ export function getLocalStorageSessionVars(): ILocalStorageSession { return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest}; } +// The pickle key is a string of unspecified length and format. For AES, we +// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES +// key. The AES key should be zeroed after it is used. +async function pickleKeyToAesKey(pickleKey: string): Promise { + const pickleKeyBuffer = new Uint8Array(pickleKey.length); + for (let i = 0; i < pickleKey.length; i++) { + pickleKeyBuffer[i] = pickleKey.charCodeAt(i); + } + const hkdfKey = await window.crypto.subtle.importKey( + "raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"], + ); + pickleKeyBuffer.fill(0); + return new Uint8Array(await window.crypto.subtle.deriveBits( + { + name: "HKDF", hash: "SHA-256", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 + salt: new Uint8Array(32), info: new Uint8Array(0), + }, + hkdfKey, + 256, + )); +} + // returns a promise which resolves to true if a session is found in // localstorage // @@ -325,7 +351,7 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis return false; } - const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars(); + const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = await getLocalStorageSessionVars(); if (accessToken && userId && hsUrl) { if (ignoreGuest && isGuest) { @@ -333,9 +359,15 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis return false; } + let decryptedAccessToken = accessToken; const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId); if (pickleKey) { console.log("Got pickle key"); + if (typeof accessToken !== "string") { + const encrKey = await pickleKeyToAesKey(pickleKey); + decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token"); + encrKey.fill(0); + } } else { console.log("No pickle key available"); } @@ -347,7 +379,7 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis await doSetLoggedIn({ userId: userId, deviceId: deviceId, - accessToken: accessToken, + accessToken: decryptedAccessToken, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: isGuest, @@ -516,7 +548,7 @@ async function doSetLoggedIn( if (localStorage) { try { - persistCredentialsToLocalStorage(credentials); + await persistCredentialsToLocalStorage(credentials); // make sure we don't think that it's a fresh login any more sessionStorage.removeItem("mx_fresh_login"); } catch (e) { @@ -545,18 +577,22 @@ function showStorageEvictedDialog(): Promise { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void { +async function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): Promise { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl); } localStorage.setItem("mx_user_id", credentials.userId); - localStorage.setItem("mx_access_token", credentials.accessToken); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); if (credentials.pickleKey) { + const encrKey = await pickleKeyToAesKey(credentials.pickleKey); + const encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token"); + encrKey.fill(0); + await idbSave("account", "mx_access_token", encryptedAccessToken); localStorage.setItem("mx_has_pickle_key", String(true)); } else { + await idbSave("account", "mx_access_token", credentials.accessToken); if (localStorage.getItem("mx_has_pickle_key")) { console.error("Expected a pickle key, but none provided. Encryption may not work."); } @@ -733,6 +769,8 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index e1a2fc5590..d1dfa5ea50 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -325,8 +325,7 @@ export default class Registration extends React.Component { // isn't a guest user since we'll usually have set a guest user session before // starting the registration process. This isn't perfect since it's possible // the user had a separate guest session they didn't actually mean to replace. - const sessionOwner = Lifecycle.getStoredSessionOwner(); - const sessionIsGuest = Lifecycle.getStoredSessionIsGuest(); + const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) { console.log( `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, From 649ea0d148ee17eb2d1fefc550582146530d6953 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Thu, 10 Dec 2020 21:52:18 -0500 Subject: [PATCH 2/2] apply changes from review, and other fixes/improvements --- src/BasePlatform.ts | 8 ++- src/IndexedDB.ts | 92 ----------------------------- src/Lifecycle.ts | 115 ++++++++++++++++++++++++++---------- src/utils/StorageManager.js | 76 ++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 125 deletions(-) delete mode 100644 src/IndexedDB.ts diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 46fa998f15..c301aa6a10 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -26,7 +26,7 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; import {Action} from "./dispatcher/actions"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; import {MatrixClientPeg} from "./MatrixClientPeg"; -import {idbLoad, idbSave, idbDelete} from "./IndexedDB"; +import {idbLoad, idbSave, idbDelete} from "./utils/StorageManager"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -344,7 +344,11 @@ export default abstract class BasePlatform { {name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray, ); - await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey}); + try { + await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey}); + } catch (e) { + return null; + } return encodeUnpaddedBase64(randomArray); } diff --git a/src/IndexedDB.ts b/src/IndexedDB.ts deleted file mode 100644 index bf03ffccb7..0000000000 --- a/src/IndexedDB.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* -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. -*/ - -/* Simple wrapper around IndexedDB. - */ - -let idb = null; - -async function idbInit(): Promise { - idb = await new Promise((resolve, reject) => { - const request = window.indexedDB.open("element", 1); - request.onerror = reject; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore: TS thinks target.result doesn't exist - request.onsuccess = (event) => { resolve(event.target.result); }; - request.onupgradeneeded = (event) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore: TS thinks target.result doesn't exist - const db = event.target.result; - db.createObjectStore("pickleKey"); - db.createObjectStore("account"); - }; - }); -} - -export async function idbLoad( - table: string, - key: string | string[], -): Promise { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb.transaction([table], "readonly"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.get(key); - request.onerror = reject; - request.onsuccess = (event) => { resolve(request.result); }; - }); -} - -export async function idbSave( - table: string, - key: string | string[], - data: any, -): Promise { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.put(data, key); - request.onerror = reject; - request.onsuccess = (event) => { resolve(); }; - }); -} - -export async function idbDelete( - table: string, - key: string | string[], -): Promise { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.delete(key); - request.onerror = reject; - request.onsuccess = (event) => { resolve(); }; - }); -} diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 2f6309415f..f87af1a791 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -51,7 +51,6 @@ import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; -import {idbLoad, idbSave, idbDelete} from "./IndexedDB"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -154,8 +153,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise * return [null, null]. */ export async function getStoredSessionOwner(): Promise<[string, boolean]> { - const {hsUrl, userId, accessToken, isGuest} = await getLocalStorageSessionVars(); - return hsUrl && userId && accessToken ? [userId, isGuest] : [null, null]; + const {hsUrl, userId, hasAccessToken, isGuest} = await getStoredSessionVars(); + return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null]; } /** @@ -193,7 +192,7 @@ export function attemptTokenLogin( ).then(function(creds) { console.log("Logged in with token"); return clearStorage().then(async () => { - await persistCredentialsToLocalStorage(creds); + await persistCredentials(creds); // remember that we just logged in sessionStorage.setItem("mx_fresh_login", String(true)); return true; @@ -271,31 +270,42 @@ function registerAsGuest( }); } -export interface ILocalStorageSession { +export interface IStoredSession { hsUrl: string; isUrl: string; - accessToken: string; + hasAccessToken: boolean; + accessToken: string | object; userId: string; deviceId: string; isGuest: boolean; } /** - * Retrieves information about the stored session in localstorage. The session + * Retrieves information about the stored session from the browser's storage. The session * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export async function getLocalStorageSessionVars(): Promise { +export async function getStoredSessionVars(): Promise { const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); - let accessToken = await idbLoad("account", "mx_access_token"); + let accessToken; + try { + accessToken = await StorageManager.idbLoad("account", "mx_access_token"); + } catch (e) {} if (!accessToken) { accessToken = localStorage.getItem("mx_access_token"); if (accessToken) { - await idbSave("account", "mx_access_token", accessToken); - localStorage.removeItem("mx_access_token"); + try { + // try to migrate access token to IndexedDB if we can + await StorageManager.idbSave("account", "mx_access_token", accessToken); + localStorage.removeItem("mx_access_token"); + } catch (e) {} } } + // if we pre-date storing "mx_has_access_token", but we retrieved an access + // token, then we should say we have an access token + const hasAccessToken = + (localStorage.getItem("mx_has_access_token") === "true") || !!accessToken; const userId = localStorage.getItem("mx_user_id"); const deviceId = localStorage.getItem("mx_device_id"); @@ -307,7 +317,7 @@ export async function getLocalStorageSessionVars(): Promise { )); } +async function abortLogin() { + const signOut = await showStorageEvictedDialog(); + if (signOut) { + await clearStorage(); + // This error feels a bit clunky, but we want to make sure we don't go any + // further and instead head back to sign in. + throw new AbortLoginAndRebuildStorage( + "Aborting login in progress because of storage inconsistency", + ); + } +} + // returns a promise which resolves to true if a session is found in // localstorage // @@ -351,7 +373,11 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis return false; } - const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = await getLocalStorageSessionVars(); + const {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest} = await getStoredSessionVars(); + + if (hasAccessToken && !accessToken) { + abortLogin(); + } if (accessToken && userId && hsUrl) { if (ignoreGuest && isGuest) { @@ -379,7 +405,7 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis await doSetLoggedIn({ userId: userId, deviceId: deviceId, - accessToken: decryptedAccessToken, + accessToken: decryptedAccessToken as string, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: isGuest, @@ -518,15 +544,7 @@ async function doSetLoggedIn( // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { - const signOut = await showStorageEvictedDialog(); - if (signOut) { - await clearStorage(); - // This error feels a bit clunky, but we want to make sure we don't go any - // further and instead head back to sign in. - throw new AbortLoginAndRebuildStorage( - "Aborting login in progress because of storage inconsistency", - ); - } + await abortLogin(); } Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); @@ -548,7 +566,7 @@ async function doSetLoggedIn( if (localStorage) { try { - await persistCredentialsToLocalStorage(credentials); + await persistCredentials(credentials); // make sure we don't think that it's a fresh login any more sessionStorage.removeItem("mx_fresh_login"); } catch (e) { @@ -577,7 +595,7 @@ function showStorageEvictedDialog(): Promise { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -async function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): Promise { +async function persistCredentials(credentials: IMatrixClientCreds): Promise { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl); @@ -585,14 +603,47 @@ async function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds) localStorage.setItem("mx_user_id", credentials.userId); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + // store whether we expect to find an access token, to detect the case + // where IndexedDB is blown away + if (credentials.accessToken) { + localStorage.setItem("mx_has_access_token", "true"); + } else { + localStorage.deleteItem("mx_has_access_token"); + } + if (credentials.pickleKey) { - const encrKey = await pickleKeyToAesKey(credentials.pickleKey); - const encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token"); - encrKey.fill(0); - await idbSave("account", "mx_access_token", encryptedAccessToken); + let encryptedAccessToken; + try { + // try to encrypt the access token using the pickle key + const encrKey = await pickleKeyToAesKey(credentials.pickleKey); + encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token"); + encrKey.fill(0); + } catch (e) { + console.warn("Could not encrypt access token", e); + } + try { + // save either the encrypted access token, or the plain access + // token if we were unable to encrypt (e.g. if the browser doesn't + // have WebCrypto). + await StorageManager.idbSave( + "account", "mx_access_token", + encryptedAccessToken || credentials.accessToken, + ); + } catch (e) { + // if we couldn't save to indexedDB, fall back to localStorage. We + // store the access token unencrypted since localStorage only saves + // strings. + localStorage.setItem("mx_access_token", credentials.accessToken); + } localStorage.setItem("mx_has_pickle_key", String(true)); } else { - await idbSave("account", "mx_access_token", credentials.accessToken); + try { + await StorageManager.idbSave( + "account", "mx_access_token", credentials.accessToken, + ); + } catch (e) { + localStorage.setItem("mx_access_token", credentials.accessToken); + } if (localStorage.getItem("mx_has_pickle_key")) { console.error("Expected a pickle key, but none provided. Encryption may not work."); } @@ -769,7 +820,9 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { + if (!indexedDB) { + throw new Error("IndexedDB not available"); + } + idb = await new Promise((resolve, reject) => { + const request = indexedDB.open("matrix-react-sdk", 1); + request.onerror = reject; + request.onsuccess = (event) => { resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + db.createObjectStore("pickleKey"); + db.createObjectStore("account"); + }; + }); +} + +export async function idbLoad( + table: string, + key: string | string[], +): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readonly"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.get(key); + request.onerror = reject; + request.onsuccess = (event) => { resolve(request.result); }; + }); +} + +export async function idbSave( + table: string, + key: string | string[], + data: any, +): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.put(data, key); + request.onerror = reject; + request.onsuccess = (event) => { resolve(); }; + }); +} + +export async function idbDelete( + table: string, + key: string | string[], +): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.delete(key); + request.onerror = reject; + request.onsuccess = (event) => { resolve(); }; + }); +}