diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 38f4b66ca6b..016569c6eb3 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add method `exportEncryptionKey` ([#5984](https://github.com/MetaMask/core/pull/5984)) + +### Changed + +- Make salt optional with method `submitEncryptionKey` ([#5984](https://github.com/MetaMask/core/pull/5984)) + ## [22.0.2] ### Fixed diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 1ebaf55af28..05e7542899e 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -3303,6 +3303,66 @@ describe('KeyringController', () => { }); }); + describe('exportEncryptionKey', () => { + it('should export encryption key and unlock', async () => { + await withController( + { cacheEncryptionKey: true }, + async ({ controller }) => { + const encryptionKey = await controller.exportEncryptionKey(); + expect(encryptionKey).toBeDefined(); + + await controller.setLocked(); + + await controller.submitEncryptionKey(encryptionKey); + + expect(controller.isUnlocked()).toBe(true); + }, + ); + }); + + it('should throw error if controller is locked', async () => { + await withController( + { cacheEncryptionKey: true }, + async ({ controller }) => { + await controller.setLocked(); + await expect(controller.exportEncryptionKey()).rejects.toThrow( + KeyringControllerError.ControllerLocked, + ); + }, + ); + }); + + it('should throw error if encryptionKey is not set', async () => { + await withController(async ({ controller }) => { + await expect(controller.exportEncryptionKey()).rejects.toThrow( + KeyringControllerError.EncryptionKeyNotSet, + ); + }); + }); + + it('should export key after password change', async () => { + await withController( + { cacheEncryptionKey: true }, + async ({ controller }) => { + await controller.changePassword('new password'); + const encryptionKey = await controller.exportEncryptionKey(); + expect(encryptionKey).toBeDefined(); + }, + ); + }); + + it('should export key after password change to the same password', async () => { + await withController( + { cacheEncryptionKey: true }, + async ({ controller }) => { + await controller.changePassword(password); + const encryptionKey = await controller.exportEncryptionKey(); + expect(encryptionKey).toBeDefined(); + }, + ); + }); + }); + describe('verifySeedPhrase', () => { it('should return current seedphrase', async () => { await withController(async ({ controller }) => { diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 65b3954f873..7062ced7e9d 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -543,6 +543,20 @@ function assertIsValidPassword(password: unknown): asserts password is string { } } +/** + * Assert that the provided encryption key is a valid non-empty string. + * + * @param encryptionKey - The encryption key to check. + * @throws If the encryption key is not a valid string. + */ +function assertIsEncryptionKeySet( + encryptionKey: string | undefined, +): asserts encryptionKey is string { + if (!encryptionKey) { + throw new Error(KeyringControllerError.EncryptionKeyNotSet); + } +} + /** * Checks if the provided value is a serialized keyrings array. * @@ -1417,6 +1431,11 @@ export class KeyringController extends BaseController< changePassword(password: string): Promise { this.#assertIsUnlocked(); + // If the password is the same, do nothing. + if (this.#password === password) { + return Promise.resolve(); + } + return this.#persistOrRollback(async () => { assertIsValidPassword(password); @@ -1434,16 +1453,17 @@ export class KeyringController extends BaseController< } /** - * Attempts to decrypt the current vault and load its keyrings, - * using the given encryption key and salt. + * Attempts to decrypt the current vault and load its keyrings, using the + * given encryption key and salt. The optional salt can be used to check for + * consistency with the vault salt. * * @param encryptionKey - Key to unlock the keychain. - * @param encryptionSalt - Salt to unlock the keychain. + * @param encryptionSalt - Optional salt to unlock the keychain. * @returns Promise resolving when the operation completes. */ async submitEncryptionKey( encryptionKey: string, - encryptionSalt: string, + encryptionSalt?: string, ): Promise { const { newMetadata } = await this.#withRollback(async () => { const result = await this.#unlockKeyrings( @@ -1470,6 +1490,22 @@ export class KeyringController extends BaseController< } } + /** + * Exports the vault encryption key. + * + * @returns The vault encryption key. + */ + async exportEncryptionKey(): Promise { + this.#assertIsUnlocked(); + + return await this.#withControllerLock(async () => { + const { encryptionKey } = this.state; + assertIsEncryptionKeySet(encryptionKey); + + return encryptionKey; + }); + } + /** * Attempts to decrypt the current vault and load its keyrings, * using the given password. @@ -2279,8 +2315,10 @@ export class KeyringController extends BaseController< } else { const parsedEncryptedVault = JSON.parse(encryptedVault); - if (encryptionSalt !== parsedEncryptedVault.salt) { + if (encryptionSalt && encryptionSalt !== parsedEncryptedVault.salt) { throw new Error(KeyringControllerError.ExpiredCredentials); + } else { + encryptionSalt = parsedEncryptedVault.salt as string; } if (typeof encryptionKey !== 'string') { @@ -2296,10 +2334,7 @@ export class KeyringController extends BaseController< // This call is required on the first call because encryptionKey // is not yet inside the memStore updatedState.encryptionKey = encryptionKey; - // we can safely assume that encryptionSalt is defined here - // because we compare it with the salt from the vault - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - updatedState.encryptionSalt = encryptionSalt!; + updatedState.encryptionSalt = encryptionSalt; } } else { if (typeof password !== 'string') { diff --git a/packages/keyring-controller/src/constants.ts b/packages/keyring-controller/src/constants.ts index d914a3d6f74..b3ee59ba03c 100644 --- a/packages/keyring-controller/src/constants.ts +++ b/packages/keyring-controller/src/constants.ts @@ -36,4 +36,5 @@ export enum KeyringControllerError { NoHdKeyring = 'KeyringController - No HD Keyring found', ControllerLockRequired = 'KeyringController - attempt to update vault during a non mutually exclusive operation', LastAccountInPrimaryKeyring = 'KeyringController - Last account in primary keyring cannot be removed', + EncryptionKeyNotSet = 'KeyringController - Encryption key not set', }