diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 12eac9eb3e9..5a3f83fffcf 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a check for `duplicate data` before adding it to the metadata store. ([#5955](https://github.com/MetaMask/core/pull/5955)) - renamed `getSeedPhraseBackupHash` to `getSecretDataBackupState` and added optional param (`type`) to look for data with specific type in the controller backup state. - updated `updateBackupMetadataState` method param with `{ keyringId?: string; data: Uint8Array; type: SecretType }`. Previously , `{ keyringId: string; seedPhrase: Uint8Array }`. +- Added `submitGlobalPassword`. ([#5995](https://github.com/MetaMask/core/pull/5995)) +- Added `storeKeyringEncryptionKey` and `loadKeyringEncryptionKey`. ([#5995](https://github.com/MetaMask/core/pull/5995)) ### Changed @@ -27,6 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - check for token expired in toprf call, refresh token and retry if expired - `submitPassword` revoke refresh token and replace with new one after password submit to prevent malicious use if refresh token leak in persisted state - Removed `recoveryRatelimitCache` from the controller state. ([#5976](https://github.com/MetaMask/core/pull/5976)). +- **BREAKING:** Changed `syncLatestGlobalPassword`. ([#5995](https://github.com/MetaMask/core/pull/5995)) + - removed parameter `oldPassword` + - no longer verifying old password + - explicitly requring unlocked controller + +### Removed + +- Removed `recoverCurrentDevicePassword`. ([#5995](https://github.com/MetaMask/core/pull/5995)) ## [1.0.0] diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index e8bae354aea..e2ba11b2ae5 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -49,7 +49,7 @@ "dependencies": { "@metamask/auth-network-utils": "^0.3.0", "@metamask/base-controller": "^8.0.1", - "@metamask/toprf-secure-backup": "^0.3.1", + "@metamask/toprf-secure-backup": "^0.4.0", "@metamask/utils": "^11.2.0", "async-mutex": "^0.5.0" }, @@ -86,7 +86,9 @@ "lavamoat": { "allowScripts": { "@lavamoat/preinstall-always-fail": false, - "@metamask/toprf-secure-backup": true + "@metamask/toprf-secure-backup": true, + "@metamask/keyring-controller>ethereumjs-wallet>ethereum-cryptography>keccak": false, + "@metamask/keyring-controller>ethereumjs-wallet>ethereum-cryptography>secp256k1": false } } } diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index e233f40c12f..390c80a073c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -26,6 +26,9 @@ import { stringToBytes, bigIntToHex, } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { utf8ToBytes } from '@noble/ciphers/utils'; +import { managedNonce } from '@noble/ciphers/webcrypto'; import type { webcrypto } from 'node:crypto'; import { @@ -91,6 +94,7 @@ const MOCK_NODE_AUTH_TOKENS = [ ]; const MOCK_KEYRING_ID = 'mock-keyring-id'; +const MOCK_KEYRING_ENCRYPTION_KEY = 'mock-keyring-encryption-key'; const MOCK_SEED_PHRASE = stringToBytes( 'horror pink muffin canal young photo magnet runway start elder patch until', ); @@ -236,12 +240,14 @@ function mockcreateLocalKey(toprfClient: ToprfSecureBackup, password: string) { const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(password); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(password); const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(password); const oprfKey = BigInt(0); const seed = stringToBytes(password); jest.spyOn(toprfClient, 'createLocalKey').mockResolvedValueOnce({ encKey, + pwEncKey, authKeyPair, oprfKey, seed, @@ -249,6 +255,7 @@ function mockcreateLocalKey(toprfClient: ToprfSecureBackup, password: string) { return { encKey, + pwEncKey, authKeyPair, oprfKey, seed, @@ -291,11 +298,13 @@ function mockRecoverEncKey( const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(password); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(password); const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(password); const rateLimitResetResult = Promise.resolve(); jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ encKey, + pwEncKey, authKeyPair, rateLimitResetResult, keyShareIndex: 1, @@ -303,6 +312,7 @@ function mockRecoverEncKey( return { encKey, + pwEncKey, authKeyPair, rateLimitResetResult, keyShareIndex: 1, @@ -324,14 +334,42 @@ function mockChangeEncKey( const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(newPassword); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(newPassword); const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(newPassword); jest.spyOn(toprfClient, 'changeEncKey').mockResolvedValueOnce({ encKey, + pwEncKey, authKeyPair, }); - return { encKey, authKeyPair }; + return { encKey, pwEncKey, authKeyPair }; +} + +/** + * Mocks the changePassword method of the SeedlessOnboardingController instance. + * + * @param controller - The SeedlessOnboardingController instance. + * @param toprfClient - The ToprfSecureBackup instance. + * @param oldPassword - The old password. + * @param newPassword - The new password. + */ +async function mockChangePassword( + controller: SeedlessOnboardingController, + toprfClient: ToprfSecureBackup, + oldPassword: string, + newPassword: string, +) { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // mock the recover enc key + mockRecoverEncKey(toprfClient, oldPassword); + + // mock the change enc key + mockChangeEncKey(toprfClient, newPassword); } /** @@ -367,6 +405,7 @@ async function mockCreateToprfKeyAndBackupSeedPhrase( * Creates a mock vault. * * @param encKey - The encryption key. + * @param pwEncKey - The password encryption key. * @param authKeyPair - The authentication key pair. * @param MOCK_PASSWORD - The mock password. * @param authTokens - The authentication tokens. @@ -376,6 +415,7 @@ async function mockCreateToprfKeyAndBackupSeedPhrase( */ async function createMockVault( encKey: Uint8Array, + pwEncKey: Uint8Array, authKeyPair: KeyPair, MOCK_PASSWORD: string, authTokens: NodeAuthTokens, @@ -386,6 +426,7 @@ async function createMockVault( const serializedKeyData = JSON.stringify({ authTokens, toprfEncryptionKey: bytesToBase64(encKey), + toprfPwEncryptionKey: bytesToBase64(pwEncKey), toprfAuthKeyPair: JSON.stringify({ sk: `0x${authKeyPair.sk.toString(16)}`, pk: bytesToBase64(authKeyPair.pk), @@ -396,11 +437,17 @@ async function createMockVault( const { vault: encryptedMockVault, exportedKeyString } = await encryptor.encryptWithDetail(MOCK_PASSWORD, serializedKeyData); + const aes = managedNonce(gcm)(pwEncKey); + const encryptedKeyringEncryptionKey = aes.encrypt( + utf8ToBytes(MOCK_KEYRING_ENCRYPTION_KEY), + ); + return { encryptedMockVault, vaultEncryptionKey: exportedKeyString, vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt, revokeToken: mockRevokeToken, + encryptedKeyringEncryptionKey, }; } @@ -445,6 +492,7 @@ async function decryptVault(vault: string, password: string) { * @param options.vault - The mock vault data. * @param options.vaultEncryptionKey - The mock vault encryption key. * @param options.vaultEncryptionSalt - The mock vault encryption salt. + * @param options.encryptedKeyringEncryptionKey - The mock encrypted keyring encryption key. * @returns The initial controller state with the mock authenticated user. */ function getMockInitialControllerState(options?: { @@ -455,6 +503,7 @@ function getMockInitialControllerState(options?: { vault?: string; vaultEncryptionKey?: string; vaultEncryptionSalt?: string; + encryptedKeyringEncryptionKey?: string; }): Partial { const state = getDefaultSeedlessOnboardingControllerState(); @@ -486,6 +535,10 @@ function getMockInitialControllerState(options?: { state.authPubKey = options.authPubKey ?? MOCK_AUTH_PUB_KEY; } + if (options?.encryptedKeyringEncryptionKey) { + state.encryptedKeyringEncryptionKey = options.encryptedKeyringEncryptionKey; + } + return state; } @@ -829,7 +882,7 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient, initialState, encryptor }) => { - const { encKey, authKeyPair } = mockcreateLocalKey( + const { encKey, pwEncKey, authKeyPair } = mockcreateLocalKey( toprfClient, MOCK_PASSWORD, ); @@ -853,6 +906,7 @@ describe('SeedlessOnboardingController', () => { // verify the vault data const { encryptedMockVault } = await createMockVault( encKey, + pwEncKey, authKeyPair, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -928,7 +982,7 @@ describe('SeedlessOnboardingController', () => { revokeToken, }); - const { encKey, authKeyPair } = mockcreateLocalKey( + const { encKey, pwEncKey, authKeyPair } = mockcreateLocalKey( toprfClient, MOCK_PASSWORD, ); @@ -952,6 +1006,7 @@ describe('SeedlessOnboardingController', () => { // verify the vault data const { encryptedMockVault } = await createMockVault( encKey, + pwEncKey, authKeyPair, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -1148,11 +1203,14 @@ describe('SeedlessOnboardingController', () => { const MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); const MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); const mockResult = await createMockVault( MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, MOCK_AUTH_KEY_PAIR, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -1375,54 +1433,6 @@ describe('SeedlessOnboardingController', () => { ); }); - 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, - ); - - mockFetchAuthPubKey( - toprfClient, - base64ToBytes(controller.state.authPubKey as string), - ); - - await expect( - controller.addNewSecretData( - NEW_KEY_RING_2.seedPhrase, - SecretType.Mnemonic, - { - keyringId: NEW_KEY_RING_2.id, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.MissingCredentials, - ); - }, - ); - }); - it('should throw error if encryptionSalt is different from the one in the vault', async () => { await withController( { @@ -1464,54 +1474,6 @@ describe('SeedlessOnboardingController', () => { ); }); - 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, - ); - - mockFetchAuthPubKey( - toprfClient, - base64ToBytes(controller.state.authPubKey as string), - ); - - await expect( - controller.addNewSecretData( - NEW_KEY_RING_2.seedPhrase, - SecretType.Mnemonic, - { - keyringId: NEW_KEY_RING_2.id, - }, - ), - ).rejects.toThrow( - SeedlessOnboardingControllerErrorMessage.WrongPasswordType, - ); - }, - ); - }); - it('should throw an error if vault unlocked has an unexpected shape', async () => { await withController( { @@ -1695,7 +1657,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller, toprfClient, initialState, encryptor }) => { // fetch and decrypt the secret data - const { encKey, authKeyPair } = mockRecoverEncKey( + const { encKey, pwEncKey, authKeyPair } = mockRecoverEncKey( toprfClient, MOCK_PASSWORD, ); @@ -1732,6 +1694,7 @@ describe('SeedlessOnboardingController', () => { // verify the vault data const { encryptedMockVault } = await createMockVault( encKey, + pwEncKey, authKeyPair, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -1760,7 +1723,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller, toprfClient, encryptor }) => { // fetch and decrypt the secret data - const { encKey, authKeyPair } = mockRecoverEncKey( + const { encKey, pwEncKey, authKeyPair } = mockRecoverEncKey( toprfClient, MOCK_PASSWORD, ); @@ -1791,6 +1754,7 @@ describe('SeedlessOnboardingController', () => { // verify the vault data const { encryptedMockVault } = await createMockVault( encKey, + pwEncKey, authKeyPair, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -1823,7 +1787,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller, toprfClient, initialState, encryptor }) => { // fetch and decrypt the secret data - const { encKey, authKeyPair } = mockRecoverEncKey( + const { encKey, pwEncKey, authKeyPair } = mockRecoverEncKey( toprfClient, MOCK_PASSWORD, ); @@ -1851,6 +1815,7 @@ describe('SeedlessOnboardingController', () => { // verify the vault data const { encryptedMockVault } = await createMockVault( encKey, + pwEncKey, authKeyPair, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -1875,11 +1840,14 @@ describe('SeedlessOnboardingController', () => { const MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); const MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); const mockResult = await createMockVault( MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, MOCK_AUTH_KEY_PAIR, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -2165,11 +2133,14 @@ describe('SeedlessOnboardingController', () => { const MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); const MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); const mockResult = await createMockVault( MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, MOCK_AUTH_KEY_PAIR, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -2942,11 +2913,11 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('recoverCurrentDevicePassword', () => { + describe('store and recover keyring encryption key', () => { const GLOBAL_PASSWORD = 'global-password'; const RECOVERED_PASSWORD = 'recovered-password'; - it('should recover the password for the current device', async () => { + it('should store and recover keyring encryption key', async () => { await withController( { state: getMockInitialControllerState({ @@ -2955,30 +2926,261 @@ describe('SeedlessOnboardingController', () => { }), }, async ({ controller, toprfClient }) => { + // Setup and store keyring encryption key. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await controller.storeKeyringEncryptionKey( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + // Mock recoverEncKey for the global password const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ encKey, authKeyPair, + pwEncKey, rateLimitResetResult: Promise.resolve(), keyShareIndex: 1, }); // Mock toprfClient.recoverPassword - jest.spyOn(toprfClient, 'recoverPassword').mockResolvedValueOnce({ - password: RECOVERED_PASSWORD, + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(RECOVERED_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, }); - const result = await controller.recoverCurrentDevicePassword({ + controller.setLocked(); + + await controller.submitGlobalPassword({ globalPassword: GLOBAL_PASSWORD, }); - expect(result).toStrictEqual({ password: RECOVERED_PASSWORD }); + const keyringEncryptionKey = + await controller.loadKeyringEncryptionKey(); + + expect(keyringEncryptionKey).toStrictEqual( + MOCK_KEYRING_ENCRYPTION_KEY, + ); expect(toprfClient.recoverEncKey).toHaveBeenCalled(); - expect(toprfClient.recoverPassword).toHaveBeenCalled(); + expect(toprfClient.recoverPwEncKey).toHaveBeenCalled(); + }, + ); + }); + + it('should throw if key not set', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: 'mock-vault', + }), + }, + async ({ controller, toprfClient }) => { + await expect( + controller.storeKeyringEncryptionKey(''), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.VaultEncryptionKeyUndefined, + ); + + // Setup and store keyring encryption key. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await expect(controller.loadKeyringEncryptionKey()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, + ); + }, + ); + }); + + it('should store and load keyring encryption key', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + // Setup and store keyring encryption key. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await controller.storeKeyringEncryptionKey( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + const result = await controller.loadKeyringEncryptionKey(); + expect(result).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); + }, + ); + }); + + it('should load keyring encryption key after change password', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + // Setup and store keyring encryption key. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await controller.storeKeyringEncryptionKey( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + await mockChangePassword( + controller, + toprfClient, + RECOVERED_PASSWORD, + GLOBAL_PASSWORD, + ); + + await controller.changePassword(GLOBAL_PASSWORD, RECOVERED_PASSWORD); + + const result = await controller.loadKeyringEncryptionKey(); + + expect(result).toStrictEqual(MOCK_KEYRING_ENCRYPTION_KEY); + }, + ); + }); + + it('should recover keyring encryption key after change password', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + // Setup and store keyring encryption key. + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await controller.storeKeyringEncryptionKey( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + await mockChangePassword( + controller, + toprfClient, + RECOVERED_PASSWORD, + GLOBAL_PASSWORD, + ); + + await controller.changePassword(GLOBAL_PASSWORD, RECOVERED_PASSWORD); + + // Mock recoverEncKey for the global password + const mockToprfEncryptor = createMockToprfEncryptor(); + const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + pwEncKey, + authKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock toprfClient.recoverPwEncKey + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, + }); + + controller.setLocked(); + + await controller.submitGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }); + + const keyringEncryptionKey = + await controller.loadKeyringEncryptionKey(); + + expect(keyringEncryptionKey).toStrictEqual( + MOCK_KEYRING_ENCRYPTION_KEY, + ); + }, + ); + }); + + it('should throw if encryptedKeyringEncryptionKey not set', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + // Mock recoverEncKey for the global password + const mockToprfEncryptor = createMockToprfEncryptor(); + const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + pwEncKey, + authKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock toprfClient.recoverPwEncKey + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(RECOVERED_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, + }); + + await expect( + controller.submitGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, + ); }, ); }); @@ -2990,7 +3192,7 @@ describe('SeedlessOnboardingController', () => { }, async ({ controller }) => { await expect( - controller.recoverCurrentDevicePassword({ + controller.submitGlobalPassword({ globalPassword: GLOBAL_PASSWORD, }), ).rejects.toThrow( @@ -3019,7 +3221,7 @@ describe('SeedlessOnboardingController', () => { ); await expect( - controller.recoverCurrentDevicePassword({ + controller.submitGlobalPassword({ globalPassword: GLOBAL_PASSWORD, }), ).rejects.toStrictEqual( @@ -3042,17 +3244,19 @@ describe('SeedlessOnboardingController', () => { async ({ controller, toprfClient }) => { const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ encKey, + pwEncKey, authKeyPair, rateLimitResetResult: Promise.resolve(), keyShareIndex: 1, }); jest - .spyOn(toprfClient, 'recoverPassword') + .spyOn(toprfClient, 'recoverPwEncKey') .mockRejectedValueOnce( new TOPRFError( TOPRFErrorCode.CouldNotFetchPassword, @@ -3061,7 +3265,7 @@ describe('SeedlessOnboardingController', () => { ); await expect( - controller.recoverCurrentDevicePassword({ + controller.submitGlobalPassword({ globalPassword: GLOBAL_PASSWORD, }), ).rejects.toStrictEqual( @@ -3084,21 +3288,23 @@ describe('SeedlessOnboardingController', () => { async ({ controller, toprfClient }) => { const mockToprfEncryptor = createMockToprfEncryptor(); const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ encKey, + pwEncKey, authKeyPair, rateLimitResetResult: Promise.resolve(), keyShareIndex: 1, }); jest - .spyOn(toprfClient, 'recoverPassword') + .spyOn(toprfClient, 'recoverPwEncKey') .mockRejectedValueOnce(new Error('Unknown error')); await expect( - controller.recoverCurrentDevicePassword({ + controller.submitGlobalPassword({ globalPassword: GLOBAL_PASSWORD, }), ).rejects.toStrictEqual( @@ -3120,16 +3326,19 @@ describe('SeedlessOnboardingController', () => { let INITIAL_AUTH_PUB_KEY: string; let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation let initialEncKey: Uint8Array; // Store initial encKey for vault creation + let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation // Generate initial keys and vault state before tests run beforeAll(async () => { const mockToprfEncryptor = createMockToprfEncryptor(); initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); + initialPwEncKey = mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); const mockResult = await createMockVault( initialEncKey, + initialPwEncKey, initialAuthKeyPair, OLD_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -3161,21 +3370,20 @@ describe('SeedlessOnboardingController', () => { // We'll use the key/salt implicitly by not providing password to unlockVaultAndGetBackupEncKey await controller.submitPassword(OLD_PASSWORD); // Unlock using the standard method - const verifyPasswordSpy = jest.spyOn( - controller, - 'verifyVaultPassword', - ); const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); // Mock recoverEncKey for the new global password const mockToprfEncryptor = createMockToprfEncryptor(); const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const newPwEncKey = + mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); const newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); recoverEncKeySpy.mockResolvedValueOnce({ encKey: newEncKey, + pwEncKey: newPwEncKey, authKeyPair: newAuthKeyPair, rateLimitResetResult: Promise.resolve(), keyShareIndex: 1, @@ -3184,15 +3392,37 @@ describe('SeedlessOnboardingController', () => { // We still need verifyPassword to work conceptually, even if unlock is bypassed // verifyPasswordSpy.mockResolvedValueOnce(); // Don't mock, let the real one run inside syncLatestGlobalPassword + controller.setLocked(); + + // Mock recoverEncKey for the global password + const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + pwEncKey, + authKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock toprfClient.recoverPwEncKey + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, + }); + + await controller.submitGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }); + await controller.syncLatestGlobalPassword({ - oldPassword: OLD_PASSWORD, globalPassword: GLOBAL_PASSWORD, }); // Assertions - expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { - skipLock: true, // skip lock since we already have the lock - }); expect(recoverEncKeySpy).toHaveBeenCalledWith( expect.objectContaining({ password: GLOBAL_PASSWORD }), ); @@ -3201,6 +3431,7 @@ describe('SeedlessOnboardingController', () => { const expectedSerializedVaultData = JSON.stringify({ authTokens: controller.state.nodeAuthTokens, toprfEncryptionKey: bytesToBase64(newEncKey), + toprfPwEncryptionKey: bytesToBase64(newPwEncKey), toprfAuthKeyPair: JSON.stringify({ sk: bigIntToHex(newAuthKeyPair.sk), pk: bytesToBase64(newAuthKeyPair.pk), @@ -3222,39 +3453,6 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should throw an error if the old password verification fails', async () => { - await withController( - { - state: getMockInitialControllerState({ - withMockAuthenticatedUser: true, - authPubKey: INITIAL_AUTH_PUB_KEY, - vault: MOCK_VAULT, - vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, - vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, - }), - }, - async ({ controller }) => { - // Unlock controller first - await controller.submitPassword(OLD_PASSWORD); - - const verifyPasswordSpy = jest - .spyOn(controller, 'verifyVaultPassword') - .mockRejectedValueOnce(new Error('Incorrect old password')); - - await expect( - controller.syncLatestGlobalPassword({ - oldPassword: 'WRONG_OLD_PASSWORD', - globalPassword: GLOBAL_PASSWORD, - }), - ).rejects.toThrow('Incorrect old password'); - - expect(verifyPasswordSpy).toHaveBeenCalledWith('WRONG_OLD_PASSWORD', { - skipLock: true, // skip lock since we already have the lock - }); - }, - ); - }); - it('should throw an error if recovering the encryption key for the global password fails', async () => { await withController( { @@ -3270,9 +3468,6 @@ describe('SeedlessOnboardingController', () => { // Unlock controller first await controller.submitPassword(OLD_PASSWORD); - const verifyPasswordSpy = jest - .spyOn(controller, 'verifyVaultPassword') - .mockResolvedValueOnce(); const recoverEncKeySpy = jest .spyOn(toprfClient, 'recoverEncKey') .mockRejectedValueOnce( @@ -3283,16 +3478,12 @@ describe('SeedlessOnboardingController', () => { await expect( controller.syncLatestGlobalPassword({ - oldPassword: OLD_PASSWORD, globalPassword: GLOBAL_PASSWORD, }), ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.LoginFailedError, ); - expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { - skipLock: true, // skip lock since we already have the lock - }); expect(recoverEncKeySpy).toHaveBeenCalledWith( expect.objectContaining({ password: GLOBAL_PASSWORD }), ); @@ -3315,9 +3506,6 @@ describe('SeedlessOnboardingController', () => { // Unlock controller first await controller.submitPassword(OLD_PASSWORD); - const verifyPasswordSpy = jest - .spyOn(controller, 'verifyVaultPassword') - .mockResolvedValueOnce(); const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); const encryptorSpy = jest .spyOn(encryptor, 'encryptWithDetail') @@ -3326,11 +3514,14 @@ describe('SeedlessOnboardingController', () => { // Mock recoverEncKey for the new global password const mockToprfEncryptor = createMockToprfEncryptor(); const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const newPwEncKey = + mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); const newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); recoverEncKeySpy.mockResolvedValueOnce({ encKey: newEncKey, + pwEncKey: newPwEncKey, authKeyPair: newAuthKeyPair, rateLimitResetResult: Promise.resolve(), keyShareIndex: 1, @@ -3338,14 +3529,10 @@ describe('SeedlessOnboardingController', () => { await expect( controller.syncLatestGlobalPassword({ - oldPassword: OLD_PASSWORD, globalPassword: GLOBAL_PASSWORD, }), ).rejects.toThrow('Vault creation failed'); - expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { - skipLock: true, // skip lock since we already have the lock - }); expect(recoverEncKeySpy).toHaveBeenCalledWith( expect.objectContaining({ password: GLOBAL_PASSWORD }), ); @@ -3480,6 +3667,8 @@ describe('SeedlessOnboardingController', () => { const mockToprfEncryptor = createMockToprfEncryptor(); const newEncKey = mockToprfEncryptor.deriveEncKey(NEW_MOCK_PASSWORD); + const newPwEncKey = + mockToprfEncryptor.derivePwEncKey(NEW_MOCK_PASSWORD); const newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(NEW_MOCK_PASSWORD); @@ -3497,6 +3686,7 @@ describe('SeedlessOnboardingController', () => { }) .mockResolvedValueOnce({ encKey: newEncKey, + pwEncKey: newPwEncKey, authKeyPair: newAuthKeyPair, }); @@ -3630,16 +3820,19 @@ describe('SeedlessOnboardingController', () => { let INITIAL_AUTH_PUB_KEY: string; let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation let initialEncKey: Uint8Array; // Store initial encKey for vault creation + let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation // Generate initial keys and vault state before tests run beforeAll(async () => { const mockToprfEncryptor = createMockToprfEncryptor(); initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); + initialPwEncKey = mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); const mockResult = await createMockVault( initialEncKey, + initialPwEncKey, initialAuthKeyPair, OLD_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -3670,16 +3863,14 @@ describe('SeedlessOnboardingController', () => { // Unlock controller first await controller.submitPassword(OLD_PASSWORD); - const verifyPasswordSpy = jest.spyOn( - controller, - 'verifyVaultPassword', - ); const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); // Mock recoverEncKey for the new global password const mockToprfEncryptor = createMockToprfEncryptor(); const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const newPwEncKey = + mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); const newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); @@ -3693,6 +3884,7 @@ describe('SeedlessOnboardingController', () => { }) .mockResolvedValueOnce({ encKey: newEncKey, + pwEncKey: newPwEncKey, authKeyPair: newAuthKeyPair, rateLimitResetResult: Promise.resolve(), keyShareIndex: 1, @@ -3705,7 +3897,6 @@ describe('SeedlessOnboardingController', () => { }); await controller.syncLatestGlobalPassword({ - oldPassword: OLD_PASSWORD, globalPassword: GLOBAL_PASSWORD, }); @@ -3721,15 +3912,11 @@ describe('SeedlessOnboardingController', () => { // Verify that authenticate was called during token refresh expect(toprfClient.authenticate).toHaveBeenCalled(); - // Verify that verifyPassword was called - expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { - skipLock: true, - }); - // Check if vault was re-encrypted with the new password and keys const expectedSerializedVaultData = JSON.stringify({ authTokens: controller.state.nodeAuthTokens, toprfEncryptionKey: bytesToBase64(newEncKey), + toprfPwEncryptionKey: bytesToBase64(newPwEncKey), toprfAuthKeyPair: JSON.stringify({ sk: bigIntToHex(newAuthKeyPair.sk), pk: bytesToBase64(newAuthKeyPair.pk), @@ -3781,7 +3968,6 @@ describe('SeedlessOnboardingController', () => { await expect( controller.syncLatestGlobalPassword({ - oldPassword: OLD_PASSWORD, globalPassword: GLOBAL_PASSWORD, }), ).rejects.toThrow( @@ -3816,7 +4002,6 @@ describe('SeedlessOnboardingController', () => { await expect( controller.syncLatestGlobalPassword({ - oldPassword: OLD_PASSWORD, globalPassword: GLOBAL_PASSWORD, }), ).rejects.toThrow( @@ -3843,11 +4028,14 @@ describe('SeedlessOnboardingController', () => { const mockToprfEncryptor = createMockToprfEncryptor(); const MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PW_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); const MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = await createMockVault( MOCK_ENCRYPTION_KEY, + MOCK_PW_ENCRYPTION_KEY, MOCK_AUTH_KEY_PAIR, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -4062,7 +4250,7 @@ describe('SeedlessOnboardingController', () => { }); }); - describe('recoverCurrentDevicePassword with token refresh', () => { + describe('recover keyring encryption key with token refresh', () => { // const OLD_PASSWORD = 'old-mock-password'; // const GLOBAL_PASSWORD = 'new-global-password'; let MOCK_VAULT: string; @@ -4071,17 +4259,21 @@ describe('SeedlessOnboardingController', () => { let INITIAL_AUTH_PUB_KEY: string; let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation let initialEncKey: Uint8Array; // Store initial encKey for vault creation + let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation + let initialEncryptedKeyringEncryptionKey: Uint8Array; // Store initial encKey for vault creation // Generate initial keys and vault state before tests run beforeAll(async () => { const mockToprfEncryptor = createMockToprfEncryptor(); initialEncKey = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + initialPwEncKey = mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); const mockResult = await createMockVault( initialEncKey, + initialPwEncKey, initialAuthKeyPair, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, @@ -4090,9 +4282,11 @@ describe('SeedlessOnboardingController', () => { MOCK_VAULT = mockResult.encryptedMockVault; MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + initialEncryptedKeyringEncryptionKey = + mockResult.encryptedKeyringEncryptionKey; }); - it('should retry recoverCurrentDevicePassword after refreshing expired tokens', async () => { + it('should retry after refreshing expired tokens', async () => { await withController( { state: getMockInitialControllerState({ @@ -4101,6 +4295,9 @@ describe('SeedlessOnboardingController', () => { vault: MOCK_VAULT, vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + encryptedKeyringEncryptionKey: bytesToBase64( + initialEncryptedKeyringEncryptionKey, + ), }), }, async ({ controller, toprfClient, mockRefreshJWTToken }) => { @@ -4113,7 +4310,7 @@ describe('SeedlessOnboardingController', () => { // Mock recoverPassword jest - .spyOn(toprfClient, 'recoverPassword') + .spyOn(toprfClient, 'recoverPwEncKey') .mockImplementationOnce(() => { // First call fails with token expired error throw new TOPRFError( @@ -4122,7 +4319,7 @@ describe('SeedlessOnboardingController', () => { ); }) .mockResolvedValueOnce({ - password: MOCK_PASSWORD, + pwEncKey: initialPwEncKey, }); // Mock authenticate for token refresh @@ -4131,12 +4328,12 @@ describe('SeedlessOnboardingController', () => { isNewUser: false, }); - await controller.recoverCurrentDevicePassword({ + await controller.submitGlobalPassword({ globalPassword: MOCK_PASSWORD, }); expect(mockRefreshJWTToken).toHaveBeenCalled(); - expect(toprfClient.recoverPassword).toHaveBeenCalledTimes(2); + expect(toprfClient.recoverPwEncKey).toHaveBeenCalledTimes(2); }, ); }); @@ -4147,11 +4344,14 @@ describe('SeedlessOnboardingController', () => { const mockToprfEncryptor = createMockToprfEncryptor(); const MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PW_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); const MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = await createMockVault( MOCK_ENCRYPTION_KEY, + MOCK_PW_ENCRYPTION_KEY, MOCK_AUTH_KEY_PAIR, MOCK_PASSWORD, MOCK_NODE_AUTH_TOKENS, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index cd4bb2799e1..a0ffcd60705 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -12,6 +12,9 @@ import { TOPRFError, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, bigIntToHex } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { bytesToUtf8, utf8ToBytes } from '@noble/ciphers/utils'; +import { managedNonce } from '@noble/ciphers/webcrypto'; import { secp256k1 } from '@noble/curves/secp256k1'; import { Mutex } from 'async-mutex'; @@ -119,6 +122,14 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< @@ -289,7 +300,7 @@ export class SeedlessOnboardingController extends BaseController< this.#assertIsAuthenticatedUser(this.state); // locally evaluate the encryption key from the password - const { encKey, authKeyPair, oprfKey } = + const { encKey, pwEncKey, authKeyPair, oprfKey } = await this.toprfClient.createLocalKey({ password, }); @@ -314,6 +325,7 @@ export class SeedlessOnboardingController extends BaseController< await this.#createNewVaultWithAuthData({ password, rawToprfEncryptionKey: encKey, + rawToprfPwEncryptionKey: pwEncKey, rawToprfAuthKeyPair: authKeyPair, }); this.#persistAuthPubKey({ @@ -387,17 +399,20 @@ export class SeedlessOnboardingController extends BaseController< this.#assertIsAuthenticatedUser(this.state); let encKey: Uint8Array; + let pwEncKey: Uint8Array; let authKeyPair: KeyPair; if (password) { const recoverEncKeyResult = await this.#recoverEncKey(password); encKey = recoverEncKeyResult.encKey; + pwEncKey = recoverEncKeyResult.pwEncKey; authKeyPair = recoverEncKeyResult.authKeyPair; } else { this.#assertIsUnlocked(); // verify the password and unlock the vault const keysFromVault = await this.#unlockVaultAndGetBackupEncKey(); encKey = keysFromVault.toprfEncryptionKey; + pwEncKey = keysFromVault.toprfPwEncryptionKey; authKeyPair = keysFromVault.toprfAuthKeyPair; } @@ -415,6 +430,7 @@ export class SeedlessOnboardingController extends BaseController< await this.#createNewVaultWithAuthData({ password, rawToprfEncryptionKey: encKey, + rawToprfPwEncryptionKey: pwEncKey, rawToprfAuthKeyPair: authKeyPair, }); @@ -472,14 +488,27 @@ export class SeedlessOnboardingController extends BaseController< }); const attemptChangePassword = async (): Promise => { + // load keyring encryption key if it exists + let keyringEncryptionKey: string | undefined; + if (this.state.encryptedKeyringEncryptionKey) { + keyringEncryptionKey = await this.loadKeyringEncryptionKey(); + } + // update the encryption key with new password and update the Metadata Store - const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = - await this.#changeEncryptionKey(newPassword, oldPassword); + const { + encKey: newEncKey, + pwEncKey: newPwEncKey, + authKeyPair: newAuthKeyPair, + } = await this.#changeEncryptionKey({ + oldPassword, + newPassword, + }); // update and encrypt the vault with new password await this.#createNewVaultWithAuthData({ password: newPassword, rawToprfEncryptionKey: newEncKey, + rawToprfPwEncryptionKey: newPwEncKey, rawToprfAuthKeyPair: newAuthKeyPair, }); @@ -487,6 +516,11 @@ export class SeedlessOnboardingController extends BaseController< authPubKey: newAuthKeyPair.pk, }); this.#resetPasswordOutdatedCache(); + + // store the keyring encryption key if it exists + if (keyringEncryptionKey) { + await this.storeKeyringEncryptionKey(keyringEncryptionKey); + } }; try { @@ -606,30 +640,25 @@ export class SeedlessOnboardingController extends BaseController< * persist the latest global password authPubKey * * @param params - The parameters for syncing the latest global password. - * @param params.oldPassword - The old password to verify. * @param params.globalPassword - The latest global password. * @returns A promise that resolves to the success of the operation. */ async syncLatestGlobalPassword({ - oldPassword, globalPassword, }: { - oldPassword: string; globalPassword: string; }) { return await this.#withControllerLock(async () => { + this.#assertIsUnlocked(); const doSyncPassword = async () => { - // verify correct old password - await this.verifyVaultPassword(oldPassword, { - skipLock: true, // skip lock since we already have the lock - }); // update vault with latest globalPassword - const { encKey, authKeyPair } = + const { encKey, pwEncKey, authKeyPair } = await this.#recoverEncKey(globalPassword); // update and encrypt the vault with new password await this.#createNewVaultWithAuthData({ password: globalPassword, rawToprfEncryptionKey: encKey, + rawToprfPwEncryptionKey: pwEncKey, rawToprfAuthKeyPair: authKeyPair, }); // persist the latest global password authPubKey @@ -646,59 +675,64 @@ export class SeedlessOnboardingController extends BaseController< } /** - * @description Fetch the password corresponding to the current authPubKey in state (current device password which is already out of sync with the current global password). - * then we use this recovered old password to unlock the vault and set the password to the new global password. + * @description Unlock the controller with the latest global password. * - * @param params - The parameters for fetching the password. + * @param params - The parameters for unlocking the controller. * @param params.globalPassword - The latest global password. - * @returns A promise that resolves to the password corresponding to the current authPubKey in state. + * @returns A promise that resolves to the success of the operation. */ - async recoverCurrentDevicePassword({ + async submitGlobalPassword({ globalPassword, }: { globalPassword: string; - }): Promise<{ password: string }> { + }): Promise { return await this.#withControllerLock(async () => { return await this.#executeWithTokenRefresh(async () => { const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); - const { password: currentDevicePassword } = await this.#recoverPassword( - { - targetPwPubKey: currentDeviceAuthPubKey, - globalPassword, - }, - ); - return { - password: currentDevicePassword, - }; - }, 'recoverCurrentDevicePassword'); + await this.#submitGlobalPassword({ + targetAuthPubKey: currentDeviceAuthPubKey, + globalPassword, + }); + }, 'submitGlobalPassword'); }); } /** - * @description Fetch the password corresponding to the targetPwPubKey. + * @description Submit the global password to the controller, verify the + * password validity and unlock the controller. * - * @param params - The parameters for fetching the password. - * @param params.targetPwPubKey - The target public key of the password to recover. + * @param params - The parameters for submitting the global password. + * @param params.targetAuthPubKey - The target public key of the keyring + * encryption key to recover. * @param params.globalPassword - The latest global password. - * @returns A promise that resolves to the password corresponding to the current authPubKey in state. + * @returns A promise that resolves to the keyring encryption key + * corresponding to the current authPubKey in state. */ - async #recoverPassword({ - targetPwPubKey, + async #submitGlobalPassword({ + targetAuthPubKey, globalPassword, }: { - targetPwPubKey: SEC1EncodedPublicKey; + targetAuthPubKey: SEC1EncodedPublicKey; globalPassword: string; - }): Promise<{ password: string }> { - const { encKey: latestPwEncKey, authKeyPair: latestPwAuthKeyPair } = + }): Promise { + const { pwEncKey: curPwEncKey, authKeyPair: curAuthKeyPair } = await this.#recoverEncKey(globalPassword); try { - const res = await this.toprfClient.recoverPassword({ - targetPwPubKey, - curEncKey: latestPwEncKey, - curAuthKeyPair: latestPwAuthKeyPair, + // Recover vault encryption key. + const res = await this.toprfClient.recoverPwEncKey({ + targetAuthPubKey, + curPwEncKey, + curAuthKeyPair, }); - return res; + const { pwEncKey } = res; + const vaultKey = await this.#loadSeedlessEncryptionKey(pwEncKey); + + // Unlock the controller + await this.#unlockVaultAndGetBackupEncKey(undefined, vaultKey); + this.#setUnlocked(); + // revoke and recyle refresh token after unlock to keep refresh token fresh, avoid malicious use of leaked refresh token + await this.revokeRefreshToken(globalPassword); } catch (error) { if (this.#isTokenExpiredError(error)) { throw error; @@ -832,6 +866,81 @@ export class SeedlessOnboardingController extends BaseController< }); } + /** + * Store the keyring encryption key in state, encrypted under the current + * encryption key. + * + * @param keyringEncryptionKey - The keyring encryption key. + */ + async storeKeyringEncryptionKey(keyringEncryptionKey: string) { + const { toprfPwEncryptionKey: encKey } = + await this.#unlockVaultAndGetBackupEncKey(); + await this.#storeKeyringEncryptionKey(encKey, keyringEncryptionKey); + } + + /** + * Load the keyring encryption key from state, decrypted under the current + * encryption key. + * + * @returns The keyring encryption key. + */ + async loadKeyringEncryptionKey() { + const { toprfPwEncryptionKey: encKey } = + await this.#unlockVaultAndGetBackupEncKey(); + return await this.#loadKeyringEncryptionKey(encKey); + } + + /** + * Encrypt the keyring encryption key and store it in state. + * + * @param encKey - The encryption key. + * @param keyringEncryptionKey - The keyring encryption key. + */ + async #storeKeyringEncryptionKey( + encKey: Uint8Array, + keyringEncryptionKey: string, + ) { + const aes = managedNonce(gcm)(encKey); + const encryptedKeyringEncryptionKey = aes.encrypt( + utf8ToBytes(keyringEncryptionKey), + ); + this.update((state) => { + state.encryptedKeyringEncryptionKey = bytesToBase64( + encryptedKeyringEncryptionKey, + ); + }); + } + + /** + * Decrypt the keyring encryption key from state. + * + * @param encKey - The encryption key. + * @returns The keyring encryption key. + */ + async #loadKeyringEncryptionKey(encKey: Uint8Array) { + const { encryptedKeyringEncryptionKey: encryptedKey } = this.state; + assertIsEncryptedKeyringEncryptionKeySet(encryptedKey); + const encryptedPasswordBytes = base64ToBytes(encryptedKey); + const aes = managedNonce(gcm)(encKey); + const password = aes.decrypt(encryptedPasswordBytes); + return bytesToUtf8(password); + } + + /** + * Decrypt the seedless encryption key from state. + * + * @param encKey - The encryption key. + * @returns The seedless encryption key. + */ + async #loadSeedlessEncryptionKey(encKey: Uint8Array) { + const { encryptedSeedlessEncryptionKey: encryptedKey } = this.state; + assertIsEncryptedSeedlessEncryptionKeySet(encryptedKey); + const encryptedKeyBytes = base64ToBytes(encryptedKey); + const aes = managedNonce(gcm)(encKey); + const seedlessEncryptionKey = aes.decrypt(encryptedKeyBytes); + return bytesToUtf8(seedlessEncryptionKey); + } + /** * Recover the authentication public key from the state. * convert to pubkey format before recovering. @@ -881,31 +990,40 @@ export class SeedlessOnboardingController extends BaseController< /** * Update the encryption key with new password and update the Metadata Store with new encryption key. * - * @param newPassword - The new password to update. - * @param oldPassword - The old password to verify. + * @param params - The function parameters. + * @param params.oldPassword - The old password to verify. + * @param params.newPassword - The new password to update. * @returns A promise that resolves to new encryption key and authentication key pair. */ - async #changeEncryptionKey(newPassword: string, oldPassword: string) { + async #changeEncryptionKey({ + oldPassword, + newPassword, + }: { + newPassword: string; + oldPassword: string; + }) { this.#assertIsAuthenticatedUser(this.state); const { authConnectionId, groupedAuthConnectionId, userId } = this.state; const { encKey, + pwEncKey, authKeyPair, keyShareIndex: newKeyShareIndex, } = await this.#recoverEncKey(oldPassword); - return await this.toprfClient.changeEncKey({ + const result = await this.toprfClient.changeEncKey({ nodeAuthTokens: this.state.nodeAuthTokens, authConnectionId, groupedAuthConnectionId, userId, oldEncKey: encKey, + oldPwEncKey: pwEncKey, oldAuthKeyPair: authKeyPair, newKeyShareIndex, - oldPassword, newPassword, }); + return result; } /** @@ -979,6 +1097,7 @@ export class SeedlessOnboardingController extends BaseController< * This method ensures thread-safety by using a mutex lock when accessing the vault. * * @param password - The optional password to unlock the vault. + * @param encryptionKey - The optional encryption key 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 @@ -989,27 +1108,26 @@ export class SeedlessOnboardingController extends BaseController< * - The password is incorrect (from encryptor.decrypt) * - The decrypted vault data is malformed */ - async #unlockVaultAndGetBackupEncKey(password?: string): Promise<{ + async #unlockVaultAndGetBackupEncKey( + password?: string, + encryptionKey?: string, + ): Promise<{ nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; + toprfPwEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; revokeToken: string; }> { return this.#withVaultLock(async () => { - const { - vault: encryptedVault, - vaultEncryptionKey, - vaultEncryptionSalt, - } = this.state; + let { vaultEncryptionKey } = this.state; + const { vault: encryptedVault, vaultEncryptionSalt } = this.state; if (!encryptedVault) { throw new Error(SeedlessOnboardingControllerErrorMessage.VaultError); } - if (!vaultEncryptionKey && !password) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.MissingCredentials, - ); + if (encryptionKey) { + vaultEncryptionKey = encryptionKey; } let decryptedVaultData: unknown; @@ -1028,20 +1146,19 @@ export class SeedlessOnboardingController extends BaseController< updatedState.vaultEncryptionKey = result.exportedKeyString; updatedState.vaultEncryptionSalt = result.salt; } else { + assertIsVaultEncryptionKeyDefined(vaultEncryptionKey); + const parsedEncryptedVault = JSON.parse(encryptedVault); - if (vaultEncryptionSalt !== parsedEncryptedVault.salt) { + if ( + vaultEncryptionSalt && + vaultEncryptionSalt !== parsedEncryptedVault.salt + ) { throw new Error( SeedlessOnboardingControllerErrorMessage.ExpiredCredentials, ); } - if (typeof vaultEncryptionKey !== 'string') { - throw new TypeError( - SeedlessOnboardingControllerErrorMessage.WrongPasswordType, - ); - } - const key = await this.#vaultEncryptor.importKey(vaultEncryptionKey); decryptedVaultData = await this.#vaultEncryptor.decryptWithKey( key, @@ -1054,6 +1171,7 @@ export class SeedlessOnboardingController extends BaseController< const { nodeAuthTokens, toprfEncryptionKey, + toprfPwEncryptionKey, toprfAuthKeyPair, revokeToken, } = this.#parseVaultData(decryptedVaultData); @@ -1069,6 +1187,7 @@ export class SeedlessOnboardingController extends BaseController< return { nodeAuthTokens, toprfEncryptionKey, + toprfPwEncryptionKey, toprfAuthKeyPair, revokeToken, }; @@ -1175,15 +1294,18 @@ export class SeedlessOnboardingController extends BaseController< * @param params - The parameters for creating a new vault. * @param params.password - The password to encrypt the vault. * @param params.rawToprfEncryptionKey - The encryption key to encrypt the vault. + * @param params.rawToprfPwEncryptionKey - The encryption key to encrypt the password. * @param params.rawToprfAuthKeyPair - The authentication key pair to encrypt the vault. */ async #createNewVaultWithAuthData({ password, rawToprfEncryptionKey, + rawToprfPwEncryptionKey, rawToprfAuthKeyPair, }: { password: string; rawToprfEncryptionKey: Uint8Array; + rawToprfPwEncryptionKey: Uint8Array; rawToprfAuthKeyPair: KeyPair; }): Promise { this.#assertIsAuthenticatedUser(this.state); @@ -1196,14 +1318,17 @@ export class SeedlessOnboardingController extends BaseController< this.#setUnlocked(); - const { toprfEncryptionKey, toprfAuthKeyPair } = this.#serializeKeyData( - rawToprfEncryptionKey, - rawToprfAuthKeyPair, - ); + const { toprfEncryptionKey, toprfPwEncryptionKey, toprfAuthKeyPair } = + this.#serializeKeyData( + rawToprfEncryptionKey, + rawToprfPwEncryptionKey, + rawToprfAuthKeyPair, + ); const serializedVaultData = JSON.stringify({ authTokens: this.state.nodeAuthTokens, toprfEncryptionKey, + toprfPwEncryptionKey, toprfAuthKeyPair, revokeToken: this.state.revokeToken, }); @@ -1211,6 +1336,7 @@ export class SeedlessOnboardingController extends BaseController< await this.#updateVault({ password, serializedVaultData, + pwEncKey: rawToprfPwEncryptionKey, }); } @@ -1220,14 +1346,17 @@ export class SeedlessOnboardingController extends BaseController< * @param params - The parameters for updating the vault. * @param params.password - The password to encrypt the vault. * @param params.serializedVaultData - The serialized authentication data to update the vault with. + * @param params.pwEncKey - The global password encryption key. * @returns A promise that resolves to the updated vault. */ async #updateVault({ password, serializedVaultData, + pwEncKey, }: { password: string; serializedVaultData: string; + pwEncKey: Uint8Array; }): Promise { await this.#withVaultLock(async () => { assertIsValidPassword(password); @@ -1241,10 +1370,15 @@ export class SeedlessOnboardingController extends BaseController< serializedVaultData, ); + // Encrypt vault key. + const aes = managedNonce(gcm)(pwEncKey); + const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); + this.update((state) => { state.vault = vault; state.vaultEncryptionKey = exportedKeyString; state.vaultEncryptionSalt = JSON.parse(vault).salt; + state.encryptedSeedlessEncryptionKey = bytesToBase64(encryptedKey); }); }); } @@ -1288,17 +1422,21 @@ export class SeedlessOnboardingController extends BaseController< * Serialize the encryption key and authentication key pair. * * @param encKey - The encryption key to serialize. + * @param pwEncKey - The password encryption key to serialize. * @param authKeyPair - The authentication key pair to serialize. * @returns The serialized encryption key and authentication key pair. */ #serializeKeyData( encKey: Uint8Array, + pwEncKey: Uint8Array, authKeyPair: KeyPair, ): { toprfEncryptionKey: string; + toprfPwEncryptionKey: string; toprfAuthKeyPair: string; } { const b64EncodedEncKey = bytesToBase64(encKey); + const b64EncodedPwEncKey = bytesToBase64(pwEncKey); const b64EncodedAuthKeyPair = JSON.stringify({ sk: bigIntToHex(authKeyPair.sk), // Convert BigInt to hex string pk: bytesToBase64(authKeyPair.pk), @@ -1306,6 +1444,7 @@ export class SeedlessOnboardingController extends BaseController< return { toprfEncryptionKey: b64EncodedEncKey, + toprfPwEncryptionKey: b64EncodedPwEncKey, toprfAuthKeyPair: b64EncodedAuthKeyPair, }; } @@ -1320,6 +1459,7 @@ export class SeedlessOnboardingController extends BaseController< #parseVaultData(data: unknown): { nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; + toprfPwEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; revokeToken: string; } { @@ -1343,6 +1483,9 @@ export class SeedlessOnboardingController extends BaseController< const rawToprfEncryptionKey = base64ToBytes( parsedVaultData.toprfEncryptionKey, ); + const rawToprfPwEncryptionKey = base64ToBytes( + parsedVaultData.toprfPwEncryptionKey, + ); const parsedToprfAuthKeyPair = JSON.parse(parsedVaultData.toprfAuthKeyPair); const rawToprfAuthKeyPair = { sk: BigInt(parsedToprfAuthKeyPair.sk), @@ -1352,6 +1495,7 @@ export class SeedlessOnboardingController extends BaseController< return { nodeAuthTokens: parsedVaultData.authTokens, toprfEncryptionKey: rawToprfEncryptionKey, + toprfPwEncryptionKey: rawToprfPwEncryptionKey, toprfAuthKeyPair: rawToprfAuthKeyPair, revokeToken: parsedVaultData.revokeToken, }; @@ -1462,6 +1606,8 @@ export class SeedlessOnboardingController extends BaseController< typeof value.authTokens !== 'object' || // authTokens is not an object !('toprfEncryptionKey' in value) || // toprfEncryptionKey is not defined typeof value.toprfEncryptionKey !== 'string' || // toprfEncryptionKey is not a string + !('toprfPwEncryptionKey' in value) || // toprfPwEncryptionKey is not defined + typeof value.toprfPwEncryptionKey !== 'string' || // toprfPwEncryptionKey is not a string !('toprfAuthKeyPair' in value) || // toprfAuthKeyPair is not defined typeof value.toprfAuthKeyPair !== 'string' || // toprfAuthKeyPair is not a string !('revokeToken' in value) || // revokeToken is not defined @@ -1516,8 +1662,12 @@ export class SeedlessOnboardingController extends BaseController< this.#assertIsUnlocked(); this.#assertIsAuthenticatedUser(this.state); // get revoke token and backup encryption key from vault (should be unlocked already) - const { revokeToken, toprfEncryptionKey, toprfAuthKeyPair } = - await this.#unlockVaultAndGetBackupEncKey(); + const { + revokeToken, + toprfEncryptionKey, + toprfPwEncryptionKey, + toprfAuthKeyPair, + } = await this.#unlockVaultAndGetBackupEncKey(); const { newRevokeToken, newRefreshToken } = await this.#revokeRefreshToken({ connection: this.state.authConnection, @@ -1534,6 +1684,7 @@ export class SeedlessOnboardingController extends BaseController< await this.#createNewVaultWithAuthData({ password, rawToprfEncryptionKey: toprfEncryptionKey, + rawToprfPwEncryptionKey: toprfPwEncryptionKey, rawToprfAuthKeyPair: toprfAuthKeyPair, }); } @@ -1673,3 +1824,51 @@ async function withLock( releaseLock(); } } + +/** + * Assert that the provided encrypted keyring encryption key is a valid non-empty string. + * + * @param encryptedKeyringEncryptionKey - The encrypted keyring encryption key to check. + * @throws If the encrypted keyring encryption key is not a valid string. + */ +function assertIsEncryptedKeyringEncryptionKeySet( + encryptedKeyringEncryptionKey: string | undefined, +): asserts encryptedKeyringEncryptionKey is string { + if (!encryptedKeyringEncryptionKey) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, + ); + } +} + +/** + * Assert that the provided encrypted seedless encryption key is a valid non-empty string. + * + * @param encryptedSeedlessEncryptionKey - The encrypted seedless encryption key to check. + * @throws If the encrypted seedless encryption key is not a valid string. + */ +function assertIsEncryptedSeedlessEncryptionKeySet( + encryptedSeedlessEncryptionKey: string | undefined, +): asserts encryptedSeedlessEncryptionKey is string { + if (!encryptedSeedlessEncryptionKey) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.EncryptedSeedlessEncryptionKeyNotSet, + ); + } +} + +/** + * Assert that the provided vault encryption key is a valid non-empty string. + * + * @param vaultEncryptionKey - The vault encryption key to check. + * @throws If the vault encryption key is not a valid string. + */ +function assertIsVaultEncryptionKeyDefined( + vaultEncryptionKey: string | undefined, +): asserts vaultEncryptionKey is string { + if (!vaultEncryptionKey) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.VaultEncryptionKeyUndefined, + ); + } +} diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index eaa871c7e16..fe6e16d4f4d 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -50,4 +50,7 @@ export enum SeedlessOnboardingControllerErrorMessage { OutdatedPassword = `${controllerName} - Outdated password`, CouldNotRecoverPassword = `${controllerName} - Could not recover password`, SRPNotBackedUpError = `${controllerName} - SRP not backed up`, + EncryptedKeyringEncryptionKeyNotSet = `${controllerName} - Encrypted keyring encryption key is not set`, + EncryptedSeedlessEncryptionKeyNotSet = `${controllerName} - Encrypted seedless encryption key is not set`, + VaultEncryptionKeyUndefined = `${controllerName} - Vault encryption key is not available`, } diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index b10502ed74c..543b2760dac 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -148,6 +148,16 @@ export type SeedlessOnboardingControllerState = * This is temporarily stored in state during authentication and then persisted in the vault. */ revokeToken?: string; + + /** + * The encrypted seedless encryption key used to encrypt the seedless vault. + */ + encryptedSeedlessEncryptionKey?: string; + + /** + * The encrypted keyring encryption key used to encrypt the keyring vault. + */ + encryptedKeyringEncryptionKey?: string; }; // Actions @@ -298,6 +308,10 @@ export type VaultData = { * The encryption key to encrypt the seed phrase. */ toprfEncryptionKey: string; + /** + * The encryption key to encrypt the password. + */ + toprfPwEncryptionKey: string; /** * The authentication key pair to authenticate the TOPRF. */ diff --git a/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts b/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts index 5476d29516e..1570d0fc11a 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts @@ -9,6 +9,8 @@ import { sha256 } from '@noble/hashes/sha256'; export class MockToprfEncryptorDecryptor { readonly #HKDF_ENCRYPTION_KEY_INFO = 'encryption-key'; + readonly #HKDF_PASSWORD_ENCRYPTION_KEY_INFO = 'password-encryption-key'; + readonly #HKDF_AUTH_KEY_INFO = 'authentication-key'; encrypt(key: Uint8Array, data: Uint8Array): string { @@ -37,6 +39,18 @@ export class MockToprfEncryptorDecryptor { return key; } + derivePwEncKey(password: string): Uint8Array { + const seed = sha256(password); + const key = hkdf( + sha256, + seed, + undefined, + this.#HKDF_PASSWORD_ENCRYPTION_KEY_INFO, + 32, + ); + return key; + } + deriveAuthKeyPair(password: string): KeyPair { const seed = sha256(password); const k = hkdf(sha256, seed, undefined, this.#HKDF_AUTH_KEY_INFO, 32); // Derive 256 bit key. diff --git a/yarn.lock b/yarn.lock index 1a6083c4f8d..511ab988db1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4296,7 +4296,7 @@ __metadata: "@metamask/base-controller": "npm:^8.0.1" "@metamask/browser-passworder": "npm:^4.3.0" "@metamask/keyring-controller": "npm:^22.0.2" - "@metamask/toprf-secure-backup": "npm:^0.3.1" + "@metamask/toprf-secure-backup": "npm:^0.4.0" "@metamask/utils": "npm:^11.2.0" "@noble/ciphers": "npm:^0.5.2" "@noble/curves": "npm:^1.2.0" @@ -4539,9 +4539,9 @@ __metadata: languageName: unknown linkType: soft -"@metamask/toprf-secure-backup@npm:^0.3.1": - version: 0.3.1 - resolution: "@metamask/toprf-secure-backup@npm:0.3.1" +"@metamask/toprf-secure-backup@npm:^0.4.0": + version: 0.4.0 + resolution: "@metamask/toprf-secure-backup@npm:0.4.0" dependencies: "@metamask/auth-network-utils": "npm:^0.3.0" "@noble/ciphers": "npm:^1.2.1" @@ -4553,7 +4553,7 @@ __metadata: "@toruslabs/fetch-node-details": "npm:^15.0.0" "@toruslabs/http-helpers": "npm:^8.1.1" bn.js: "npm:^5.2.1" - checksum: 10/fec535a02236faf9b0dd4123a079e6f8f211d9fc9758944f823cc018ad5000957ed9402c38bbdf0fdfd660b365a10af52903438c477125db8a6880c325f09cdf + checksum: 10/8aebf34e1051a2715bbbd5af576084b8c6eb4ecd1b8383e326aabf390c486d520746777d4fb0fd19078ca8f714e92b0a693795afe7acc38439d820ed22ec7a52 languageName: node linkType: hard