From 11d65522afde0110ed04d9c91ba3ea00edb0c67b Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 22 Apr 2025 12:52:47 +0800 Subject: [PATCH 01/10] feat: added 'encryptionKey' and 'encryptionSalt' to state --- .../src/SeedlessOnboardingController.ts | 8 ++++++++ packages/seedless-onboarding-controller/src/types.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index f7de5b1eaac..32c1b5a885c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -81,6 +81,14 @@ const seedlessOnboardingMetadata: StateMetadata Date: Tue, 22 Apr 2025 13:08:58 +0800 Subject: [PATCH 02/10] feat: add new private method, 'verifyPassword` to verify password validity --- .../src/SeedlessOnboardingController.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 32c1b5a885c..f617aae3b2f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -102,6 +102,8 @@ export class SeedlessOnboardingController extends BaseController< readonly toprfClient: ToprfSecureBackup; + #password?: string; + /** * Creates a new SeedlessOnboardingController instance. * @@ -271,7 +273,7 @@ export class SeedlessOnboardingController extends BaseController< */ async changePassword(newPassword: string, oldPassword: string) { // verify the old password of the encrypted vault - await this.#unlockVaultWithPassword(oldPassword); + await this.#verifyPassword(oldPassword); try { // update the encryption key with new password and update the Metadata Store @@ -416,11 +418,23 @@ export class SeedlessOnboardingController extends BaseController< } } + /** + * Verify the password validity by decrypting the vault. + * + * @param password - The password to verify. + * @throws {Error} If the password is invalid or the vault is not initialized. + */ + async #verifyPassword(password: string) { + if (!this.state.vault) { + throw new Error(SeedlessOnboardingControllerError.VaultError); + } + await this.#vaultEncryptor.decrypt(password, this.state.vault); + } + /** * Unlocks the encrypted vault using the provided password and returns the decrypted vault data. * This method ensures thread-safety by using a mutex lock when accessing the vault. * - * @param password - The password to decrypt the vault. * @returns A promise that resolves to an object containing: * - nodeAuthTokens: Authentication tokens to communicate with the TOPRF service * - toprfEncryptionKey: The decrypted TOPRF encryption key @@ -431,12 +445,13 @@ export class SeedlessOnboardingController extends BaseController< * - The password is incorrect (from encryptor.decrypt) * - The decrypted vault data is malformed */ - async #unlockVaultWithPassword(password: string): Promise<{ + async #unlockVaultWithPassword(): Promise<{ nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; }> { return this.#withVaultLock(async () => { + const password = this.#password; assertIsValidPassword(password); const encryptedVault = this.state.vault; @@ -584,6 +599,8 @@ export class SeedlessOnboardingController extends BaseController< this.update((state) => { state.vault = updatedState.vault; }); + + this.#password = password; }); } From 3aa52baf4eb4e26f50a8e670d0d0080b4c4a5664 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 22 Apr 2025 13:33:06 +0800 Subject: [PATCH 03/10] feat: updated vault encryption scheme --- .../src/SeedlessOnboardingController.ts | 96 ++++++++++++++++--- .../src/constants.ts | 2 + .../src/types.ts | 52 ++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index f617aae3b2f..e61470bef83 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1,7 +1,15 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; import type { StateMetadata } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { encrypt, decrypt } from '@metamask/browser-passworder'; +import { + type EncryptionKey, + encrypt, + decrypt, + decryptWithDetail, + encryptWithDetail, + decryptWithKey, + importKey as importKeyBrowserPassworder, +} from '@metamask/browser-passworder'; import type { KeyPair, NodeAuthTokens, @@ -48,6 +56,26 @@ export function getDefaultSeedlessOnboardingControllerState(): SeedlessOnboardin }; } +/** + * Get the default vault encryptor for the Seedless Onboarding Controller. + * + * By default, we'll use the encryption utilities from `@metamask/browser-passworder`. + * + * @returns The default vault encryptor for the Seedless Onboarding Controller. + */ +export function getDefaultSeedlessOnboardingVaultEncryptor(): VaultEncryptor { + return { + encrypt, + encryptWithDetail, + decrypt, + decryptWithDetail, + decryptWithKey, + importKey: (key) => { + return importKeyBrowserPassworder(key) as Promise; + }, + }; +} + /** * Seedless Onboarding Controller State Metadata. * @@ -116,7 +144,7 @@ export class SeedlessOnboardingController extends BaseController< constructor({ messenger, state, - encryptor = { encrypt, decrypt }, // default to `encrypt` and `decrypt` from `@metamask/browser-passworder` + encryptor = getDefaultSeedlessOnboardingVaultEncryptor(), network = Web3AuthNetwork.Mainnet, }: SeedlessOnboardingControllerOptions) { super({ @@ -451,20 +479,52 @@ export class SeedlessOnboardingController extends BaseController< toprfAuthKeyPair: KeyPair; }> { return this.#withVaultLock(async () => { + const { + vault: encryptedVault, + vaultEncryptionKey, + vaultEncryptionSalt, + } = this.state; const password = this.#password; - assertIsValidPassword(password); - const encryptedVault = this.state.vault; if (!encryptedVault) { throw new Error(SeedlessOnboardingControllerError.VaultError); } - // Note that vault decryption using the password is a very costly operation as it involves deriving the encryption key - // from the password using an intentionally slow key derivation function. - // We should make sure that we only call it very intentionally. - const decryptedVaultData = await this.#vaultEncryptor.decrypt( - password, - encryptedVault, - ); + + if (!vaultEncryptionKey || !password) { + throw new Error(SeedlessOnboardingControllerError.MissingCredentials); + } + + let decryptedVaultData: unknown; + + if (vaultEncryptionKey) { + const parsedEncryptedVault = JSON.parse(encryptedVault); + + if (vaultEncryptionSalt !== parsedEncryptedVault.salt) { + throw new Error(SeedlessOnboardingControllerError.ExpiredCredentials); + } + + if (typeof vaultEncryptionKey !== 'string') { + throw new TypeError( + SeedlessOnboardingControllerError.WrongPasswordType, + ); + } + + const key = await this.#vaultEncryptor.importKey(vaultEncryptionKey); + decryptedVaultData = await this.#vaultEncryptor.decryptWithKey( + key, + parsedEncryptedVault, + ); + } else { + assertIsValidPassword(password); + // Note that vault decryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const result = await this.#vaultEncryptor.decryptWithDetail( + password, + encryptedVault, + ); + decryptedVaultData = result.vault; + } const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = this.#parseVaultData(decryptedVaultData); @@ -591,13 +651,19 @@ export class SeedlessOnboardingController extends BaseController< // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key // from the password using an intentionally slow key derivation function. // We should make sure that we only call it very intentionally. - updatedState.vault = await this.#vaultEncryptor.encrypt( - password, - serializedVaultData, - ); + const { vault, exportedKeyString } = + await this.#vaultEncryptor.encryptWithDetail( + password, + serializedVaultData, + ); + updatedState.vault = vault; + updatedState.vaultEncryptionKey = exportedKeyString; + updatedState.vaultEncryptionSalt = JSON.parse(vault).salt; this.update((state) => { state.vault = updatedState.vault; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; }); this.#password = password; diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 01ff0136e2e..56ad52dff7c 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -11,6 +11,8 @@ export enum SeedlessOnboardingControllerError { FailedToPersistOprfKey = `${controllerName} - Failed to persist OPRF key`, LoginFailedError = `${controllerName} - Login failed`, InsufficientAuthToken = `${controllerName} - Insufficient auth token`, + MissingCredentials = `${controllerName} - Cannot unlock vault without password and encryption key`, + ExpiredCredentials = `${controllerName} - Encryption key and salt provided are expired`, InvalidEmptyPassword = `${controllerName} - Password cannot be empty.`, WrongPasswordType = `${controllerName} - Password must be of type string.`, InvalidVaultData = `${controllerName} - Invalid vault data`, diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 8b7d74cca86..c0d9aa73e32 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,6 +1,12 @@ import type { RestrictedMessenger } from '@metamask/base-controller'; import type { ControllerGetStateAction } from '@metamask/base-controller'; import type { ControllerStateChangeEvent } from '@metamask/base-controller'; +import type { + DetailedDecryptResult, + DetailedEncryptionResult, + EncryptionKey, + EncryptionResult, +} from '@metamask/browser-passworder'; import type { NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { Json } from '@metamask/utils'; import type { MutexInterface } from 'async-mutex'; @@ -101,6 +107,20 @@ export type VaultEncryptor = { * @returns The encrypted string. */ encrypt: (password: string, object: Json) => Promise; + /** + * Encrypts the given object with the given password, and returns the + * encryption result and the exported key string. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @param salt - The optional salt to use for encryption. + * @returns The encrypted string and the exported key string. + */ + encryptWithDetail: ( + password: string, + object: Json, + salt?: string, + ) => Promise; /** * Decrypts the given encrypted string with the given password. * @@ -109,6 +129,38 @@ export type VaultEncryptor = { * @returns The decrypted object. */ decrypt: (password: string, encryptedString: string) => Promise; + /** + * Decrypts the given encrypted string with the given password, and returns + * the decrypted object and the salt and exported key string used for + * encryption. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object and the salt and exported key string used for + * encryption. + */ + decryptWithDetail: ( + password: string, + encryptedString: string, + ) => Promise; + /** + * Decrypts the given encrypted string with the given encryption key. + * + * @param key - The encryption key to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decryptWithKey: ( + key: EncryptionKey, + encryptedString: EncryptionResult, + ) => Promise; + /** + * Generates an encryption key from exported key string. + * + * @param key - The exported key string. + * @returns The encryption key. + */ + importKey: (key: string) => Promise; }; /** From 5347ab50a977ea336375cde77feb5ffad5c04135 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 22 Apr 2025 13:36:15 +0800 Subject: [PATCH 04/10] feat: add new method 'addNewSeedPhraseBackup' for multi-srp --- .../src/SeedlessOnboardingController.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index e61470bef83..53649cb5825 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -250,6 +250,25 @@ export class SeedlessOnboardingController extends BaseController< }); } + /** + * Add a new seed phrase backup to the metadata store. + * + * @param seedPhrase - The seed phrase to backup. + * @returns A promise that resolves to the success of the operation. + */ + async addNewSeedPhraseBackup(seedPhrase: Uint8Array): Promise { + // verify the password and unlock the vault + const { toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSeedPhraseBackup( + seedPhrase, + toprfEncryptionKey, + toprfAuthKeyPair, + ); + } + /** * Fetches all encrypted seed phrases and metadata for user's account from the metadata store. * @@ -473,7 +492,7 @@ export class SeedlessOnboardingController extends BaseController< * - The password is incorrect (from encryptor.decrypt) * - The decrypted vault data is malformed */ - async #unlockVaultWithPassword(): Promise<{ + async #unlockVaultAndGetBackupEncKey(): Promise<{ nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; From 3301236ff7b57bacef2b643716fbf2c48face67e Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 22 Apr 2025 14:43:42 +0800 Subject: [PATCH 05/10] fix: fixed vaultEncKey and password check --- .../src/SeedlessOnboardingController.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 53649cb5825..66c5505a644 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -70,9 +70,9 @@ export function getDefaultSeedlessOnboardingVaultEncryptor(): VaultEncryptor { decrypt, decryptWithDetail, decryptWithKey, - importKey: (key) => { - return importKeyBrowserPassworder(key) as Promise; - }, + importKey: importKeyBrowserPassworder as ( + key: string, + ) => Promise, }; } @@ -509,13 +509,23 @@ export class SeedlessOnboardingController extends BaseController< throw new Error(SeedlessOnboardingControllerError.VaultError); } - if (!vaultEncryptionKey || !password) { + if (!vaultEncryptionKey && !password) { throw new Error(SeedlessOnboardingControllerError.MissingCredentials); } let decryptedVaultData: unknown; - if (vaultEncryptionKey) { + if (password) { + assertIsValidPassword(password); + // Note that vault decryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const result = await this.#vaultEncryptor.decryptWithDetail( + password, + encryptedVault, + ); + decryptedVaultData = result.vault; + } else { const parsedEncryptedVault = JSON.parse(encryptedVault); if (vaultEncryptionSalt !== parsedEncryptedVault.salt) { @@ -533,16 +543,6 @@ export class SeedlessOnboardingController extends BaseController< key, parsedEncryptedVault, ); - } else { - assertIsValidPassword(password); - // Note that vault decryption using the password is a very costly operation as it involves deriving the encryption key - // from the password using an intentionally slow key derivation function. - // We should make sure that we only call it very intentionally. - const result = await this.#vaultEncryptor.decryptWithDetail( - password, - encryptedVault, - ); - decryptedVaultData = result.vault; } const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = From 534f04cb4add38572a4882fcfc55673b71b95790 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 22 Apr 2025 14:44:08 +0800 Subject: [PATCH 06/10] test: updated tests for multi-srp and new vault encryption scheme --- .../src/SeedlessOnboardingController.test.ts | 346 ++++++++++++++---- .../tests/mocks/vaultEncryptor.ts | 56 ++- 2 files changed, 329 insertions(+), 73 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index dd4f6d46b1b..3025a4e2702 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -8,6 +8,7 @@ import { type ToprfSecureBackup, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, stringToBytes } from '@metamask/utils'; +import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; import { Web3AuthNetwork, @@ -236,12 +237,14 @@ async function createMockVault( }), }); - const encryptedMockVault = await encryptor.encrypt( - MOCK_PASSWORD, - serializedKeyData, - ); + const { vault: encryptedMockVault, exportedKeyString } = + await encryptor.encryptWithDetail(MOCK_PASSWORD, serializedKeyData); - return encryptedMockVault; + return { + encryptedMockVault, + vaultEncryptionKey: exportedKeyString, + vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt, + }; } /** @@ -303,11 +306,15 @@ const MOCK_NODE_AUTH_TOKENS = [ * @param options - The options. * @param options.withMockAuthenticatedUser - Whether to skip the authenticate method and use the mock authenticated user. * @param options.vault - The mock vault data. + * @param options.vaultEncryptionKey - The mock vault encryption key. + * @param options.vaultEncryptionSalt - The mock vault encryption salt. * @returns The initial controller state with the mock authenticated user. */ function getMockInitialControllerState(options?: { withMockAuthenticatedUser?: boolean; vault?: string; + vaultEncryptionKey?: string; + vaultEncryptionSalt?: string; }): Partial { const state: Partial = { backupHashes: [], @@ -317,6 +324,14 @@ function getMockInitialControllerState(options?: { state.vault = options.vault; } + if (options?.vaultEncryptionKey) { + state.vaultEncryptionKey = options.vaultEncryptionKey; + } + + if (options?.vaultEncryptionSalt) { + state.vaultEncryptionSalt = options.vaultEncryptionSalt; + } + if (options?.withMockAuthenticatedUser) { state.nodeAuthTokens = MOCK_NODE_AUTH_TOKENS; state.authConnectionId = authConnectionId; @@ -513,7 +528,7 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.vault).not.toStrictEqual({}); // verify the vault data - const encryptedMockVault = await createMockVault( + const { encryptedMockVault } = await createMockVault( encKey, authKeyPair, MOCK_PASSWORD, @@ -574,7 +589,7 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.vault).not.toStrictEqual({}); // verify the vault data - const encryptedMockVault = await createMockVault( + const { encryptedMockVault } = await createMockVault( encKey, authKeyPair, MOCK_PASSWORD, @@ -720,6 +735,260 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('addNewSeedPhraseBackup', () => { + const MOCK_PASSWORD = 'mock-password'; + const NEW_SEED_PHRASE_1 = 'new mock seed phrase 1'; + const NEW_SEED_PHRASE_2 = 'new mock seed phrase 2'; + const NEW_SEED_PHRASE_3 = 'new mock seed phrase 3'; + let MOCK_VAULT = ''; + let MOCK_VAULT_ENCRYPTION_KEY = ''; + let MOCK_VAULT_ENCRYPTION_SALT = ''; + + beforeEach(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should be able to add a new seed phrase backup', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + stringToBytes(NEW_SEED_PHRASE_1), + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + }, + ); + }); + + it('should be able to add a new seed phrase backup to the existing seed phrase backups', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + stringToBytes(NEW_SEED_PHRASE_1), + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + expect(controller.state.backupHashes).toStrictEqual([ + keccak256AndHexify(stringToBytes(NEW_SEED_PHRASE_1)), + ]); + + // add another seed phrase backup + const mockSecretDataAdd2 = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + stringToBytes(NEW_SEED_PHRASE_2), + ); + + expect(mockSecretDataAdd2.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + + const { backupHashes } = controller.state; + expect(backupHashes).toStrictEqual([ + keccak256AndHexify(stringToBytes(NEW_SEED_PHRASE_1)), + keccak256AndHexify(stringToBytes(NEW_SEED_PHRASE_2)), + ]); + + // should be able to get the hash of the seed phrase backup from the state + expect( + controller.getSeedPhraseBackupHash( + stringToBytes(NEW_SEED_PHRASE_1), + ), + ).toBeDefined(); + + // should return undefined if the seed phrase is not backed up + expect( + controller.getSeedPhraseBackupHash( + stringToBytes(NEW_SEED_PHRASE_3), + ), + ).toBeUndefined(); + }, + ); + }); + + it('should throw an error if failed to parse vault data', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, encryptor }) => { + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce('{ "foo": "bar"'); + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); + }, + ); + }); + + it('should throw an error if vault is missing', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller }) => { + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow(SeedlessOnboardingControllerError.VaultError); + }, + ); + }); + + it('should throw error if encryptionKey is missing', async () => { + await withController( + { state: getMockInitialControllerState({ vault: MOCK_VAULT }) }, + async ({ controller }) => { + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow( + SeedlessOnboardingControllerError.MissingCredentials, + ); + }, + ); + }); + + it('should throw error if encryptionSalt is different from the one in the vault', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: '0x1234', + }), + }, + async ({ controller }) => { + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow( + SeedlessOnboardingControllerError.ExpiredCredentials, + ); + }, + ); + }); + + it('should throw error if encryptionKey is of an unexpected type', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + // @ts-expect-error intentional test case + vaultEncryptionKey: 123, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow( + SeedlessOnboardingControllerError.WrongPasswordType, + ); + }, + ); + }); + + it('should throw an error if vault unlocked has an unexpected shape', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, encryptor }) => { + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce({ foo: 'bar' }); + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); + + jest.spyOn(encryptor, 'decryptWithKey').mockResolvedValueOnce('null'); + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); + }, + ); + }); + + it('should throw an error if vault unlocked has invalid authentication data', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, encryptor }) => { + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce(MOCK_VAULT); + await expect( + controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); + }, + ); + }); + }); + describe('fetchAndRestoreSeedPhrase', () => { const MOCK_PASSWORD = 'mock-password'; @@ -756,7 +1025,7 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.vault).not.toStrictEqual({}); // verify the vault data - const encryptedMockVault = await createMockVault( + const { encryptedMockVault } = await createMockVault( encKey, authKeyPair, MOCK_PASSWORD, @@ -813,7 +1082,7 @@ describe('SeedlessOnboardingController', () => { ]); // verify the vault data - const encryptedMockVault = await createMockVault( + const { encryptedMockVault } = await createMockVault( encKey, authKeyPair, MOCK_PASSWORD, @@ -912,7 +1181,7 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.vault).not.toStrictEqual({}); // verify the vault data - const encryptedMockVault = await createMockVault( + const { encryptedMockVault } = await createMockVault( encKey, authKeyPair, MOCK_PASSWORD, @@ -1241,63 +1510,6 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should throw an error if failed to parse vault data', async () => { - await withController( - { - state: getMockInitialControllerState({ vault: '{ "foo": "bar"' }), - }, - async ({ controller, encryptor }) => { - jest - .spyOn(encryptor, 'decrypt') - .mockResolvedValueOnce('{ "foo": "bar"'); - await expect( - controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), - ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); - }, - ); - }); - - it('should throw an error if vault unlocked has an unexpected shape', async () => { - await withController( - { - state: getMockInitialControllerState({ - vault: MOCK_VAULT, - withMockAuthenticatedUser: true, - }), - }, - async ({ controller, encryptor }) => { - jest - .spyOn(encryptor, 'decrypt') - .mockResolvedValueOnce({ foo: 'bar' }); - await expect( - controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), - ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); - - jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce('null'); - await expect( - controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), - ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); - }, - ); - }); - - it('should throw an error if vault unlocked has invalid authentication data', async () => { - await withController( - { - state: getMockInitialControllerState({ - vault: MOCK_VAULT, - withMockAuthenticatedUser: true, - }), - }, - async ({ controller, encryptor }) => { - jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce(MOCK_VAULT); - await expect( - controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), - ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); - }, - ); - }); - it('should throw an error if the old password is incorrect', async () => { await withController( { diff --git a/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts index 154df2544f2..53dd1951b74 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts @@ -5,7 +5,9 @@ import type { } from '@metamask/browser-passworder'; import { webcrypto } from 'node:crypto'; -export default class MockVaultEncryptor { +import type { VaultEncryptor } from '../../src/types'; + +export default class MockVaultEncryptor implements VaultEncryptor { DEFAULT_DERIVATION_PARAMS: KeyDerivationOptions = { algorithm: 'PBKDF2', params: { @@ -15,13 +17,55 @@ export default class MockVaultEncryptor { DEFAULT_SALT = 'RANDOM_SALT'; - async importKey(keyString: string) { + async encryptWithDetail( + password: string, + dataObj: unknown, + salt: string = this.DEFAULT_SALT, + keyDerivationOptions: KeyDerivationOptions = this.DEFAULT_DERIVATION_PARAMS, + ) { + const key = await this.keyFromPassword( + password, + salt, + true, + keyDerivationOptions, + ); + const exportedKeyString = await this.exportKey(key); + const vault = await this.encrypt(password, dataObj, key, salt); + + return { + vault, + exportedKeyString, + }; + } + + async decryptWithDetail(password: string, text: string) { + const payload = JSON.parse(text); + const { salt, keyMetadata } = payload; + const key = await this.keyFromPassword(password, salt, true, keyMetadata); + const exportedKeyString = await this.exportKey(key); + const vault = await this.decrypt(password, text, key); + + return { + exportedKeyString, + vault, + salt, + }; + } + + async importKey(keyString: string): Promise { try { const parsedKey = JSON.parse(keyString); - return webcrypto.subtle.importKey('jwk', parsedKey, 'AES-GCM', false, [ - 'encrypt', - 'decrypt', - ]); + const key = await webcrypto.subtle.importKey( + 'jwk', + parsedKey, + 'AES-GCM', + false, + ['encrypt', 'decrypt'], + ); + return { + key, + derivationOptions: this.DEFAULT_DERIVATION_PARAMS, + }; } catch (error) { console.error(error); throw new Error('Failed to import key'); From b795ed6ff4ac49df50203dd6a8a961d524405967 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Apr 2025 19:53:48 +0700 Subject: [PATCH 07/10] feat: updated vault and added lock --- .../package.json | 4 + .../src/SeedlessOnboardingController.ts | 343 +++++++++++++----- .../src/constants.ts | 7 + .../src/types.ts | 109 ++---- .../tsconfig.build.json | 3 + .../tsconfig.json | 3 + yarn.lock | 3 + 7 files changed, 304 insertions(+), 168 deletions(-) diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index 069b478f97d..3ce77e5dbb0 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-controller": "^21.0.3", "@noble/ciphers": "^0.5.2", "@noble/curves": "^1.2.0", "@noble/hashes": "^1.4.0", @@ -70,6 +71,9 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.2.2" }, + "peerDependencies": { + "@metamask/keyring-controller": "^21.0.0" + }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 66c5505a644..853d9cf930a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -2,12 +2,11 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; import type { StateMetadata } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { - type EncryptionKey, encrypt, decrypt, decryptWithDetail, encryptWithDetail, - decryptWithKey, + decryptWithKey as decryptWithKeyBrowserPassworder, importKey as importKeyBrowserPassworder, } from '@metamask/browser-passworder'; import type { @@ -26,6 +25,7 @@ import { import { Mutex } from 'async-mutex'; import { + type AuthConnection, controllerName, SeedlessOnboardingControllerError, Web3AuthNetwork, @@ -34,13 +34,14 @@ import { RecoveryError } from './errors'; import { projectLogger, createModuleLogger } from './logger'; import { SeedPhraseMetadata } from './SeedPhraseMetadata'; import type { - VaultEncryptor, MutuallyExclusiveCallback, SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState, VaultData, AuthenticatedUserDetails, + SocialBackupsMetadata, + VaultEncryptor, } from './types'; const log = createModuleLogger(projectLogger, controllerName); @@ -52,7 +53,7 @@ const log = createModuleLogger(projectLogger, controllerName); */ export function getDefaultSeedlessOnboardingControllerState(): SeedlessOnboardingControllerState { return { - backupHashes: [], + socialBackupsMetadata: [], }; } @@ -63,16 +64,17 @@ export function getDefaultSeedlessOnboardingControllerState(): SeedlessOnboardin * * @returns The default vault encryptor for the Seedless Onboarding Controller. */ -export function getDefaultSeedlessOnboardingVaultEncryptor(): VaultEncryptor { +export function getDefaultSeedlessOnboardingVaultEncryptor() { return { encrypt, encryptWithDetail, decrypt, decryptWithDetail, - decryptWithKey, - importKey: importKeyBrowserPassworder as ( - key: string, - ) => Promise, + decryptWithKey: decryptWithKeyBrowserPassworder as ( + key: unknown, + payload: unknown, + ) => Promise, + importKey: importKeyBrowserPassworder, }; } @@ -89,7 +91,7 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< typeof controllerName, SeedlessOnboardingControllerState, SeedlessOnboardingControllerMessenger > { - readonly #vaultEncryptor: VaultEncryptor; + readonly #vaultEncryptor: VaultEncryptor; readonly #vaultOperationMutex = new Mutex(); readonly toprfClient: ToprfSecureBackup; - #password?: string; + /** + * Controller lock state. + * + * The controller lock is synchronized with the keyring lock. + */ + #isUnlocked = false; /** * Creates a new SeedlessOnboardingController instance. @@ -144,9 +159,9 @@ export class SeedlessOnboardingController extends BaseController< constructor({ messenger, state, - encryptor = getDefaultSeedlessOnboardingVaultEncryptor(), + encryptor, network = Web3AuthNetwork.Mainnet, - }: SeedlessOnboardingControllerOptions) { + }: SeedlessOnboardingControllerOptions) { super({ name: controllerName, metadata: seedlessOnboardingMetadata, @@ -161,6 +176,14 @@ export class SeedlessOnboardingController extends BaseController< this.toprfClient = new ToprfSecureBackup({ network, }); + + // setup subscriptions to the keyring lock event + // when the keyring is locked (wallet is locked), the controller will be cleared of its credentials + this.messagingSystem.subscribe('KeyringController:lock', this.setLocked); + this.messagingSystem.subscribe( + 'KeyringController:unlock', + this.#setUnlocked, + ); } /** @@ -169,21 +192,31 @@ export class SeedlessOnboardingController extends BaseController< * * @param params - The parameters for authenticate OAuth user. * @param params.idTokens - The ID token(s) issued by OAuth verification service. Currently this array only contains a single idToken which is verified by all the nodes, in future we are considering to issue a unique idToken for each node. + * @param params.authConnection - The social login provider. * @param params.authConnectionId - OAuth authConnectionId from dashboard * @param params.userId - user email or id from Social login * @param params.groupedAuthConnectionId - Optional grouped authConnectionId to be used for the authenticate request. + * @param params.socialLoginEmail - The user email from Social login. * You can pass this to use aggregate multiple OAuth connections. Useful when you want user to have same account while using different OAuth connections. * @returns A promise that resolves to the authentication result. */ async authenticate(params: { idTokens: string[]; + authConnection: AuthConnection; authConnectionId: string; userId: string; groupedAuthConnectionId?: string; + socialLoginEmail?: string; }) { try { - const { idTokens, authConnectionId, groupedAuthConnectionId, userId } = - params; + const { + idTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + authConnection, + socialLoginEmail, + } = params; const hashedIdTokenHexes = idTokens.map((idToken) => { return remove0x(keccak256AndHexify(stringToBytes(idToken))); }); @@ -202,6 +235,8 @@ export class SeedlessOnboardingController extends BaseController< state.authConnectionId = authConnectionId; state.groupedAuthConnectionId = groupedAuthConnectionId; state.userId = userId; + state.authConnection = authConnection; + state.socialLoginEmail = socialLoginEmail; }); return authenticationResult; } catch (error) { @@ -215,11 +250,13 @@ export class SeedlessOnboardingController extends BaseController< * * @param password - The password used to create new wallet and seedphrase * @param seedPhrase - The seed phrase to backup + * @param keyringId - The keyring id of the backup seed phrase * @returns A promise that resolves to the encrypted seed phrase and the encryption key. */ async createToprfKeyAndBackupSeedPhrase( password: string, seedPhrase: Uint8Array, + keyringId: string, ): Promise { // to make sure that fail fast, // assert that the user is authenticated before creating the TOPRF key and backing up the seed phrase @@ -231,11 +268,12 @@ export class SeedlessOnboardingController extends BaseController< }); // encrypt and store the seed phrase backup - await this.#encryptAndStoreSeedPhraseBackup( + await this.#encryptAndStoreSeedPhraseBackup({ + keyringId, seedPhrase, encKey, authKeyPair, - ); + }); // store/persist the encryption key shares // We store the seed phrase metadata in the metadata store first. If this operation fails, @@ -254,19 +292,25 @@ export class SeedlessOnboardingController extends BaseController< * Add a new seed phrase backup to the metadata store. * * @param seedPhrase - The seed phrase to backup. + * @param keyringId - The keyring id of the backup seed phrase. * @returns A promise that resolves to the success of the operation. */ - async addNewSeedPhraseBackup(seedPhrase: Uint8Array): Promise { + async addNewSeedPhraseBackup( + seedPhrase: Uint8Array, + keyringId: string, + ): Promise { + this.#assertIsUnlocked(); // verify the password and unlock the vault const { toprfEncryptionKey, toprfAuthKeyPair } = await this.#unlockVaultAndGetBackupEncKey(); // encrypt and store the seed phrase backup - await this.#encryptAndStoreSeedPhraseBackup( + await this.#encryptAndStoreSeedPhraseBackup({ + keyringId, seedPhrase, - toprfEncryptionKey, - toprfAuthKeyPair, - ); + encKey: toprfEncryptionKey, + authKeyPair: toprfAuthKeyPair, + }); } /** @@ -297,11 +341,7 @@ export class SeedlessOnboardingController extends BaseController< }); } - return this.#withPersistedSeedPhraseBackupsState(() => - Promise.resolve( - SeedPhraseMetadata.parseSeedPhraseFromMetadataStore(secretData), - ), - ); + return SeedPhraseMetadata.parseSeedPhraseFromMetadataStore(secretData); } catch (error) { log('Error fetching seed phrase metadata', error); throw new Error( @@ -319,8 +359,9 @@ export class SeedlessOnboardingController extends BaseController< * @param oldPassword - The old password to verify. */ async changePassword(newPassword: string, oldPassword: string) { + this.#assertIsUnlocked(); // verify the old password of the encrypted vault - await this.#verifyPassword(oldPassword); + await this.verifyPassword(oldPassword); try { // update the encryption key with new password and update the Metadata Store @@ -339,6 +380,43 @@ export class SeedlessOnboardingController extends BaseController< } } + /** + * Update the backup metadata state for the given seed phrase. + * + * @param data - The data to backup, can be a single backup or array of backups. + * @param data.keyringId - The keyring id associated with the backup seed phrase. + * @param data.seedPhrase - The seed phrase to update the backup metadata state. + */ + updateBackupMetadataState( + data: + | { + keyringId: string; + seedPhrase: Uint8Array; + } + | { + keyringId: string; + seedPhrase: Uint8Array; + }[], + ) { + this.#assertIsUnlocked(); + + this.#filterDupesAndUpdateSocialBackupsMetadata(data); + } + + /** + * Verify the password validity by decrypting the vault. + * + * @param password - The password to verify. + * @throws {Error} If the password is invalid or the vault is not initialized. + */ + async verifyPassword(password: string): Promise { + if (!this.state.vault) { + throw new Error(SeedlessOnboardingControllerError.VaultError); + } + + await this.#vaultEncryptor.decrypt(password, this.state.vault); + } + /** * Get the hash of the seed phrase backup for the given seed phrase, from the state. * @@ -347,9 +425,48 @@ export class SeedlessOnboardingController extends BaseController< * @param seedPhrase - The seed phrase to get the hash of. * @returns A promise that resolves to the hash of the seed phrase backup. */ - getSeedPhraseBackupHash(seedPhrase: Uint8Array): string | undefined { + getSeedPhraseBackupHash( + seedPhrase: Uint8Array, + ): SocialBackupsMetadata | undefined { const seedPhraseHash = keccak256AndHexify(seedPhrase); - return this.state.backupHashes.find((hash) => hash === seedPhraseHash); + return this.state.socialBackupsMetadata.find( + (backup) => backup.hash === seedPhraseHash, + ); + } + + /** + * Submit the password to the controller, verify the password validity and unlock the controller. + * + * This method will be used especially when user rehydrate/unlock the wallet. + * The provided password will be verified against the encrypted vault, encryption key will be derived and saved in the controller state. + * + * This operation is useful when user performs some actions that requires the user password/encryption key. e.g. add new srp backup + * + * @param password - The password to submit. + */ + async submitPassword(password: string): Promise { + await this.#unlockVaultAndGetBackupEncKey(password); + this.#setUnlocked(); + } + + /** + * Set the controller to locked state, and deallocate the secrets (vault encryption key and salt). + * + * When the controller is locked, the user will not be able to perform any operations on the controller/vault. + */ + setLocked(): void { + this.#assertIsUnlocked(); + + this.update((state) => { + delete state.vaultEncryptionKey; + delete state.vaultEncryptionSalt; + }); + + this.#isUnlocked = false; + } + + #setUnlocked(): void { + this.#isUnlocked = true; } /** @@ -435,18 +552,23 @@ export class SeedlessOnboardingController extends BaseController< /** * Encrypt and store the seed phrase backup in the metadata store. * - * @param seedPhrase - The seed phrase to store. - * @param encKey - The encryption key to store. - * @param authKeyPair - The authentication key pair to store. + * @param params - The parameters for encrypting and storing the seed phrase backup. + * @param params.keyringId - The keyring id of the backup seed phrase. + * @param params.seedPhrase - The seed phrase to store. + * @param params.encKey - The encryption key to store. + * @param params.authKeyPair - The authentication key pair to store. * * @returns A promise that resolves to the success of the operation. */ - async #encryptAndStoreSeedPhraseBackup( - seedPhrase: Uint8Array, - encKey: Uint8Array, - authKeyPair: KeyPair, - ): Promise { + async #encryptAndStoreSeedPhraseBackup(params: { + keyringId: string; + seedPhrase: Uint8Array; + encKey: Uint8Array; + authKeyPair: KeyPair; + }): Promise { try { + const { keyringId, seedPhrase, encKey, authKeyPair } = params; + const seedPhraseMetadata = new SeedPhraseMetadata(seedPhrase); const secretData = seedPhraseMetadata.toBytes(); await this.#withPersistedSeedPhraseBackupsState(async () => { @@ -455,7 +577,10 @@ export class SeedlessOnboardingController extends BaseController< secretData, authKeyPair, }); - return seedPhrase; + return { + keyringId, + seedPhrase, + }; }); } catch (error) { log('Error encrypting and storing seed phrase backup', error); @@ -465,23 +590,11 @@ export class SeedlessOnboardingController extends BaseController< } } - /** - * Verify the password validity by decrypting the vault. - * - * @param password - The password to verify. - * @throws {Error} If the password is invalid or the vault is not initialized. - */ - async #verifyPassword(password: string) { - if (!this.state.vault) { - throw new Error(SeedlessOnboardingControllerError.VaultError); - } - await this.#vaultEncryptor.decrypt(password, this.state.vault); - } - /** * Unlocks the encrypted vault using the provided password and returns the decrypted vault data. * This method ensures thread-safety by using a mutex lock when accessing the vault. * + * @param password - The optional password to unlock the vault. * @returns A promise that resolves to an object containing: * - nodeAuthTokens: Authentication tokens to communicate with the TOPRF service * - toprfEncryptionKey: The decrypted TOPRF encryption key @@ -492,7 +605,7 @@ export class SeedlessOnboardingController extends BaseController< * - The password is incorrect (from encryptor.decrypt) * - The decrypted vault data is malformed */ - async #unlockVaultAndGetBackupEncKey(): Promise<{ + async #unlockVaultAndGetBackupEncKey(password?: string): Promise<{ nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; @@ -503,7 +616,6 @@ export class SeedlessOnboardingController extends BaseController< vaultEncryptionKey, vaultEncryptionSalt, } = this.state; - const password = this.#password; if (!encryptedVault) { throw new Error(SeedlessOnboardingControllerError.VaultError); @@ -514,6 +626,7 @@ export class SeedlessOnboardingController extends BaseController< } let decryptedVaultData: unknown; + const updatedState: Partial = {}; if (password) { assertIsValidPassword(password); @@ -525,6 +638,8 @@ export class SeedlessOnboardingController extends BaseController< encryptedVault, ); decryptedVaultData = result.vault; + updatedState.vaultEncryptionKey = result.exportedKeyString; + updatedState.vaultEncryptionSalt = result.salt; } else { const parsedEncryptedVault = JSON.parse(encryptedVault); @@ -543,6 +658,8 @@ export class SeedlessOnboardingController extends BaseController< key, parsedEncryptedVault, ); + updatedState.vaultEncryptionKey = vaultEncryptionKey; + updatedState.vaultEncryptionSalt = vaultEncryptionSalt; } const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = @@ -551,6 +668,8 @@ export class SeedlessOnboardingController extends BaseController< // update the state with the restored nodeAuthTokens this.update((state) => { state.nodeAuthTokens = nodeAuthTokens; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; }); return { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair }; @@ -569,46 +688,84 @@ export class SeedlessOnboardingController extends BaseController< * This is a wrapper method that should be used around any operation that creates * or restores seed phrases to ensure their hashes are properly tracked. * - * @param createOrRestoreSeedPhraseBackupCallback - function that returns either a single seed phrase + * @param createSeedPhraseBackupCallback - function that returns either a single seed phrase * or an array of seed phrases as Uint8Array(s) * @returns The original seed phrase(s) returned by the callback * @throws Rethrows any errors from the callback with additional logging */ - async #withPersistedSeedPhraseBackupsState< - Result extends Uint8Array | Uint8Array[], - >( - createOrRestoreSeedPhraseBackupCallback: () => Promise, - ): Promise { + async #withPersistedSeedPhraseBackupsState( + createSeedPhraseBackupCallback: () => Promise<{ + keyringId: string; + seedPhrase: Uint8Array; + }>, + ): Promise<{ + keyringId: string; + seedPhrase: Uint8Array; + }> { try { - const backedUpSeedPhrases = - await createOrRestoreSeedPhraseBackupCallback(); - let backedUpHashB64Strings: string[] = []; + const newBackup = await createSeedPhraseBackupCallback(); - if (Array.isArray(backedUpSeedPhrases)) { - backedUpHashB64Strings = backedUpSeedPhrases.map((seedPhrase) => - keccak256AndHexify(seedPhrase), - ); - } else { - backedUpHashB64Strings = [keccak256AndHexify(backedUpSeedPhrases)]; - } - - const existingBackedUpHashes = this.state.backupHashes; - const uniqueHashesSet = new Set([ - ...existingBackedUpHashes, - ...backedUpHashB64Strings, - ]); + this.#filterDupesAndUpdateSocialBackupsMetadata(newBackup); - this.update((state) => { - state.backupHashes = Array.from(uniqueHashesSet); - }); - - return backedUpSeedPhrases; + return newBackup; } catch (error) { log('Error persisting seed phrase backups', error); throw error; } } + /** + * Updates the social backups metadata state by adding new unique seed phrase backups. + * This method ensures no duplicate backups are stored by checking the hash of each seed phrase. + * + * @param data - The backup data to add to the state + * @param data.id - The identifier for the backup + * @param data.seedPhrase - The seed phrase to backup as a Uint8Array + */ + #filterDupesAndUpdateSocialBackupsMetadata( + data: + | { + keyringId: string; + seedPhrase: Uint8Array; + } + | { + keyringId: string; + seedPhrase: Uint8Array; + }[], + ) { + const currentBackupsMetadata = this.state.socialBackupsMetadata; + + const newBackupsMetadata = Array.isArray(data) ? data : [data]; + const filteredNewBackupsMetadata: SocialBackupsMetadata[] = []; + + // filter out the backed up metadata that already exists in the state + // to prevent duplicates + newBackupsMetadata.forEach((item) => { + const { keyringId, seedPhrase } = item; + const backupHash = keccak256AndHexify(seedPhrase); + + const existingBackupMetadata = currentBackupsMetadata.find( + (backup) => backup.hash === backupHash, + ); + + if (!existingBackupMetadata) { + filteredNewBackupsMetadata.push({ + id: keyringId, + hash: backupHash, + }); + } + }); + + if (filteredNewBackupsMetadata.length > 0) { + this.update((state) => { + state.socialBackupsMetadata = [ + ...state.socialBackupsMetadata, + ...filteredNewBackupsMetadata, + ]; + }); + } + } + /** * Create a new vault with the given authentication data. * @@ -629,6 +786,7 @@ export class SeedlessOnboardingController extends BaseController< rawToprfAuthKeyPair: KeyPair; }): Promise { this.#assertIsAuthenticatedUser(this.state); + this.#setUnlocked(); const { toprfEncryptionKey, toprfAuthKeyPair } = this.#serializeKeyData( rawToprfEncryptionKey, @@ -665,8 +823,6 @@ export class SeedlessOnboardingController extends BaseController< await this.#withVaultLock(async () => { assertIsValidPassword(password); - const updatedState: Partial = {}; - // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key // from the password using an intentionally slow key derivation function. // We should make sure that we only call it very intentionally. @@ -675,17 +831,12 @@ export class SeedlessOnboardingController extends BaseController< password, serializedVaultData, ); - updatedState.vault = vault; - updatedState.vaultEncryptionKey = exportedKeyString; - updatedState.vaultEncryptionSalt = JSON.parse(vault).salt; this.update((state) => { - state.vault = updatedState.vault; - state.vaultEncryptionKey = updatedState.vaultEncryptionKey; - state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.vault = vault; + state.vaultEncryptionKey = exportedKeyString; + state.vaultEncryptionSalt = JSON.parse(vault).salt; }); - - this.#password = password; }); } @@ -773,6 +924,12 @@ export class SeedlessOnboardingController extends BaseController< }; } + #assertIsUnlocked(): void { + if (!this.#isUnlocked) { + throw new Error(SeedlessOnboardingControllerError.ControllerLocked); + } + } + /** * Assert that the provided value contains valid authenticated user information. * diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 56ad52dff7c..df26d8a55c5 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -5,7 +5,14 @@ export enum Web3AuthNetwork { Devnet = 'sapphire_devnet', } +// user social login provider +export enum AuthConnection { + Google = 'google', + Apple = 'apple', +} + export enum SeedlessOnboardingControllerError { + ControllerLocked = `${controllerName} - The operation cannot be completed while the controller is locked.`, AuthenticationError = `${controllerName} - Authentication error`, MissingAuthUserInfo = `${controllerName} - Missing authenticated user information`, FailedToPersistOprfKey = `${controllerName} - Failed to persist OPRF key`, diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index c0d9aa73e32..052bd920163 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -2,18 +2,30 @@ import type { RestrictedMessenger } from '@metamask/base-controller'; import type { ControllerGetStateAction } from '@metamask/base-controller'; import type { ControllerStateChangeEvent } from '@metamask/base-controller'; import type { - DetailedDecryptResult, - DetailedEncryptionResult, - EncryptionKey, - EncryptionResult, -} from '@metamask/browser-passworder'; + ExportableKeyEncryptor, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; import type { NodeAuthTokens } from '@metamask/toprf-secure-backup'; -import type { Json } from '@metamask/utils'; import type { MutexInterface } from 'async-mutex'; -import type { controllerName, Web3AuthNetwork } from './constants'; +import type { + AuthConnection, + controllerName, + Web3AuthNetwork, +} from './constants'; + +export type SocialBackupsMetadata = { + id: string; + hash: string; +}; export type AuthenticatedUserDetails = { + /** + * Type of social login provider. + */ + authConnection: AuthConnection; + /** * The node auth tokens from OAuth User authentication after the Social login. * @@ -35,6 +47,11 @@ export type AuthenticatedUserDetails = { * The user email or ID from Social login. */ userId: string; + + /** + * The user email from Social login. + */ + socialLoginEmail: string; }; // State @@ -50,7 +67,7 @@ export type SeedlessOnboardingControllerState = * * This is to facilitate the UI to display backup status of the seed phrases. */ - backupHashes: string[]; + socialBackupsMetadata: SocialBackupsMetadata[]; /** * The encryption key derived from the password and used to encrypt @@ -84,7 +101,9 @@ export type SeedlessOnboardingControllerStateChangeEvent = export type SeedlessOnboardingControllerEvents = SeedlessOnboardingControllerStateChangeEvent; -export type AllowedEvents = never; +export type AllowedEvents = + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; // Messenger export type SeedlessOnboardingControllerMessenger = RestrictedMessenger< @@ -98,70 +117,10 @@ export type SeedlessOnboardingControllerMessenger = RestrictedMessenger< /** * Encryptor interface for encrypting and decrypting seedless onboarding vault. */ -export type VaultEncryptor = { - /** - * Encrypts the given object with the given password. - * - * @param password - The password to encrypt with. - * @param object - The object to encrypt. - * @returns The encrypted string. - */ - encrypt: (password: string, object: Json) => Promise; - /** - * Encrypts the given object with the given password, and returns the - * encryption result and the exported key string. - * - * @param password - The password to encrypt with. - * @param object - The object to encrypt. - * @param salt - The optional salt to use for encryption. - * @returns The encrypted string and the exported key string. - */ - encryptWithDetail: ( - password: string, - object: Json, - salt?: string, - ) => Promise; - /** - * Decrypts the given encrypted string with the given password. - * - * @param password - The password to decrypt with. - * @param encryptedString - The encrypted string to decrypt. - * @returns The decrypted object. - */ - decrypt: (password: string, encryptedString: string) => Promise; - /** - * Decrypts the given encrypted string with the given password, and returns - * the decrypted object and the salt and exported key string used for - * encryption. - * - * @param password - The password to decrypt with. - * @param encryptedString - The encrypted string to decrypt. - * @returns The decrypted object and the salt and exported key string used for - * encryption. - */ - decryptWithDetail: ( - password: string, - encryptedString: string, - ) => Promise; - /** - * Decrypts the given encrypted string with the given encryption key. - * - * @param key - The encryption key to decrypt with. - * @param encryptedString - The encrypted string to decrypt. - * @returns The decrypted object. - */ - decryptWithKey: ( - key: EncryptionKey, - encryptedString: EncryptionResult, - ) => Promise; - /** - * Generates an encryption key from exported key string. - * - * @param key - The exported key string. - * @returns The encryption key. - */ - importKey: (key: string) => Promise; -}; +export type VaultEncryptor = Omit< + ExportableKeyEncryptor, + 'encryptWithKey' +>; /** * Seedless Onboarding Controller Options. @@ -170,7 +129,7 @@ export type VaultEncryptor = { * @param state - The initial state to set on this controller. * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. */ -export type SeedlessOnboardingControllerOptions = { +export type SeedlessOnboardingControllerOptions = { messenger: SeedlessOnboardingControllerMessenger; /** @@ -183,7 +142,7 @@ export type SeedlessOnboardingControllerOptions = { * * @default browser-passworder @link https://github.com/MetaMask/browser-passworder */ - encryptor?: VaultEncryptor; + encryptor: VaultEncryptor; /** * Type of Web3Auth network to be used for the Seedless Onboarding flow. diff --git a/packages/seedless-onboarding-controller/tsconfig.build.json b/packages/seedless-onboarding-controller/tsconfig.build.json index 38d8a31843f..363d67c8df8 100644 --- a/packages/seedless-onboarding-controller/tsconfig.build.json +++ b/packages/seedless-onboarding-controller/tsconfig.build.json @@ -11,6 +11,9 @@ }, { "path": "../message-manager/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/seedless-onboarding-controller/tsconfig.json b/packages/seedless-onboarding-controller/tsconfig.json index 831b2ae3b47..9167ff78a2a 100644 --- a/packages/seedless-onboarding-controller/tsconfig.json +++ b/packages/seedless-onboarding-controller/tsconfig.json @@ -9,6 +9,9 @@ }, { "path": "../message-manager" + }, + { + "path": "../keyring-controller" } ], "include": ["../../types", "./src", "./tests"] diff --git a/yarn.lock b/yarn.lock index 6d51b149355..c2f9ed97cca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4257,6 +4257,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.0" "@metamask/browser-passworder": "npm:^4.3.0" + "@metamask/keyring-controller": "npm:^21.0.3" "@metamask/toprf-secure-backup": ./toprf-secure-backup.tgz "@metamask/utils": "npm:^11.2.0" "@noble/ciphers": "npm:^0.5.2" @@ -4273,6 +4274,8 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/keyring-controller": ^21.0.0 languageName: unknown linkType: soft From 18db087ef7dcde49072ac750adccc996b29d6662 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Apr 2025 20:05:30 +0700 Subject: [PATCH 08/10] fix: updated array order from fetchAllSeedlPhrases method --- .../seedless-onboarding-controller/src/SeedPhraseMetadata.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedPhraseMetadata.ts b/packages/seedless-onboarding-controller/src/SeedPhraseMetadata.ts index c162fa60be5..d932d15fd81 100644 --- a/packages/seedless-onboarding-controller/src/SeedPhraseMetadata.ts +++ b/packages/seedless-onboarding-controller/src/SeedPhraseMetadata.ts @@ -96,7 +96,7 @@ export class SeedPhraseMetadata implements ISeedPhraseMetadata { /** * Parse the seed phrase metadata from the metadata store and return the array of raw seed phrases. * - * This method also sorts the seed phrases by timestamp in descending order, i.e. the newest seed phrase will be the first element in the array. + * This method also sorts the seed phrases by timestamp in ascending order, i.e. the oldest seed phrase will be the first element in the array. * * @param seedPhraseMetadataArr - The array of SeedPhrase Metadata from the metadata store. * @returns The array of raw seed phrases. @@ -141,7 +141,7 @@ export class SeedPhraseMetadata implements ISeedPhraseMetadata { */ static sort( seedPhrases: SeedPhraseMetadata[], - order: 'asc' | 'desc' = 'desc', + order: 'asc' | 'desc' = 'asc', ): SeedPhraseMetadata[] { return seedPhrases.sort((a, b) => { if (order === 'asc') { From b10a458e116e9311c68d1d23e05b0359763fee72 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Apr 2025 20:05:41 +0700 Subject: [PATCH 09/10] fix: fixed tests --- .../src/SeedlessOnboardingController.test.ts | 557 ++++++++++++++---- .../tests/mocks/vaultEncryptor.ts | 20 +- 2 files changed, 456 insertions(+), 121 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 056fb353a3d..d7cd72e2a59 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -1,4 +1,5 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; +import type { EncryptionKey } from '@metamask/browser-passworder'; import { TOPRFError, type ChangeEncryptionKeyResult, @@ -8,7 +9,7 @@ import { type ToprfSecureBackup, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, stringToBytes } from '@metamask/utils'; -import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; +import type { webcrypto } from 'node:crypto'; import { Web3AuthNetwork, @@ -18,6 +19,7 @@ import { import { RecoveryError } from './errors'; import { getDefaultSeedlessOnboardingControllerState, + getDefaultSeedlessOnboardingVaultEncryptor, SeedlessOnboardingController, } from './SeedlessOnboardingController'; import { SeedPhraseMetadata } from './SeedPhraseMetadata'; @@ -25,6 +27,7 @@ import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState, + VaultEncryptor, } from './types'; import { handleMockSecretDataGet, @@ -39,24 +42,26 @@ import { import { MockToprfEncryptorDecryptor } from '../tests/mocks/toprfEncryptor'; import MockVaultEncryptor from '../tests/mocks/vaultEncryptor'; -type WithControllerCallback = ({ +type WithControllerCallback = ({ controller, initialState, encryptor, messenger, }: { - controller: SeedlessOnboardingController; - encryptor: MockVaultEncryptor; + controller: SeedlessOnboardingController; + encryptor: VaultEncryptor; initialState: SeedlessOnboardingControllerState; messenger: SeedlessOnboardingControllerMessenger; toprfClient: ToprfSecureBackup; }) => Promise | ReturnValue; -type WithControllerOptions = Partial; +type WithControllerOptions = Partial< + SeedlessOnboardingControllerOptions +>; -type WithControllerArgs = - | [WithControllerCallback] - | [WithControllerOptions, WithControllerCallback]; +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; /** * Creates a mock user operation messenger. @@ -93,7 +98,7 @@ function createMockVaultEncryptor() { * @returns Whatever the callback returns. */ async function withController( - ...args: WithControllerArgs + ...args: WithControllerArgs ) { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; const encryptor = new MockVaultEncryptor(); @@ -106,7 +111,6 @@ async function withController( ...rest, }); const { toprfClient } = controller; - return await fn({ controller, encryptor, @@ -214,6 +218,35 @@ function mockChangeEncKey( return { encKey, authKeyPair }; } +/** + * Mocks the createToprfKeyAndBackupSeedPhrase method of the SeedlessOnboardingController instance. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param controller - The SeedlessOnboardingController instance. + * @param password - The mock password. + * @param seedPhrase - The mock seed phrase. + * @param keyringId - The mock keyring id. + */ +async function mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient: ToprfSecureBackup, + controller: SeedlessOnboardingController, + password: string, + seedPhrase: Uint8Array, + keyringId: string, +) { + mockcreateLocalKey(toprfClient, password); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + await controller.createToprfKeyAndBackupSeedPhrase( + password, + seedPhrase, + keyringId, + ); +} + /** * Creates a mock vault. * @@ -357,11 +390,12 @@ describe('SeedlessOnboardingController', () => { const messenger = buildSeedlessOnboardingControllerMessenger(); const controller = new SeedlessOnboardingController({ messenger, + encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), }); expect(controller).toBeDefined(); - expect(controller.state).toStrictEqual({ - socialBackupsMetadata: [], - }); + expect(controller.state).toStrictEqual( + getDefaultSeedlessOnboardingControllerState(), + ); }); it('should be able to instantiate with an encryptor', () => { @@ -763,9 +797,18 @@ describe('SeedlessOnboardingController', () => { describe('addNewSeedPhraseBackup', () => { const MOCK_PASSWORD = 'mock-password'; - const NEW_SEED_PHRASE_1 = 'new mock seed phrase 1'; - const NEW_SEED_PHRASE_2 = 'new mock seed phrase 2'; - const NEW_SEED_PHRASE_3 = 'new mock seed phrase 3'; + const NEW_KEY_RING_1 = { + id: 'new-keyring-1', + seedPhrase: stringToBytes('new mock seed phrase 1'), + }; + const NEW_KEY_RING_2 = { + id: 'new-keyring-2', + seedPhrase: stringToBytes('new mock seed phrase 2'), + }; + const NEW_KEY_RING_3 = { + id: 'new-keyring-3', + seedPhrase: stringToBytes('new mock seed phrase 3'), + }; let MOCK_VAULT = ''; let MOCK_VAULT_ENCRYPTION_KEY = ''; let MOCK_VAULT_ENCRYPTION_SALT = ''; @@ -790,6 +833,17 @@ describe('SeedlessOnboardingController', () => { MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; }); + it('should throw an error if the controller is locked', async () => { + await withController(async ({ controller }) => { + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).rejects.toThrow(SeedlessOnboardingControllerError.ControllerLocked); + }); + }); + it('should be able to add a new seed phrase backup', async () => { await withController( { @@ -801,10 +855,13 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + // encrypt and store the secret data const mockSecretDataAdd = handleMockSecretDataAdd(); await controller.addNewSeedPhraseBackup( - stringToBytes(NEW_SEED_PHRASE_1), + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, ); expect(mockSecretDataAdd.isDone()).toBe(true); @@ -827,10 +884,13 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + // encrypt and store the secret data const mockSecretDataAdd = handleMockSecretDataAdd(); await controller.addNewSeedPhraseBackup( - stringToBytes(NEW_SEED_PHRASE_1), + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, ); expect(mockSecretDataAdd.isDone()).toBe(true); @@ -838,14 +898,18 @@ describe('SeedlessOnboardingController', () => { expect(controller.state.nodeAuthTokens).toStrictEqual( MOCK_NODE_AUTH_TOKENS, ); - expect(controller.state.backupHashes).toStrictEqual([ - keccak256AndHexify(stringToBytes(NEW_SEED_PHRASE_1)), + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { + id: NEW_KEY_RING_1.id, + hash: keccak256AndHexify(NEW_KEY_RING_1.seedPhrase), + }, ]); // add another seed phrase backup const mockSecretDataAdd2 = handleMockSecretDataAdd(); await controller.addNewSeedPhraseBackup( - stringToBytes(NEW_SEED_PHRASE_2), + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, ); expect(mockSecretDataAdd2.isDone()).toBe(true); @@ -854,24 +918,25 @@ describe('SeedlessOnboardingController', () => { MOCK_NODE_AUTH_TOKENS, ); - const { backupHashes } = controller.state; - expect(backupHashes).toStrictEqual([ - keccak256AndHexify(stringToBytes(NEW_SEED_PHRASE_1)), - keccak256AndHexify(stringToBytes(NEW_SEED_PHRASE_2)), + const { socialBackupsMetadata } = controller.state; + expect(socialBackupsMetadata).toStrictEqual([ + { + id: NEW_KEY_RING_1.id, + hash: keccak256AndHexify(NEW_KEY_RING_1.seedPhrase), + }, + { + id: NEW_KEY_RING_2.id, + hash: keccak256AndHexify(NEW_KEY_RING_2.seedPhrase), + }, ]); - // should be able to get the hash of the seed phrase backup from the state expect( - controller.getSeedPhraseBackupHash( - stringToBytes(NEW_SEED_PHRASE_1), - ), + controller.getSeedPhraseBackupHash(NEW_KEY_RING_1.seedPhrase), ).toBeDefined(); // should return undefined if the seed phrase is not backed up expect( - controller.getSeedPhraseBackupHash( - stringToBytes(NEW_SEED_PHRASE_3), - ), + controller.getSeedPhraseBackupHash(NEW_KEY_RING_3.seedPhrase), ).toBeUndefined(); }, ); @@ -888,37 +953,54 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, encryptor }) => { + await controller.submitPassword(MOCK_PASSWORD); + jest .spyOn(encryptor, 'decryptWithKey') .mockResolvedValueOnce('{ "foo": "bar"'); await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); }, ); }); - it('should throw an error if vault is missing', async () => { + it('should throw error if encryptionKey is missing', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, + vault: MOCK_VAULT, }), }, - async ({ controller }) => { - await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), - ).rejects.toThrow(SeedlessOnboardingControllerError.VaultError); - }, - ); - }); + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + // @ts-expect-error intentional test case + exportedKeyString: undefined, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); - it('should throw error if encryptionKey is missing', async () => { - await withController( - { state: getMockInitialControllerState({ vault: MOCK_VAULT }) }, - async ({ controller }) => { await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), ).rejects.toThrow( SeedlessOnboardingControllerError.MissingCredentials, ); @@ -931,14 +1013,27 @@ describe('SeedlessOnboardingController', () => { { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: '0x1234', }), }, - async ({ controller }) => { + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // intentionally mock the JSON.parse to return an object with a different salt + jest.spyOn(global.JSON, 'parse').mockReturnValueOnce({ + salt: 'different-salt', + }); + await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), ).rejects.toThrow( SeedlessOnboardingControllerError.ExpiredCredentials, ); @@ -952,14 +1047,33 @@ describe('SeedlessOnboardingController', () => { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: MOCK_VAULT, - // @ts-expect-error intentional test case - vaultEncryptionKey: 123, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, }), }, - async ({ controller }) => { + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + // @ts-expect-error intentional test case + exportedKeyString: 123, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), ).rejects.toThrow( SeedlessOnboardingControllerError.WrongPasswordType, ); @@ -973,21 +1087,43 @@ describe('SeedlessOnboardingController', () => { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, }), }, - async ({ controller, encryptor }) => { + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + exportedKeyString: MOCK_VAULT_ENCRYPTION_KEY, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + jest .spyOn(encryptor, 'decryptWithKey') .mockResolvedValueOnce({ foo: 'bar' }); await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); jest.spyOn(encryptor, 'decryptWithKey').mockResolvedValueOnce('null'); await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); }, ); @@ -999,16 +1135,35 @@ describe('SeedlessOnboardingController', () => { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, }), }, - async ({ controller, encryptor }) => { + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + exportedKeyString: MOCK_VAULT_ENCRYPTION_KEY, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + jest .spyOn(encryptor, 'decryptWithKey') .mockResolvedValueOnce(MOCK_VAULT); await expect( - controller.addNewSeedPhraseBackup(stringToBytes(NEW_SEED_PHRASE_1)), + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); }, ); @@ -1099,12 +1254,12 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); - // `fetchAndRestoreSeedPhraseMetadata` should sort the seed phrases by timestamp and return the seed phrases in the correct order - // the seed phrases are sorted in descending order, so the firstly created seed phrase is the latest item in the array + // `fetchAndRestoreSeedPhraseMetadata` should sort the seed phrases by timestamp in ascending order and return the seed phrases in the correct order + // the seed phrases are sorted in ascending order, so the oldest seed phrase is the first item in the array expect(secretData).toStrictEqual([ - stringToBytes('seedPhrase3'), - stringToBytes('seedPhrase2'), stringToBytes('seedPhrase1'), + stringToBytes('seedPhrase2'), + stringToBytes('seedPhrase3'), ]); // verify the vault data @@ -1337,19 +1492,127 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('submitPassword', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should throw error if the vault is missing', async () => { + await withController(async ({ controller }) => { + await expect(controller.submitPassword(MOCK_PASSWORD)).rejects.toThrow( + SeedlessOnboardingControllerError.VaultError, + ); + }); + }); + + it('should throw error if the password is invalid', async () => { + await withController( + { + state: { + vault: 'MOCK_VAULT', + }, + }, + async ({ controller }) => { + // @ts-expect-error intentional test case + await expect(controller.submitPassword(123)).rejects.toThrow( + SeedlessOnboardingControllerError.WrongPasswordType, + ); + }, + ); + }); + }); + + describe('verifyPassword', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should not throw an error if the password is valid', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: 'MOCK_VAULT', + }), + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce('MOCK_VAULT'); + + expect(async () => { + await controller.verifyPassword(MOCK_PASSWORD); + }).not.toThrow(); + }, + ); + }); + + it('should throw an error if the password is invalid', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: 'MOCK_VAULT', + }), + }, + async ({ controller, encryptor }) => { + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValueOnce(new Error('Incorrect password')); + + await expect( + controller.verifyPassword(MOCK_PASSWORD), + ).rejects.toThrow('Incorrect password'); + }, + ); + }); + + it('should throw an error if the vault is missing', async () => { + await withController(async ({ controller }) => { + await expect(controller.verifyPassword(MOCK_PASSWORD)).rejects.toThrow( + SeedlessOnboardingControllerError.VaultError, + ); + }); + }); + }); + describe('updateBackupMetadataState', () => { + const MOCK_PASSWORD = 'mock-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + + beforeEach(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + it('should be able to update the backup metadata state', async () => { await withController( { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, }), }, async ({ controller }) => { - controller.updateBackupMetadataState( - MOCK_KEYRING_ID, - MOCK_SEED_PHRASE, - ); + await controller.submitPassword(MOCK_PASSWORD); + + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); expect(controller.state.socialBackupsMetadata).toStrictEqual([ { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, @@ -1363,28 +1626,69 @@ describe('SeedlessOnboardingController', () => { { state: getMockInitialControllerState({ withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, }), }, async ({ controller }) => { - controller.updateBackupMetadataState( - MOCK_KEYRING_ID, - MOCK_SEED_PHRASE, - ); + await controller.submitPassword(MOCK_PASSWORD); + + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); expect(controller.state.socialBackupsMetadata).toStrictEqual([ { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, ]); - controller.updateBackupMetadataState( - MOCK_KEYRING_ID, - MOCK_SEED_PHRASE, - ); + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); expect(controller.state.socialBackupsMetadata).toStrictEqual([ { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, ]); }, ); }); + + it('should be able to update the backup metadata state with an array of backups', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + const MOCK_SEED_PHRASE_2 = stringToBytes('mock-seed-phrase-2'); + const MOCK_KEYRING_ID_2 = 'mock-keyring-id-2'; + + controller.updateBackupMetadataState([ + { + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }, + { + keyringId: MOCK_KEYRING_ID_2, + seedPhrase: MOCK_SEED_PHRASE_2, + }, + ]); + const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); + const MOCK_SEED_PHRASE_2_HASH = + keccak256AndHexify(MOCK_SEED_PHRASE_2); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, + { id: MOCK_KEYRING_ID_2, hash: MOCK_SEED_PHRASE_2_HASH }, + ]); + }, + ); + }); }); describe('changePassword', () => { @@ -1400,13 +1704,9 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient }) => { - mockcreateLocalKey(toprfClient, MOCK_PASSWORD); - - // persist the local enc key - jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); - // encrypt and store the secret data - handleMockSecretDataAdd(); - await controller.createToprfKeyAndBackupSeedPhrase( + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, MOCK_PASSWORD, MOCK_SEED_PHRASE, MOCK_KEYRING_ID, @@ -1469,13 +1769,9 @@ describe('SeedlessOnboardingController', () => { }, }, async ({ controller, toprfClient }) => { - mockcreateLocalKey(toprfClient, MOCK_PASSWORD); - - // persist the local enc key - jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); - // encrypt and store the secret data - handleMockSecretDataAdd(); - await controller.createToprfKeyAndBackupSeedPhrase( + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, MOCK_PASSWORD, MOCK_SEED_PHRASE, MOCK_KEYRING_ID, @@ -1528,19 +1824,12 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should throw an error if vault is missing', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - }), - }, - async ({ controller }) => { - await expect( - controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), - ).rejects.toThrow(SeedlessOnboardingControllerError.VaultError); - }, - ); + it('should throw an error if the controller is locked', async () => { + await withController(async ({ controller }) => { + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow(SeedlessOnboardingControllerError.ControllerLocked); + }); }); it('should throw an error if the old password is incorrect', async () => { @@ -1551,7 +1840,15 @@ describe('SeedlessOnboardingController', () => { withMockAuthenticatedUser: true, }), }, - async ({ controller, encryptor }) => { + async ({ controller, encryptor, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + jest .spyOn(encryptor, 'decrypt') .mockRejectedValueOnce(new Error('Incorrect password')); @@ -1570,13 +1867,9 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient }) => { - mockcreateLocalKey(toprfClient, MOCK_PASSWORD); - - // persist the local enc key - jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); - // encrypt and store the secret data - handleMockSecretDataAdd(); - await controller.createToprfKeyAndBackupSeedPhrase( + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, MOCK_PASSWORD, MOCK_SEED_PHRASE, MOCK_KEYRING_ID, @@ -1688,6 +1981,38 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('lock', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should lock the controller', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + controller.setLocked(); + + await expect( + controller.addNewSeedPhraseBackup( + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow(SeedlessOnboardingControllerError.ControllerLocked); + }, + ); + }); + }); + describe('SeedPhraseMetadata', () => { it('should be able to create a seed phrase metadata', () => { // should be able to create a SeedPhraseMetadata instance via constructor diff --git a/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts index 53dd1951b74..e3568755c45 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts @@ -3,11 +3,14 @@ import type { EncryptionResult, KeyDerivationOptions, } from '@metamask/browser-passworder'; +import type { Json } from '@metamask/utils'; import { webcrypto } from 'node:crypto'; import type { VaultEncryptor } from '../../src/types'; -export default class MockVaultEncryptor implements VaultEncryptor { +export default class MockVaultEncryptor + implements VaultEncryptor +{ DEFAULT_DERIVATION_PARAMS: KeyDerivationOptions = { algorithm: 'PBKDF2', params: { @@ -19,7 +22,7 @@ export default class MockVaultEncryptor implements VaultEncryptor { async encryptWithDetail( password: string, - dataObj: unknown, + dataObj: Json, salt: string = this.DEFAULT_SALT, keyDerivationOptions: KeyDerivationOptions = this.DEFAULT_DERIVATION_PARAMS, ) { @@ -148,8 +151,15 @@ export default class MockVaultEncryptor implements VaultEncryptor { async decryptWithKey( encryptionKey: EncryptionKey | webcrypto.CryptoKey, - encData: EncryptionResult, + payload: string, ) { + let encData: EncryptionResult; + if (typeof payload === 'string') { + encData = JSON.parse(payload); + } else { + encData = payload; + } + const encryptedData = Buffer.from(encData.data, 'base64'); const vector = Buffer.from(encData.iv, 'base64'); const key = 'key' in encryptionKey ? encryptionKey.key : encryptionKey; @@ -167,9 +177,9 @@ export default class MockVaultEncryptor implements VaultEncryptor { return decryptedObj; } - async encrypt( + async encrypt( password: string, - dataObj: R, + dataObj: Json, // eslint-disable-next-line n/no-unsupported-features/node-builtins key?: EncryptionKey | CryptoKey, salt: string = this.DEFAULT_SALT, From 8578a4853324b3d1a83501648cdaefe3221f7e6e Mon Sep 17 00:00:00 2001 From: Lwin <147362763+lwin-kyaw@users.noreply.github.com> Date: Tue, 29 Apr 2025 20:06:43 +0700 Subject: [PATCH 10/10] fix: updated 'MissingCredentials' error message Co-authored-by: himanshuchawla009 --- packages/seedless-onboarding-controller/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index df26d8a55c5..fde6dde5a57 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -18,7 +18,7 @@ export enum SeedlessOnboardingControllerError { FailedToPersistOprfKey = `${controllerName} - Failed to persist OPRF key`, LoginFailedError = `${controllerName} - Login failed`, InsufficientAuthToken = `${controllerName} - Insufficient auth token`, - MissingCredentials = `${controllerName} - Cannot unlock vault without password and encryption key`, + MissingCredentials = `${controllerName} - Cannot unlock vault without password or encryption key.`, ExpiredCredentials = `${controllerName} - Encryption key and salt provided are expired`, InvalidEmptyPassword = `${controllerName} - Password cannot be empty.`, WrongPasswordType = `${controllerName} - Password must be of type string.`,