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/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') { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index e24d5215cac..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,6 +9,7 @@ import { type ToprfSecureBackup, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, stringToBytes } from '@metamask/utils'; +import type { webcrypto } from 'node:crypto'; import { Web3AuthNetwork, @@ -17,6 +19,7 @@ import { import { RecoveryError } from './errors'; import { getDefaultSeedlessOnboardingControllerState, + getDefaultSeedlessOnboardingVaultEncryptor, SeedlessOnboardingController, } from './SeedlessOnboardingController'; import { SeedPhraseMetadata } from './SeedPhraseMetadata'; @@ -24,6 +27,7 @@ import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState, + VaultEncryptor, } from './types'; import { handleMockSecretDataGet, @@ -38,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. @@ -92,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(); @@ -105,7 +111,6 @@ async function withController( ...rest, }); const { toprfClient } = controller; - return await fn({ controller, encryptor, @@ -213,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. * @@ -240,12 +274,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, + }; } /** @@ -309,11 +345,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 = getDefaultSeedlessOnboardingControllerState(); @@ -321,6 +361,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; @@ -342,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', () => { @@ -531,7 +580,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, @@ -595,7 +644,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, @@ -746,6 +795,381 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('addNewSeedPhraseBackup', () => { + const MOCK_PASSWORD = 'mock-password'; + 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 = ''; + + 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 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( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + 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 }) => { + await controller.submitPassword(MOCK_PASSWORD); + + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + 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( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ); + + expect(mockSecretDataAdd2.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + + 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(NEW_KEY_RING_1.seedPhrase), + ).toBeDefined(); + + // should return undefined if the seed phrase is not backed up + expect( + controller.getSeedPhraseBackupHash(NEW_KEY_RING_3.seedPhrase), + ).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 }) => { + await controller.submitPassword(MOCK_PASSWORD); + + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce('{ "foo": "bar"'); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); + }, + ); + }); + + it('should throw error if encryptionKey is missing', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + }), + }, + 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, + ); + + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).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, + }), + }, + 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( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).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, + }), + }, + 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( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).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, + }), + }, + 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( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow(SeedlessOnboardingControllerError.InvalidVaultData); + + jest.spyOn(encryptor, 'decryptWithKey').mockResolvedValueOnce('null'); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).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, + }), + }, + 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( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow(SeedlessOnboardingControllerError.VaultDataError); + }, + ); + }); + }); + describe('fetchAndRestoreSeedPhrase', () => { const MOCK_PASSWORD = 'mock-password'; @@ -782,7 +1206,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, @@ -830,16 +1254,16 @@ 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 - const encryptedMockVault = await createMockVault( + const { encryptedMockVault } = await createMockVault( encKey, authKeyPair, MOCK_PASSWORD, @@ -895,7 +1319,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, @@ -1068,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 }, @@ -1094,24 +1626,65 @@ 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 }, ]); }, ); @@ -1131,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, @@ -1200,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, @@ -1259,76 +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 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 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 () => { @@ -1339,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')); @@ -1358,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, @@ -1476,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/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index baa7120143b..853d9cf930a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1,7 +1,14 @@ 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 { + encrypt, + decrypt, + decryptWithDetail, + encryptWithDetail, + decryptWithKey as decryptWithKeyBrowserPassworder, + importKey as importKeyBrowserPassworder, +} from '@metamask/browser-passworder'; import type { KeyPair, NodeAuthTokens, @@ -27,7 +34,6 @@ import { RecoveryError } from './errors'; import { projectLogger, createModuleLogger } from './logger'; import { SeedPhraseMetadata } from './SeedPhraseMetadata'; import type { - VaultEncryptor, MutuallyExclusiveCallback, SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, @@ -35,6 +41,7 @@ import type { VaultData, AuthenticatedUserDetails, SocialBackupsMetadata, + VaultEncryptor, } from './types'; const log = createModuleLogger(projectLogger, controllerName); @@ -50,6 +57,27 @@ 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() { + return { + encrypt, + encryptWithDetail, + decrypt, + decryptWithDetail, + decryptWithKey: decryptWithKeyBrowserPassworder as ( + key: unknown, + payload: unknown, + ) => Promise, + importKey: importKeyBrowserPassworder, + }; +} + /** * Seedless Onboarding Controller State Metadata. * @@ -91,19 +119,34 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< typeof controllerName, SeedlessOnboardingControllerState, SeedlessOnboardingControllerMessenger > { - readonly #vaultEncryptor: VaultEncryptor; + readonly #vaultEncryptor: VaultEncryptor; readonly #vaultOperationMutex = new Mutex(); readonly toprfClient: ToprfSecureBackup; + /** + * Controller lock state. + * + * The controller lock is synchronized with the keyring lock. + */ + #isUnlocked = false; + /** * Creates a new SeedlessOnboardingController instance. * @@ -116,9 +159,9 @@ export class SeedlessOnboardingController extends BaseController< constructor({ messenger, state, - encryptor = { encrypt, decrypt }, // default to `encrypt` and `decrypt` from `@metamask/browser-passworder` + encryptor, network = Web3AuthNetwork.Mainnet, - }: SeedlessOnboardingControllerOptions) { + }: SeedlessOnboardingControllerOptions) { super({ name: controllerName, metadata: seedlessOnboardingMetadata, @@ -133,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, + ); } /** @@ -237,6 +288,31 @@ 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, + 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({ + keyringId, + seedPhrase, + encKey: toprfEncryptionKey, + authKeyPair: toprfAuthKeyPair, + }); + } + /** * Fetches all encrypted seed phrases and metadata for user's account from the metadata store. * @@ -283,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.#unlockVaultWithPassword(oldPassword); + await this.verifyPassword(oldPassword); try { // update the encryption key with new password and update the Metadata Store @@ -306,16 +383,38 @@ export class SeedlessOnboardingController extends BaseController< /** * Update the backup metadata state for the given seed phrase. * - * @param keyringId - The keyring id of the backup seed phrase. - * @param seedPhrase - The seed phrase to update the backup metadata for. + * @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(keyringId: string, seedPhrase: Uint8Array) { - const newBackupMetadata = { - id: keyringId, - hash: keccak256AndHexify(seedPhrase), - }; + 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); + } - this.#updateSocialBackupsMetadata(newBackupMetadata); + await this.#vaultEncryptor.decrypt(password, this.state.vault); } /** @@ -335,6 +434,41 @@ export class SeedlessOnboardingController extends BaseController< ); } + /** + * 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; + } + /** * Persist the encryption key for the seedless onboarding flow. * @@ -444,7 +578,7 @@ export class SeedlessOnboardingController extends BaseController< authKeyPair, }); return { - id: keyringId, + keyringId, seedPhrase, }; }); @@ -460,7 +594,7 @@ export class SeedlessOnboardingController extends BaseController< * 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. + * @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 @@ -471,25 +605,62 @@ export class SeedlessOnboardingController extends BaseController< * - The password is incorrect (from encryptor.decrypt) * - The decrypted vault data is malformed */ - async #unlockVaultWithPassword(password: string): Promise<{ + async #unlockVaultAndGetBackupEncKey(password?: string): Promise<{ nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; }> { return this.#withVaultLock(async () => { - assertIsValidPassword(password); + const { + vault: encryptedVault, + vaultEncryptionKey, + vaultEncryptionSalt, + } = this.state; - 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; + const updatedState: Partial = {}; + + 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; + updatedState.vaultEncryptionKey = result.exportedKeyString; + updatedState.vaultEncryptionSalt = result.salt; + } else { + 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, + ); + updatedState.vaultEncryptionKey = vaultEncryptionKey; + updatedState.vaultEncryptionSalt = vaultEncryptionSalt; + } const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = this.#parseVaultData(decryptedVaultData); @@ -497,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 }; @@ -522,41 +695,72 @@ export class SeedlessOnboardingController extends BaseController< */ async #withPersistedSeedPhraseBackupsState( createSeedPhraseBackupCallback: () => Promise<{ - id: string; + keyringId: string; seedPhrase: Uint8Array; }>, ): Promise<{ - id: string; + keyringId: string; seedPhrase: Uint8Array; }> { try { - const backUps = await createSeedPhraseBackupCallback(); - const newBackupMetadata = { - id: backUps.id, - hash: keccak256AndHexify(backUps.seedPhrase), - }; + const newBackup = await createSeedPhraseBackupCallback(); - this.#updateSocialBackupsMetadata(newBackupMetadata); + this.#filterDupesAndUpdateSocialBackupsMetadata(newBackup); - return backUps; + return newBackup; } catch (error) { log('Error persisting seed phrase backups', error); throw error; } } - #updateSocialBackupsMetadata(newSocialBackupMetadata: SocialBackupsMetadata) { + /** + * 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 - const existingBackupsMetadata = this.state.socialBackupsMetadata.find( - (backup) => backup.id === newSocialBackupMetadata.id, - ); + 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 (!existingBackupsMetadata) { + if (filteredNewBackupsMetadata.length > 0) { this.update((state) => { state.socialBackupsMetadata = [ ...state.socialBackupsMetadata, - newSocialBackupMetadata, + ...filteredNewBackupsMetadata, ]; }); } @@ -582,6 +786,7 @@ export class SeedlessOnboardingController extends BaseController< rawToprfAuthKeyPair: KeyPair; }): Promise { this.#assertIsAuthenticatedUser(this.state); + this.#setUnlocked(); const { toprfEncryptionKey, toprfAuthKeyPair } = this.#serializeKeyData( rawToprfEncryptionKey, @@ -618,18 +823,19 @@ 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. - updatedState.vault = await this.#vaultEncryptor.encrypt( - password, - serializedVaultData, - ); + const { vault, exportedKeyString } = + await this.#vaultEncryptor.encryptWithDetail( + password, + serializedVaultData, + ); this.update((state) => { - state.vault = updatedState.vault; + state.vault = vault; + state.vaultEncryptionKey = exportedKeyString; + state.vaultEncryptionSalt = JSON.parse(vault).salt; }); }); } @@ -718,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 3a6b1920780..fde6dde5a57 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -12,11 +12,14 @@ export enum AuthConnection { } 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`, LoginFailedError = `${controllerName} - Login failed`, InsufficientAuthToken = `${controllerName} - Insufficient auth token`, + 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.`, InvalidVaultData = `${controllerName} - Invalid vault data`, diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 83befb5b016..052bd920163 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,8 +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 { + 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 { @@ -64,6 +68,17 @@ export type SeedlessOnboardingControllerState = * This is to facilitate the UI to display backup status of the seed phrases. */ socialBackupsMetadata: SocialBackupsMetadata[]; + + /** + * The encryption key derived from the password and used to encrypt + * the vault. + */ + vaultEncryptionKey?: string; + + /** + * The salt used to derive the encryption key from the password. + */ + vaultEncryptionSalt?: string; }; // Actions @@ -86,7 +101,9 @@ export type SeedlessOnboardingControllerStateChangeEvent = export type SeedlessOnboardingControllerEvents = SeedlessOnboardingControllerStateChangeEvent; -export type AllowedEvents = never; +export type AllowedEvents = + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; // Messenger export type SeedlessOnboardingControllerMessenger = RestrictedMessenger< @@ -100,24 +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; - /** - * 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; -}; +export type VaultEncryptor = Omit< + ExportableKeyEncryptor, + 'encryptWithKey' +>; /** * Seedless Onboarding Controller Options. @@ -126,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; /** @@ -139,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/tests/mocks/vaultEncryptor.ts b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts index 154df2544f2..e3568755c45 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts @@ -3,9 +3,14 @@ import type { EncryptionResult, KeyDerivationOptions, } from '@metamask/browser-passworder'; +import type { Json } from '@metamask/utils'; 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 +20,55 @@ export default class MockVaultEncryptor { DEFAULT_SALT = 'RANDOM_SALT'; - async importKey(keyString: string) { + async encryptWithDetail( + password: string, + dataObj: Json, + 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'); @@ -104,8 +151,15 @@ export default class MockVaultEncryptor { 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; @@ -123,9 +177,9 @@ export default class MockVaultEncryptor { 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, 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 fdd5a7bc435..b01c5ad6da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4258,6 +4258,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" @@ -4274,6 +4275,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