Skip to content

Commit e4f8a47

Browse files
authored
feat: apply mutex on controller lock (#6292)
## Explanation This PR adds `lock (mutex)` mechanism to the seedless-onboarding-controller `setLocked` method to achieve the atomic state updates and prevent unexpected issues (state out of sync) while locking the controller and another operation (such as `changePassword`, `addNewSecretData` etc) is in progress on the other hand. As a result, we can't simply lock the seedless-onboarding-controller, syncing with keyring's. Syncing with keyring's lock will cause more issues in the controller communications as seedless-onboarding `setLocked`, now, has some side effects and it won't get locked immediately in some scenarios (when another seedless operation in progress). Hence, `setLocked` becomes independent of the Keyring's and this PR also removes the `KeyringController:lock` and `KeyringController:unlock` events from the seedless-onboarding messenger. **BREAKING:** - Removed `Keyring:lock` and `Keyring:unlock` events from the controller allowed events. - ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent 761cd35 commit e4f8a47

File tree

5 files changed

+32
-158
lines changed

5 files changed

+32
-158
lines changed

packages/seedless-onboarding-controller/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Changed
1717

18+
- **BREAKING:** Updated ControllerMessenger `AllowedEvents`. ([#6292](https://github.com/MetaMask/core/pull/6292))
19+
- Update `setLocked()` method with `mutex` and it becomes `async` method. ([#6292](https://github.com/MetaMask/core/pull/6292))
1820
- Bump `@metamask/base-controller` from `^8.1.0` to `^8.3.0` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465))
1921

2022
### Removed
2123

24+
- **BREAKING:** Removed `Keyring:lock` and `Keyring:unlock` events from the controller allowed events. ([#6292](https://github.com/MetaMask/core/pull/6292))
2225
- Removed `revokeRefreshToken` method ([#6275](https://github.com/MetaMask/core/pull/6275))
2326

2427
## [3.0.0]

packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts

Lines changed: 12 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { gcm } from '@noble/ciphers/aes';
2929
import { utf8ToBytes } from '@noble/ciphers/utils';
3030
import { managedNonce } from '@noble/ciphers/webcrypto';
31+
import { Mutex } from 'async-mutex';
3132
import type { webcrypto } from 'node:crypto';
3233

3334
import {
@@ -2665,29 +2666,6 @@ describe('SeedlessOnboardingController', () => {
26652666
);
26662667
});
26672668

2668-
it('should throw an error if the old password is incorrect', async () => {
2669-
await withController(
2670-
{
2671-
state: getMockInitialControllerState({
2672-
vault: MOCK_VAULT,
2673-
withMockAuthenticatedUser: true,
2674-
}),
2675-
},
2676-
async ({ controller, encryptor, baseMessenger }) => {
2677-
// unlock the controller
2678-
baseMessenger.publish('KeyringController:unlock');
2679-
await new Promise((resolve) => setTimeout(resolve, 100));
2680-
2681-
jest
2682-
.spyOn(encryptor, 'decrypt')
2683-
.mockRejectedValueOnce(new Error('Incorrect password'));
2684-
await expect(
2685-
controller.changePassword(NEW_MOCK_PASSWORD, 'INCORRECT_PASSWORD'),
2686-
).rejects.toThrow('Incorrect password');
2687-
},
2688-
);
2689-
});
2690-
26912669
it('should throw an error if failed to change password', async () => {
26922670
await withController(
26932671
{
@@ -2777,40 +2755,6 @@ describe('SeedlessOnboardingController', () => {
27772755
);
27782756
});
27792757

2780-
it('should throw error when authentication info is missing for assertPasswordInSync', async () => {
2781-
await withController(
2782-
{
2783-
state: {
2784-
// Create a state with vault but missing auth info
2785-
vault: JSON.stringify({ mockVault: 'data' }),
2786-
authPubKey: MOCK_AUTH_PUB_KEY,
2787-
socialBackupsMetadata: [],
2788-
// Intentionally missing nodeAuthTokens, authConnectionId, userId
2789-
},
2790-
},
2791-
async ({ controller, baseMessenger, encryptor }) => {
2792-
// Mock the encryptor to pass verifyVaultPassword
2793-
jest
2794-
.spyOn(encryptor, 'decrypt')
2795-
.mockResolvedValueOnce('mock decrypted data');
2796-
2797-
// unlock the controller
2798-
baseMessenger.publish('KeyringController:unlock');
2799-
await new Promise((resolve) => setTimeout(resolve, 100));
2800-
2801-
await expect(
2802-
controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD),
2803-
).rejects.toThrow(
2804-
SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo,
2805-
);
2806-
2807-
expect(controller.state.isSeedlessOnboardingUserAuthenticated).toBe(
2808-
false,
2809-
);
2810-
},
2811-
);
2812-
});
2813-
28142758
it('should call recoverEncKey when keyIndex is missing', async () => {
28152759
await withController(
28162760
{
@@ -2991,42 +2935,17 @@ describe('SeedlessOnboardingController', () => {
29912935
const MOCK_PASSWORD = 'mock-password';
29922936

29932937
it('should lock the controller', async () => {
2994-
await withController(
2995-
{
2996-
state: getMockInitialControllerState({
2997-
withMockAuthenticatedUser: true,
2998-
}),
2999-
},
3000-
async ({ controller, toprfClient }) => {
3001-
await mockCreateToprfKeyAndBackupSeedPhrase(
3002-
toprfClient,
3003-
controller,
3004-
MOCK_PASSWORD,
3005-
MOCK_SEED_PHRASE,
3006-
MOCK_KEYRING_ID,
3007-
);
3008-
3009-
controller.setLocked();
2938+
const mutexAcquireSpy = jest
2939+
.spyOn(Mutex.prototype, 'acquire')
2940+
.mockResolvedValueOnce(jest.fn());
30102941

3011-
await expect(
3012-
controller.addNewSecretData(MOCK_SEED_PHRASE, SecretType.Mnemonic, {
3013-
keyringId: MOCK_KEYRING_ID,
3014-
}),
3015-
).rejects.toThrow(
3016-
SeedlessOnboardingControllerErrorMessage.ControllerLocked,
3017-
);
3018-
},
3019-
);
3020-
});
3021-
3022-
it('should lock the controller when the keyring is locked', async () => {
30232942
await withController(
30242943
{
30252944
state: getMockInitialControllerState({
30262945
withMockAuthenticatedUser: true,
30272946
}),
30282947
},
3029-
async ({ controller, baseMessenger, toprfClient }) => {
2948+
async ({ controller, toprfClient }) => {
30302949
await mockCreateToprfKeyAndBackupSeedPhrase(
30312950
toprfClient,
30322951
controller,
@@ -3035,53 +2954,18 @@ describe('SeedlessOnboardingController', () => {
30352954
MOCK_KEYRING_ID,
30362955
);
30372956

3038-
baseMessenger.publish('KeyringController:lock');
2957+
await controller.setLocked();
30392958

3040-
await expect(
3041-
controller.addNewSecretData(MOCK_SEED_PHRASE, SecretType.Mnemonic, {
3042-
keyringId: MOCK_KEYRING_ID,
3043-
}),
3044-
).rejects.toThrow(
3045-
SeedlessOnboardingControllerErrorMessage.ControllerLocked,
3046-
);
3047-
},
3048-
);
3049-
});
2959+
// verify that the mutex acquire was called
2960+
expect(mutexAcquireSpy).toHaveBeenCalled();
30502961

3051-
it('should unlock the controller when the keyring is unlocked', async () => {
3052-
await withController(
3053-
{
3054-
state: getMockInitialControllerState({
3055-
withMockAuthenticatedUser: true,
3056-
}),
3057-
},
3058-
async ({ controller, baseMessenger }) => {
30592962
await expect(
30602963
controller.addNewSecretData(MOCK_SEED_PHRASE, SecretType.Mnemonic, {
30612964
keyringId: MOCK_KEYRING_ID,
30622965
}),
30632966
).rejects.toThrow(
30642967
SeedlessOnboardingControllerErrorMessage.ControllerLocked,
30652968
);
3066-
3067-
baseMessenger.publish('KeyringController:unlock');
3068-
3069-
await new Promise((resolve) => setTimeout(resolve, 100));
3070-
3071-
controller.updateBackupMetadataState({
3072-
keyringId: MOCK_KEYRING_ID,
3073-
data: MOCK_SEED_PHRASE,
3074-
type: SecretType.Mnemonic,
3075-
});
3076-
3077-
const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE);
3078-
expect(controller.state.socialBackupsMetadata).toStrictEqual([
3079-
{
3080-
type: SecretType.Mnemonic,
3081-
keyringId: MOCK_KEYRING_ID,
3082-
hash: MOCK_SEED_PHRASE_HASH,
3083-
},
3084-
]);
30852969
},
30862970
);
30872971
});
@@ -3306,7 +3190,7 @@ describe('SeedlessOnboardingController', () => {
33063190
pwEncKey: recoveredPwEncKey,
33073191
});
33083192

3309-
controller.setLocked();
3193+
await controller.setLocked();
33103194

33113195
await controller.submitGlobalPassword({
33123196
globalPassword: GLOBAL_PASSWORD,
@@ -3474,7 +3358,7 @@ describe('SeedlessOnboardingController', () => {
34743358
pwEncKey: recoveredPwEncKey,
34753359
});
34763360

3477-
controller.setLocked();
3361+
await controller.setLocked();
34783362

34793363
await controller.submitGlobalPassword({
34803364
globalPassword: GLOBAL_PASSWORD,
@@ -3791,7 +3675,7 @@ describe('SeedlessOnboardingController', () => {
37913675
// We still need verifyPassword to work conceptually, even if unlock is bypassed
37923676
// verifyPasswordSpy.mockResolvedValueOnce(); // Don't mock, let the real one run inside syncLatestGlobalPassword
37933677

3794-
controller.setLocked();
3678+
await controller.setLocked();
37953679

37963680
// Mock recoverEncKey for the global password
37973681
const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD);
@@ -4023,7 +3907,7 @@ describe('SeedlessOnboardingController', () => {
40233907
},
40243908
async ({ controller, toprfClient }) => {
40253909
// Ensure the controller is locked
4026-
controller.setLocked();
3910+
await controller.setLocked();
40273911

40283912
// Mock fetchAuthPubKey to return a valid response
40293913
jest.spyOn(toprfClient, 'fetchAuthPubKey').mockResolvedValue({

packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,6 @@ export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
260260
this.#refreshJWTToken = refreshJWTToken;
261261
this.#revokeRefreshToken = revokeRefreshToken;
262262
this.#renewRefreshToken = renewRefreshToken;
263-
264-
// setup subscriptions to the keyring lock event
265-
// when the keyring is locked (wallet is locked), the controller will be cleared of its credentials
266-
this.messagingSystem.subscribe('KeyringController:lock', () => {
267-
this.setLocked();
268-
});
269-
this.messagingSystem.subscribe('KeyringController:unlock', () => {
270-
this.#setUnlocked();
271-
});
272263
}
273264

274265
async fetchMetadataAccessCreds(): Promise<{
@@ -697,17 +688,21 @@ export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
697688
* Set the controller to locked state, and deallocate the secrets (vault encryption key and salt).
698689
*
699690
* When the controller is locked, the user will not be able to perform any operations on the controller/vault.
691+
*
692+
* @returns A promise that resolves to the success of the operation.
700693
*/
701-
setLocked() {
702-
this.update((state) => {
703-
delete state.vaultEncryptionKey;
704-
delete state.vaultEncryptionSalt;
705-
delete state.revokeToken;
706-
delete state.accessToken;
707-
});
694+
async setLocked() {
695+
return await this.#withControllerLock(async () => {
696+
this.update((state) => {
697+
delete state.vaultEncryptionKey;
698+
delete state.vaultEncryptionSalt;
699+
delete state.revokeToken;
700+
delete state.accessToken;
701+
});
708702

709-
this.#cachedDecryptedVaultData = undefined;
710-
this.#isUnlocked = false;
703+
this.#cachedDecryptedVaultData = undefined;
704+
this.#isUnlocked = false;
705+
});
711706
}
712707

713708
/**
@@ -1693,17 +1688,13 @@ export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
16931688
authPubKey: SEC1EncodedPublicKey;
16941689
latestKeyIndex: number;
16951690
}> {
1691+
this.#assertIsAuthenticatedUser(this.state);
16961692
const {
16971693
nodeAuthTokens,
16981694
authConnectionId,
16991695
groupedAuthConnectionId,
17001696
userId,
17011697
} = this.state;
1702-
if (!nodeAuthTokens || !authConnectionId || !userId) {
1703-
throw new Error(
1704-
SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo,
1705-
);
1706-
}
17071698

17081699
const { authPubKey, keyIndex: latestKeyIndex } = await this.toprfClient
17091700
.fetchAuthPubKey({

packages/seedless-onboarding-controller/src/types.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import type { RestrictedMessenger } from '@metamask/base-controller';
22
import type { ControllerGetStateAction } from '@metamask/base-controller';
33
import type { ControllerStateChangeEvent } from '@metamask/base-controller';
4-
import type {
5-
ExportableKeyEncryptor,
6-
KeyringControllerLockEvent,
7-
KeyringControllerUnlockEvent,
8-
} from '@metamask/keyring-controller';
4+
import type { ExportableKeyEncryptor } from '@metamask/keyring-controller';
95
import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup';
106
import type { MutexInterface } from 'async-mutex';
117

@@ -206,7 +202,7 @@ export type SeedlessOnboardingControllerStateChangeEvent =
206202
export type SeedlessOnboardingControllerEvents =
207203
SeedlessOnboardingControllerStateChangeEvent;
208204

209-
type AllowedEvents = KeyringControllerLockEvent | KeyringControllerUnlockEvent;
205+
type AllowedEvents = never;
210206

211207
// Messenger
212208
export type SeedlessOnboardingControllerMessenger = RestrictedMessenger<

packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function createCustomSeedlessOnboardingMessenger() {
1919
const messenger = baseMessenger.getRestricted({
2020
name: 'SeedlessOnboardingController',
2121
allowedActions: [],
22-
allowedEvents: ['KeyringController:lock', 'KeyringController:unlock'],
22+
allowedEvents: [],
2323
});
2424

2525
return {

0 commit comments

Comments
 (0)