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.`,