diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3978321adbf..f7d8e29ec70 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -151,7 +151,9 @@ /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations /packages/wallet/src/initialization/instances/connectivity-controller/ @MetaMask/core-platform /packages/wallet/src/initialization/instances/keyring-controller/ @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/wallet/src/initialization/instances/passkey-controller/ @MetaMask/web3auth /packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/wallet/src/initialization/instances/seedless-onboarding-controller/ @MetaMask/web3auth /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 diff --git a/README.md b/README.md index 3b29b9996de..ec3d61dc7b9 100644 --- a/README.md +++ b/README.md @@ -652,7 +652,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/codeowners.ts b/codeowners.ts index 7020d46d5df..ce77d6b6877 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -248,6 +248,7 @@ const PACKAGES: Record = { }, 'passkey-controller': { teams: ['@MetaMask/web3auth'], + initializationPath: 'passkey-controller', }, 'permission-controller': { teams: ['@MetaMask/core-platform'], @@ -298,6 +299,7 @@ const PACKAGES: Record = { }, 'seedless-onboarding-controller': { teams: ['@MetaMask/web3auth'], + initializationPath: 'seedless-onboarding-controller', }, 'selected-network-controller': { teams: ['@MetaMask/core-platform'], diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index c6c807a23eb..b39b7418e51 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)) + ### Changed - Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.1.0` ([#9568](https://github.com/MetaMask/core/pull/9568)) diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 9a202dde1cf..b56f1b253f5 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": "^3.0.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.1.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 624fe8bc567..858e36e9769 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.js export { connectivityController } from './connectivity-controller/connectivity-controller.js'; export { keyringController } from './keyring-controller/keyring-controller.js'; export { networkController } from './network-controller/network-controller.js'; +export { passkeyController } from './passkey-controller/passkey-controller.js'; export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller.js'; +export { seedlessOnboardingController } from './seedless-onboarding-controller/seedless-onboarding-controller.js'; export { storageService } from './storage-service/storage-service.js'; export { transactionController } from './transaction-controller/transaction-controller.js'; 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..7c31e0b3e70 --- /dev/null +++ b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.test.ts @@ -0,0 +1,177 @@ +import { Messenger } from '@metamask/messenger'; +import { + PasskeyController, + PasskeyControllerErrorCode, + getDefaultPasskeyControllerState, +} from '@metamask/passkey-controller'; +import type { + PasskeyAuthenticationResponse, + PasskeyRecord, + PasskeyRegistrationResponse, +} from '@metamask/passkey-controller'; + +import { defaultConfigurations } from '../../defaults.js'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import { passkeyController } from './passkey-controller.js'; +import type { PasskeyControllerInstanceOptions } from './types.js'; + +const REQUIRED_OPTIONS: PasskeyControllerInstanceOptions = { + expectedRPID: 'extension-id', + expectedOrigin: 'https://extension.origin', + rpName: 'MetaMask', + getIsOnboardingCompleted: () => false, +}; + +/** + * Creates a root messenger for use in tests. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +describe('passkeyController', () => { + it('is registered as a default initialization configuration', () => { + // Proves the controller is part of the default ensemble that `initialize()` + // wires, without constructing a `Wallet` (which keeps this PR independent of + // the constructor-options shape). + 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(PasskeyController); + 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('defaults userName and userDisplayName to rpName when omitted', () => { + const messenger = passkeyController.getMessenger(getRootMessenger()); + + const instance = passkeyController.init({ + state: undefined, + messenger, + options: REQUIRED_OPTIONS, + }); + + const options = instance.generateRegistrationOptions({ + prfAvailable: false, + }); + + expect(options.user).toStrictEqual({ + id: expect.any(String), + name: 'MetaMask', + displayName: 'MetaMask', + }); + }); + + it('uses custom passkey configuration options', () => { + const messenger = passkeyController.getMessenger(getRootMessenger()); + + const instance = passkeyController.init({ + state: undefined, + messenger, + options: { + ...REQUIRED_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', + }, + }); + + const options = instance.generateRegistrationOptions({ + prfAvailable: false, + }); + + expect(options.rp).toStrictEqual({ + name: 'Custom RP', + id: 'rp-id', + }); + expect(options.user).toStrictEqual({ + id: expect.any(String), + name: 'custom-user', + displayName: 'Custom Display Name', + }); + }); + + it('uses the provided getIsOnboardingCompleted callback', async () => { + const messenger = passkeyController.getMessenger(getRootMessenger()); + const getIsOnboardingCompleted = jest.fn().mockReturnValue(true); + + const instance = passkeyController.init({ + state: undefined, + messenger, + options: { + ...REQUIRED_OPTIONS, + getIsOnboardingCompleted, + }, + }); + + await expect( + instance.protectVaultKeyWithPasskey({ + registrationResponse: {} as PasskeyRegistrationResponse, + authenticationResponse: {} as PasskeyAuthenticationResponse, + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.EnrollmentPasswordRequired, + }); + + expect(getIsOnboardingCompleted).toHaveBeenCalled(); + }); + + 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..7e40e7ce590 --- /dev/null +++ b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts @@ -0,0 +1,47 @@ +import { Messenger } from '@metamask/messenger'; +import { + PasskeyController, + PasskeyControllerMessenger, +} from '@metamask/passkey-controller'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import { InitializationConfiguration } from '../../types.js'; + +export const passkeyController: InitializationConfiguration< + PasskeyController, + PasskeyControllerMessenger +> = { + name: 'PasskeyController', + init: ({ state, messenger, options }) => + new PasskeyController({ + ...options, + messenger, + state, + }), + getMessenger: (parent: RootMessenger) => { + const passkeyControllerMessenger: PasskeyControllerMessenger = + new Messenger({ + namespace: 'PasskeyController', + parent, + }); + + parent.delegate({ + messenger: passkeyControllerMessenger, + actions: [ + 'KeyringController:verifyPassword', + 'KeyringController:exportEncryptionKey', + 'KeyringController:submitEncryptionKey', + 'KeyringController:changePassword', + 'KeyringController:exportSeedPhrase', + 'KeyringController:exportAccount', + ], + events: [], + }); + + return passkeyControllerMessenger; + }, +}; 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..e450e056d04 --- /dev/null +++ b/packages/wallet/src/initialization/instances/passkey-controller/types.ts @@ -0,0 +1,6 @@ +import type { PasskeyControllerOptions } from '@metamask/passkey-controller'; + +export type PasskeyControllerInstanceOptions = Omit< + PasskeyControllerOptions, + '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..8d208eb649e --- /dev/null +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts @@ -0,0 +1,106 @@ +import { Messenger } from '@metamask/messenger'; +import { + SecretType, + SeedlessOnboardingController, + getDefaultSeedlessOnboardingControllerState, +} from '@metamask/seedless-onboarding-controller'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import { encryptorFactory } from '../keyring-controller/encryptor.js'; +import { seedlessOnboardingController } from './seedless-onboarding-controller.js'; +import type { SeedlessOnboardingControllerInstanceOptions } from './types.js'; + +/** + * 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(): SeedlessOnboardingControllerInstanceOptions { + return { + encryptor: encryptorFactory( + 600_000, + ) as SeedlessOnboardingControllerInstanceOptions['encryptor'], + refreshJWTToken: jest.fn(), + revokeRefreshToken: jest.fn(), + renewRefreshToken: jest.fn(), + }; +} + +describe('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(SeedlessOnboardingController); + 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('reports unauthenticated when initialized with default state', async () => { + const messenger = + seedlessOnboardingController.getMessenger(getRootMessenger()); + + const instance = seedlessOnboardingController.init({ + state: undefined, + messenger, + options: getSeedlessOnboardingOptions(), + }); + + expect(await instance.getIsUserAuthenticated()).toBe(false); + }); + + 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..1f07d40ffc6 --- /dev/null +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts @@ -0,0 +1,27 @@ +import { Messenger } from '@metamask/messenger'; +import { + SeedlessOnboardingController, + SeedlessOnboardingControllerMessenger, +} from '@metamask/seedless-onboarding-controller'; + +import { InitializationConfiguration } from '../../types.js'; +import { encryptorFactory } from '../keyring-controller/encryptor.js'; + +export const seedlessOnboardingController: InitializationConfiguration< + SeedlessOnboardingController, + SeedlessOnboardingControllerMessenger +> = { + name: 'SeedlessOnboardingController', + init: ({ state, messenger, options }) => + new SeedlessOnboardingController({ + ...options, + encryptor: options?.encryptor ?? encryptorFactory(600_000), + state, + messenger, + }), + 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..3e978ac682d --- /dev/null +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/types.ts @@ -0,0 +1,6 @@ +import type { SeedlessOnboardingControllerOptions } from '@metamask/seedless-onboarding-controller'; + +export type SeedlessOnboardingControllerInstanceOptions = Omit< + SeedlessOnboardingControllerOptions, + 'messenger' | 'state' +>; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 8912d490bea..46bb9c5993c 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.js'; import type { KeyringControllerInstanceOptions } from './initialization/instances/keyring-controller/types.js'; import type { NetworkControllerInstanceOptions } from './initialization/instances/network-controller/types.js'; +import type { PasskeyControllerInstanceOptions } from './initialization/instances/passkey-controller/types.js'; import type { RemoteFeatureFlagControllerInstanceOptions } from './initialization/instances/remote-feature-flag-controller/types.js'; +import type { SeedlessOnboardingControllerInstanceOptions } from './initialization/instances/seedless-onboarding-controller/types.js'; import type { StorageServiceInstanceOptions } from './initialization/instances/storage-service/types.js'; import type { TransactionControllerInstanceOptions } from './initialization/instances/transaction-controller/types.js'; import type { InitializationConfiguration } from './initialization/types.js'; @@ -32,4 +34,6 @@ export type InstanceSpecificOptions = { remoteFeatureFlagController: RemoteFeatureFlagControllerInstanceOptions; storageService: StorageServiceInstanceOptions; transactionController?: TransactionControllerInstanceOptions; + passkeyController?: PasskeyControllerInstanceOptions; + seedlessOnboardingController?: SeedlessOnboardingControllerInstanceOptions; }; 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 70a14247272..8fc98d15567 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7932,7 +7932,7 @@ __metadata: languageName: node linkType: hard -"@metamask/passkey-controller@workspace:packages/passkey-controller": +"@metamask/passkey-controller@npm:^3.0.0, @metamask/passkey-controller@workspace:packages/passkey-controller": version: 0.0.0-use.local resolution: "@metamask/passkey-controller@workspace:packages/passkey-controller" dependencies: @@ -8439,7 +8439,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: @@ -9173,8 +9173,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:^3.0.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.1.0" "@metamask/utils": "npm:^11.11.0"