diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 49f39f1ce08..2c0825f7132 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -150,6 +150,8 @@ /packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/wallet/src/initialization/instances/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/wallet/src/initialization/instances/transaction-controller/ @MetaMask/confirmations +/packages/wallet/src/initialization/instances/passkey-controller @MetaMask/web3auth +/packages/wallet/src/initialization/instances/seedless-onboarding-controller @MetaMask/web3auth ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/README.md b/README.md index b8b3a2d8ca8..e34c4a2e77b 100644 --- a/README.md +++ b/README.md @@ -650,7 +650,9 @@ linkStyle default opacity:0.5 wallet --> keyring_controller; wallet --> messenger; wallet --> network_controller; + wallet --> passkey_controller; wallet --> remote_feature_flag_controller; + wallet --> seedless_onboarding_controller; wallet --> storage_service; wallet --> transaction_controller; wallet_cli --> base_controller; diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 79caefd17d3..46131ea3fee 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2240,11 +2240,6 @@ "count": 2 } }, - "packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts": { "no-restricted-syntax": { "count": 1 diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 706c4ac482c..df287bf355b 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING** Wire `SeedlessOnboardingController` and `PasskeyController` into the default wallet initialization ([#9533](https://github.com/MetaMask/core/pull/9533)) + ## [7.0.1] ### Changed diff --git a/packages/wallet/package.json b/packages/wallet/package.json index a31271b98a0..7754effd742 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -65,8 +65,10 @@ "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^34.0.0", + "@metamask/passkey-controller": "^2.1.0", "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/scure-bip39": "^2.1.1", + "@metamask/seedless-onboarding-controller": "^10.0.3", "@metamask/storage-service": "^1.0.2", "@metamask/transaction-controller": "^69.0.0", "@metamask/utils": "^11.11.0" diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index a15db956032..3f2be223691 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -4,6 +4,8 @@ export { approvalController } from './approval-controller/approval-controller'; export { connectivityController } from './connectivity-controller/connectivity-controller'; export { keyringController } from './keyring-controller/keyring-controller'; export { networkController } from './network-controller/network-controller'; +export { passkeyController } from './passkey-controller/passkey-controller'; export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller'; +export { seedlessOnboardingController } from './seedless-onboarding-controller/seedless-onboarding-controller'; export { storageService } from './storage-service/storage-service'; export { transactionController } from './transaction-controller/transaction-controller'; diff --git a/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.test.ts b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.test.ts new file mode 100644 index 00000000000..7606b942618 --- /dev/null +++ b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.test.ts @@ -0,0 +1,134 @@ +import { Messenger } from '@metamask/messenger'; +import { + PasskeyController, + getDefaultPasskeyControllerState, +} from '@metamask/passkey-controller'; +import type { PasskeyRecord } from '@metamask/passkey-controller'; + +import { defaultConfigurations } from '../../defaults'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import { passkeyController } from './passkey-controller'; + +const { PasskeyController: ActualPasskeyController } = jest.requireActual( + '@metamask/passkey-controller', +); + +jest.mock('@metamask/passkey-controller', () => ({ + ...jest.requireActual('@metamask/passkey-controller'), + PasskeyController: jest.fn(), +})); + +const REQUIRED_OPTIONS = { + expectedRPID: 'extension-id', + expectedOrigin: 'https://extension.origin', + rpName: 'MetaMask', +}; + +/** + * Creates a root messenger for use in tests. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +describe('passkeyController', () => { + beforeEach(() => { + jest.clearAllMocks(); + (PasskeyController as jest.Mock).mockImplementation( + (...args: unknown[]) => new ActualPasskeyController(...args), + ); + }); + + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain(passkeyController); + }); + + it('initializes a PasskeyController with default state', () => { + const messenger = passkeyController.getMessenger(getRootMessenger()); + + const instance = passkeyController.init({ + state: undefined, + messenger, + options: REQUIRED_OPTIONS, + }); + + expect(instance).toBeInstanceOf(ActualPasskeyController); + expect(instance.state).toStrictEqual(getDefaultPasskeyControllerState()); + }); + + it('forwards the provided state to the controller', () => { + const messenger = passkeyController.getMessenger(getRootMessenger()); + + const passkeyRecord: PasskeyRecord = { + credential: { + id: 'credential-id', + publicKey: 'public-key', + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + }, + encryptedVaultKey: { + ciphertext: 'YQ==', + iv: 'YWFhYWFhYWFhYQ==', + }, + keyDerivation: { method: 'userHandle' }, + }; + + const instance = passkeyController.init({ + state: { passkeyRecord }, + messenger, + options: REQUIRED_OPTIONS, + }); + + expect(instance.state.passkeyRecord).toStrictEqual(passkeyRecord); + }); + + it('forwards custom passkey configuration options', () => { + const messenger = passkeyController.getMessenger(getRootMessenger()); + + passkeyController.init({ + state: undefined, + messenger, + options: { + expectedRPID: ['extension-id', 'other-id'], + expectedOrigin: ['https://a.example', 'https://b.example'], + rpId: 'rp-id', + rpName: 'Custom RP', + userName: 'custom-user', + userDisplayName: 'Custom Display Name', + }, + }); + + expect(PasskeyController).toHaveBeenCalledWith( + expect.objectContaining({ + rpId: 'rp-id', + rpName: 'Custom RP', + userName: 'custom-user', + userDisplayName: 'Custom Display Name', + expectedRPID: ['extension-id', 'other-id'], + expectedOrigin: ['https://a.example', 'https://b.example'], + }), + ); + }); + + it('exposes its state through the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = passkeyController.getMessenger(rootMessenger); + + passkeyController.init({ + state: undefined, + messenger, + options: REQUIRED_OPTIONS, + }); + + expect(rootMessenger.call('PasskeyController:getState')).toStrictEqual( + getDefaultPasskeyControllerState(), + ); + }); +}); diff --git a/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts new file mode 100644 index 00000000000..bf1836fa5c0 --- /dev/null +++ b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts @@ -0,0 +1,25 @@ +import { Messenger } from '@metamask/messenger'; +import { + PasskeyController, + PasskeyControllerMessenger, +} from '@metamask/passkey-controller'; + +import { InitializationConfiguration } from '../../types'; + +export const passkeyController: InitializationConfiguration< + PasskeyController, + PasskeyControllerMessenger +> = { + name: 'PasskeyController', + init: ({ state, messenger, options }) => + new PasskeyController({ + ...options, + messenger, + state, + }), + getMessenger: (parent) => + new Messenger({ + namespace: 'PasskeyController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/passkey-controller/types.ts b/packages/wallet/src/initialization/instances/passkey-controller/types.ts new file mode 100644 index 00000000000..b25bcf1c27a --- /dev/null +++ b/packages/wallet/src/initialization/instances/passkey-controller/types.ts @@ -0,0 +1,6 @@ +import { PasskeyController } from '@metamask/passkey-controller'; + +export type PasskeyControllerInstanceOptions = Omit< + ConstructorParameters[0], + 'messenger' | 'state' +>; diff --git a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts new file mode 100644 index 00000000000..9fdfcace186 --- /dev/null +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts @@ -0,0 +1,182 @@ +import { Messenger } from '@metamask/messenger'; +import { + SecretType, + SeedlessOnboardingController, + getDefaultSeedlessOnboardingControllerState, +} from '@metamask/seedless-onboarding-controller'; + +import { defaultConfigurations } from '../../defaults'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import { encryptorFactory } from '../keyring-controller/encryptor'; +import * as encryptorModule from '../keyring-controller/encryptor'; +import { seedlessOnboardingController } from './seedless-onboarding-controller'; +import type { SeedlessOnboardingControllerInstanceOptions } from './types'; + +const { SeedlessOnboardingController: ActualSeedlessOnboardingController } = + jest.requireActual('@metamask/seedless-onboarding-controller'); + +jest.mock('@metamask/seedless-onboarding-controller', () => ({ + ...jest.requireActual('@metamask/seedless-onboarding-controller'), + SeedlessOnboardingController: jest.fn(), +})); + +/** + * Creates a root messenger for use in tests. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +/** + * Creates the required seedless onboarding options for tests. + * + * @returns Seedless onboarding controller options. + */ +function getSeedlessOnboardingOptions(): Pick< + SeedlessOnboardingControllerInstanceOptions, + 'refreshJWTToken' | 'revokeRefreshToken' | 'renewRefreshToken' +> { + return { + refreshJWTToken: jest.fn(), + revokeRefreshToken: jest.fn(), + renewRefreshToken: jest.fn(), + }; +} + +describe('seedlessOnboardingController', () => { + beforeEach(() => { + jest.clearAllMocks(); + (SeedlessOnboardingController as jest.Mock).mockImplementation( + (...args: unknown[]) => new ActualSeedlessOnboardingController(...args), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain( + seedlessOnboardingController, + ); + }); + + it('initializes a SeedlessOnboardingController with default state', () => { + const messenger = + seedlessOnboardingController.getMessenger(getRootMessenger()); + + const instance = seedlessOnboardingController.init({ + state: undefined, + messenger, + options: getSeedlessOnboardingOptions(), + }); + + expect(instance).toBeInstanceOf(ActualSeedlessOnboardingController); + expect(instance.state).toStrictEqual( + getDefaultSeedlessOnboardingControllerState(), + ); + }); + + it('forwards the provided state to the controller', () => { + const messenger = + seedlessOnboardingController.getMessenger(getRootMessenger()); + + const instance = seedlessOnboardingController.init({ + state: { + migrationVersion: 1, + isSeedlessOnboardingUserAuthenticated: false, + socialBackupsMetadata: [{ hash: 'abc', type: SecretType.Mnemonic }], + }, + messenger, + options: getSeedlessOnboardingOptions(), + }); + + expect(instance.state.migrationVersion).toBe(1); + expect(instance.state.socialBackupsMetadata).toStrictEqual([ + { hash: 'abc', type: SecretType.Mnemonic }, + ]); + }); + + it('forwards seedless onboarding options to the controller', () => { + const messenger = + seedlessOnboardingController.getMessenger(getRootMessenger()); + const options = getSeedlessOnboardingOptions(); + + seedlessOnboardingController.init({ + state: undefined, + messenger, + options, + }); + + expect(SeedlessOnboardingController).toHaveBeenCalledWith( + expect.objectContaining({ + refreshJWTToken: options.refreshJWTToken, + revokeRefreshToken: options.revokeRefreshToken, + renewRefreshToken: options.renewRefreshToken, + }), + ); + }); + + it('applies the default encryptor when omitted', () => { + const defaultEncryptor = encryptorFactory(600_000); + const encryptorFactorySpy = jest + .spyOn(encryptorModule, 'encryptorFactory') + .mockReturnValue(defaultEncryptor); + const messenger = + seedlessOnboardingController.getMessenger(getRootMessenger()); + + seedlessOnboardingController.init({ + state: undefined, + messenger, + options: getSeedlessOnboardingOptions(), + }); + + const { encryptor } = (SeedlessOnboardingController as jest.Mock).mock + .calls[0][0] as { + encryptor: ReturnType; + }; + + expect(encryptorFactorySpy).toHaveBeenCalledWith(600_000); + expect(encryptor).toBe(defaultEncryptor); + }); + + it('uses the provided encryptor when supplied', () => { + const messenger = + seedlessOnboardingController.getMessenger(getRootMessenger()); + const encryptor = encryptorFactory(100_000); + + seedlessOnboardingController.init({ + state: undefined, + messenger, + options: { + ...getSeedlessOnboardingOptions(), + encryptor, + }, + }); + + expect(SeedlessOnboardingController).toHaveBeenCalledWith( + expect.objectContaining({ encryptor }), + ); + }); + + it('exposes its state through the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = seedlessOnboardingController.getMessenger(rootMessenger); + + seedlessOnboardingController.init({ + state: undefined, + messenger, + options: getSeedlessOnboardingOptions(), + }); + + expect( + rootMessenger.call('SeedlessOnboardingController:getState'), + ).toStrictEqual(getDefaultSeedlessOnboardingControllerState()); + }); +}); diff --git a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts new file mode 100644 index 00000000000..03bd03d3778 --- /dev/null +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts @@ -0,0 +1,30 @@ +import { EncryptionKey } from '@metamask/browser-passworder'; +import { Encryptor } from '@metamask/keyring-controller'; +import { Messenger } from '@metamask/messenger'; +import { + SeedlessOnboardingController, + SeedlessOnboardingControllerMessenger, +} from '@metamask/seedless-onboarding-controller'; + +import { InitializationConfiguration } from '../../types'; +import { encryptorFactory } from '../keyring-controller/encryptor'; + +export const seedlessOnboardingController: InitializationConfiguration< + SeedlessOnboardingController, + SeedlessOnboardingControllerMessenger +> = { + name: 'SeedlessOnboardingController', + init: ({ state, messenger, options }) => + new SeedlessOnboardingController({ + ...options, + state, + messenger, + encryptor: (options.encryptor ?? + encryptorFactory(600_000)) as Encryptor, + }), + getMessenger: (parent) => + new Messenger({ + namespace: 'SeedlessOnboardingController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/types.ts b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/types.ts new file mode 100644 index 00000000000..8bbf862698c --- /dev/null +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/types.ts @@ -0,0 +1,14 @@ +import type { SeedlessOnboardingControllerOptions } from '@metamask/seedless-onboarding-controller'; + +import { GenericEncryptor } from '../keyring-controller/encryptor'; + +export type SeedlessOnboardingControllerInstanceOptions = Omit< + SeedlessOnboardingControllerOptions, + 'messenger' | 'state' | 'encryptor' +> & { + /** + * Encryptor used to protect the seedless onboarding vault. Defaults to a PBKDF2 encryptor + * configured with 600,000 iterations. + */ + encryptor?: GenericEncryptor; +}; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 1f330e9bafd..910ea28c171 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -9,7 +9,9 @@ import type { ApprovalControllerInstanceOptions } from './initialization/instanc import type { ConnectivityControllerInstanceOptions } from './initialization/instances/connectivity-controller/types'; import type { KeyringControllerInstanceOptions } from './initialization/instances/keyring-controller/types'; import type { NetworkControllerInstanceOptions } from './initialization/instances/network-controller/types'; +import { PasskeyControllerInstanceOptions } from './initialization/instances/passkey-controller/types'; import type { RemoteFeatureFlagControllerInstanceOptions } from './initialization/instances/remote-feature-flag-controller/types'; +import { SeedlessOnboardingControllerInstanceOptions } from './initialization/instances/seedless-onboarding-controller/types'; import type { StorageServiceInstanceOptions } from './initialization/instances/storage-service/types'; import type { TransactionControllerInstanceOptions } from './initialization/instances/transaction-controller/types'; import type { InitializationConfiguration } from './initialization/types'; @@ -29,7 +31,9 @@ export type InstanceSpecificOptions = { connectivityController: ConnectivityControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; networkController: NetworkControllerInstanceOptions; + passkeyController?: PasskeyControllerInstanceOptions; remoteFeatureFlagController: RemoteFeatureFlagControllerInstanceOptions; + seedlessOnboardingController?: SeedlessOnboardingControllerInstanceOptions; storageService: StorageServiceInstanceOptions; transactionController?: TransactionControllerInstanceOptions; }; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index cc15d40bc18..d4caa50ec1c 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -15,7 +15,9 @@ { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../passkey-controller/tsconfig.build.json" }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, + { "path": "../seedless-onboarding-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" } ], diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index ce310304ecc..62f5137fc5b 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -31,9 +31,15 @@ { "path": "../network-controller" }, + { + "path": "../passkey-controller" + }, { "path": "../remote-feature-flag-controller" }, + { + "path": "../seedless-onboarding-controller" + }, { "path": "../storage-service" }, diff --git a/yarn.lock b/yarn.lock index 6b51e9580a4..d839fbbabbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7931,7 +7931,7 @@ __metadata: languageName: node linkType: hard -"@metamask/passkey-controller@workspace:packages/passkey-controller": +"@metamask/passkey-controller@npm:^2.1.0, @metamask/passkey-controller@workspace:packages/passkey-controller": version: 0.0.0-use.local resolution: "@metamask/passkey-controller@workspace:packages/passkey-controller" dependencies: @@ -8436,7 +8436,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^10.0.3, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -9164,8 +9164,10 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" + "@metamask/passkey-controller": "npm:^2.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/seedless-onboarding-controller": "npm:^10.0.3" "@metamask/storage-service": "npm:^1.0.2" "@metamask/transaction-controller": "npm:^69.0.0" "@metamask/utils": "npm:^11.11.0"