diff --git a/@shared/api/helpers/__tests__/mnemonic.test.ts b/@shared/api/helpers/__tests__/mnemonic.test.ts new file mode 100644 index 0000000000..92db169686 --- /dev/null +++ b/@shared/api/helpers/__tests__/mnemonic.test.ts @@ -0,0 +1,39 @@ +import { Buffer } from "buffer"; +import { entropyToMnemonic, validateMnemonic, wordlists } from "bip39"; +import { isValidImportMnemonic, normalizeMnemonic } from "../mnemonic"; + +describe("normalizeMnemonic", () => { + it("trims, lowercases and collapses whitespace", () => { + expect(normalizeMnemonic(" Abandon\n abandon\tABOUT ")).toBe( + "abandon abandon about", + ); + }); +}); + +describe("isValidImportMnemonic", () => { + const twelve = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const twentyFour = `${"abandon ".repeat(23)}art`; + it("accepts a valid 12-word phrase", () => + expect(isValidImportMnemonic(twelve)).toBe(true)); + it("accepts a valid 24-word phrase", () => + expect(isValidImportMnemonic(twentyFour)).toBe(true)); + it("accepts a messy valid phrase", () => + expect(isValidImportMnemonic(`\n${twelve.toUpperCase()} `)).toBe(true)); + it("refuses 15 words even if the checksum passes", () => { + const fifteen = entropyToMnemonic(Buffer.alloc(20)); + expect(fifteen.split(" ")).toHaveLength(15); + expect(validateMnemonic(fifteen, wordlists.english)).toBe(true); + expect(isValidImportMnemonic(fifteen)).toBe(false); + }); + it("refuses a bad checksum", () => + expect(isValidImportMnemonic(twelve.replace(/about$/, "abandon"))).toBe( + false, + )); + it("refuses a word not in the list", () => + expect(isValidImportMnemonic(twelve.replace(/^abandon/, "tomato1"))).toBe( + false, + )); + it("refuses an empty string", () => + expect(isValidImportMnemonic("")).toBe(false)); +}); diff --git a/@shared/api/helpers/mnemonic.ts b/@shared/api/helpers/mnemonic.ts new file mode 100644 index 0000000000..6d98d73b54 --- /dev/null +++ b/@shared/api/helpers/mnemonic.ts @@ -0,0 +1,18 @@ +import { validateMnemonic, wordlists } from "bip39"; + +/** Trim, lowercase and collapse whitespace runs to a single space. */ +export const normalizeMnemonic = (phrase: string): string => + phrase.trim().toLowerCase().split(/\s+/).filter(Boolean).join(" "); + +/** + * True when the phrase, once normalized, is 12 or 24 words and passes the + * BIP-39 English wordlist and checksum. + */ +export const isValidImportMnemonic = (phrase: string): boolean => { + const normalized = normalizeMnemonic(phrase); + const wordCount = normalized === "" ? 0 : normalized.split(" ").length; + if (wordCount !== 12 && wordCount !== 24) { + return false; + } + return validateMnemonic(normalized, wordlists.english); +}; diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index ba437e0f95..b64a8a27dd 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -311,6 +311,51 @@ export const addRecoveryPhraseSource = async ({ return result; }; +/** Imports a further recovery phrase; see `ImportRecoveryPhraseMessage`. */ +export const importRecoveryPhrase = async ({ + activePublicKey, + password, + mnemonicPhrase, +}: { + activePublicKey: string; + password: string; + mnemonicPhrase: string; +}): Promise<{ + publicKey: string; + activeAccountId: string; + allAccounts: Array; + hasPrivateKey: boolean; + secretSources: SecretSource[]; +}> => { + let response = { + error: "", + publicKey: "", + activeAccountId: "", + allAccounts: [] as Array, + hasPrivateKey: false, + secretSources: [] as SecretSource[], + }; + + try { + response = await sendMessageToBackground({ + activePublicKey, + password, + mnemonicPhrase, + type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE, + }); + } catch (e) { + console.error(e); + throw e; + } + + const { error, ...result } = response; + if (error) { + throw new Error(error); + } + + return result; +}; + export const importAccount = async ({ password, privateKey, diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 3d9372761b..2f61844b9d 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -150,6 +150,17 @@ export interface AddRecoveryPhraseSourceMessage extends BaseMessage { pendingPhraseId: string; } +/** + * Adds a recovery phrase the user typed as a new phrase source, with its + * index 0 and every funded index up to the discovery depth. Nothing already + * in the wallet changes. + */ +export interface ImportRecoveryPhraseMessage extends BaseMessage { + type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE; + password: string; + mnemonicPhrase: string; +} + export interface ImportAccountMessage extends BaseMessage { type: SERVICE_TYPES.IMPORT_ACCOUNT; password: string; @@ -588,6 +599,7 @@ export type ServiceMessageRequest = | AddAccountMessage | GenerateRecoveryPhraseMessage | AddRecoveryPhraseSourceMessage + | ImportRecoveryPhraseMessage | ImportAccountMessage | ImportHardWareWalletMessage | MakeAccountActiveMessage diff --git a/@shared/constants/errors.ts b/@shared/constants/errors.ts index 097459f0a5..e5bc284b74 100644 --- a/@shared/constants/errors.ts +++ b/@shared/constants/errors.ts @@ -14,3 +14,20 @@ export class SorobanRpcNotSupportedError extends Error { */ export const ACCOUNT_ALREADY_EXISTS_ERROR = "This account is already in your wallet"; + +/** Returned when a phrase being added is already one of the wallet's sources. */ +export const PHRASE_ALREADY_HELD_ERROR = + "This recovery phrase is already in your wallet"; + +/** Returned when storing a phrase being added to a live wallet fails. */ +export const IMPORT_PHRASE_FAILED_ERROR = "Error adding recovery phrase"; + +/** Returned when an add needs a password but the wallet has no software keystore to check it against. */ +export const NO_SOFTWARE_KEYSTORE_ERROR = + "This wallet has no password to check against"; + +/** Returned when the password doesn't unlock the active software keystore. */ +export const INCORRECT_PASSWORD_ERROR = "Incorrect password"; + +/** Returned when logging in to every account with the password fails on a locked session. */ +export const UNABLE_TO_LOGIN_ERROR = "Unable to login"; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 8d44b20719..6597487b44 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -5,6 +5,7 @@ export enum SERVICE_TYPES { IMPORT_ACCOUNT = "IMPORT_ACCOUNT", GENERATE_RECOVERY_PHRASE = "GENERATE_RECOVERY_PHRASE", ADD_RECOVERY_PHRASE_SOURCE = "ADD_RECOVERY_PHRASE_SOURCE", + IMPORT_RECOVERY_PHRASE = "IMPORT_RECOVERY_PHRASE", IMPORT_HARDWARE_WALLET = "IMPORT_HARDWARE_WALLET", LOAD_ACCOUNT = "LOAD_ACCOUNT", MAKE_ACCOUNT_ACTIVE = "MAKE_ACCOUNT_ACTIVE", diff --git a/extension/e2e-tests/addWallet.test.ts b/extension/e2e-tests/addWallet.test.ts index 75a469b837..30f1932c95 100644 --- a/extension/e2e-tests/addWallet.test.ts +++ b/extension/e2e-tests/addWallet.test.ts @@ -1,3 +1,4 @@ +import { generateMnemonic } from "bip39"; import { Keypair } from "stellar-sdk"; import { test, expect } from "./test-fixtures"; @@ -5,6 +6,7 @@ import { loginToTestAccount } from "./helpers/login"; import { addAccountToPhrase, generateRecoveryPhraseSource, + importRecoveryPhraseSource, } from "./helpers/addWallet"; // loginToTestAccount sets this password on the wallet it imports. @@ -123,3 +125,80 @@ test("Generates a recovery phrase while an imported key is active", async ({ group(page, "Recovery phrase 2").getByTestId("wallet-row-select"), ).toHaveAttribute("aria-current", "true"); }); + +test("Imports a second recovery phrase without changing the first", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + await openSheet(page); + // With one phrase the sheet may not label the group, so record every row. + await expect(page.getByTestId("wallet-row-select").first()).toBeVisible(); + const firstBefore = await page + .getByTestId("wallet-row-select") + .allTextContents(); + expect(firstBefore.length).toBeGreaterThan(0); + const firstRows = group(page, "Recovery phrase 1").getByTestId( + "wallet-row-select", + ); + + await importRecoveryPhraseSource({ + page, + password: WALLET_PASSWORD, + words: generateMnemonic().split(" "), + }); + + // Discovery looks up ~49 addresses on mainnet before the view returns. + await openSheet(page); + await expect(page.getByTestId("account-sheet-group-toggle")).toHaveText([ + "Recovery phrase 1", + "Recovery phrase 2", + ]); + await expect(firstRows).toHaveText(firstBefore); + await expect( + group(page, "Recovery phrase 2").getByTestId("wallet-row-select"), + ).toHaveCount(1); +}); + +test("Refuses a recovery phrase the wallet already holds", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + const words = generateMnemonic().split(" "); + await openSheet(page); + await importRecoveryPhraseSource({ + page, + password: WALLET_PASSWORD, + words, + }); + + await openSheet(page); + await expect(page.getByTestId("account-sheet-group-toggle")).toHaveText([ + "Recovery phrase 1", + "Recovery phrase 2", + ]); + await importRecoveryPhraseSource({ + page, + password: WALLET_PASSWORD, + words, + }); + await expect( + page.getByText("This recovery phrase is already in your wallet"), + ).toBeVisible({ timeout: 30000 }); + + // Back out of the phrase screen and the chooser to the account view. + await page.goBack(); + await page.goBack(); + await openSheet(page); + await expect(page.getByTestId("account-sheet-group-toggle")).toHaveText([ + "Recovery phrase 1", + "Recovery phrase 2", + ]); +}); diff --git a/extension/e2e-tests/helpers/addWallet.ts b/extension/e2e-tests/helpers/addWallet.ts index dac29e06ea..881b9e5527 100644 --- a/extension/e2e-tests/helpers/addWallet.ts +++ b/extension/e2e-tests/helpers/addWallet.ts @@ -58,3 +58,25 @@ export const generateRecoveryPhraseSource = async ({ await page.getByRole("button", { name: "Create", exact: true }).click(); return words; }; + +/** + * From an open account sheet: Add wallet, Import Recovery Phrase, enter the + * words, and confirm with the password. Discovery runs after the password, so + * callers should allow a generous wait for the account view. + */ +export const importRecoveryPhraseSource = async ({ + page, + password, + words, +}: { + page: Page; + password: string; + words: string[]; +}) => { + await page.getByTestId("add-wallet").click(); + await page.getByTestId("add-wallet-import-phrase").click(); + await page.getByTestId("import-phrase-input").fill(words.join(" ")); + await page.getByTestId("import-phrase-submit").click(); + await page.locator("#password-input").fill(password); + await page.getByRole("button", { name: "Import", exact: true }).click(); +}; diff --git a/extension/src/background/helpers/secret-sources.ts b/extension/src/background/helpers/secret-sources.ts index 9cd36b2d1e..40f28d60db 100644 --- a/extension/src/background/helpers/secret-sources.ts +++ b/extension/src/background/helpers/secret-sources.ts @@ -193,6 +193,33 @@ export const addSecretSource = async ({ return source; }; +/** + * Unregisters one source. Only for undoing a registration that never got an + * account (a phrase import whose first account failed to store); nothing + * else removes sources. If the source was pinned, the pin moves to the + * earliest remaining source. + */ +export const removeSecretSource = async ({ + localStore, + id, +}: { + localStore: DataStorageAccess; + id: string; +}) => { + const sources = await getSecretSources({ localStore }); + if (!sources[id]) { + return; + } + + const { [id]: _removed, ...rest } = sources; + await localStore.setItem(SECRET_SOURCES_ID, rest); + + if ((await getPinnedSourceId({ localStore })) === id) { + await clearPinnedSourceId({ localStore }); + await pinFirstSource({ localStore }); + } +}; + export const clearSecretSources = async ({ localStore, }: { diff --git a/extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts b/extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts new file mode 100644 index 0000000000..95c243ec10 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts @@ -0,0 +1,157 @@ +import { captureException } from "@sentry/browser"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { discoverFundedIndices } from "../helpers/discover-funded-indices"; + +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + +const pk = (i: number) => `PK${i}`; +const wallet = { + getPublicKey: (i: number) => pk(i), + getSecret: (i: number) => `SK${i}`, +} as any; + +const mockFetch = (handler: (publicKey: string) => Promise) => { + const fn = jest.fn((url: string) => { + const publicKey = url.split("/accounts/")[1]; + return handler(publicKey).then((body) => ({ + json: () => Promise.resolve(body), + })); + }); + global.fetch = fn as unknown as typeof fetch; + return fn; +}; + +describe("discoverFundedIndices", () => { + beforeEach(() => { + (captureException as jest.Mock).mockClear(); + }); + + it("returns funded indices in ascending order", async () => { + const delays: Record = { + [pk(7)]: 5, + [pk(2)]: 30, + [pk(49)]: 1, + }; + mockFetch( + (publicKey) => + new Promise((resolve) => + setTimeout( + () => + resolve( + publicKey in delays + ? { account_id: publicKey } + : { status: 404 }, + ), + delays[publicKey] ?? 0, + ), + ), + ); + + expect(await discoverFundedIndices({ wallet, from: 1, to: 49 })).toEqual([ + 2, 7, 49, + ]); + }); + + it("treats a 404 body as not funded", async () => { + mockFetch(async () => ({ status: 404 })); + + expect(await discoverFundedIndices({ wallet, from: 1, to: 5 })).toEqual([]); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("skips an index whose lookup throws and reports it", async () => { + mockFetch(async (publicKey) => { + if (publicKey === pk(3)) throw new Error("network down"); + return publicKey === pk(4) ? { account_id: publicKey } : { status: 404 }; + }); + + expect(await discoverFundedIndices({ wallet, from: 1, to: 5 })).toEqual([ + 4, + ]); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it("queries mainnet Horizon", async () => { + const fetchMock = mockFetch(async () => ({ status: 404 })); + + await discoverFundedIndices({ wallet, from: 1, to: 10 }); + + expect(fetchMock).toHaveBeenCalledTimes(10); + fetchMock.mock.calls.forEach(([url]) => { + expect( + url.startsWith(`${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/`), + ).toBe(true); + }); + }); + + it("keeps the requests in flight bounded", async () => { + let inFlight = 0; + let peak = 0; + mockFetch(async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 2)); + inFlight -= 1; + return { status: 404 }; + }); + + await discoverFundedIndices({ wallet, from: 1, to: 49 }); + + expect(peak).toBeLessThanOrEqual(10); + // More than one at a time, so the lookups really run in parallel. + expect(peak).toBeGreaterThan(1); + }); + + it("gives up on a lookup that stalls past the timeout and returns the rest", async () => { + // AbortSignal.timeout runs on the runtime's own timer, which fake timers + // don't drive. Hand out signals this test can abort instead. + const controllers: AbortController[] = []; + const timeoutSpy = jest + .spyOn(AbortSignal, "timeout") + .mockImplementation(() => { + const controller = new AbortController(); + controllers.push(controller); + return controller.signal; + }); + let stalled: () => void = () => {}; + const isStalled = new Promise((r) => { + stalled = r; + }); + global.fetch = jest.fn( + (url: string, { signal }: { signal: AbortSignal }) => { + const publicKey = url.split("/accounts/")[1]; + if (publicKey === pk(2)) { + // Never resolves; only the abort ends it. + return new Promise((_, reject) => { + signal.addEventListener("abort", () => + reject(new DOMException("timed out", "TimeoutError")), + ); + stalled(); + }); + } + return Promise.resolve({ + json: () => + Promise.resolve( + publicKey === pk(1) || publicKey === pk(3) + ? { account_id: publicKey } + : { status: 404 }, + ), + }); + }, + ) as unknown as typeof fetch; + + try { + const result = discoverFundedIndices({ wallet, from: 1, to: 3 }); + await isStalled; + expect(timeoutSpy).toHaveBeenCalledWith(5000); + // Fire the timeouts. Only index 2's lookup is still waiting on one. + controllers.forEach((controller) => controller.abort()); + + expect(await result).toEqual([1, 3]); + expect(captureException).toHaveBeenCalledTimes(1); + } finally { + timeoutSpy.mockRestore(); + } + }); +}); diff --git a/extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts b/extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts new file mode 100644 index 0000000000..9d777d209b --- /dev/null +++ b/extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts @@ -0,0 +1,518 @@ +import { logIn, sessionSlice } from "background/ducks/session"; +import { markDerivationIndexUsed } from "background/helpers/derivation-indices"; +import { popupMessageListener } from "background/messageListener/popupMessageListener"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { ACCOUNT_ALREADY_EXISTS_ERROR } from "@shared/constants/errors"; +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { + ACCOUNT_SOURCE_VECTORS, + AUTH_KEYPAIR_VECTORS, +} from "@shared/api/helpers/authKeypairVectors"; +import { + Account, + AccountProvenanceMap, + SecretSource, + SecretSourceType, +} from "@shared/api/types"; +import { + mockDataStorage, + mockSessionStore, + mockKeyManager, + MockBrowserAlarm, + mockStorageApi, +} from "background/messageListener/helpers/test-helpers"; +import { storeAccount } from "background/messageListener/helpers/store-account"; +import { + addSecretSource, + getSecretSources, +} from "background/helpers/secret-sources"; +import { + ACCOUNT_NAME_LIST_ID, + ACCOUNT_PROVENANCE_ID, + APPLICATION_ID, + DERIVATION_INDICES_ID, + KEY_ID, + KEY_ID_LIST, + REBRAND_ANNOUNCEMENT_STATUS_ID, + SECRET_SOURCES_ID, +} from "constants/localStorageTypes"; +import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { PHRASE_ALREADY_HELD_ERROR } from "../handlers/addRecoveryPhraseSource"; +import { IMPORT_PHRASE_FAILED_ERROR } from "../handlers/importRecoveryPhrase"; + +const [PHRASE_1, PHRASE_2] = AUTH_KEYPAIR_VECTORS; +const PASSWORD = "test"; +const DEPTH = 50; + +jest.mock("background/helpers/link-sources", () => ({ + linkWalletSources: jest.fn().mockResolvedValue(undefined), +})); + +// Delegates to the real storeAccount; a test can make one call fail. +jest.mock("background/messageListener/helpers/store-account", () => { + const actual = jest.requireActual( + "background/messageListener/helpers/store-account", + ); + return { + ...actual, + storeAccount: jest.fn(actual.storeAccount), + }; +}); +const mockStoreAccount = storeAccount as jest.MockedFunction< + typeof storeAccount +>; +const realStoreAccount = jest.requireActual( + "background/messageListener/helpers/store-account", +).storeAccount; + +// StellarHDWallet.fromMnemonic cannot run under jest (bip39 CJS interop), so +// derive from the pinned (mnemonic, index) rows, and past them from a +// deterministic seed per (mnemonic, index). Only the pinned phrases resolve. +const mockGenerated: string[] = []; +jest.mock("stellar-hd-wallet", () => { + const { createHash } = jest.requireActual("crypto"); + const { Keypair } = jest.requireActual("stellar-sdk"); + const { ACCOUNT_SOURCE_VECTORS: vectors } = jest.requireActual( + "@shared/api/helpers/authKeypairVectors", + ); + return { + generateMnemonic: () => mockGenerated.shift(), + fromMnemonic: (mnemonic: string) => { + const rows = vectors.find((v: any) => v.mnemonic === mnemonic).accounts; + const keypair = (i: number) => + Keypair.fromRawEd25519Seed( + createHash("sha256").update(`${mnemonic}:${i}`).digest(), + ); + return { + getPublicKey: (i: number) => + rows[i] ? rows[i].publicKey : keypair(i).publicKey(), + getSecret: (i: number) => + rows[i] ? rows[i].secret : keypair(i).secret(), + }; + }, + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromMnemonic } = require("stellar-hd-wallet"); +const wallet2 = fromMnemonic(PHRASE_2.mnemonic); +const ROWS_1 = ACCOUNT_SOURCE_VECTORS[0].accounts; + +const testAlarm = new MockBrowserAlarm(() => {}); +const send = (request: object) => + popupMessageListener( + request as any, + mockSessionStore, + mockDataStorage, + mockKeyManager, + testAlarm as any, + { id: "fake-extension-id" }, + ) as Promise; + +const importPhrase = (mnemonicPhrase: string, password = PASSWORD) => + send({ + type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE, + password, + mnemonicPhrase, + }); + +/** Funded addresses for the mocked Horizon. */ +let funded = new Set(); +const fund = (...indices: number[]) => { + funded = new Set(indices.map((i) => wallet2.getPublicKey(i))); +}; + +const HORIZON_ACCOUNTS = `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/`; + +/** The mainnet account lookups discovery made, ignoring other requests. */ +const horizonLookups = () => + (global.fetch as jest.Mock).mock.calls.filter( + ([url]) => typeof url === "string" && url.startsWith(HORIZON_ACCOUNTS), + ); + +const allAccounts = (): Account[] => + mockSessionStore.getState().session.allAccounts ?? []; + +/** One phrase source (Seed Phrase 1) with accounts at indices 0 and 1. */ +const createWallet = async () => { + mockGenerated.push(PHRASE_1.mnemonic); + await send({ + type: SERVICE_TYPES.CREATE_ACCOUNT, + password: PASSWORD, + isOverwritingAccount: false, + }); + await send({ type: SERVICE_TYPES.ADD_ACCOUNT, password: PASSWORD }); +}; + +/** The first source's stored state, to compare after an import. */ +const snapshotFirstSource = async () => { + const provenance = ((await mockDataStorage.getItem(ACCOUNT_PROVENANCE_ID)) || + {}) as AccountProvenanceMap; + const keyIds = Object.keys(provenance).filter( + (id) => provenance[id].sourceId === PHRASE_1.userId, + ); + const names = (await mockDataStorage.getItem(ACCOUNT_NAME_LIST_ID)) || {}; + const keystores: Record = {}; + for (const id of keyIds) { + keystores[id] = await mockDataStorage.getItem(`stellarkeys:${id}`); + } + return { + keyIds, + keystores, + names: Object.fromEntries(keyIds.map((id) => [id, names[id]])), + indices: ((await mockDataStorage.getItem(DERIVATION_INDICES_ID)) || {})[ + PHRASE_1.userId + ], + provenance: Object.fromEntries(keyIds.map((id) => [id, provenance[id]])), + }; +}; + +/** What "writes nothing" compares. */ +const snapshotWrites = async () => ({ + keyIdList: await mockDataStorage.getItem(KEY_ID_LIST), + secretSources: await mockDataStorage.getItem(SECRET_SOURCES_ID), + names: await mockDataStorage.getItem(ACCOUNT_NAME_LIST_ID), + keyId: await mockDataStorage.getItem(KEY_ID), +}); + +const newAccounts = () => + allAccounts().filter(({ sourceId }) => sourceId === PHRASE_2.userId); + +const indicesOf = async (sourceId: string) => + ((await mockDataStorage.getItem(DERIVATION_INDICES_ID)) || {})[sourceId]; + +describe("importing an additional recovery phrase", () => { + jest.setTimeout(120_000); + + let before: Awaited>; + + beforeEach(async () => { + await mockStorageApi.clear(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + mockGenerated.length = 0; + mockStoreAccount.mockReset(); + mockStoreAccount.mockImplementation(realStoreAccount); + funded = new Set(); + global.fetch = jest.fn((url: string) => { + const publicKey = String(url).startsWith(HORIZON_ACCOUNTS) + ? String(url).slice(HORIZON_ACCOUNTS.length) + : ""; + return Promise.resolve({ + json: () => + Promise.resolve( + funded.has(publicKey) ? { account_id: publicKey } : {}, + ), + }); + }) as unknown as typeof fetch; + + await createWallet(); + before = await snapshotFirstSource(); + (global.fetch as jest.Mock).mockClear(); + }); + + it("adds the second phrase as Seed Phrase 2 with index 0 and its funded indices", async () => { + fund(3, 10); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBeUndefined(); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + wallet2.getPublicKey(3), + wallet2.getPublicKey(10), + ]); + expect(newAccounts().every(({ imported }) => !imported)).toBe(true); + expect(await indicesOf(PHRASE_2.userId)).toEqual([0, 3, 10]); + expect( + (res.secretSources as SecretSource[]).map(({ id, label }) => [id, label]), + ).toEqual([ + [PHRASE_1.userId, "Seed Phrase 1"], + [PHRASE_2.userId, "Seed Phrase 2"], + ]); + expect(res.allAccounts).toHaveLength(5); + // Indices 1 to 49 are checked. + expect(horizonLookups()).toHaveLength(DEPTH - 1); + }); + + it("leaves the first phrase's keystores, names, indices and provenance unchanged", async () => { + fund(3, 10); + const keyIdList = await mockDataStorage.getItem(KEY_ID_LIST); + + await importPhrase(PHRASE_2.mnemonic); + + expect(await snapshotFirstSource()).toEqual(before); + expect(before.keyIds).toHaveLength(2); + expect(before.indices).toEqual([0, 1]); + expect( + (await mockDataStorage.getItem(KEY_ID_LIST)).slice(0, keyIdList.length), + ).toEqual(keyIdList); + }); + + it("adds only index 0 when nothing is funded", async () => { + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBeUndefined(); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + ]); + expect(await indicesOf(PHRASE_2.userId)).toEqual([0]); + }); + + it("makes the new phrase's index 0 active", async () => { + fund(3, 10); + + const res = await importPhrase(PHRASE_2.mnemonic); + + const first = newAccounts()[0]; + expect(res.publicKey).toBe(wallet2.getPublicKey(0)); + expect(res.activeAccountId).toBe(first.id); + expect(await mockDataStorage.getItem(KEY_ID)).toBe(first.id); + expect(mockSessionStore.getState().session.publicKey).toBe( + wallet2.getPublicKey(0), + ); + }); + + it("accepts a phrase with capitals, newlines and extra spaces and registers the clean phrase's source id", async () => { + const messy = `\n ${PHRASE_2.mnemonic + .toUpperCase() + .split(" ") + .join(" \n\t ")} `; + + const res = await importPhrase(messy); + + expect(res.error).toBeUndefined(); + expect( + Object.keys(await getSecretSources({ localStore: mockDataStorage })), + ).toEqual([PHRASE_1.userId, PHRASE_2.userId]); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + ]); + }); + + it('refuses an invalid phrase with "Invalid mnemonic phrase"', async () => { + const writes = await snapshotWrites(); + const fifteen = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon address"; + const badChecksum = PHRASE_1.mnemonic.replace(/about$/, "abandon"); + + for (const phrase of [fifteen, badChecksum]) { + const res = await importPhrase(phrase); + expect(res.error).toBe("Invalid mnemonic phrase"); + } + + expect(await snapshotWrites()).toEqual(writes); + expect(horizonLookups()).toHaveLength(0); + }); + + it("refuses a phrase the wallet already holds with PHRASE_ALREADY_HELD_ERROR and makes no Horizon request", async () => { + const writes = await snapshotWrites(); + + const res = await importPhrase(PHRASE_1.mnemonic.toUpperCase()); + + expect(res.error).toBe(PHRASE_ALREADY_HELD_ERROR); + expect(horizonLookups()).toHaveLength(0); + expect(await snapshotWrites()).toEqual(writes); + }); + + it("refuses the whole import when a funded index is already in the wallet", async () => { + await send({ + type: SERVICE_TYPES.IMPORT_ACCOUNT, + password: PASSWORD, + privateKey: wallet2.getSecret(3), + }); + fund(3, 10); + const writes = await snapshotWrites(); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(ACCOUNT_ALREADY_EXISTS_ERROR); + expect( + (await getSecretSources({ localStore: mockDataStorage }))[ + PHRASE_2.userId + ], + ).toBeUndefined(); + expect(newAccounts()).toHaveLength(0); + expect(await snapshotWrites()).toEqual(writes); + }); + + it("refuses when index 0 was earlier imported as a secret key", async () => { + await send({ + type: SERVICE_TYPES.IMPORT_ACCOUNT, + password: PASSWORD, + privateKey: wallet2.getSecret(0), + }); + const writes = await snapshotWrites(); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(ACCOUNT_ALREADY_EXISTS_ERROR); + expect(await snapshotWrites()).toEqual(writes); + }); + + it('refuses a wrong password on a locked session with "Unable to login" and writes nothing', async () => { + const writes = await snapshotWrites(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + + const res = await importPhrase(PHRASE_2.mnemonic, "wrong"); + + expect(res.error).toBe("Unable to login"); + expect(await snapshotWrites()).toEqual(writes); + expect(horizonLookups()).toHaveLength(0); + }); + + it("leaves the source registered when a storeAccount fails partway, and a retry is refused as already held", async () => { + fund(3); + mockStoreAccount + .mockImplementationOnce(realStoreAccount) + .mockImplementationOnce(() => Promise.reject(new Error("disk full"))); + const activeBefore = await mockDataStorage.getItem(KEY_ID); + const publicKeyBefore = mockSessionStore.getState().session.publicKey; + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(IMPORT_PHRASE_FAILED_ERROR); + // The active account is put back to the one active before the import. + expect(await mockDataStorage.getItem(KEY_ID)).toBe(activeBefore); + expect(mockSessionStore.getState().session.publicKey).toBe(publicKeyBefore); + expect( + (await getSecretSources({ localStore: mockDataStorage }))[ + PHRASE_2.userId + ], + ).toBeDefined(); + + const retry = await importPhrase(PHRASE_2.mnemonic); + expect(retry.error).toBe(PHRASE_ALREADY_HELD_ERROR); + }); + + it("rolls back when the first storeAccount fails before writing, and a retry imports", async () => { + fund(3); + mockStoreAccount.mockImplementationOnce(() => + Promise.reject(new Error("disk full")), + ); + const writes = await snapshotWrites(); + const accountsBefore = allAccounts(); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(IMPORT_PHRASE_FAILED_ERROR); + expect( + (await getSecretSources({ localStore: mockDataStorage }))[ + PHRASE_2.userId + ], + ).toBeUndefined(); + expect((await indicesOf(PHRASE_2.userId)) ?? []).toEqual([]); + expect(await snapshotWrites()).toEqual(writes); + expect(allAccounts()).toEqual(accountsBefore); + + const retry = await importPhrase(PHRASE_2.mnemonic); + expect(retry.error).toBeUndefined(); + expect( + (retry.secretSources as SecretSource[]).find( + ({ id }) => id === PHRASE_2.userId, + )?.label, + ).toBe("Seed Phrase 2"); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + wallet2.getPublicKey(3), + ]); + }); + + it("rolls back the session when the first store fails after joining it", async () => { + // Fails the way storeAccount would after its session dispatch and index + // mark, but before the keystore id reaches the list. + mockStoreAccount.mockImplementationOnce(async (args) => { + await markDerivationIndexUsed({ + localStore: args.localStore, + sourceId: PHRASE_2.userId, + index: 0, + }); + await args.sessionStore.dispatch( + logIn({ + publicKey: args.keyPair.publicKey, + allAccounts: [ + ...allAccounts(), + { + id: "phantom", + sourceId: PHRASE_2.userId, + publicKey: args.keyPair.publicKey, + name: "Account 3", + imported: false, + }, + ], + localStore: args.localStore, + }) as any, + ); + throw new Error("disk full"); + }); + const accountsBefore = allAccounts(); + const publicKeyBefore = mockSessionStore.getState().session.publicKey; + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(IMPORT_PHRASE_FAILED_ERROR); + expect(allAccounts()).toEqual(accountsBefore); + expect(mockSessionStore.getState().session.publicKey).toBe(publicKeyBefore); + expect((await indicesOf(PHRASE_2.userId)) ?? []).toEqual([]); + expect( + (await getSecretSources({ localStore: mockDataStorage }))[ + PHRASE_2.userId + ], + ).toBeUndefined(); + + const retry = await importPhrase(PHRASE_2.mnemonic); + expect(retry.error).toBeUndefined(); + expect(newAccounts()).toHaveLength(1); + }); + + it("refuses with PHRASE_ALREADY_HELD_ERROR when the phrase is registered while discovery runs", async () => { + fund(3, 10); + const writes = await snapshotWrites(); + mockStoreAccount.mockClear(); + const realFetch = global.fetch as jest.Mock; + let registered: Promise | undefined; + global.fetch = jest.fn((url: string) => { + // Another window finishes importing the same phrase mid-discovery. + registered ??= addSecretSource({ + localStore: mockDataStorage, + id: PHRASE_2.userId, + type: SecretSourceType.PHRASE, + }).then(() => undefined); + return registered.then(() => realFetch(url)); + }) as unknown as typeof fetch; + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(PHRASE_ALREADY_HELD_ERROR); + expect(newAccounts()).toHaveLength(0); + expect(mockStoreAccount).not.toHaveBeenCalled(); + const after = await snapshotWrites(); + expect(after.keyIdList).toEqual(writes.keyIdList); + expect(after.names).toEqual(writes.names); + expect(after.keyId).toEqual(writes.keyId); + }); + + it("does not touch application state or the rebrand announcement status", async () => { + await mockDataStorage.setItem( + APPLICATION_ID, + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + await mockDataStorage.setItem( + REBRAND_ANNOUNCEMENT_STATUS_ID, + REBRAND_ANNOUNCEMENT_STATUS.PENDING, + ); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBeUndefined(); + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBe( + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + expect(await mockDataStorage.getItem(REBRAND_ANNOUNCEMENT_STATUS_ID)).toBe( + REBRAND_ANNOUNCEMENT_STATUS.PENDING, + ); + expect(allAccounts().map(({ publicKey }) => publicKey)).toEqual( + expect.arrayContaining([ROWS_1[0].publicKey, ROWS_1[1].publicKey]), + ); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/secretSources.test.ts b/extension/src/background/messageListener/__tests__/secretSources.test.ts index 365658116f..b1ac58d0de 100644 --- a/extension/src/background/messageListener/__tests__/secretSources.test.ts +++ b/extension/src/background/messageListener/__tests__/secretSources.test.ts @@ -31,6 +31,7 @@ import { getPhraseSourceId, getPinnedSourceId, getSecretSources, + removeSecretSource, } from "background/helpers/secret-sources"; import { getSigningIdentity } from "background/helpers/signing-identity"; import { @@ -322,3 +323,52 @@ describe("secret sources", () => { expect(Object.keys(keyStore.extra)).toEqual(["imported", "mnemonicPhrase"]); }); }); + +describe("removeSecretSource", () => { + const ID_A = "a".repeat(64); + const ID_B = "b".repeat(64); + const ID_C = "c".repeat(64); + + const register = async (...ids: string[]) => { + for (const id of ids) { + await addSecretSource({ + localStore: mockDataStorage, + id, + type: SecretSourceType.PHRASE, + }); + } + }; + + beforeEach(async () => { + await mockStorageApi.clear(); + }); + + it("deletes only the given source", async () => { + await register(ID_A, ID_B, ID_C); + + await removeSecretSource({ localStore: mockDataStorage, id: ID_B }); + + const sources = await getSecretSources({ localStore: mockDataStorage }); + expect(Object.keys(sources)).toEqual([ID_A, ID_C]); + expect(sources[ID_C].label).toBe("Seed Phrase 3"); + expect(await getPinnedSourceId({ localStore: mockDataStorage })).toBe(ID_A); + }); + + it("re-pins when the removed source was pinned", async () => { + await register(ID_A, ID_B); + + await removeSecretSource({ localStore: mockDataStorage, id: ID_A }); + + expect(await getPinnedSourceId({ localStore: mockDataStorage })).toBe(ID_B); + }); + + it("is a no-op for an unknown id", async () => { + await register(ID_A); + const before = await mockDataStorage.getItem(SECRET_SOURCES_ID); + + await removeSecretSource({ localStore: mockDataStorage, id: ID_B }); + + expect(await mockDataStorage.getItem(SECRET_SOURCES_ID)).toEqual(before); + expect(await getPinnedSourceId({ localStore: mockDataStorage })).toBe(ID_A); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts b/extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts new file mode 100644 index 0000000000..5c1388dbbe --- /dev/null +++ b/extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts @@ -0,0 +1,100 @@ +import { Keypair } from "stellar-sdk"; + +import { sessionSlice } from "background/ducks/session"; +import { + mockDataStorage, + mockSessionStore, + mockKeyManager, + mockStorageApi, + MockBrowserAlarm, +} from "background/messageListener/helpers/test-helpers"; +import { storeAccount } from "background/messageListener/helpers/store-account"; +import { storeHardwareWalletAccount } from "background/messageListener/helpers/store-hardware-wallet"; +import { WalletType } from "@shared/constants/hardwareWallet"; +import { + NO_SOFTWARE_KEYSTORE_ERROR, + verifyAddPassword, +} from "../helpers/verify-add-password"; + +const PASSWORD = "test"; +const testAlarm = new MockBrowserAlarm(() => {}); + +const verify = (password: string) => + verifyAddPassword({ + password, + localStore: mockDataStorage, + sessionStore: mockSessionStore, + keyManager: mockKeyManager, + sessionTimer: testAlarm as any, + }); + +const createSoftwareWallet = async () => { + const secret = Keypair.random().secret(); + await storeAccount({ + password: PASSWORD, + keyPair: { + publicKey: Keypair.fromSecret(secret).publicKey(), + privateKey: secret, + }, + sourceId: "source-1", + imported: true, + isSettingHashKey: true, + sessionStore: mockSessionStore, + localStore: mockDataStorage, + keyManager: mockKeyManager, + }); +}; + +describe("verifyAddPassword", () => { + jest.setTimeout(60_000); + + beforeEach(async () => { + await mockStorageApi.clear(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + }); + + it("returns null for the right password", async () => { + await createSoftwareWallet(); + + expect(await verify(PASSWORD)).toBeNull(); + }); + + it('returns "Incorrect password" for a wrong password on an unlocked session', async () => { + await createSoftwareWallet(); + + expect(await verify("wrong")).toBe("Incorrect password"); + }); + + it('returns "Unable to login" for a wrong password on a locked session', async () => { + await createSoftwareWallet(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + + expect(await verify("wrong")).toBe("Unable to login"); + }); + + it("returns null for the right password on a locked session", async () => { + await createSoftwareWallet(); + // No active hash key: the session is locked. + mockSessionStore.dispatch(sessionSlice.actions.reset()); + + expect(await verify(PASSWORD)).toBeNull(); + }); + + it("returns NO_SOFTWARE_KEYSTORE_ERROR when there is no KEY_ID", async () => { + expect(await verify(PASSWORD)).toBe(NO_SOFTWARE_KEYSTORE_ERROR); + }); + + it("checks the software keystore while a hardware account is active", async () => { + await createSoftwareWallet(); + await storeHardwareWalletAccount({ + publicKey: Keypair.random().publicKey(), + hardwareWalletType: WalletType.LEDGER, + bipPath: "44'/148'/0'", + sessionStore: mockSessionStore, + localStore: mockDataStorage, + }); + + expect(await verify(PASSWORD)).toBeNull(); + expect(await verify("wrong")).toBe("Incorrect password"); + }); +}); diff --git a/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts b/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts index 07aab7bcec..0809628534 100644 --- a/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts +++ b/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts @@ -5,24 +5,22 @@ import { captureException } from "@sentry/browser"; import { AddRecoveryPhraseSourceMessage } from "@shared/api/types/message-request"; import { SecretSourceType } from "@shared/api/types"; -import { ACCOUNT_ALREADY_EXISTS_ERROR } from "@shared/constants/errors"; +import { + ACCOUNT_ALREADY_EXISTS_ERROR, + IMPORT_PHRASE_FAILED_ERROR, + NO_SOFTWARE_KEYSTORE_ERROR, + PHRASE_ALREADY_HELD_ERROR, +} from "@shared/constants/errors"; import { DataStorageAccess } from "background/helpers/dataStorageAccess"; import { linkWalletSources } from "background/helpers/link-sources"; -import { - getActiveHashKeyCryptoKey, - SessionTimer, -} from "background/helpers/session"; +import { SessionTimer } from "background/helpers/session"; import { addSecretSource, getPhraseSourceId, getSecretSources, listSecretSources, } from "background/helpers/secret-sources"; -import { - getIsHardwareWalletActive, - getActiveAccountId, -} from "background/helpers/account"; -import { KEY_ID } from "constants/localStorageTypes"; +import { getActiveAccountId } from "background/helpers/account"; import { allAccountsSelector, buildHasPrivateKeySelector, @@ -31,19 +29,14 @@ import { publicKeySelector, setPendingMnemonicPhrase, } from "background/ducks/session"; -import { loginToAllAccounts } from "../helpers/login-all-accounts"; -import { getNonHwKeyID } from "../helpers/get-non-hw-key-id"; -import { unlockKeystore } from "../helpers/unlock-keystore"; +import { verifyAddPassword } from "../helpers/verify-add-password"; import { storeAccount } from "../helpers/store-account"; const { fromMnemonic } = StellarHDWallet; export const PENDING_PHRASE_MISSING_ERROR = "The new recovery phrase is no longer available. Start again."; -export const PHRASE_ALREADY_HELD_ERROR = - "This recovery phrase is already in your wallet"; -export const NO_SOFTWARE_KEYSTORE_ERROR = - "This wallet has no password to check against"; +export { NO_SOFTWARE_KEYSTORE_ERROR, PHRASE_ALREADY_HELD_ERROR }; /* Stores the phrase `generateRecoveryPhrase` is holding as a new phrase source @@ -81,41 +74,15 @@ export const addRecoveryPhraseSource = async ({ return { error: PENDING_PHRASE_MISSING_ERROR }; } - const keyID = (await getIsHardwareWalletActive({ localStore })) - ? await getNonHwKeyID({ localStore }) - : (await localStore.getItem(KEY_ID)) || ""; - - // Onboarding always writes a password-protected software keystore before a - // hardware account can be added, so this is unreachable today. Without - // one, every password would fail as if it were wrong. - if (!keyID) { - return { error: NO_SOFTWARE_KEYSTORE_ERROR }; - } - - if ((await getActiveHashKeyCryptoKey({ sessionStore })) === null) { - try { - await loginToAllAccounts( - password, - localStore, - sessionStore, - keyManager, - sessionTimer, - ); - } catch (e) { - captureException( - `Error logging in to all accounts in Add Recovery Phrase Source - ${JSON.stringify( - e, - )}`, - ); - return { error: "Unable to login" }; - } - } - - try { - await unlockKeystore({ keyID, password, keyManager }); - } catch (e) { - console.error(e); - return { error: "Incorrect password" }; + const passwordError = await verifyAddPassword({ + password, + localStore, + sessionStore, + keyManager, + sessionTimer, + }); + if (passwordError) { + return { error: passwordError }; } let sourceId: string; @@ -130,7 +97,7 @@ export const addRecoveryPhraseSource = async ({ }; } catch (e) { captureException(`Error deriving new phrase source: ${JSON.stringify(e)}`); - return { error: "Error adding recovery phrase" }; + return { error: IMPORT_PHRASE_FAILED_ERROR }; } // A generated phrase colliding is not a real case, but the checks are what @@ -166,7 +133,7 @@ export const addRecoveryPhraseSource = async ({ }); } catch (e) { captureException(`Error adding recovery phrase: ${JSON.stringify(e)}`); - return { error: "Error adding recovery phrase" }; + return { error: IMPORT_PHRASE_FAILED_ERROR }; } sessionStore.dispatch( diff --git a/extension/src/background/messageListener/handlers/importRecoveryPhrase.ts b/extension/src/background/messageListener/handlers/importRecoveryPhrase.ts new file mode 100644 index 0000000000..8e34b102a8 --- /dev/null +++ b/extension/src/background/messageListener/handlers/importRecoveryPhrase.ts @@ -0,0 +1,238 @@ +import { Store } from "redux"; +import StellarHDWallet from "stellar-hd-wallet"; +import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; +import { captureException } from "@sentry/browser"; + +import { ImportRecoveryPhraseMessage } from "@shared/api/types/message-request"; +import { SecretSourceType } from "@shared/api/types"; +import { + ACCOUNT_ALREADY_EXISTS_ERROR, + IMPORT_PHRASE_FAILED_ERROR, + PHRASE_ALREADY_HELD_ERROR, +} from "@shared/constants/errors"; +import { + isValidImportMnemonic, + normalizeMnemonic, +} from "@shared/api/helpers/mnemonic"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { linkWalletSources } from "background/helpers/link-sources"; +import { SessionTimer } from "background/helpers/session"; +import { + addSecretSource, + getPhraseSourceId, + getSecretSources, + listSecretSources, + removeSecretSource, +} from "background/helpers/secret-sources"; +import { releaseDerivationIndex } from "background/helpers/derivation-indices"; +import { getActiveAccountId, getKeyIdList } from "background/helpers/account"; +import { + allAccountsSelector, + buildHasPrivateKeySelector, + logIn, + publicKeySelector, +} from "background/ducks/session"; +import { verifyAddPassword } from "../helpers/verify-add-password"; +import { discoverFundedIndices } from "../helpers/discover-funded-indices"; +import { storeAccount } from "../helpers/store-account"; +import { activatePublicKey } from "../helpers/activate-public-key"; + +const { fromMnemonic } = StellarHDWallet; + +export { IMPORT_PHRASE_FAILED_ERROR }; + +/* + Adds a recovery phrase the user typed to a live wallet as a new phrase + source: index 0 always, then every index from 1 to `discoveryDepth - 1` + that is funded on mainnet. Every refusal returns before the first write. + + Unlike onboarding recover, this never clears the wallet: existing sources, + accounts, names, indices, provenance and the application state stay as + they are. +*/ +export const importRecoveryPhrase = async ({ + request, + localStore, + sessionStore, + keyManager, + sessionTimer, + discoveryDepth, +}: { + request: ImportRecoveryPhraseMessage; + localStore: DataStorageAccess; + sessionStore: Store; + keyManager: KeyManager; + sessionTimer: SessionTimer; + discoveryDepth: number; +}) => { + const { password } = request; + const mnemonicPhrase = normalizeMnemonic(request.mnemonicPhrase || ""); + + if (!isValidImportMnemonic(mnemonicPhrase)) { + return { error: "Invalid mnemonic phrase" }; + } + + const passwordError = await verifyAddPassword({ + password, + localStore, + sessionStore, + keyManager, + sessionTimer, + }); + if (passwordError) { + return { error: passwordError }; + } + + let sourceId: string; + let wallet: ReturnType; + try { + sourceId = await getPhraseSourceId(mnemonicPhrase); + wallet = fromMnemonic(mnemonicPhrase); + } catch (e) { + captureException( + `Error deriving imported phrase source: ${JSON.stringify(e)}`, + ); + return { error: IMPORT_PHRASE_FAILED_ERROR }; + } + + const isSourceHeld = async () => + Boolean((await getSecretSources({ localStore }))[sourceId]); + + // Checked before discovery to skip the lookups for a held phrase. + if (await isSourceHeld()) { + return { error: PHRASE_ALREADY_HELD_ERROR }; + } + + const fundedIndices = await discoverFundedIndices({ + wallet, + from: 1, + to: discoveryDepth - 1, + }); + const indices = [0, ...fundedIndices]; + + // Checked again after discovery: another import of the same phrase may + // have finished while the lookups ran. + if (await isSourceHeld()) { + return { error: PHRASE_ALREADY_HELD_ERROR }; + } + + // Collisions are refused, never merged: if any address this phrase would + // add is already in the wallet (say, imported earlier as a secret key), + // nothing is added. + const held = new Set( + allAccountsSelector(sessionStore.getState()).map( + ({ publicKey }) => publicKey, + ), + ); + if (indices.some((i) => held.has(wallet.getPublicKey(i)))) { + return { error: ACCOUNT_ALREADY_EXISTS_ERROR }; + } + + const previousActiveId = await getActiveAccountId({ localStore }); + // A copy: storeAccount pushes onto the list it reads. + const previousKeyIds: string[] = [...(await getKeyIdList({ localStore }))]; + const previousState = sessionStore.getState(); + const previousPublicKey = publicKeySelector(previousState); + const previousAccounts = allAccountsSelector(previousState); + + try { + // The source goes first, so a failure after some accounts are stored + // leaves them under a labeled group, and a retry is refused as held. If + // no account was stored, the catch removes the source again. + await addSecretSource({ + localStore, + id: sourceId, + type: SecretSourceType.PHRASE, + }); + + for (const i of indices) { + await storeAccount({ + password, + keyPair: { + publicKey: wallet.getPublicKey(i), + privateKey: wallet.getSecret(i), + }, + mnemonicPhrase, + phraseSourceId: sourceId, + sourceId, + derivationIndex: i, + sessionStore, + keyManager, + localStore, + }); + } + + // storeAccount leaves the last stored account active; the phrase's + // first account is the one to land on. + const first = allAccountsSelector(sessionStore.getState()).find( + (account) => + account.sourceId === sourceId && + account.publicKey === wallet.getPublicKey(0), + ); + await activatePublicKey({ + id: first?.id, + publicKey: wallet.getPublicKey(0), + sessionStore, + localStore, + }); + } catch (e) { + captureException(`Error importing recovery phrase: ${JSON.stringify(e)}`); + + // An account is stored once its keystore id is in the list. If none + // made it, undo the registration so a retry starts clean instead of + // being refused as already held. + try { + const keyIds: string[] = await getKeyIdList({ localStore }); + const storedAny = keyIds.some((id) => !previousKeyIds.includes(id)); + if (!storedAny) { + await removeSecretSource({ localStore, id: sourceId }); + for (const index of indices) { + await releaseDerivationIndex({ localStore, sourceId, index }); + } + // storeAccount adds the account to the session before listing it. + await sessionStore.dispatch( + logIn({ + publicKey: previousPublicKey, + allAccounts: previousAccounts, + localStore, + }) as any, + ); + } + } catch (rollbackError) { + captureException( + `Error rolling back failed import: ${JSON.stringify(rollbackError)}`, + ); + } + + // storeAccount moves the active account to each account it stores. Put + // back the one the popup still shows. + try { + await activatePublicKey({ + id: previousActiveId, + sessionStore, + localStore, + }); + } catch (restoreError) { + captureException( + `Error restoring active account after failed import: ${JSON.stringify( + restoreError, + )}`, + ); + } + return { error: IMPORT_PHRASE_FAILED_ERROR }; + } + + // The new phrase is a source of its own. Not awaited. + void linkWalletSources({ sessionStore, localStore }); + + const currentState = sessionStore.getState(); + const hasPrivateKeySelector = buildHasPrivateKeySelector(localStore); + + return { + publicKey: publicKeySelector(currentState), + activeAccountId: await getActiveAccountId({ localStore }), + allAccounts: allAccountsSelector(currentState), + hasPrivateKey: await hasPrivateKeySelector(currentState), + secretSources: await listSecretSources({ localStore }), + }; +}; diff --git a/extension/src/background/messageListener/handlers/recoverAccount.ts b/extension/src/background/messageListener/handlers/recoverAccount.ts index 4dfafcd5bd..bdc9884d39 100644 --- a/extension/src/background/messageListener/handlers/recoverAccount.ts +++ b/extension/src/background/messageListener/handlers/recoverAccount.ts @@ -23,7 +23,7 @@ import { storeAccount } from "../helpers/store-account"; import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; import { captureException } from "@sentry/browser"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; -import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { discoverFundedIndices } from "../helpers/discover-funded-indices"; import { activatePublicKey } from "../helpers/activate-public-key"; import { addSecretSource, @@ -40,8 +40,6 @@ import { linkWalletSources } from "background/helpers/link-sources"; const { fromMnemonic } = StellarHDWallet; -const FUNDED_LOOKUP_TIMEOUT_MS = 5_000; - export const recoverAccount = async ({ request, localStore, @@ -150,43 +148,35 @@ export const recoverAccount = async ({ // lets check first couple of accounts and pre-load them if funded on mainnet - for (let i = 1; i <= numOfPublicKeysToCheck; i += 1) { - try { - const publicKey = wallet.getPublicKey(i); - const privateKey = wallet.getSecret(i); - - // Bounded: this runs under the account-mutation lock, and a stalled - // lookup would hold off sign-out and auto-lock. A timeout lands in - // the catch below and the index is skipped as unfunded. - const resp = await fetch( - `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/${publicKey}`, - { signal: AbortSignal.timeout(FUNDED_LOOKUP_TIMEOUT_MS) }, - ); + const fundedIndices = await discoverFundedIndices({ + wallet, + from: 1, + to: numOfPublicKeysToCheck, + }); - const j = await resp.json(); - if (j.account_id) { - const newKeyPair = { - publicKey, - privateKey, - }; - - // Not `imported`. These are derived from the recovery phrase we - // just took, at derivation indices 1..n — the same thing - // `addAccount` produces, which stores them unflagged. "Imported" - // is reserved for accounts brought in from a secret key, which - // the phrase cannot re-derive. - await storeAccount({ - password, - keyPair: newKeyPair, - mnemonicPhrase: recoverMnemonic, - phraseSourceId, - sourceId: phraseSourceId, - derivationIndex: i, - sessionStore, - localStore, - keyManager, - }); - } + for (const i of fundedIndices) { + try { + const newKeyPair = { + publicKey: wallet.getPublicKey(i), + privateKey: wallet.getSecret(i), + }; + + // Not `imported`. These are derived from the recovery phrase we + // just took, at derivation indices 1..n — the same thing + // `addAccount` produces, which stores them unflagged. "Imported" + // is reserved for accounts brought in from a secret key, which + // the phrase cannot re-derive. + await storeAccount({ + password, + keyPair: newKeyPair, + mnemonicPhrase: recoverMnemonic, + phraseSourceId, + sourceId: phraseSourceId, + derivationIndex: i, + sessionStore, + localStore, + keyManager, + }); } catch (e) { captureException( `Error preloading account: ${JSON.stringify(e)} - ${i}`, diff --git a/extension/src/background/messageListener/helpers/discover-funded-indices.ts b/extension/src/background/messageListener/helpers/discover-funded-indices.ts new file mode 100644 index 0000000000..60a70400cd --- /dev/null +++ b/extension/src/background/messageListener/helpers/discover-funded-indices.ts @@ -0,0 +1,60 @@ +import StellarHDWallet from "stellar-hd-wallet"; +import { captureException } from "@sentry/browser"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; + +const MAX_IN_FLIGHT = 10; +// Callers run under the account-mutation lock, so a stalled lookup would +// hold off sign-out and auto-lock until it times out. +const LOOKUP_TIMEOUT_MS = 5_000; + +/* + Checks derivation indices `from` to `to` (inclusive) on mainnet and returns + the funded ones in ascending order. A lookup that throws, or takes longer + than LOOKUP_TIMEOUT_MS, counts as not found and is reported. +*/ +export const discoverFundedIndices = async ({ + wallet, + from, + to, +}: { + wallet: ReturnType; + from: number; + to: number; +}): Promise => { + const indices: number[] = []; + for (let i = from; i <= to; i += 1) { + indices.push(i); + } + + const funded = new Set(); + let next = 0; + + const worker = async () => { + while (next < indices.length) { + const i = indices[next]; + next += 1; + try { + const publicKey = wallet.getPublicKey(i); + const resp = await fetch( + `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/${publicKey}`, + { signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) }, + ); + const j = await resp.json(); + if (j.account_id) { + funded.add(i); + } + } catch (e) { + captureException( + `Error preloading account: ${JSON.stringify(e)} - ${i}`, + ); + } + } + }; + + await Promise.all( + Array.from({ length: Math.min(MAX_IN_FLIGHT, indices.length) }, worker), + ); + + return indices.filter((i) => funded.has(i)); +}; diff --git a/extension/src/background/messageListener/helpers/verify-add-password.ts b/extension/src/background/messageListener/helpers/verify-add-password.ts new file mode 100644 index 0000000000..efb74d0e93 --- /dev/null +++ b/extension/src/background/messageListener/helpers/verify-add-password.ts @@ -0,0 +1,79 @@ +import { Store } from "redux"; +import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; +import { captureException } from "@sentry/browser"; + +import { + INCORRECT_PASSWORD_ERROR, + NO_SOFTWARE_KEYSTORE_ERROR, + UNABLE_TO_LOGIN_ERROR, +} from "@shared/constants/errors"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { + getActiveHashKeyCryptoKey, + SessionTimer, +} from "background/helpers/session"; +import { getIsHardwareWalletActive } from "background/helpers/account"; +import { KEY_ID } from "constants/localStorageTypes"; +import { loginToAllAccounts } from "./login-all-accounts"; +import { getNonHwKeyID } from "./get-non-hw-key-id"; +import { unlockKeystore } from "./unlock-keystore"; + +export { NO_SOFTWARE_KEYSTORE_ERROR }; + +/* + Checks a password against the active software keystore, for handlers that + add to a live wallet. Returns null when the password unlocks it, otherwise + the error string to hand back to the popup. +*/ +export const verifyAddPassword = async ({ + password, + localStore, + sessionStore, + keyManager, + sessionTimer, +}: { + password: string; + localStore: DataStorageAccess; + sessionStore: Store; + keyManager: KeyManager; + sessionTimer: SessionTimer; +}): Promise => { + const keyID = (await getIsHardwareWalletActive({ localStore })) + ? await getNonHwKeyID({ localStore }) + : (await localStore.getItem(KEY_ID)) || ""; + + // Onboarding always writes a password-protected software keystore before a + // hardware account can be added, so this is unreachable today. Without + // one, every password would fail as if it were wrong. + if (!keyID) { + return NO_SOFTWARE_KEYSTORE_ERROR; + } + + if ((await getActiveHashKeyCryptoKey({ sessionStore })) === null) { + try { + await loginToAllAccounts( + password, + localStore, + sessionStore, + keyManager, + sessionTimer, + ); + } catch (e) { + captureException( + `Error logging in to all accounts while verifying password - ${JSON.stringify( + e, + )}`, + ); + return UNABLE_TO_LOGIN_ERROR; + } + } + + try { + await unlockKeystore({ keyID, password, keyManager }); + } catch (e) { + console.error(e); + return INCORRECT_PASSWORD_ERROR; + } + + return null; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 82d13a86ab..2cfff99129 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -88,6 +88,7 @@ import { getMigratableAccounts } from "./handlers/getMigratableAccounts"; import { getMigratedMnemonicPhrase } from "./handlers/getMigratedMnemonicPhrase"; import { generateRecoveryPhrase } from "./handlers/generateRecoveryPhrase"; import { addRecoveryPhraseSource } from "./handlers/addRecoveryPhraseSource"; +import { importRecoveryPhrase } from "./handlers/importRecoveryPhrase"; import { migrateAccounts } from "./handlers/migrateAccounts"; import { addAssetsList } from "./handlers/addAssetsList"; import { modifyAssetsList } from "./handlers/modifyAssetsList"; @@ -114,6 +115,7 @@ import { getCachedSwapTopTokens } from "./handlers/getCachedSwapTopTokens"; import { cacheSwapTopTokens } from "./handlers/cacheSwapTopTokens"; const numOfPublicKeysToCheck = 5; +const phraseImportDiscoveryDepth = 50; let sidebarWindowId: number | null = null; @@ -254,6 +256,16 @@ export const popupMessageListener = ( sessionTimer, }); } + case SERVICE_TYPES.IMPORT_RECOVERY_PHRASE: { + return importRecoveryPhrase({ + request, + localStore, + sessionStore, + keyManager, + sessionTimer, + discoveryDepth: phraseImportDiscoveryDepth, + }); + } case SERVICE_TYPES.IMPORT_ACCOUNT: { return importAccount({ request, diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index 7c2d4cc8c2..ea4692e62e 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -28,6 +28,7 @@ import { AccountHistory } from "popup/views/AccountHistory"; import { AccountCreator } from "popup/views/AccountCreator"; import { AddWallet } from "popup/views/AddWallet"; import { CreateWallet } from "popup/views/CreateWallet"; +import { ImportRecoveryPhrase } from "popup/views/ImportRecoveryPhrase"; import { ManageAssetsLists } from "popup/views/ManageAssetsLists"; import { ImportAccount } from "popup/views/AddAccount/ImportAccount"; import { SelectHardwareWallet } from "popup/views/AddAccount/connect/SelectHardwareWallet"; @@ -212,6 +213,10 @@ export const Router = () => ( > }> }> + } + > }> }> void; confirmButtonTitle?: string; cancelButtonTitle?: string; + /** Rewrites the auth error before it is shown, e.g. to localized copy. */ + formatAuthError?: (error: string) => string; } export const EnterPassword = ({ @@ -32,6 +34,7 @@ export const EnterPassword = ({ onCancel, confirmButtonTitle, cancelButtonTitle, + formatAuthError, }: EnterPasswordProps) => { const { t } = useTranslation(); const titleLabel = title || t("Enter your password"); @@ -45,7 +48,11 @@ export const EnterPassword = ({ password: "", }; - const authError = useSelector(authErrorSelector); + const rawAuthError = useSelector(authErrorSelector); + const authError = + rawAuthError && formatAuthError + ? formatAuthError(rawAuthError) + : rawAuthError; const handleSubmit = async (values: FormValues) => { await onConfirm(values.password); diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 9ba3006ed5..311e55854c 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -14,6 +14,7 @@ export enum ROUTES { swap = "/swap", addWallet = "/add-wallet", createWallet = "/add-wallet/create", + importRecoveryPhrase = "/add-wallet/import-phrase", addToken = "/add-token", signTransaction = "/sign-transaction", reviewAuthorization = "/review-auth", diff --git a/extension/src/popup/ducks/accountServices.ts b/extension/src/popup/ducks/accountServices.ts index e655c74632..575fdac397 100644 --- a/extension/src/popup/ducks/accountServices.ts +++ b/extension/src/popup/ducks/accountServices.ts @@ -10,6 +10,7 @@ import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { addAccount as addAccountService, addRecoveryPhraseSource as addRecoveryPhraseSourceService, + importRecoveryPhrase as importRecoveryPhraseService, importAccount as importAccountService, importHardwareWallet as importHardwareWalletService, makeAccountActive as makeAccountActiveService, @@ -160,6 +161,31 @@ export const addRecoveryPhraseSource = createAsyncThunk< }, ); +export const importRecoveryPhrase = createAsyncThunk< + AddedAccountPayload, + { password: string; mnemonicPhrase: string }, + { rejectValue: ErrorMessage } +>( + "auth/importRecoveryPhrase", + async ({ password, mnemonicPhrase }, { getState, rejectWithValue }) => { + const activePublicKey = publicKeySelector(getState() as AppState); + + try { + return await importRecoveryPhraseService({ + activePublicKey, + password, + mnemonicPhrase, + }); + } catch (e) { + const message = e instanceof Error ? e.message : JSON.stringify(e); + console.error("Failed when importing a recovery phrase: ", message); + return rejectWithValue({ + errorMessage: message, + }); + } + }, +); + export const importAccount = createAsyncThunk< { publicKey: string; @@ -815,6 +841,8 @@ const authSlice = createSlice({ builder.addCase(addAccount.rejected, onAccountAddFailed); builder.addCase(addRecoveryPhraseSource.fulfilled, onAccountAdded); builder.addCase(addRecoveryPhraseSource.rejected, onAccountAddFailed); + builder.addCase(importRecoveryPhrase.fulfilled, onAccountAdded); + builder.addCase(importRecoveryPhrase.rejected, onAccountAddFailed); builder.addCase(importAccount.fulfilled, (state, action) => { const { publicKey, allAccounts, hasPrivateKey } = action.payload || { publicKey: "", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index dec50ada1d..983fba8694 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -256,6 +256,7 @@ "Enter your mnemonic phrase to restore your wallet.": "Enter your mnemonic phrase to restore your wallet.", "Enter your password": "Enter your password", "Error": "Error", + "Error adding recovery phrase": "Error adding recovery phrase", "Error connecting. Please try again.": "Error connecting. Please try again.", "Executable Owner": "Executable Owner", "Executable Tag": "Executable Tag", @@ -353,15 +354,18 @@ "Import": "Import", "Import a Stellar secret key": "Import a Stellar secret key", "Import a wallet using its secret key.": "Import a wallet using its secret key.", + "Import Recovery Phrase": "Import Recovery Phrase", "Import Secret Key": "Import Secret Key", "Import Stellar Secret Key": "Import Stellar Secret Key", "Import wallet": "Import wallet", "Import wallet from recovery phrase": "Import wallet from recovery phrase", + "Import wallets using a recovery phrase.": "Import wallets using a recovery phrase.", "Important": "Important", "Important, Please Read": "Important, Please Read", "Imported": "Imported", "In this process, Varial will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "In this process, Varial will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.", "Inclusion Fee": "Inclusion Fee", + "Incorrect password": "Incorrect password", "Inflation Destination": "Inflation Destination", "Insufficient balance": "Insufficient balance", "Insufficient Balance": "Insufficient Balance", @@ -566,6 +570,7 @@ "Reserved Balance*": "Reserved Balance*", "Reset wallet": "Reset wallet", "Resource Fee": "Resource Fee", + "Restore your wallet with the 12 or 24-word recovery phrase": "Restore your wallet with the 12 or 24-word recovery phrase", "Retry": "Retry", "Reveal recovery phrase": "Reveal recovery phrase", "Reveal secret key": "Reveal secret key", @@ -724,6 +729,7 @@ "This is not a valid contract id.": "This is not a valid contract id.", "This Ledger account is already in your wallet. Choose a different account on the device.": "This Ledger account is already in your wallet. Choose a different account on the device.", "This message is too large for your Ledger to display. Ask the site for a shorter message.": "This message is too large for your Ledger to display. Ask the site for a shorter message.", + "This recovery phrase is already in your wallet.": "This recovery phrase is already in your wallet.", "This site does not appear safe for the following reasons": "This site does not appear safe for the following reasons", "This site has been flagged with potential concerns": "This site has been flagged with potential concerns", "This site was flagged as malicious": "This site was flagged as malicious", @@ -746,6 +752,7 @@ "This transaction was flagged as malicious (override active)": "This transaction was flagged as malicious (override active)", "This transaction was flagged as suspicious": "This transaction was flagged as suspicious", "This transaction was flagged as suspicious (override active)": "This transaction was flagged as suspicious (override active)", + "This wallet has no password to check against": "This wallet has no password to check against", "This will add a trustline to {{code}}": "This will add a trustline to {{code}}", "This will be used to unlock your wallet": "This will be used to unlock your wallet", "Timeout": "Timeout", @@ -802,6 +809,7 @@ "Unable to load history": "Unable to load history", "Unable to load network details": "Unable to load network details", "Unable to load price data": "Unable to load price data", + "Unable to login": "Unable to login", "Unable to migrate": "Unable to migrate", "Unable to parse assets lists": "Unable to parse assets lists", "Unable to remove {{label}}": "Unable to remove {{label}}", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 77eeff487c..cf48ac54b1 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -256,6 +256,7 @@ "Enter your mnemonic phrase to restore your wallet.": "Insira sua frase mnemônica para restaurar sua carteira.", "Enter your password": "Insira sua senha", "Error": "Erro", + "Error adding recovery phrase": "Erro ao adicionar a frase de recuperação", "Error connecting. Please try again.": "Erro ao conectar. Por favor, tente novamente.", "Executable Owner": "Proprietário do Executável", "Executable Tag": "Tag do Executável", @@ -353,15 +354,18 @@ "Import": "Importar", "Import a Stellar secret key": "Importar uma chave secreta Stellar", "Import a wallet using its secret key.": "Import a wallet using its secret key.", + "Import Recovery Phrase": "Importar Frase de Recuperação", "Import Secret Key": "Import Secret Key", "Import Stellar Secret Key": "Importar Stellar Secret Key", "Import wallet": "Importar carteira", "Import wallet from recovery phrase": "Importar carteira da frase de recuperação", + "Import wallets using a recovery phrase.": "Importe carteiras usando uma frase de recuperação.", "Important": "Importante", "Important, Please Read": "Importante, Por Favor Leia", "Imported": "Importado", "In this process, Varial will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "Neste processo, o Varial criará uma nova frase de backup para você e migrará seus lumens, trustlines e ativos para a nova conta.", "Inclusion Fee": "Taxa de Inclusão", + "Incorrect password": "Senha incorreta", "Inflation Destination": "Destino de Inflação", "Insufficient balance": "Saldo insuficiente", "Insufficient Balance": "Saldo Insuficiente", @@ -566,6 +570,7 @@ "Reserved Balance*": "Saldo Reservado*", "Reset wallet": "Redefinir carteira", "Resource Fee": "Taxa de Recurso", + "Restore your wallet with the 12 or 24-word recovery phrase": "Restaure sua carteira com a frase de recuperação de 12 ou 24 palavras", "Retry": "Tentar novamente", "Reveal recovery phrase": "Revelar frase de recuperação", "Reveal secret key": "Revelar chave secreta", @@ -724,6 +729,7 @@ "This is not a valid contract id.": "Este não é um ID de contrato válido.", "This Ledger account is already in your wallet. Choose a different account on the device.": "Esta conta Ledger já está na sua carteira. Escolha outra conta no dispositivo.", "This message is too large for your Ledger to display. Ask the site for a shorter message.": "Esta mensagem é grande demais para o seu Ledger exibir. Peça ao site uma mensagem mais curta.", + "This recovery phrase is already in your wallet.": "Esta frase de recuperação já está na sua carteira.", "This site does not appear safe for the following reasons": "Este site não parece seguro pelos seguintes motivos", "This site has been flagged with potential concerns": "Este site foi sinalizado com possíveis preocupações", "This site was flagged as malicious": "Este site foi marcado como malicioso", @@ -746,6 +752,7 @@ "This transaction was flagged as malicious (override active)": "Esta transação foi sinalizada como maliciosa (substituição ativa)", "This transaction was flagged as suspicious": "Esta transação foi marcada como suspeita", "This transaction was flagged as suspicious (override active)": "Esta transação foi sinalizada como suspeita (substituição ativa)", + "This wallet has no password to check against": "Esta carteira não tem uma senha para verificar", "This will add a trustline to {{code}}": "Isto vai adicionar uma linha de confiança para {{code}}", "This will be used to unlock your wallet": "Isso será usado para desbloquear sua carteira", "Timeout": "Tempo esgotado", @@ -802,6 +809,7 @@ "Unable to load history": "Não foi possível carregar o histórico", "Unable to load network details": "Não foi possível carregar os detalhes da rede", "Unable to load price data": "Não foi possível carregar os dados de preço", + "Unable to login": "Não foi possível entrar", "Unable to migrate": "Não foi possível migrar", "Unable to parse assets lists": "Não foi possível fazer parse das asset lists", "Unable to remove {{label}}": "Unable to remove {{label}}", diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index 041c6a6af4..b0b38e274b 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -45,6 +45,10 @@ const SCREEN_BY_ROUTE: Partial> = { [ROUTES.accountHistory]: { screen_name: "account_history", flow: "history" }, [ROUTES.addWallet]: { screen_name: "add_wallet", flow: "onboarding" }, [ROUTES.createWallet]: { screen_name: "create_wallet", flow: "onboarding" }, + [ROUTES.importRecoveryPhrase]: { + screen_name: "import_recovery_phrase", + flow: "onboarding", + }, [ROUTES.importAccount]: { screen_name: "import_account", flow: "onboarding" }, [ROUTES.connectWallet]: { screen_name: "connect_wallet", flow: "onboarding" }, [ROUTES.connectWalletPlugin]: { diff --git a/extension/src/popup/views/AddWallet/index.tsx b/extension/src/popup/views/AddWallet/index.tsx index 032c9ca40c..f47599c163 100644 --- a/extension/src/popup/views/AddWallet/index.tsx +++ b/extension/src/popup/views/AddWallet/index.tsx @@ -12,9 +12,9 @@ import { View } from "popup/basics/layout/View"; import "./styles.scss"; /** - * The ways to add a wallet, opened from the account switcher. Importing a - * further recovery phrase joins this list once it can be done without - * replacing the wallet. + * The ways to add a wallet, opened from the account switcher: create, import + * a further recovery phrase, import a secret key, or connect a hardware + * wallet. None of them replaces the wallet. */ export const AddWallet = () => { const { t } = useTranslation(); @@ -27,6 +27,13 @@ export const AddWallet = () => { link: ROUTES.createWallet, testId: "add-wallet-create", }, + { + icon: , + title: t("Import Recovery Phrase"), + description: t("Import wallets using a recovery phrase."), + link: ROUTES.importRecoveryPhrase, + testId: "add-wallet-import-phrase", + }, { icon: , title: t("Import Secret Key"), diff --git a/extension/src/popup/views/ImportRecoveryPhrase/index.tsx b/extension/src/popup/views/ImportRecoveryPhrase/index.tsx new file mode 100644 index 0000000000..008846c4b6 --- /dev/null +++ b/extension/src/popup/views/ImportRecoveryPhrase/index.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { Button, Textarea } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { + isValidImportMnemonic, + normalizeMnemonic, +} from "@shared/api/helpers/mnemonic"; +import { + ACCOUNT_ALREADY_EXISTS_ERROR, + IMPORT_PHRASE_FAILED_ERROR, + INCORRECT_PASSWORD_ERROR, + NO_SOFTWARE_KEYSTORE_ERROR, + PHRASE_ALREADY_HELD_ERROR, + UNABLE_TO_LOGIN_ERROR, +} from "@shared/constants/errors"; +import { emitMetric } from "helpers/metrics"; + +import { AppDispatch } from "popup/App"; +import { ROUTES } from "popup/constants/routes"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { navigateTo } from "popup/helpers/navigate"; +import { + clearApiError, + importRecoveryPhrase, +} from "popup/ducks/accountServices"; + +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { ScreenTitle } from "popup/components/ScreenTitle"; +import { View } from "popup/basics/layout/View"; +import { EnterPassword } from "popup/components/EnterPassword"; + +import "./styles.scss"; + +/* + Import a further recovery phrase into the wallet. + + phrase: the textarea, checked in the popup before anything else happens. + password: re-authentication before the background stores anything. + + The phrase lives in component state between the steps and is dropped on + success and when the screen goes away. +*/ +type Step = "phrase" | "password"; + +export const ImportRecoveryPhrase = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [step, setStep] = useState("phrase"); + const [phrase, setPhrase] = useState(""); + const [phraseError, setPhraseError] = useState(""); + // EnterPassword's button is disabled while loading, but Enter in the + // field still submits the form. Two concurrent imports could store the + // same accounts twice. + const isImporting = useRef(false); + + useEffect( + () => () => { + // Clear any import error so it doesn't show on the next visit. + dispatch(clearApiError()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // The background answers with fixed English strings; show each one this + // screen can get in the user's language. + const formatAuthError = (error: string) => { + switch (error) { + case ACCOUNT_ALREADY_EXISTS_ERROR: + return t("This account is already in your wallet."); + case PHRASE_ALREADY_HELD_ERROR: + return t("This recovery phrase is already in your wallet."); + case IMPORT_PHRASE_FAILED_ERROR: + return t("Error adding recovery phrase"); + case NO_SOFTWARE_KEYSTORE_ERROR: + return t("This wallet has no password to check against"); + case INCORRECT_PASSWORD_ERROR: + return t("Incorrect password"); + case UNABLE_TO_LOGIN_ERROR: + return t("Unable to login"); + default: + return error; + } + }; + + // There's no Paste button: extension pages can't read the clipboard + // without the clipboardRead permission. A keyboard paste lands in the + // textarea natively; afterwards the clipboard is cleared, as onboarding + // recovery does. + const handleKeyboardPaste = async () => { + try { + await navigator.clipboard.writeText(""); + } catch (e) { + // Nothing to do if the clipboard can't be written. + } + }; + + const handleImport = () => { + if (!isValidImportMnemonic(phrase)) { + setPhraseError(t("Invalid mnemonic phrase")); + return; + } + setPhraseError(""); + setStep("password"); + }; + + const handlePassword = async (password: string) => { + if (isImporting.current) { + return; + } + isImporting.current = true; + try { + const res = await dispatch( + importRecoveryPhrase({ + password, + mnemonicPhrase: normalizeMnemonic(phrase), + }), + ); + + if (importRecoveryPhrase.fulfilled.match(res)) { + emitMetric(METRIC_NAMES.accountCreated, { + number_of_accounts: res.payload.allAccounts.length, + source: "imported_phrase", + }); + setPhrase(""); + navigateTo(ROUTES.account, navigate); + } + } finally { + isImporting.current = false; + } + }; + + if (step === "password") { + return ( + + { + dispatch(clearApiError()); + setStep("phrase"); + }} + /> + + + ); + } + + return ( + + { + setPhrase(""); + navigateTo(ROUTES.addWallet, navigate); + }} + /> + + +
+