From 6e559266dfd180bbc643f0f814838309d8552811 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 2 Oct 2023 20:08:41 +0100 Subject: [PATCH 1/3] Cypress test for incoming user verification (#11688) * Cypress test for incoming user verification * Fix complaint from cypress about immediate values --- cypress/e2e/crypto/crypto.spec.ts | 20 +-- cypress/e2e/crypto/utils.ts | 27 +++- cypress/e2e/crypto/verification.spec.ts | 168 +++++++++++++++++++++--- 3 files changed, 178 insertions(+), 37 deletions(-) diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 94fdf982e6..1891263629 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -20,6 +20,7 @@ import type { CypressBot } from "../../support/bot"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; import { + createSharedRoomWithUser, doTwoWaySasVerification, downloadKey, enableKeyBackup, @@ -284,16 +285,7 @@ describe("Cryptography", function () { autoJoin(this.bob); // we need to have a room with the other user present, so we can open the verification panel - let roomId: string; - cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }).then((_room1Id) => { - roomId = _room1Id; - cy.log(`Created test room ${roomId}`); - cy.visit(`/#/room/${roomId}`); - // wait for Bob to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText("Bob joined the room").should("exist"); - }); - + createSharedRoomWithUser(this.bob.getUserId()); verify.call(this); }); @@ -305,21 +297,15 @@ describe("Cryptography", function () { autoJoin(bob); // create an encrypted room - cy.createRoom({ name: "TestRoom", invite: [bob.getUserId()] }) + createSharedRoomWithUser(bob.getUserId()) .as("testRoomId") .then((roomId) => { testRoomId = roomId; - cy.log(`Created test room ${roomId}`); - cy.visit(`/#/room/${roomId}`); // enable encryption cy.getClient().then((cli) => { cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); }); - - // wait for Bob to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText("Bob joined the room").should("exist"); }); }); diff --git a/cypress/e2e/crypto/utils.ts b/cypress/e2e/crypto/utils.ts index a3c8078a80..d2101aaa17 100644 --- a/cypress/e2e/crypto/utils.ts +++ b/cypress/e2e/crypto/utils.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; export type EmojiMapping = [emoji: string, name: string]; @@ -200,3 +200,28 @@ export function downloadKey() { cy.findByRole("button", { name: "Download" }).click(); cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); } + +/** + * Create a shared, unencrypted room with the given user, and wait for them to join + * + * @param other - UserID of the other user + * @param opts - other options for the createRoom call + * + * @returns a cypress chainable which will yield the room ID + */ +export function createSharedRoomWithUser( + other: string, + opts: Omit = { name: "TestRoom" }, +): Cypress.Chainable { + return cy.createRoom({ ...opts, invite: [other] }).then((roomId) => { + cy.log(`Created test room ${roomId}`); + cy.viewRoomById(roomId); + + // wait for the other user to join the room, otherwise our attempt to open his user details may race + // with his join. + cy.findByText(" joined the room", { exact: false }).should("exist"); + + // Cypress complains if we return an immediate here rather than a promise. + return Promise.resolve(roomId); + }); +} diff --git a/cypress/e2e/crypto/verification.spec.ts b/cypress/e2e/crypto/verification.spec.ts index 0bc6c71034..d221a39df7 100644 --- a/cypress/e2e/crypto/verification.spec.ts +++ b/cypress/e2e/crypto/verification.spec.ts @@ -16,12 +16,14 @@ limitations under the License. import jsQR from "jsqr"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api/verification"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; import { CypressBot } from "../../support/bot"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { emitPromise } from "../../support/util"; import { checkDeviceIsCrossSigned, doTwoWaySasVerification, logIntoElement, waitForVerificationRequest } from "./utils"; import { getToast } from "../../support/toasts"; +import { UserCredentials } from "../../support/login"; /** Render a data URL and return the rendered image data */ async function renderQRCode(dataUrl: string): Promise { @@ -122,15 +124,9 @@ describe("Device verification", () => { /* the bot scans the QR code */ cy.get("@verificationRequest") .then(async (request: VerificationRequest) => { - // because I don't know how to scrape the imagedata from the cypress browser window, - // we extract the data url and render it to a new canvas. - const imageData = await renderQRCode(qrCode.attr("src")); - - // now we can decode the QR code... - const result = jsQR(imageData.data, imageData.width, imageData.height); - - // ... and feed it into the verification request. - return await request.scanQRCode(new Uint8Array(result.binaryData)); + // feed the QR code into the verification request. + const qrData = await readQrCode(qrCode); + return await request.scanQRCode(qrData); }) .as("verifier"); }); @@ -244,15 +240,7 @@ describe("Device verification", () => { cy.findByRole("button", { name: "Start" }).click(); /* on the bot side, wait for the verifier to exist ... */ - async function awaitVerifier() { - // wait for the verifier to exist - while (!botVerificationRequest.verifier) { - await emitPromise(botVerificationRequest, "change"); - } - return botVerificationRequest.verifier; - } - - cy.then(() => cy.wrap(awaitVerifier())).then((verifier: Verifier) => { + cy.then(() => cy.wrap(awaitVerifier(botVerificationRequest))).then((verifier: Verifier) => { // ... confirm ... botVerificationRequest.verifier.verify(); @@ -268,3 +256,145 @@ describe("Device verification", () => { }); }); }); + +describe("User verification", () => { + // note that there are other tests that check user verification works in `crypto.spec.ts`. + + let aliceCredentials: UserCredentials; + let homeserver: HomeserverInstance; + let bob: CypressBot; + + beforeEach(() => { + cy.startHomeserver("default") + .as("homeserver") + .then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { + aliceCredentials = credentials; + }); + return cy.getBot(homeserver, { + displayName: "Bob", + autoAcceptInvites: true, + userIdPrefix: "bob_", + }); + }) + .then((data) => { + bob = data; + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("can receive a verification request when there is no existing DM", () => { + cy.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + let dmRoomId: string; + let bobVerificationRequest: VerificationRequest; + cy.wrap(0).then(async () => { + dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + }); + + // accept the DM + cy.viewRoomByName("Bob"); + cy.findByRole("button", { name: "Start chatting" }).click(); + + // once Alice has joined, Bob starts the verification + cy.wrap(0).then(async () => { + const room = bob.getRoom(dmRoomId)!; + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + // @ts-ignore can't access the enum here + room.once("RoomState.members", resolve); + }); + } + bobVerificationRequest = await bob.getCrypto()!.requestVerificationDM(aliceCredentials.userId, dmRoomId); + }); + + // there should also be a toast + getToast("Verification requested").within(() => { + // it should contain the details of the requesting user + cy.contains(`Bob (${bob.credentials.userId})`); + + // Accept + cy.findByRole("button", { name: "Verify Session" }).click(); + }); + + // request verification by emoji + cy.get("#mx_RightPanel").findByRole("button", { name: "Verify by emoji" }).click(); + + cy.wrap(0) + .then(async () => { + /* on the bot side, wait for the verifier to exist ... */ + const verifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + verifier.verify(); + return verifier; + }) + .then((botVerifier) => { + // ... and then check the emoji match + doTwoWaySasVerification(botVerifier); + }); + + cy.findByRole("button", { name: "They match" }).click(); + cy.findByText("You've successfully verified Bob!").should("exist"); + cy.findByRole("button", { name: "Got it" }).click(); + }); +}); + +/** Extract the qrcode out of an on-screen html element */ +async function readQrCode(qrCode: JQuery) { + // because I don't know how to scrape the imagedata from the cypress browser window, + // we extract the data url and render it to a new canvas. + const imageData = await renderQRCode(qrCode.attr("src")); + + // now we can decode the QR code. + const result = jsQR(imageData.data, imageData.width, imageData.height); + return new Uint8Array(result.binaryData); +} + +async function createDMRoom(client: MatrixClient, userId: string): Promise { + const r = await client.createRoom({ + // @ts-ignore can't access the enum here + preset: "trusted_private_chat", + // @ts-ignore can't access the enum here + visibility: "private", + invite: [userId], + is_direct: true, + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + + const roomId = r.room_id; + + // wait for the room to come down /sync + while (!client.getRoom(roomId)) { + await new Promise((resolve) => { + //@ts-ignore can't access the enum here + client.once("Room", resolve); + }); + } + + return roomId; +} + +/** + * Wait for a verifier to exist for a VerificationRequest + * + * @param botVerificationRequest + */ +async function awaitVerifier(botVerificationRequest: VerificationRequest): Promise { + while (!botVerificationRequest.verifier) { + await emitPromise(botVerificationRequest, "change"); + } + return botVerificationRequest.verifier; +} From e8890467fe78772aa55eeafa00914f727e9bfde1 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 2 Oct 2023 21:07:42 +0100 Subject: [PATCH 2/3] Fix a flaky cypress crypto test (#11687) * Fix a flaky cypress crypto test We need to be a bit proactive to get existing shields to update when a device is deleted. * Handle different text between rust and legacy crypto --- cypress/e2e/crypto/crypto.spec.ts | 58 +++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 1891263629..99a6b647f3 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -317,12 +317,16 @@ describe("Cryptography", function () { }); /* Should show an error for a decryption failure */ - cy.wrap(0).then(() => - bob.sendEvent(testRoomId, "m.room.encrypted", { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "the bird is in the hand", - }), - ); + cy.log("Testing decryption failure"); + + cy.wrap(0) + .then(() => + bob.sendEvent(testRoomId, "m.room.encrypted", { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "the bird is in the hand", + }), + ) + .then((resp) => cy.log(`Bob sent undecryptable event ${resp.event_id}`)); cy.get(".mx_EventTile_last") .should("contain", "Unable to decrypt message") @@ -331,6 +335,7 @@ describe("Cryptography", function () { .should("have.attr", "aria-label", "This message could not be decrypted"); /* Should show a red padlock for an unencrypted message in an e2e room */ + cy.log("Testing unencrypted message"); cy.wrap(0) .then(() => bob.http.authedRequest( @@ -353,6 +358,8 @@ describe("Cryptography", function () { .should("have.attr", "aria-label", "Not encrypted"); /* Should show no padlock for an unverified user */ + cy.log("Testing message from unverified user"); + // bob sends a valid event cy.wrap(0) .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) @@ -365,6 +372,8 @@ describe("Cryptography", function () { .should("not.have.descendants", ".mx_EventTile_e2eIcon"); /* Now verify Bob */ + cy.log("Verifying Bob"); + verify.call(this); /* Existing message should be updated when user is verified. */ @@ -374,6 +383,7 @@ describe("Cryptography", function () { .should("not.have.descendants", ".mx_EventTile_e2eIcon"); /* should show no padlock, and be verified, for a message from a verified device */ + cy.log("Testing message from verified device"); cy.wrap(0) .then(() => bob.sendTextMessage(testRoomId, "test encrypted 2")) .then((resp) => cy.log(`Bob sent second message from primary device with event id ${resp.event_id}`)); @@ -384,6 +394,7 @@ describe("Cryptography", function () { .should("not.have.descendants", ".mx_EventTile_e2eIcon"); /* should show red padlock for a message from an unverified device */ + cy.log("Testing message from unverified device of verified user"); cy.wrap(0) .then(() => bobSecondDevice.sendTextMessage(testRoomId, "test encrypted from unverified")) .then((resp) => cy.log(`Bob sent message from unverified device with event id ${resp.event_id}`)); @@ -395,12 +406,45 @@ describe("Cryptography", function () { .should("have.attr", "aria-label", "Encrypted by a device not verified by its owner."); /* Should show a grey padlock for a message from an unknown device */ + cy.log("Testing message from unknown device"); - // bob deletes his second device, making the encrypted event from the unverified device "unknown". + // bob deletes his second device cy.wrap(0) .then(() => bobSecondDevice.logout(true)) .then(() => cy.log(`Bob logged out second device`)); + // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. + function awaitOneDevice(iterations = 1) { + let sessionCountText: string; + cy.get(".mx_RightPanel") + .within(() => { + cy.findByRole("button", { name: "Room members" }).click(); + cy.findByText("Bob").click(); + return cy + .get(".mx_UserInfo_devices") + .findByText(" session", { exact: false }) + .then((data) => { + sessionCountText = data.text(); + }); + }) + .then(() => { + cy.log(`At ${new Date().toISOString()}: Bob has '${sessionCountText}'`); + // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here + if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { + if (iterations >= 10) { + throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); + } + awaitOneDevice(iterations + 1); + } + }); + } + + awaitOneDevice(); + + // close and reopen the room, to get the shield to update. + cy.viewRoomByName("Bob"); + cy.viewRoomByName("TestRoom"); + // some debate over whether this should have a red or a grey shield. Legacy crypto shows a grey shield, // Rust crypto a red one. cy.get(".mx_EventTile_last") From 66854039a33ed6cfe1fc635ff2daa8bb261c0b56 Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 3 Oct 2023 11:09:13 +1300 Subject: [PATCH 3/3] OIDC: extract token persistence functions to utils (#11690) * extract token persistence functions to utils * add sugar --- src/Lifecycle.ts | 169 +++---------------------------- src/utils/tokens/tokens.ts | 202 +++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 155 deletions(-) create mode 100644 src/utils/tokens/tokens.ts diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 0b34e02409..bfef686ea8 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -20,7 +20,7 @@ limitations under the License. import { ReactNode } from "react"; import { createClient, MatrixClient, SSOAction } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; -import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; +import { IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { MINIMUM_MATRIX_VERSION } from "matrix-js-sdk/src/version-support"; @@ -67,27 +67,21 @@ import { messageForLoginError } from "./utils/ErrorUtils"; import { completeOidcLogin } from "./utils/oidc/authorize"; import { persistOidcAuthenticatedSettings } from "./utils/oidc/persistOidcSettings"; import GenericToast from "./components/views/toasts/GenericToast"; +import { + ACCESS_TOKEN_IV, + ACCESS_TOKEN_STORAGE_KEY, + HAS_ACCESS_TOKEN_STORAGE_KEY, + HAS_REFRESH_TOKEN_STORAGE_KEY, + persistAccessTokenInStorage, + persistRefreshTokenInStorage, + REFRESH_TOKEN_IV, + REFRESH_TOKEN_STORAGE_KEY, + tryDecryptToken, +} from "./utils/tokens/tokens"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; -/* - * Keys used when storing the tokens in indexeddb or localstorage - */ -const ACCESS_TOKEN_STORAGE_KEY = "mx_access_token"; -const REFRESH_TOKEN_STORAGE_KEY = "mx_refresh_token"; -/* - * Used as initialization vector during encryption in persistTokenInStorage - * And decryption in restoreFromLocalStorage - */ -const ACCESS_TOKEN_IV = "access_token"; -const REFRESH_TOKEN_IV = "refresh_token"; -/* - * Keys for localstorage items which indicate whether we expect a token in indexeddb. - */ -const HAS_ACCESS_TOKEN_STORAGE_KEY = "mx_has_access_token"; -const HAS_REFRESH_TOKEN_STORAGE_KEY = "mx_has_refresh_token"; - dis.register((payload) => { if (payload.action === Action.TriggerLogout) { // noinspection JSIgnoredPromiseFromCall - we don't care if it fails @@ -566,32 +560,6 @@ export async function getStoredSessionVars(): Promise> { return { hsUrl, isUrl, hasAccessToken, accessToken, refreshToken, hasRefreshToken, 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, - ), - ); -} - async function abortLogin(): Promise { const signOut = await showStorageEvictedDialog(); if (signOut) { @@ -602,36 +570,6 @@ async function abortLogin(): Promise { } } -const isEncryptedPayload = (token?: IEncryptedPayload | string | undefined): token is IEncryptedPayload => { - return !!token && typeof token !== "string"; -}; -/** - * Try to decrypt a token retrieved from storage - * Where token is not encrypted (plain text) returns the plain text token - * Where token is encrypted, attempts decryption. Returns successfully decrypted token, else undefined. - * @param pickleKey pickle key used during encryption of token, or undefined - * @param token - * @param tokenIv initialization vector used during encryption of token eg ACCESS_TOKEN_IV - * @returns the decrypted token, or the plain text token. Returns undefined when token cannot be decrypted - */ -async function tryDecryptToken( - pickleKey: string | undefined, - token: IEncryptedPayload | string | undefined, - tokenIv: string, -): Promise { - if (pickleKey && isEncryptedPayload(token)) { - const encrKey = await pickleKeyToAesKey(pickleKey); - const decryptedToken = await decryptAES(token, encrKey, tokenIv); - encrKey.fill(0); - return decryptedToken; - } - // if the token wasn't encrypted (plain string) just return it back - if (typeof token === "string") { - return token; - } - // otherwise return undefined -} - // returns a promise which resolves to true if a session is found in // localstorage // @@ -901,73 +839,6 @@ async function showStorageEvictedDialog(): Promise { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error {} -/** - * Persist a token in storage - * When pickle key is present, will attempt to encrypt the token - * Stores in idb, falling back to localStorage - * - * @param storageKey key used to store the token - * @param initializationVector Initialization vector for encrypting the token. Only used when `pickleKey` is present - * @param token the token to store, when undefined any existing token at the storageKey is removed from storage - * @param pickleKey optional pickle key used to encrypt token - * @param hasTokenStorageKey Localstorage key for an item which stores whether we expect to have a token in indexeddb, eg "mx_has_access_token". - */ -async function persistTokenInStorage( - storageKey: string, - initializationVector: string, - token: string | undefined, - pickleKey: string | undefined, - hasTokenStorageKey: string, -): Promise { - // store whether we expect to find a token, to detect the case - // where IndexedDB is blown away - if (token) { - localStorage.setItem(hasTokenStorageKey, "true"); - } else { - localStorage.removeItem(hasTokenStorageKey); - } - - if (pickleKey) { - let encryptedToken: IEncryptedPayload | undefined; - try { - if (!token) { - throw new Error("No token: not attempting encryption"); - } - // try to encrypt the access token using the pickle key - const encrKey = await pickleKeyToAesKey(pickleKey); - encryptedToken = await encryptAES(token, encrKey, initializationVector); - encrKey.fill(0); - } catch (e) { - logger.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", storageKey, encryptedToken || token); - } catch (e) { - // if we couldn't save to indexedDB, fall back to localStorage. We - // store the access token unencrypted since localStorage only saves - // strings. - if (!!token) { - localStorage.setItem(storageKey, token); - } else { - localStorage.removeItem(storageKey); - } - } - } else { - try { - await StorageManager.idbSave("account", storageKey, token); - } catch (e) { - if (!!token) { - localStorage.setItem(storageKey, token); - } else { - localStorage.removeItem(storageKey); - } - } - } -} - async function persistCredentials(credentials: IMatrixClientCreds): Promise { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { @@ -976,20 +847,8 @@ async function persistCredentials(credentials: IMatrixClientCreds): 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, + ), + ); +} + +const isEncryptedPayload = (token?: IEncryptedPayload | string | undefined): token is IEncryptedPayload => { + return !!token && typeof token !== "string"; +}; +/** + * Try to decrypt a token retrieved from storage + * Where token is not encrypted (plain text) returns the plain text token + * Where token is encrypted, attempts decryption. Returns successfully decrypted token, else undefined. + * @param pickleKey pickle key used during encryption of token, or undefined + * @param token + * @param tokenIv initialization vector used during encryption of token eg ACCESS_TOKEN_IV + * @returns the decrypted token, or the plain text token. Returns undefined when token cannot be decrypted + */ +export async function tryDecryptToken( + pickleKey: string | undefined, + token: IEncryptedPayload | string | undefined, + tokenIv: string, +): Promise { + if (pickleKey && isEncryptedPayload(token)) { + const encrKey = await pickleKeyToAesKey(pickleKey); + const decryptedToken = await decryptAES(token, encrKey, tokenIv); + encrKey.fill(0); + return decryptedToken; + } + // if the token wasn't encrypted (plain string) just return it back + if (typeof token === "string") { + return token; + } + // otherwise return undefined +} + +/** + * Persist a token in storage + * When pickle key is present, will attempt to encrypt the token + * Stores in idb, falling back to localStorage + * + * @param storageKey key used to store the token + * @param initializationVector Initialization vector for encrypting the token. Only used when `pickleKey` is present + * @param token the token to store, when undefined any existing token at the storageKey is removed from storage + * @param pickleKey optional pickle key used to encrypt token + * @param hasTokenStorageKey Localstorage key for an item which stores whether we expect to have a token in indexeddb, eg "mx_has_access_token". + */ +export async function persistTokenInStorage( + storageKey: string, + initializationVector: string, + token: string | undefined, + pickleKey: string | undefined, + hasTokenStorageKey: string, +): Promise { + // store whether we expect to find a token, to detect the case + // where IndexedDB is blown away + if (token) { + localStorage.setItem(hasTokenStorageKey, "true"); + } else { + localStorage.removeItem(hasTokenStorageKey); + } + + if (pickleKey) { + let encryptedToken: IEncryptedPayload | undefined; + try { + if (!token) { + throw new Error("No token: not attempting encryption"); + } + // try to encrypt the access token using the pickle key + const encrKey = await pickleKeyToAesKey(pickleKey); + encryptedToken = await encryptAES(token, encrKey, initializationVector); + encrKey.fill(0); + } catch (e) { + logger.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", storageKey, encryptedToken || token); + } catch (e) { + // if we couldn't save to indexedDB, fall back to localStorage. We + // store the access token unencrypted since localStorage only saves + // strings. + if (!!token) { + localStorage.setItem(storageKey, token); + } else { + localStorage.removeItem(storageKey); + } + } + } else { + try { + await StorageManager.idbSave("account", storageKey, token); + } catch (e) { + if (!!token) { + localStorage.setItem(storageKey, token); + } else { + localStorage.removeItem(storageKey); + } + } + } +} + +/** + * Wraps persistTokenInStorage with accessToken storage keys + * @param token the token to store, when undefined any existing accessToken is removed from storage + * @param pickleKey optional pickle key used to encrypt token + */ +export async function persistAccessTokenInStorage( + token: string | undefined, + pickleKey: string | undefined, +): Promise { + return persistTokenInStorage( + ACCESS_TOKEN_STORAGE_KEY, + ACCESS_TOKEN_IV, + token, + pickleKey, + HAS_ACCESS_TOKEN_STORAGE_KEY, + ); +} + +/** + * Wraps persistTokenInStorage with refreshToken storage keys + * @param token the token to store, when undefined any existing refreshToken is removed from storage + * @param pickleKey optional pickle key used to encrypt token + */ +export async function persistRefreshTokenInStorage( + token: string | undefined, + pickleKey: string | undefined, +): Promise { + return persistTokenInStorage( + REFRESH_TOKEN_STORAGE_KEY, + REFRESH_TOKEN_IV, + token, + pickleKey, + HAS_REFRESH_TOKEN_STORAGE_KEY, + ); +}