feat: add duplicate account import prevention #189

Merged
jose merged 10 commits from account-import-duplicate-prevention into master 5 days ago
  1. 8
      src/constants/notifications.ts
  2. 138
      src/libs/util.ts
  3. 36
      src/views/ImportAccountView.vue
  4. 11
      src/views/ImportDerivedAccountView.vue
  5. 63
      test-playwright/03-duplicate-import-test.spec.ts

8
src/constants/notifications.ts

@ -1689,3 +1689,11 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
title: "They're Added To Your List", title: "They're Added To Your List",
message: "Would you like to go to the main page now?", message: "Would you like to go to the main page now?",
}; };
// ImportAccountView.vue specific constants
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
title: "Account Already Imported",
message:
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
};

138
src/libs/util.ts

@ -614,57 +614,64 @@ export const retrieveAllAccountsMetadata = async (): Promise<
return result; return result;
}; };
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
/** /**
* Saves a new identity to both SQL and Dexie databases * Saves a new identity to SQL database
*/ */
export async function saveNewIdentity( export async function saveNewIdentity(
identity: IIdentifier, identity: IIdentifier,
mnemonic: string, mnemonic: string,
derivationPath: string, derivationPath: string,
): Promise<void> { ): Promise<void> {
try { // add to the new sql db
// add to the new sql db const platformService = await getPlatformService();
const platformService = await getPlatformService();
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
);
}
const secretBase64 = secrets.values[0][0] as string; // Check if account already exists before attempting to save
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[identity.did],
);
const secret = base64ToArrayBuffer(secretBase64); if (existingAccount?.values?.length) {
const identityStr = JSON.stringify(identity); throw new Error(
const encryptedIdentity = await simpleEncrypt(identityStr, secret); `Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret); );
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity); }
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex) const secrets = await platformService.dbQuery(
VALUES (?, ?, ?, ?, ?, ?)`; `SELECT secretBase64 FROM secret`,
const params = [ );
new Date().toISOString(), if (!secrets?.values?.length || !secrets.values[0]?.length) {
derivationPath,
identity.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
identity.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertNewDidIntoSettings(identity.did);
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error( throw new Error(
"Failed to set default settings. Please try again or restart the app.", "No initial encryption supported. We recommend you clear your data and start over.",
); );
} }
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const identityStr = JSON.stringify(identity);
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [
new Date().toISOString(),
derivationPath,
identity.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
identity.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertNewDidIntoSettings(identity.did);
} }
/** /**
@ -1032,3 +1039,58 @@ export async function importFromMnemonic(
} }
} }
} }
/**
* Checks if an account with the given DID already exists in the database
*
* @param did - The DID to check for duplicates
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(did: string): Promise<boolean>;
/**
* Checks if an account with the given DID already exists in the database
*
* @param mnemonic - The mnemonic phrase to derive DID from
* @param derivationPath - The derivation path to use
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(
mnemonic: string,
derivationPath: string,
): Promise<boolean>;
/**
* Implementation of checkForDuplicateAccount with overloaded signatures
*/
export async function checkForDuplicateAccount(
didOrMnemonic: string,
derivationPath?: string,
): Promise<boolean> {
let didToCheck: string;
if (derivationPath) {
// Derive the DID from mnemonic and derivation path
const [address, privateHex, publicHex] = deriveAddress(
didOrMnemonic.trim().toLowerCase(),
derivationPath,
);
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
didToCheck = newId.did;
} else {
// Use the provided DID directly
didToCheck = didOrMnemonic;
}
// Check if an account with this DID already exists
const platformService = await getPlatformService();
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[didToCheck],
);
return (existingAccount?.values?.length ?? 0) > 0;
}

36
src/views/ImportAccountView.vue

@ -88,9 +88,15 @@ import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto"; import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util"; import {
retrieveAccountCount,
importFromMnemonic,
checkForDuplicateAccount,
DUPLICATE_ACCOUNT_ERROR,
} from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
/** /**
* Import Account View Component * Import Account View Component
@ -198,6 +204,19 @@ export default class ImportAccountView extends Vue {
} }
try { try {
// Check for duplicate account before importing
const isDuplicate = await checkForDuplicateAccount(
this.mnemonic,
this.derivationPath,
);
if (isDuplicate) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
await importFromMnemonic( await importFromMnemonic(
this.mnemonic, this.mnemonic,
this.derivationPath, this.derivationPath,
@ -223,9 +242,20 @@ export default class ImportAccountView extends Vue {
this.$router.push({ name: "account" }); this.$router.push({ name: "account" });
} catch (error: unknown) { } catch (error: unknown) {
this.$logError("Import failed: " + error); this.$logError("Import failed: " + error);
// Check if this is a duplicate account error from saveNewIdentity
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
this.notify.error( this.notify.error(
(error instanceof Error ? error.message : String(error)) || errorMessage || "Failed to import account.",
"Failed to import account.",
TIMEOUTS.LONG, TIMEOUTS.LONG,
); );
} }

11
src/views/ImportDerivedAccountView.vue

@ -83,6 +83,7 @@ import {
retrieveAllAccountsMetadata, retrieveAllAccountsMetadata,
retrieveFullyDecryptedAccount, retrieveFullyDecryptedAccount,
saveNewIdentity, saveNewIdentity,
checkForDuplicateAccount,
} from "../libs/util"; } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
@ -171,6 +172,16 @@ export default class ImportAccountView extends Vue {
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath); const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
try { try {
// Check for duplicate account before creating
const isDuplicate = await checkForDuplicateAccount(newId.did);
if (isDuplicate) {
this.notify.warning(
"This derived account already exists. Please try a different derivation path.",
TIMEOUTS.LONG,
);
return;
}
await saveNewIdentity(newId, mne, newDerivPath); await saveNewIdentity(newId, mne, newDerivPath);
// record that as the active DID // record that as the active DID

63
test-playwright/03-duplicate-import-test.spec.ts

@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test';
import { importUserFromAccount, getTestUserData } from './testUtils';
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from '../src/constants/notifications';
/**
* Test duplicate account import functionality
*
* This test verifies that:
* 1. A user can successfully import an account the first time
* 2. Attempting to import the same account again shows a warning message
* 3. The duplicate import is prevented
*/
test.describe('Duplicate Account Import', () => {
test('should prevent importing the same account twice', async ({ page }) => {
const userData = getTestUserData("00");
// First import - should succeed
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userData.did);
// Navigate back to start page for second import attempt
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify duplicate import shows warning message
// The warning can appear either from the pre-check or from the saveNewIdentity error handling
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).toBeVisible();
// Verify we're still on the import page (not redirected to account)
await expect(page.getByPlaceholder("Seed Phrase")).toBeVisible();
});
test('should allow importing different accounts', async ({ page }) => {
const userZeroData = getTestUserData("00");
const userOneData = getTestUserData("01");
// Import first user
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userZeroData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userZeroData.did);
// Navigate back to start page for second user import
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userOneData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify second import was successful (should not show duplicate warning)
await expect(page.getByRole("code")).toContainText(userOneData.did);
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).not.toBeVisible();
});
});
Loading…
Cancel
Save