Skip to content

seedless controller: store encrypted password locally #5988

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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions packages/seedless-onboarding-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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)).
- Changed `recoverCurrentDevicePassword` to no longer persist passwords in the backup store. ([#5988](https://github.com/MetaMask/core/pull/5988))

## [1.0.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -396,10 +399,14 @@ async function createMockVault(
const { vault: encryptedMockVault, exportedKeyString } =
await encryptor.encryptWithDetail(MOCK_PASSWORD, serializedKeyData);

const aes = managedNonce(gcm)(encKey);
const encryptedPassword = aes.encrypt(utf8ToBytes(MOCK_PASSWORD));

return {
encryptedMockVault,
vaultEncryptionKey: exportedKeyString,
vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt,
encryptedPassword,
revokeToken: mockRevokeToken,
};
}
Expand Down Expand Up @@ -445,6 +452,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.encryptedPassword - The mock encrypted password.
* @returns The initial controller state with the mock authenticated user.
*/
function getMockInitialControllerState(options?: {
Expand All @@ -455,6 +463,7 @@ function getMockInitialControllerState(options?: {
vault?: string;
vaultEncryptionKey?: string;
vaultEncryptionSalt?: string;
encryptedPassword?: string;
}): Partial<SeedlessOnboardingControllerState> {
const state = getDefaultSeedlessOnboardingControllerState();

Expand Down Expand Up @@ -486,6 +495,10 @@ function getMockInitialControllerState(options?: {
state.authPubKey = options.authPubKey ?? MOCK_AUTH_PUB_KEY;
}

if (options?.encryptedPassword) {
state.encryptedPassword = options.encryptedPassword;
}

return state;
}

Expand Down Expand Up @@ -2955,6 +2968,14 @@ describe('SeedlessOnboardingController', () => {
}),
},
async ({ controller, toprfClient }) => {
await mockCreateToprfKeyAndBackupSeedPhrase(
toprfClient,
controller,
RECOVERED_PASSWORD,
MOCK_SEED_PHRASE,
MOCK_KEYRING_ID,
);

// Mock recoverEncKey for the global password
const mockToprfEncryptor = createMockToprfEncryptor();
const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD);
Expand All @@ -2968,8 +2989,10 @@ describe('SeedlessOnboardingController', () => {
});

// Mock toprfClient.recoverPassword
const recoveredEncKey =
mockToprfEncryptor.deriveEncKey(RECOVERED_PASSWORD);
jest.spyOn(toprfClient, 'recoverPassword').mockResolvedValueOnce({
password: RECOVERED_PASSWORD,
password: bytesToBase64(recoveredEncKey),
});

const result = await controller.recoverCurrentDevicePassword({
Expand All @@ -2983,6 +3006,45 @@ describe('SeedlessOnboardingController', () => {
);
});

it('should throw if encryptedPassword 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 authKeyPair =
mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD);
jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({
encKey,
authKeyPair,
rateLimitResetResult: Promise.resolve(),
keyShareIndex: 1,
});

// Mock toprfClient.recoverPassword
const recoveredEncKey =
mockToprfEncryptor.deriveEncKey(RECOVERED_PASSWORD);
jest.spyOn(toprfClient, 'recoverPassword').mockResolvedValueOnce({
password: bytesToBase64(recoveredEncKey),
});

await expect(
controller.recoverCurrentDevicePassword({
globalPassword: GLOBAL_PASSWORD,
}),
).rejects.toThrow(
SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword,
);
},
);
});

