Merge branch 'develop' into andybalaam/share-code-between-read-receipt-tests

pull/28217/head
Andy Balaam 2023-10-03 09:08:00 +01:00 committed by GitHub
commit d34ed6b66c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 445 additions and 199 deletions

View File

@ -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");
});
});
@ -331,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")
@ -345,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<ISendEventResponse>(
@ -367,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"))
@ -379,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. */
@ -388,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}`));
@ -398,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}`));
@ -409,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")

View File

@ -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<ICreateRoomOpts, "invite"> = { name: "TestRoom" },
): Cypress.Chainable<string> {
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);
});
}

View File

@ -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<ImageData> {
@ -122,15 +124,9 @@ describe("Device verification", () => {
/* the bot scans the QR code */
cy.get<VerificationRequest>("@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<HTMLElement>) {
// 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<string> {
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<Verifier> {
while (!botVerificationRequest.verifier) {
await emitPromise(botVerificationRequest, "change");
}
return botVerificationRequest.verifier;
}

View File

@ -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<Partial<IStoredSession>> {
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<Uint8Array> {
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<void> {
const signOut = await showStorageEvictedDialog();
if (signOut) {
@ -602,36 +570,6 @@ async function abortLogin(): Promise<void> {
}
}
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<string | undefined> {
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<boolean> {
// `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<void> {
// 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<void> {
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
if (credentials.identityServerUrl) {
@ -976,20 +847,8 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
localStorage.setItem("mx_user_id", credentials.userId);
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
await persistTokenInStorage(
ACCESS_TOKEN_STORAGE_KEY,
ACCESS_TOKEN_IV,
credentials.accessToken,
credentials.pickleKey,
HAS_ACCESS_TOKEN_STORAGE_KEY,
);
await persistTokenInStorage(
REFRESH_TOKEN_STORAGE_KEY,
REFRESH_TOKEN_IV,
credentials.refreshToken,
credentials.pickleKey,
HAS_REFRESH_TOKEN_STORAGE_KEY,
);
await persistAccessTokenInStorage(credentials.accessToken, credentials.pickleKey);
await persistRefreshTokenInStorage(credentials.refreshToken, credentials.pickleKey);
if (credentials.pickleKey) {
localStorage.setItem("mx_has_pickle_key", String(true));

202
src/utils/tokens/tokens.ts Normal file
View File

@ -0,0 +1,202 @@
/*
Copyright 2023 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 { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
import { logger } from "matrix-js-sdk/src/logger";
import * as StorageManager from "../StorageManager";
/**
* Utility functions related to the storage and retrieval of access tokens
*/
/*
* Keys used when storing the tokens in indexeddb or localstorage
*/
export const ACCESS_TOKEN_STORAGE_KEY = "mx_access_token";
export const REFRESH_TOKEN_STORAGE_KEY = "mx_refresh_token";
/*
* Used as initialization vector during encryption in persistTokenInStorage
* And decryption in restoreFromLocalStorage
*/
export const ACCESS_TOKEN_IV = "access_token";
export const REFRESH_TOKEN_IV = "refresh_token";
/*
* Keys for localstorage items which indicate whether we expect a token in indexeddb.
*/
export const HAS_ACCESS_TOKEN_STORAGE_KEY = "mx_has_access_token";
export const HAS_REFRESH_TOKEN_STORAGE_KEY = "mx_has_refresh_token";
/**
* 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.
* @param pickleKey
* @returns AES key
*/
async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array> {
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<string | undefined> {
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<void> {
// 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<void> {
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<void> {
return persistTokenInStorage(
REFRESH_TOKEN_STORAGE_KEY,
REFRESH_TOKEN_IV,
token,
pickleKey,
HAS_REFRESH_TOKEN_STORAGE_KEY,
);
}