Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions @shared/api/helpers/__tests__/mnemonic.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
18 changes: 18 additions & 0 deletions @shared/api/helpers/mnemonic.ts
Original file line number Diff line number Diff line change
@@ -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);
};
45 changes: 45 additions & 0 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Account>;
hasPrivateKey: boolean;
secretSources: SecretSource[];
}> => {
let response = {
error: "",
publicKey: "",
activeAccountId: "",
allAccounts: [] as Array<Account>,
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,
Expand Down
12 changes: 12 additions & 0 deletions @shared/api/types/message-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -588,6 +599,7 @@ export type ServiceMessageRequest =
| AddAccountMessage
| GenerateRecoveryPhraseMessage
| AddRecoveryPhraseSourceMessage
| ImportRecoveryPhraseMessage
| ImportAccountMessage
| ImportHardWareWalletMessage
| MakeAccountActiveMessage
Expand Down
17 changes: 17 additions & 0 deletions @shared/constants/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions @shared/constants/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions extension/e2e-tests/addWallet.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { generateMnemonic } from "bip39";
import { Keypair } from "stellar-sdk";

import { test, expect } from "./test-fixtures";
import { loginToTestAccount } from "./helpers/login";
import {
addAccountToPhrase,
generateRecoveryPhraseSource,
importRecoveryPhraseSource,
} from "./helpers/addWallet";

// loginToTestAccount sets this password on the wallet it imports.
Expand Down Expand Up @@ -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",
]);
});
22 changes: 22 additions & 0 deletions extension/e2e-tests/helpers/addWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
27 changes: 27 additions & 0 deletions extension/src/background/helpers/secret-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand Down
Loading
Loading