Skip to content

feat(KeyringController): add exportEncryptionKey method to export vault key #5984

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/keyring-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions packages/keyring-controller/src/KeyringController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
53 changes: 44 additions & 9 deletions packages/keyring-controller/src/KeyringController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -1417,6 +1431,11 @@ export class KeyringController extends BaseController<
changePassword(password: string): Promise<void> {
this.#assertIsUnlocked();

// If the password is the same, do nothing.
if (this.#password === password) {
return Promise.resolve();
}

return this.#persistOrRollback(async () => {
assertIsValidPassword(password);

Expand All @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It would be nice to add a test case to ensure this works if the salt is omitted

Copy link
Contributor Author

@matthiasgeihs matthiasgeihs Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do test this in the new test cases 👌

await controller.submitEncryptionKey(encryptionKey);

): Promise<void> {
const { newMetadata } = await this.#withRollback(async () => {
const result = await this.#unlockKeyrings(
Expand All @@ -1470,6 +1490,22 @@ export class KeyringController extends BaseController<
}
}

/**
* Exports the vault encryption key.
*
* @returns The vault encryption key.
*/
async exportEncryptionKey(): Promise<string> {
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.
Expand Down Expand Up @@ -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') {
Expand All @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions packages/keyring-controller/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Loading