it('should throw SRPNotBackedUpError if no authPubKey in state', async () => {
await withController(
{
Expand Down Expand Up @@ -4071,6 +4133,7 @@ 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 initialEncryptedPassword: Uint8Array;

// Generate initial keys and vault state before tests run
beforeAll(async () => {
Expand All @@ -4090,6 +4153,7 @@ describe('SeedlessOnboardingController', () => {
MOCK_VAULT = mockResult.encryptedMockVault;
MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey;
MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt;
initialEncryptedPassword = mockResult.encryptedPassword;
});

it('should retry recoverCurrentDevicePassword after refreshing expired tokens', async () => {
Expand All @@ -4101,6 +4165,7 @@ describe('SeedlessOnboardingController', () => {
vault: MOCK_VAULT,
vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY,
vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT,
encryptedPassword: bytesToBase64(initialEncryptedPassword),
}),
},
async ({ controller, toprfClient, mockRefreshJWTToken }) => {
Expand All @@ -4122,7 +4187,7 @@ describe('SeedlessOnboardingController', () => {
);
})
.mockResolvedValueOnce({
password: MOCK_PASSWORD,
password: bytesToBase64(initialEncKey),
});

// Mock authenticate for token refresh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -119,6 +122,10 @@ const seedlessOnboardingMetadata: StateMetadata<SeedlessOnboardingControllerStat
persist: false,
anonymous: true,
},
encryptedPassword: {
persist: true,
Copy link
Contributor

Choose a reason for hiding this comment

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

May I know why do we need to persist this?

Copy link
Contributor Author

@matthiasgeihs matthiasgeihs Jun 16, 2025

Choose a reason for hiding this comment

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

because you need it when the keyring vault is locked.

let's say you are locked out of your device.
now you get the message, password changed.

you enter new password.
the seedless controller will fetch the encryption key that was used to encrypt your old password here.
will load the encryptedPassword, decrypt it, and return it.

so now other controller can log the user in.

Copy link
Contributor

Choose a reason for hiding this comment

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

Got it. Thanks for the explanation.

anonymous: true,
},
};

export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
Expand Down Expand Up @@ -319,6 +326,7 @@ export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
this.#persistAuthPubKey({
authPubKey: authKeyPair.pk,
});
await this.#storeEncryptedPassword(encKey, password);
};

await this.#executeWithTokenRefresh(
Expand Down Expand Up @@ -698,7 +706,15 @@ export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
curEncKey: latestPwEncKey,
curAuthKeyPair: latestPwAuthKeyPair,
});
return res;

const { password: recoveredEncryptionKeyBase64 } = res;
const recoveredEncryptionKey = base64ToBytes(
recoveredEncryptionKeyBase64,
);
const password = await this.#loadEncryptedPassword(
recoveredEncryptionKey,
);
return { password };
} catch (error) {
if (this.#isTokenExpiredError(error)) {
throw error;
Expand Down Expand Up @@ -895,17 +911,37 @@ export class SeedlessOnboardingController<EncryptionKey> extends BaseController<
keyShareIndex: newKeyShareIndex,
} = await this.#recoverEncKey(oldPassword);

return await this.toprfClient.changeEncKey({
const changeEncKeyResult = await this.toprfClient.changeEncKey({
nodeAuthTokens: this.state.nodeAuthTokens,
authConnectionId,
groupedAuthConnectionId,
userId,
oldEncKey: encKey,
oldAuthKeyPair: authKeyPair,
newKeyShareIndex,
oldPassword,
oldPassword: bytesToBase64(encKey), // Store the old encryption key.
newPassword,
});

await this.#storeEncryptedPassword(encKey, newPassword);
return changeEncKeyResult;
}

async #storeEncryptedPassword(encKey: Uint8Array, password: string) {
const aes = managedNonce(gcm)(encKey);
const encryptedPassword = aes.encrypt(utf8ToBytes(password));
this.update((state) => {
state.encryptedPassword = bytesToBase64(encryptedPassword);
});
}

async #loadEncryptedPassword(encKey: Uint8Array) {
const { encryptedPassword } = this.state;
assertIsEncryptedPasswordSet(encryptedPassword);
const encryptedPasswordBytes = base64ToBytes(encryptedPassword);
const aes = managedNonce(gcm)(encKey);
const password = aes.decrypt(encryptedPasswordBytes);
return bytesToUtf8(password);
}

/**
Expand Down Expand Up @@ -1673,3 +1709,19 @@ async function withLock<Result>(
releaseLock();
}
}

/**
* Assert that the provided encrypted password is a valid non-empty string.
*
* @param encryptedPassword - The encrypted password to check.
* @throws If the encrypted password is not a valid string.
*/
function assertIsEncryptedPasswordSet(
encryptedPassword: string | undefined,
): asserts encryptedPassword is string {
if (!encryptedPassword) {
throw new Error(
SeedlessOnboardingControllerErrorMessage.EncryptedPasswordNotSet,
);
}
}
1 change: 1 addition & 0 deletions packages/seedless-onboarding-controller/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ export enum SeedlessOnboardingControllerErrorMessage {
OutdatedPassword = `${controllerName} - Outdated password`,
CouldNotRecoverPassword = `${controllerName} - Could not recover password`,
SRPNotBackedUpError = `${controllerName} - SRP not backed up`,
EncryptedPasswordNotSet = `${controllerName} - Encrypted password not set`,
}
5 changes: 5 additions & 0 deletions packages/seedless-onboarding-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ export type SeedlessOnboardingControllerState =
* This is temporarily stored in state during authentication and then persisted in the vault.
*/
revokeToken?: string;

/**
* The encrypted password used to encrypt the vault.
*/
encryptedPassword?: string;
};

// Actions
Expand Down
Loading