diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 174a030a7f..e89306c8e3 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -87,16 +87,22 @@ export const createAccount = async ( let publicKey = ""; let allAccounts = [] as Array; let hasPrivateKey = false; + let error = ""; try { - ({ allAccounts, publicKey, hasPrivateKey } = await sendMessageToBackground({ - password, - type: SERVICE_TYPES.CREATE_ACCOUNT, - })); + ({ allAccounts, publicKey, hasPrivateKey, error } = + await sendMessageToBackground({ + password, + type: SERVICE_TYPES.CREATE_ACCOUNT, + })); } catch (e) { console.error(e); } + if (error) { + throw new Error(error); + } + return { allAccounts, publicKey, hasPrivateKey }; }; diff --git a/config/jest/setupTests.tsx b/config/jest/setupTests.tsx index 201435bed2..270cb65f75 100755 --- a/config/jest/setupTests.tsx +++ b/config/jest/setupTests.tsx @@ -3,10 +3,12 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import { JSDOM } from "jsdom"; +import crypto from "crypto"; import React from "react"; import fetch from "isomorphic-unfetch"; import "jest-localstorage-mock"; import "jsdom-global"; +import { TextEncoder, TextDecoder } from "util"; // make a JSDOM thing so we can fuck with mount const jsdom = new JSDOM(""); @@ -18,6 +20,16 @@ global.DEV_SERVER = true; global.DEV_EXTENSION = true; global.PRODUCTION = false; global.EXPERIMENTAL = false; +global.TextEncoder = TextEncoder; +// @ts-expect-error +global.TextDecoder = TextDecoder; + +Object.defineProperty(global.self, "crypto", { + value: { + getRandomValues: crypto.getRandomValues, + subtle: crypto.webcrypto.subtle, + }, +}); process.env.INDEXER_URL = "http://localhost:3002/api/v1"; diff --git a/extension/e2e-tests/helpers/sendPayment.ts b/extension/e2e-tests/helpers/sendPayment.ts new file mode 100644 index 0000000000..53a4098d1b --- /dev/null +++ b/extension/e2e-tests/helpers/sendPayment.ts @@ -0,0 +1,66 @@ +import { expect, expectPageToHaveScreenshot } from "../test-fixtures"; + +export const sendXlmPayment = async ({ page }) => { + await page.getByTitle("Send Payment").click({ force: true }); + + await expect(page.getByText("Send To")).toBeVisible(); + await expectPageToHaveScreenshot({ + page, + screenshot: "send-payment-to.png", + }); + await page + .getByTestId("send-to-input") + .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); + await page.getByText("Continue").click({ force: true }); + + await expect(page.getByText("Send XLM")).toBeVisible(); + await expectPageToHaveScreenshot({ + page, + screenshot: "send-payment-amount.png", + }); + await page.getByTestId("send-amount-amount-input").fill("1"); + await page.getByText("Continue").click({ force: true }); + + await expect(page.getByText("Send Settings")).toBeVisible(); + await expect(page.getByTestId("SendSettingsTransactionFee")).toHaveText( + /[0-9]/, + ); + // 100 XLM is the default, so likely a sign the fee was not set properly from Horizon + await expect( + page.getByTestId("SendSettingsTransactionFee"), + ).not.toContainText("100 XLM"); + await expectPageToHaveScreenshot( + { + page, + screenshot: "send-payment-settings.png", + }, + { + mask: [page.locator("[data-testid='SendSettingsTransactionFee']")], + }, + ); + await page.getByText("Review Send").click({ force: true }); + + await expect(page.getByText("Confirm Send")).toBeVisible(); + await expect(page.getByText("XDR")).toBeVisible(); + await expectPageToHaveScreenshot({ + page, + screenshot: "send-payment-confirm.png", + }); + await page.getByTestId("transaction-details-btn-send").click({ force: true }); + + await expect(page.getByText("Successfully sent")).toBeVisible({ + timeout: 60000, + }); + await expectPageToHaveScreenshot({ + page, + screenshot: "send-payment-sent.png", + }); + + await page.getByText("Details").click({ force: true }); + await expectPageToHaveScreenshot({ + page, + screenshot: "send-payment-details.png", + }); + await expect(page.getByText("Sent XLM")).toBeVisible(); + await expect(page.getByTestId("asset-amount")).toContainText("1"); +}; diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index dc6df5cbdb..44f89059a7 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -6,7 +6,7 @@ import { PASSWORD, } from "./helpers/login"; import { TEST_TOKEN_ADDRESS } from "./helpers/test-token"; -import { toBeVisible } from "@testing-library/jest-dom/matchers"; +import { sendXlmPayment } from "./helpers/sendPayment"; test("Swap doesn't throw error when account is unfunded", async ({ page, @@ -39,71 +39,64 @@ test("Send doesn't throw error when account is unfunded", async ({ ); }); -test("Send XLM payment to G address", async ({ page, extensionId }) => { +test("Send XLM payments from multiple accounts to G Address", async ({ + page, + extensionId, +}) => { test.slow(); await loginAndFund({ page, extensionId }); - await page.getByTitle("Send Payment").click({ force: true }); - - await expect(page.getByText("Send To")).toBeVisible(); - await expectPageToHaveScreenshot({ - page, - screenshot: "send-payment-to.png", + await sendXlmPayment({ page }); + + await page.getByTestId("BackButton").click(); + await page.getByTestId("BottomNav-link-account").click(); + await page.getByTestId("AccountHeader__icon-btn").click(); + await page.getByText("Create a new Stellar address").click(); + + // test incorrect password + await page.locator("#password-input").fill("wrong password"); + await page.getByText("Create New Address").click(); + await expect(page.getByText("Incorrect password")).toBeVisible(); + await page.locator("#password-input").fill(PASSWORD); + await page.getByText("Create New Address").click(); + + await expect(page.getByTestId("not-funded")).toBeVisible({ + timeout: 10000, }); - await page - .getByTestId("send-to-input") - .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); - await page.getByText("Continue").click({ force: true }); + await page.getByRole("button", { name: "Fund with Friendbot" }).click(); - await expect(page.getByText("Send XLM")).toBeVisible(); - await expectPageToHaveScreenshot({ - page, - screenshot: "send-payment-amount.png", + await expect(page.getByTestId("account-assets")).toBeVisible({ + timeout: 30000, }); - await page.getByTestId("send-amount-amount-input").fill("1"); - await page.getByText("Continue").click({ force: true }); + await sendXlmPayment({ page }); - await expect(page.getByText("Send Settings")).toBeVisible(); - await expect(page.getByTestId("SendSettingsTransactionFee")).toHaveText( - /[0-9]/, - ); - // 100 XLM is the default, so likely a sign the fee was not set properly from Horizon - await expect( - page.getByTestId("SendSettingsTransactionFee"), - ).not.toContainText("100 XLM"); - await expectPageToHaveScreenshot( - { - page, - screenshot: "send-payment-settings.png", - }, - { - mask: [page.locator("[data-testid='SendSettingsTransactionFee']")], - }, - ); - await page.getByText("Review Send").click({ force: true }); + await page.getByTestId("BackButton").click(); + await page.getByTestId("BottomNav-link-account").click(); + await page.getByTestId("AccountHeader__icon-btn").click(); - await expect(page.getByText("Confirm Send")).toBeVisible(); - await expect(page.getByText("XDR")).toBeVisible(); - await expectPageToHaveScreenshot({ - page, - screenshot: "send-payment-confirm.png", - }); - await page.getByTestId("transaction-details-btn-send").click({ force: true }); + await page.getByText("Account 1").click(); + await sendXlmPayment({ page }); - await expect(page.getByText("Successfully sent")).toBeVisible({ - timeout: 60000, - }); - await expectPageToHaveScreenshot({ - page, - screenshot: "send-payment-sent.png", - }); + await page.getByTestId("BackButton").click(); + await page.getByTestId("BottomNav-link-account").click(); + await page.getByTestId("AccountHeader__icon-btn").click(); + await page.getByText("Import a Stellar secret key").click(); - await page.getByText("Details").click({ force: true }); - await expectPageToHaveScreenshot({ - page, - screenshot: "send-payment-details.png", - }); - await expect(page.getByText("Sent XLM")).toBeVisible(); - await expect(page.getByTestId("asset-amount")).toContainText("1 XLM"); + // test private key account from different mnemonic phrase + await page + .locator("#privateKey-input") + .fill("SDCUXKGHQ4HX5NRX5JN7GMJZUXQBWZXLKF34DLVYZ4KLXXIZTG7Q26JJ"); + // test incorrect password + await page.locator("#password-input").fill("wrongpassword"); + await page.locator("#authorization-input").click({ force: true }); + + await page.getByTestId("import-account-button").click(); + await expect( + page.getByText("Please enter a valid secret key/password combination"), + ).toHaveCount(2); + await page.locator("#password-input").fill(PASSWORD); + await page.getByTestId("import-account-button").click(); + + await sendXlmPayment({ page }); }); test("Send XLM payment to C address", async ({ page, extensionId }) => { diff --git a/extension/package.json b/extension/package.json index b6e4b245b2..36e2b5c652 100644 --- a/extension/package.json +++ b/extension/package.json @@ -58,7 +58,7 @@ "i18next-resources-to-backend": "^1.0.0", "i18next-scanner-webpack": "^0.9.1", "jest-canvas-mock": "^2.4.0", - "jest-environment-jsdom": "^28.1.3", + "jest-environment-jsdom": "^29.7.0", "jsonschema": "^1.4.1", "lodash": "^4.17.15", "mini-css-extract-plugin": "^2.9.0", diff --git a/extension/src/background/ducks/session.ts b/extension/src/background/ducks/session.ts index b9d4c13b17..aba2445277 100644 --- a/extension/src/background/ducks/session.ts +++ b/extension/src/background/ducks/session.ts @@ -14,12 +14,11 @@ export const logIn = createAsyncThunk< UiData, UiData, { rejectValue: ErrorMessage } ->("logIn", async ({ publicKey, mnemonicPhrase, allAccounts }, thunkApi) => { +>("logIn", async ({ publicKey, allAccounts }, thunkApi) => { try { await internalSubscribeAccount(publicKey); return { publicKey, - mnemonicPhrase, allAccounts, }; } catch (e) { @@ -45,24 +44,32 @@ export const setActivePublicKey = createAsyncThunk< } }); -const initialState = { +export type InitialState = UiData & AppData; + +export interface SessionState { + session: InitialState; +} + +const initialState: InitialState = { publicKey: "", - privateKey: "", - mnemonicPhrase: "", + hashKey: { + iv: "", + key: "", + }, allAccounts: [] as Account[], migratedMnemonicPhrase: "", }; interface UiData { publicKey: string; - mnemonicPhrase?: string; allAccounts?: Account[]; migratedMnemonicPhrase?: string; } interface AppData { privateKey?: string; - password: string; + hashKey?: { key: string; iv: string }; + password?: string; } export const sessionSlice = createSlice({ @@ -71,13 +78,17 @@ export const sessionSlice = createSlice({ reducers: { reset: () => initialState, logOut: () => initialState, - setActivePrivateKey: (state, action: { payload: AppData }) => { - const { privateKey = "", password = "" } = action.payload; + setActiveHashKey: (state, action: { payload: AppData }) => { + const { + hashKey = { + iv: "", + key: "", + }, + } = action.payload; return { ...state, - privateKey, - password, + hashKey, }; }, setMigratedMnemonicPhrase: ( @@ -93,7 +104,10 @@ export const sessionSlice = createSlice({ }, timeoutAccountAccess: (state) => ({ ...state, - privateKey: "", + hashKey: { + iv: "", + key: "", + }, password: "", }), updateAllAccountsAccountName: ( @@ -102,6 +116,10 @@ export const sessionSlice = createSlice({ ) => { const { updatedAccountName = "" } = action.payload; + if (!state.allAccounts) { + return state; + } + const newAllAccounts = state.allAccounts.map((account) => { if (state.publicKey === account.publicKey) { // this is the current active public key, let's edit it @@ -123,7 +141,6 @@ export const sessionSlice = createSlice({ extraReducers: (builder) => { builder.addCase(logIn.fulfilled, (state, action) => { state.publicKey = action.payload.publicKey; - state.mnemonicPhrase = action.payload.mnemonicPhrase || ""; state.allAccounts = action.payload.allAccounts || []; }); builder.addCase(setActivePublicKey.fulfilled, (state, action) => { @@ -140,7 +157,7 @@ export const { actions: { reset, logOut, - setActivePrivateKey, + setActiveHashKey, timeoutAccountAccess, updateAllAccountsAccountName, setMigratedMnemonicPhrase, @@ -151,10 +168,6 @@ export const publicKeySelector = createSelector( sessionSelector, (session) => session.publicKey, ); -export const mnemonicPhraseSelector = createSelector( - sessionSelector, - (session) => session.mnemonicPhrase, -); export const migratedMnemonicPhraseSelector = createSelector( sessionSelector, (session) => session.migratedMnemonicPhrase, @@ -167,14 +180,10 @@ export const hasPrivateKeySelector = createSelector( sessionSelector, async (session) => { const isHardwareWalletActive = await getIsHardwareWalletActive(); - return isHardwareWalletActive || !!session?.privateKey?.length; + return isHardwareWalletActive || !!session?.hashKey?.key; }, ); -export const privateKeySelector = createSelector( - sessionSelector, - (session) => session.privateKey || "", -); -export const passwordSelector = createSelector( +export const hashKeySelector = createSelector( sessionSelector, - (session) => session.password, + (session) => session.hashKey, ); diff --git a/extension/src/background/helpers/__tests__/session.test.ts b/extension/src/background/helpers/__tests__/session.test.ts new file mode 100644 index 0000000000..511c2003a7 --- /dev/null +++ b/extension/src/background/helpers/__tests__/session.test.ts @@ -0,0 +1,64 @@ +import { + deriveKeyFromString, + encryptHashString, + decryptHashString, +} from "../session"; + +describe("session", () => { + it("should be able to encrypt and decrypt a string", async () => { + const password = "password"; + const privateKey = "privateKey"; + + const { key, iv } = await deriveKeyFromString(password); + + const encryptedPrivateKey = await encryptHashString({ + str: privateKey, + keyObject: { key, iv }, + }); + + const decryptedPrivateKey = await decryptHashString({ + hash: encryptedPrivateKey, + keyObject: { key, iv }, + }); + + expect(decryptedPrivateKey).toEqual(privateKey); + }); + it("should be able to encrypt and decrypt a very long string with different characters", async () => { + const password = + "passwordpasswordpasswordpasswordpasswordpassworw21w1w1@@@@dpasswordpasswordpasswordpcxassad@@@@asswordpasswordpasswordpasswordpassword"; + const privateKey = "privateKeyprivateKeyprivateKeyprivateKey"; + + const { key, iv } = await deriveKeyFromString(password); + + const encryptedPrivateKey = await encryptHashString({ + str: privateKey, + keyObject: { key, iv }, + }); + + const decryptedPrivateKey = await decryptHashString({ + hash: encryptedPrivateKey, + keyObject: { key, iv }, + }); + + expect(decryptedPrivateKey).toEqual(privateKey); + }); + it("should be able to encrypt and decrypt an empty string", async () => { + // this is an edge case and should never happen, but want to make sure this does not throw an error + const password = ""; + const privateKey = ""; + + const { key, iv } = await deriveKeyFromString(password); + + const encryptedPrivateKey = await encryptHashString({ + str: privateKey, + keyObject: { key, iv }, + }); + + const decryptedPrivateKey = await decryptHashString({ + hash: encryptedPrivateKey, + keyObject: { key, iv }, + }); + + expect(decryptedPrivateKey).toEqual(privateKey); + }); +}); diff --git a/extension/src/background/helpers/base64-arraybuffer.js b/extension/src/background/helpers/base64-arraybuffer.js new file mode 100644 index 0000000000..901961e4b9 --- /dev/null +++ b/extension/src/background/helpers/base64-arraybuffer.js @@ -0,0 +1,99 @@ +/* + * base64-arraybuffer + * https://github.com/niklasvh/base64-arraybuffer + * + * Copyright (c) 2017-2023 Brett Zamir, 2012 Niklas von Hertzen + * Licensed under the MIT license. + */ + +/** + * @typedef {number} Integer + */ + +const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// Use a lookup table to find the index. +const lookup = new Uint8Array(256); +for (let i = 0; i < chars.length; i++) { + lookup[/** @type {number} */ (chars.codePointAt(i))] = i; +} + +/** + * @param {ArrayBuffer} arraybuffer + * @param {Integer} [byteOffset] + * @param {Integer} [lngth] + * @returns {string} + */ +export const encode = function (arraybuffer, byteOffset, lngth) { + if (lngth === null || lngth === undefined) { + lngth = arraybuffer.byteLength; // Needed for Safari + } + const bytes = new Uint8Array( + arraybuffer, + byteOffset || 0, // Default needed for Safari + lngth, + ); + const len = bytes.length; + + let base64 = ""; + for (let i = 0; i < len; i += 3) { + base64 += chars[bytes[i] >> 2]; + base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + base64 += chars[bytes[i + 2] & 63]; + } + + if (len % 3 === 2) { + base64 = base64.slice(0, -1) + "="; + } else if (len % 3 === 1) { + base64 = base64.slice(0, -2) + "=="; + } + + return base64; +}; + +/** + * @param {string} base64 + * @param {{ + * maxByteLength: number + * }} [options] + * @returns {ArrayBuffer} + */ +export const decode = function (base64, options) { + const len = base64.length; + + if (len % 4) { + throw new Error("Bad base64 length: not divisible by four"); + } + + let bufferLength = base64.length * 0.75; + let p = 0; + let encoded1, encoded2, encoded3, encoded4; + + if (base64.at(-1) === "=") { + bufferLength--; + if (base64.at(-2) === "=") { + bufferLength--; + } + } + + // @ts-expect-error Second argument is not yet standard + const arraybuffer = new ArrayBuffer(bufferLength, options), + bytes = new Uint8Array(arraybuffer); + + for (let i = 0; i < len; i += 4) { + // We know the result will not be undefined, as we have a text + // length divisible by four + encoded1 = lookup[/** @type {number} */ (base64.codePointAt(i))]; + encoded2 = lookup[/** @type {number} */ (base64.codePointAt(i + 1))]; + encoded3 = lookup[/** @type {number} */ (base64.codePointAt(i + 2))]; + encoded4 = lookup[/** @type {number} */ (base64.codePointAt(i + 3))]; + + bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); + bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); + bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); + } + + return arraybuffer; +}; diff --git a/extension/src/background/helpers/dataStorageAccess.ts b/extension/src/background/helpers/dataStorageAccess.ts index 2b1ac07934..5629559087 100644 --- a/extension/src/background/helpers/dataStorageAccess.ts +++ b/extension/src/background/helpers/dataStorageAccess.ts @@ -38,6 +38,13 @@ export const dataStorage = ( }, }); +export interface DataStorageAccess { + getItem: (key: string) => Promise; + setItem: (key: string, value: any) => Promise; + clear: () => Promise; + remove: (keys: string | string[]) => Promise; +} + export const dataStorageAccess = ( storageApi: StorageOption = browserLocalStorage, ) => { diff --git a/extension/src/background/helpers/session.ts b/extension/src/background/helpers/session.ts index 04340131d6..b6ced2dcbc 100644 --- a/extension/src/background/helpers/session.ts +++ b/extension/src/background/helpers/session.ts @@ -1,4 +1,15 @@ import browser from "webextension-polyfill"; +import { Store } from "redux"; + +import { + setActiveHashKey, + hashKeySelector, + SessionState, + timeoutAccountAccess, +} from "../ducks/session"; +import { DataStorageAccess } from "./dataStorageAccess"; +import { TEMPORARY_STORE_ID } from "../../constants/localStorageTypes"; +import { encode, decode } from "./base64-arraybuffer"; // 24 hours const SESSION_LENGTH = 60 * 24; @@ -17,3 +28,234 @@ export class SessionTimer { }); } } + +interface HashString { + str: string; + keyObject: { iv: ArrayBuffer; key: CryptoKey }; +} + +const TEMPORARY_STORE_ENCRYPTION_NAME = "AES-CBC"; +const HASH_KEY_ENCRYPTION_PARAMS = { name: "PBKDF2", hash: "SHA-256" }; + +export const encryptHashString = ({ str, keyObject }: HashString) => { + const encoder = new TextEncoder(); + const encodedStr = encoder.encode(str); + + return crypto.subtle.encrypt( + { + name: TEMPORARY_STORE_ENCRYPTION_NAME, + iv: keyObject.iv, + }, + keyObject.key, + encodedStr, + ); +}; + +interface DecodeHashString { + hash: ArrayBuffer; + keyObject: { iv: ArrayBuffer; key: CryptoKey }; +} + +export const decryptHashString = async ({ + hash, + keyObject, +}: DecodeHashString) => { + const decrypted = await crypto.subtle.decrypt( + { + name: TEMPORARY_STORE_ENCRYPTION_NAME, + iv: keyObject.iv, + }, + keyObject.key, + hash, + ); + + const textDecoder = new TextDecoder(); + + return textDecoder.decode(decrypted); +}; + +export const deriveKeyFromString = async (str: string) => { + const iterations = 1000; + const keylen = 32; + const keyLength = 48; + // randomized salt will make sure the hashed password is different on every login + const salt = crypto.getRandomValues(new Uint8Array(16)).toString(); + + const encoder = new TextEncoder(); + const keyMaterial = encoder.encode(str); + + const importedKey = await crypto.subtle.importKey( + "raw", + keyMaterial, + HASH_KEY_ENCRYPTION_PARAMS, + false, + ["deriveBits"], + ); + + const saltBuffer = encoder.encode(salt); + const params = { + ...HASH_KEY_ENCRYPTION_PARAMS, + salt: saltBuffer, + iterations, + }; + const derivation = await crypto.subtle.deriveBits( + params, + importedKey, + keyLength * 8, + ); + + const derivedKey = derivation.slice(0, keylen); + const iv = derivation.slice(keylen); + + const importedEncryptionKey = await crypto.subtle.importKey( + "raw", + derivedKey, + { name: TEMPORARY_STORE_ENCRYPTION_NAME }, + true, + ["encrypt", "decrypt"], + ); + + return { + key: importedEncryptionKey, + iv, + }; +}; + +interface StoreActiveHashKey { + sessionStore: Store; + hashKey: { + key: CryptoKey; + iv: ArrayBuffer; + }; +} + +export const storeActiveHashKey = async ({ + sessionStore, + hashKey, +}: StoreActiveHashKey) => { + const format = "jwk"; // JSON Web Key format + // export the key for transferability + const exportedKey = await crypto.subtle.exportKey(format, hashKey.key); + + // store hashed password in memory + sessionStore.dispatch( + setActiveHashKey({ + hashKey: { + // properly encode ArrayBuffer into serializable format + iv: encode(hashKey.iv), + // JSON Web Key is able to be stringified without encoding + key: JSON.stringify(exportedKey), + }, + }), + ); +}; + +interface GetActiveHashKey { + sessionStore: Store; +} + +export const getActiveHashKeyCryptoKey = async ({ + sessionStore, +}: GetActiveHashKey) => { + const hashKey = hashKeySelector(sessionStore.getState() as SessionState); + + if (hashKey?.key && hashKey?.iv) { + try { + const format = "jwk"; + // JSON Web Key can be parsed with decoding + const exportedHashKey = JSON.parse(hashKey.key) as JsonWebKey; + // import the password key for future use indecryption + const key = await crypto.subtle.importKey( + format, + exportedHashKey, + TEMPORARY_STORE_ENCRYPTION_NAME, + true, + ["encrypt", "decrypt"], + ); + + return { + iv: decode(hashKey.iv), + key, + }; + } catch (e) { + return null; + } + } + + return null; +}; + +interface StoreEncryptedTemporaryData { + localStore: DataStorageAccess; + keyName: string; + temporaryData: string; + hashKey: { + iv: ArrayBuffer; + key: CryptoKey; + }; +} + +export const storeEncryptedTemporaryData = async ({ + localStore, + keyName, + temporaryData, + hashKey, +}: StoreEncryptedTemporaryData) => { + const encryptedPrivateKey = await encryptHashString({ + str: temporaryData, + keyObject: hashKey, + }); + + const existingTemporaryStore = await localStore.getItem(TEMPORARY_STORE_ID); + + // store encrypted private key in local storage, a separate space from where the password is stored + await localStore.setItem(TEMPORARY_STORE_ID, { + ...existingTemporaryStore, + [keyName]: encode(encryptedPrivateKey), + }); +}; + +interface GetEncryptedTemporaryData { + sessionStore: Store; + localStore: DataStorageAccess; + keyName: string; +} + +export const getEncryptedTemporaryData = async ({ + sessionStore, + localStore, + keyName, +}: GetEncryptedTemporaryData) => { + const temoraryStore = (await localStore.getItem(TEMPORARY_STORE_ID)) || {}; + const encryptedKeyJSON = temoraryStore[keyName]; + if (!encryptedKeyJSON) { + return ""; + } + const encryptedKey = decode(encryptedKeyJSON as string); + const hashKey = await getActiveHashKeyCryptoKey({ sessionStore }); + + if (hashKey !== null) { + // use the hashed password to decrypt the private key + const activePrivateKey = await decryptHashString({ + hash: encryptedKey, + keyObject: hashKey, + }); + + return activePrivateKey; + } + + return ""; +}; + +interface ClearSession { + localStore: DataStorageAccess; + sessionStore: Store; +} + +export const clearSession = async ({ + localStore, + sessionStore, +}: ClearSession) => { + sessionStore.dispatch(timeoutAccountAccess()); + await localStore.remove(TEMPORARY_STORE_ID); +}; diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index fe9a12f7dc..e08cea93e8 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -9,14 +9,17 @@ import { buildStore } from "background/store"; import { popupMessageListener } from "./messageListener/popupMessageListener"; import { freighterApiMessageListener } from "./messageListener/freighterApiMessageListener"; -import { SESSION_ALARM_NAME } from "./helpers/session"; -import { timeoutAccountAccess } from "./ducks/session"; +import { SESSION_ALARM_NAME, clearSession } from "./helpers/session"; import { migrateFriendBotUrlNetworkDetails, normalizeMigratedData, migrateSorobanRpcUrlNetworkDetails, versionedMigration, } from "./helpers/dataStorage"; +import { + dataStorageAccess, + browserLocalStorage, +} from "./helpers/dataStorageAccess"; export const initContentScriptMessageListener = () => { browser?.runtime?.onMessage?.addListener((message) => { @@ -82,8 +85,10 @@ export const initInstalledListener = () => { export const initAlarmListener = () => { browser?.alarms?.onAlarm.addListener(async ({ name }: { name: string }) => { const sessionStore = await buildStore(); + const localStore = dataStorageAccess(browserLocalStorage); + if (name === SESSION_ALARM_NAME) { - sessionStore.dispatch(timeoutAccountAccess()); + await clearSession({ sessionStore, localStore }); } }); }; diff --git a/extension/src/background/messageListener/__tests__/popupMessageListener.test.js b/extension/src/background/messageListener/__tests__/popupMessageListener.test.js index e88eacdf4a..538ff0895c 100644 --- a/extension/src/background/messageListener/__tests__/popupMessageListener.test.js +++ b/extension/src/background/messageListener/__tests__/popupMessageListener.test.js @@ -29,7 +29,6 @@ describe.skip("regular account flow", () => { // check store expect(publicKeySelector(store.getState())).toBeTruthy(); - expect(privateKeySelector(store.getState())).toBe(""); expect(allAccountsSelector(store.getState()).length).toBe(1); // check localStorage expect(JSON.parse(localStorage.getItem("keyIdList")).length).toBe(1); diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 34b2f5ce8b..2c53b11bf3 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -53,6 +53,8 @@ import { IS_HASH_SIGNING_ENABLED_ID, IS_NON_SSL_ENABLED_ID, IS_HIDE_DUST_ENABLED_ID, + TEMPORARY_STORE_ID, + TEMPORARY_STORE_EXTRA_ID, } from "constants/localStorageTypes"; import { FUTURENET_NETWORK_DETAILS, @@ -88,7 +90,15 @@ import { getFeatureFlags, verifySorobanRpcUrls, } from "background/helpers/account"; -import { SessionTimer } from "background/helpers/session"; +import { + SessionTimer, + deriveKeyFromString, + getEncryptedTemporaryData, + storeEncryptedTemporaryData, + getActiveHashKeyCryptoKey, + storeActiveHashKey, + clearSession, +} from "background/helpers/session"; import { cachedFetch } from "background/helpers/cachedFetch"; import { dataStorageAccess, @@ -100,18 +110,13 @@ import { xlmToStroop } from "helpers/stellar"; import { allAccountsSelector, hasPrivateKeySelector, - privateKeySelector, logIn, logOut, migratedMnemonicPhraseSelector, - mnemonicPhraseSelector, publicKeySelector, setActivePublicKey, - setActivePrivateKey, - timeoutAccountAccess, updateAllAccountsAccountName, reset, - passwordSelector, setMigratedMnemonicPhrase, } from "background/ducks/session"; import { STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL } from "background/constants/apiUrls"; @@ -120,6 +125,7 @@ import { DEFAULT_ASSETS_LISTS, } from "@shared/constants/soroban/token"; import { getSdk } from "@shared/helpers/stellar"; +import { captureException } from "@sentry/browser"; // number of public keys to auto-import const numOfPublicKeysToCheck = 5; @@ -199,8 +205,6 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { hardwareWalletType: WalletType; bipPath: string; }) => { - const mnemonicPhrase = mnemonicPhraseSelector(sessionStore.getState()); - const password = passwordSelector(sessionStore.getState()) || ""; let allAccounts = allAccountsSelector(sessionStore.getState()); const keyId = `${HW_PREFIX}${publicKey}`; @@ -239,35 +243,44 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { await sessionStore.dispatch( logIn({ publicKey, - mnemonicPhrase, allAccounts, }) as any, ); - - // an active hw account should not have an active private key - sessionStore.dispatch(setActivePrivateKey({ privateKey: "", password })); }; + /* Append an additional account to user's account list */ const _storeAccount = async ({ mnemonicPhrase, password, keyPair, imported = false, + isSettingHashKey = false, }: { mnemonicPhrase: string; password: string; keyPair: KeyPair; imported?: boolean; + isSettingHashKey?: boolean; }) => { const { publicKey, privateKey } = keyPair; const allAccounts = allAccountsSelector(sessionStore.getState()); const accountName = `Account ${allAccounts.length + 1}`; + let activeHashKey = await getActiveHashKeyCryptoKey({ sessionStore }); + if (activeHashKey === null && isSettingHashKey) { + // this should only happen on account creation & account recovery + activeHashKey = await deriveKeyFromString(password); + } + + if (activeHashKey === null) { + throw new Error("Error deriving hash key"); + } + + // set the active public key await sessionStore.dispatch( logIn({ publicKey, - mnemonicPhrase, allAccounts: [ ...allAccounts, { @@ -293,11 +306,28 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { let keyStore = { id: "" }; - try { - keyStore = await keyManager.storeKey(keyMetadata); - } catch (e) { - console.error(e); - } + // store encrypted extra data + + keyStore = await keyManager.storeKey(keyMetadata); + await storeEncryptedTemporaryData({ + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + temporaryData: mnemonicPhrase, + hashKey: activeHashKey, + }); + + // store encrypted keypair data + await storeEncryptedTemporaryData({ + localStore, + keyName: keyStore.id, + temporaryData: keyPair.privateKey, + hashKey: activeHashKey, + }); + + await storeActiveHashKey({ + sessionStore, + hashKey: activeHashKey, + }); const keyIdListArr = await getKeyIdList(); keyIdListArr.push(keyStore.id); @@ -342,7 +372,6 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { await sessionStore.dispatch( logIn({ publicKey, - mnemonicPhrase, allAccounts: newAllAccounts, }) as any, ); @@ -417,35 +446,38 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const wallet = fromMnemonic(mnemonicPhrase); const KEY_DERIVATION_NUMBER = 0; + const keyId = KEY_DERIVATION_NUMBER.toString(); - await localStore.setItem( - KEY_DERIVATION_NUMBER_ID, - KEY_DERIVATION_NUMBER.toString(), - ); + await localStore.setItem(KEY_DERIVATION_NUMBER_ID, keyId); const keyPair = { publicKey: wallet.getPublicKey(KEY_DERIVATION_NUMBER), privateKey: wallet.getSecret(KEY_DERIVATION_NUMBER), }; - await _storeAccount({ - password, - keyPair, - mnemonicPhrase, - }); + await clearSession({ localStore, sessionStore }); + + try { + await _storeAccount({ + password, + keyPair, + mnemonicPhrase, + isSettingHashKey: true, + }); + } catch (e) { + console.error(e); + captureException(`Error creating account: ${JSON.stringify(e)}`); + return { error: "Error creating account" }; + } + await localStore.setItem( APPLICATION_ID, APPLICATION_STATE.PASSWORD_CREATED, ); - sessionStore.dispatch(timeoutAccountAccess()); + const currentState = sessionStore.getState(); sessionTimer.startSession(); - sessionStore.dispatch( - setActivePrivateKey({ privateKey: keyPair.privateKey, password }), - ); - - const currentState = sessionStore.getState(); return { allAccounts: allAccountsSelector(currentState), @@ -455,30 +487,55 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { }; const addAccount = async () => { - let password = request.password; - // in case a password is not provided, let's try using the value saved - // in current session store - if (!password) { - password = passwordSelector(sessionStore.getState()) || ""; - } + const password = request.password; - const mnemonicPhrase = mnemonicPhraseSelector(sessionStore.getState()); + let mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); if (!mnemonicPhrase) { - return { error: "Mnemonic phrase not found" }; + try { + await loginToAllAccounts(password); + mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + } catch (e) { + captureException( + `Error logging in to all accounts in Add Account - ${JSON.stringify( + e, + )}`, + ); + return { error: "Unable to login" }; + } } const keyID = (await getIsHardwareWalletActive()) ? await _getNonHwKeyID() : (await localStore.getItem(KEY_ID)) || ""; + // if the session is active, confirm that the password is correct and the hashkey properly unlocks + let activePrivateKey = ""; try { await _unlockKeystore({ keyID, password }); + activePrivateKey = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: keyID, + }); } catch (e) { console.error(e); return { error: "Incorrect password" }; } + if (!activePrivateKey) { + captureException("Error decrypting active private key in Add Account"); + return { error: "Incorrect password" }; + } + const wallet = fromMnemonic(mnemonicPhrase); const keyNumber = Number(await localStore.getItem(KEY_DERIVATION_NUMBER_ID)) + 1; @@ -488,20 +545,21 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { privateKey: wallet.getSecret(keyNumber), }; - await _storeAccount({ - password, - keyPair, - mnemonicPhrase, - }); - - await localStore.setItem(KEY_DERIVATION_NUMBER_ID, keyNumber.toString()); - - sessionStore.dispatch(timeoutAccountAccess()); + // Add the new account to our data store + try { + await _storeAccount({ + password, + keyPair, + mnemonicPhrase, + }); + } catch (e) { + await clearSession({ localStore, sessionStore }); + captureException(`Error adding account: ${JSON.stringify(e)}`); + return { error: "Error adding account" }; + } - sessionTimer.startSession(); - sessionStore.dispatch( - setActivePrivateKey({ privateKey: keyPair.privateKey, password }), - ); + const keyId = keyNumber.toString(); + await localStore.setItem(KEY_DERIVATION_NUMBER_ID, keyId); const currentState = sessionStore.getState(); @@ -515,12 +573,44 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const importAccount = async () => { const { password, privateKey } = request; let sourceKeys; + + let mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + + if (!mnemonicPhrase) { + try { + await loginToAllAccounts(password); + mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + } catch (e) { + captureException( + `Error logging in to all accounts in Import Account - ${JSON.stringify( + e, + )}`, + ); + return { error: "Unable to login" }; + } + } + const keyID = (await getIsHardwareWalletActive()) ? await _getNonHwKeyID() : (await localStore.getItem(KEY_ID)) || ""; + // if the session is active, confirm that the password is correct and the hashkey properly unlocks + let activePrivateKey = ""; try { await _unlockKeystore({ keyID, password }); + activePrivateKey = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: keyID, + }); sourceKeys = StellarSdk.Keypair.fromSecret(privateKey); } catch (e) { console.error(e); @@ -532,21 +622,22 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { privateKey, }; - const mnemonicPhrase = mnemonicPhraseSelector(sessionStore.getState()); - - if (!mnemonicPhrase) { - return { error: "Mnemonic phrase not found" }; + try { + await _storeAccount({ + password, + keyPair, + mnemonicPhrase, + imported: true, + }); + } catch (e) { + captureException(`Error importing account: ${JSON.stringify(e)}`); + return { error: "Error importing account" }; } - await _storeAccount({ - password, - keyPair, - mnemonicPhrase, - imported: true, - }); - - sessionTimer.startSession(); - sessionStore.dispatch(setActivePrivateKey({ privateKey, password })); + if (!activePrivateKey) { + captureException("Error decrypting active private key in Import Account"); + return { error: "Error importing account" }; + } const currentState = sessionStore.getState(); @@ -577,21 +668,6 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const makeAccountActive = async () => { const { publicKey } = request; await _activatePublicKey({ publicKey }); - - const password = passwordSelector(sessionStore.getState()) || ""; - const keyID = (await localStore.getItem(KEY_ID)) || ""; - - try { - const wallet = await _unlockKeystore({ keyID, password }); - const privateKey = wallet.privateKey; - - if (!(await getIsHardwareWalletActive())) { - sessionStore.dispatch(setActivePrivateKey({ privateKey, password })); - } - } catch (e) { - console.error(e); - } - const currentState = sessionStore.getState(); return { @@ -739,21 +815,38 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { ? await _getNonHwKeyID() : (await localStore.getItem(KEY_ID)) || ""; + let mnemonicPhrase = ""; + try { await _unlockKeystore({ keyID, password }); } catch (e) { console.error(e); return { error: "Incorrect password" }; } + + try { + mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + } catch (e) { + console.error(e); + return { error: "Mnemonic phrase not found" }; + } + return { - mnemonicPhrase: mnemonicPhraseSelector(sessionStore.getState()), + mnemonicPhrase, }; }; const confirmMnemonicPhrase = async () => { - const isCorrectPhrase = - mnemonicPhraseSelector(sessionStore.getState()) === - request.mnemonicPhraseToConfirm; + const mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + const isCorrectPhrase = mnemonicPhrase === request.mnemonicPhraseToConfirm; const applicationState = isCorrectPhrase ? APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED @@ -815,11 +908,18 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { await localStore.setItem(KEY_DERIVATION_NUMBER_ID, "0"); - await _storeAccount({ - mnemonicPhrase: recoverMnemonic, - password, - keyPair, - }); + await clearSession({ localStore, sessionStore }); + + try { + await _storeAccount({ + mnemonicPhrase: recoverMnemonic, + password, + keyPair, + isSettingHashKey: true, + }); + } catch (e) { + captureException(`Error recovering account: ${JSON.stringify(e)}`); + } // if we don't have an application state, assign them one applicationState = @@ -858,6 +958,9 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { await localStore.setItem(KEY_DERIVATION_NUMBER_ID, String(i)); } } catch (e) { + captureException( + `Error preloading account: ${JSON.stringify(e)} - ${i}`, + ); // continue } } @@ -867,9 +970,6 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { // start the timer now that we have active private key sessionTimer.startSession(); - sessionStore.dispatch( - setActivePrivateKey({ privateKey: wallet.getSecret(0), password }), - ); } const currentState = sessionStore.getState(); @@ -895,8 +995,27 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return { error: "Incorrect Password" }; } + let mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + + if (!mnemonicPhrase) { + try { + await loginToAllAccounts(password); + mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); + } catch (e) { + return { error: "Incorrect password" }; + } + } + return { - mnemonicPhrase: mnemonicPhraseSelector(sessionStore.getState()), + mnemonicPhrase, }; }; @@ -946,26 +1065,10 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return unlockedAccounts; }; - const confirmPassword = async () => { - /* In Popup, we call loadAccount to figure out what the state the user is in, - then redirect them to if there's any missing data (public/private key, allAccounts, etc.) - calls this method to fill in any missing data */ - - const { password } = request; + /* Retrive and store encrypted data for all existing accounts */ + const loginToAllAccounts = async (password: string) => { const keyIdList = await getKeyIdList(); - /* migration needed to v1.0.6-beta data model */ - if (!keyIdList.length) { - const keyId = await localStore.getItem(KEY_ID); - if (keyId) { - keyIdList.push(keyId); - await localStore.setItem(KEY_ID_LIST, keyIdList); - await localStore.setItem(KEY_DERIVATION_NUMBER_ID, "0"); - await addAccountName({ keyId, accountName: "Account 1" }); - } - } - /* end migration script */ - // if active hw then use the first non-hw keyID to check password // with keyManager let keyID = (await localStore.getItem(KEY_ID)) || ""; @@ -975,26 +1078,19 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { keyID = await _getNonHwKeyID(); } - let activeAccountKeystore; - // first make sure the password is correct to get active keystore, short circuit if not - try { - activeAccountKeystore = await _unlockKeystore({ - keyID, - password, - }); - } catch (e) { - console.error(e); - return { error: "Could not log into selected account" }; - } + const activeAccountKeystore = await _unlockKeystore({ + keyID, + password, + }); const { publicKey: activePublicKey, - privateKey: activePrivateKey, extra: activeExtra = { mnemonicPhrase: "" }, } = activeAccountKeystore; const activeMnemonicPhrase = activeExtra.mnemonicPhrase; + const hashKey = await deriveKeyFromString(password); if ( !publicKeySelector(sessionStore.getState()) || @@ -1007,18 +1103,93 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { await sessionStore.dispatch( logIn({ publicKey: hwPublicKey || activePublicKey, - mnemonicPhrase: activeMnemonicPhrase, allAccounts: await _getLocalStorageAccounts(password), }) as any, ); } + // clear the temporary store (if it exists) so we can replace it with the new encrypted data + await localStore.remove(TEMPORARY_STORE_ID); + + try { + await storeEncryptedTemporaryData({ + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + temporaryData: activeMnemonicPhrase, + hashKey, + }); + } catch (e) { + await clearSession({ localStore, sessionStore }); + captureException( + `Error storing encrypted temporary data: ${JSON.stringify(e)}`, + ); + } + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < keyIdList.length; i += 1) { + const currentKeyId = keyIdList[i]; + + if (!currentKeyId.includes(HW_PREFIX)) { + const keyStoreToUnlock = await _unlockKeystore({ + keyID: keyIdList[i], + password, + }); + + try { + await storeEncryptedTemporaryData({ + localStore, + keyName: keyIdList[i], + temporaryData: keyStoreToUnlock.privateKey, + hashKey, + }); + } catch (e) { + captureException( + `Error storing encrypted temporary data: ${JSON.stringify( + e, + )} - ${JSON.stringify(keyIdList)}: ${i}`, + ); + } + } + } + + try { + await storeActiveHashKey({ + sessionStore, + hashKey, + }); + } catch (e) { + await clearSession({ localStore, sessionStore }); + captureException(`Error storing active hash key: ${JSON.stringify(e)}`); + } + // start the timer now that we have active private key sessionTimer.startSession(); - if (!(await getIsHardwareWalletActive())) { - sessionStore.dispatch( - setActivePrivateKey({ privateKey: activePrivateKey, password }), - ); + }; + + const confirmPassword = async () => { + /* In Popup, we call loadAccount to figure out what the state the user is in, + then redirect them to if there's any missing data (public/private key, allAccounts, etc.) + calls this method to fill in any missing data */ + + const { password } = request; + const keyIdList = await getKeyIdList(); + + /* migration needed to v1.0.6-beta data model */ + if (!keyIdList.length) { + const keyId = await localStore.getItem(KEY_ID); + if (keyId) { + keyIdList.push(keyId); + await localStore.setItem(KEY_ID_LIST, keyIdList); + await localStore.setItem(KEY_DERIVATION_NUMBER_ID, "0"); + await addAccountName({ keyId, accountName: "Account 1" }); + } + } + /* end migration script */ + + try { + await loginToAllAccounts(password); + } catch (e) { + return { error: "Incorrect password" }; } return { @@ -1072,7 +1243,21 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { }; const signTransaction = async () => { - const privateKey = privateKeySelector(sessionStore.getState()); + const keyId = (await localStore.getItem(KEY_ID)) || ""; + let privateKey = ""; + + try { + privateKey = await getEncryptedTemporaryData({ + localStore, + sessionStore, + keyName: keyId, + }); + } catch (e) { + captureException( + `Sign transaction: No private key found: ${JSON.stringify(e)}`, + ); + } + const networkDetails = await getNetworkDetails(); const Sdk = getSdk(networkDetails.networkPassphrase); @@ -1106,7 +1291,19 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { }; const signBlob = async () => { - const privateKey = privateKeySelector(sessionStore.getState()); + const keyId = (await localStore.getItem(KEY_ID)) || ""; + let privateKey = ""; + + try { + privateKey = await getEncryptedTemporaryData({ + localStore, + sessionStore, + keyName: keyId, + }); + } catch (e) { + captureException(`Sign blob: No private key found: ${JSON.stringify(e)}`); + } + const networkDetails = await getNetworkDetails(); const Sdk = getSdk(networkDetails.networkPassphrase); @@ -1131,7 +1328,21 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { }; const signAuthEntry = async () => { - const privateKey = privateKeySelector(sessionStore.getState()); + const keyId = (await localStore.getItem(KEY_ID)) || ""; + let privateKey = ""; + + try { + privateKey = await getEncryptedTemporaryData({ + localStore, + sessionStore, + keyName: keyId, + }); + } catch (e) { + captureException( + `Sign auth entry: No private key found: ${JSON.stringify(e)}`, + ); + } + const networkDetails = await getNetworkDetails(); const Sdk = getSdk(networkDetails.networkPassphrase); @@ -1163,14 +1374,28 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { } }; - const signFreighterTransaction = () => { + const signFreighterTransaction = async () => { const { transactionXDR, network } = request; const Sdk = getSdk(network); const transaction = Sdk.TransactionBuilder.fromXDR(transactionXDR, network); + const keyId = (await localStore.getItem(KEY_ID)) || ""; + let privateKey = ""; + try { + privateKey = await getEncryptedTemporaryData({ + localStore, + sessionStore, + keyName: keyId, + }); + } catch (e) { + captureException( + `Sign freighter transaction: No private key found: ${JSON.stringify( + e, + )}`, + ); + } - const privateKey = privateKeySelector(sessionStore.getState()); if (privateKey.length) { const sourceKeys = Sdk.Keypair.fromSecret(privateKey); transaction.sign(sourceKeys); @@ -1180,14 +1405,29 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return { error: "Session timed out" }; }; - const signFreighterSorobanTransaction = () => { + const signFreighterSorobanTransaction = async () => { const { transactionXDR, network } = request; const Sdk = getSdk(network); const transaction = Sdk.TransactionBuilder.fromXDR(transactionXDR, network); + const keyId = (await localStore.getItem(KEY_ID)) || ""; + let privateKey = ""; + + try { + privateKey = await getEncryptedTemporaryData({ + localStore, + sessionStore, + keyName: keyId, + }); + } catch (e) { + captureException( + `Sign freighter Soroban transaction: No private key found: ${JSON.stringify( + e, + )}`, + ); + } - const privateKey = privateKeySelector(sessionStore.getState()); if (privateKey.length) { const sourceKeys = Sdk.Keypair.fromSecret(privateKey); transaction.sign(sourceKeys); @@ -1222,6 +1462,7 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const signOut = async () => { sessionStore.dispatch(logOut()); + await localStore.remove(TEMPORARY_STORE_ID); return { publicKey: publicKeySelector(sessionStore.getState()), @@ -1475,7 +1716,11 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const getMigratableAccounts = async () => { const keyIdList = (await getKeyIdList()) as string[]; - const mnemonicPhrase = mnemonicPhraseSelector(sessionStore.getState()); + const mnemonicPhrase = await getEncryptedTemporaryData({ + sessionStore, + localStore, + keyName: TEMPORARY_STORE_EXTRA_ID, + }); const allAccounts = allAccountsSelector(sessionStore.getState()); const wallet = fromMnemonic(mnemonicPhrase); @@ -1513,14 +1758,14 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { }; const migrateAccounts = async () => { - const { balancesToMigrate, isMergeSelected, recommendedFee } = request; + const { balancesToMigrate, isMergeSelected, recommendedFee, password } = + request; const migratedMnemonicPhrase = migratedMnemonicPhraseSelector( sessionStore.getState(), ); const migratedAccounts = []; - const password = passwordSelector(sessionStore.getState()); if (!password || !migratedMnemonicPhrase) { return { error: "Authentication error" }; } @@ -1697,12 +1942,20 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { // let's make the first public key the active one await _activatePublicKey({ publicKey: newWallet.getPublicKey(0) }); - sessionStore.dispatch(timeoutAccountAccess()); + await clearSession({ localStore, sessionStore }); sessionTimer.startSession(); - sessionStore.dispatch( - setActivePrivateKey({ privateKey: newWallet.getSecret(0), password }), - ); + const hashKey = await deriveKeyFromString(password); + await storeEncryptedTemporaryData({ + localStore, + keyName: await localStore.getItem(KEY_ID), + temporaryData: newWallet.getSecret(0), + hashKey, + }); + await storeActiveHashKey({ + sessionStore, + hashKey, + }); } const currentState = sessionStore.getState(); diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index eca894aefc..f1daf072f6 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -23,3 +23,5 @@ export const IS_HASH_SIGNING_ENABLED_ID = "isHashSigningEnabled"; export const IS_NON_SSL_ENABLED_ID = "isNonSSLEnabled"; export const IS_BLOCKAID_ANNOUNCED_ID = "isBlockaidAnnounced"; export const IS_HIDE_DUST_ENABLED_ID = "isHideDustEnabled"; +export const TEMPORARY_STORE_ID = "temporaryStore"; +export const TEMPORARY_STORE_EXTRA_ID = "temporaryStoreExtra"; diff --git a/extension/src/popup/views/AddAccount/AddAccount/index.tsx b/extension/src/popup/views/AddAccount/AddAccount/index.tsx index 231e749d6a..71db976167 100644 --- a/extension/src/popup/views/AddAccount/AddAccount/index.tsx +++ b/extension/src/popup/views/AddAccount/AddAccount/index.tsx @@ -11,9 +11,7 @@ import { navigateTo } from "popup/helpers/navigate"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { addAccount, - authErrorSelector, clearApiError, - hasPrivateKeySelector, publicKeySelector, } from "popup/ducks/accountServices"; import { EnterPassword } from "popup/components/EnterPassword"; @@ -21,14 +19,10 @@ import { EnterPassword } from "popup/components/EnterPassword"; export const AddAccount = () => { const { t } = useTranslation(); const dispatch: AppDispatch = useDispatch(); - const authError = useSelector(authErrorSelector); const publicKey = useSelector(publicKeySelector); - const hasPrivateKey = useSelector(hasPrivateKeySelector); - // In case a password is not provided here popupMessageListener/addAccount - // will try to use the existing password value saved in the session store const handleAddAccount = useCallback( - async (password: string = "") => { + async (password: string) => { const res = await dispatch(addAccount(password)); if (addAccount.fulfilled.match(res)) { @@ -46,26 +40,11 @@ export const AddAccount = () => { await handleAddAccount(password); }; - // If we have a private key we can assume the user password is also saved in - // the current session store, so no need to ask for it again - useEffect(() => { - if (hasPrivateKey) { - handleAddAccount(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useEffect( () => () => dispatch(clearApiError()) as unknown as void, [dispatch], ); - // No need to ask for password if it's already stored, so let's just briefly - // wait until user is navigated to the next screen - if (hasPrivateKey && !authError) { - return null; - } - // Ask for user password in case it's not saved in current session store return ( diff --git a/extension/src/popup/views/AddAccount/ImportAccount/index.tsx b/extension/src/popup/views/AddAccount/ImportAccount/index.tsx index 17617585a2..c9c4d43322 100644 --- a/extension/src/popup/views/AddAccount/ImportAccount/index.tsx +++ b/extension/src/popup/views/AddAccount/ImportAccount/index.tsx @@ -142,6 +142,7 @@ export const ImportAccount = () => { {t("Cancel")}