diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 79df2ab129..b3da40e318 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,9 @@ ## Wallet UX Team /packages/announcement-controller @MetaMask/wallet-ux +## Web3Auth Team +/packages/seedless-onboarding-controller @MetaMask/web3auth + ## Joint team ownership /packages/eth-json-rpc-provider @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers /packages/json-rpc-engine @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers @@ -148,3 +151,5 @@ /packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/wallet-framework-engineers /packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/wallet-framework-engineers /packages/app-metadata-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/wallet-framework-engineers +/packages/seedless-onboarding-controller/package.json @MetaMask/web3auth @MetaMask/wallet-framework-engineers +/packages/seedless-onboarding-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/wallet-framework-engineers diff --git a/README.md b/README.md index 9b866ce71d..6010bd84f4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/rate-limit-controller`](packages/rate-limit-controller) - [`@metamask/remote-feature-flag-controller`](packages/remote-feature-flag-controller) - [`@metamask/sample-controllers`](packages/sample-controllers) +- [`@metamask/seedless-onboarding-controller`](packages/seedless-onboarding-controller) - [`@metamask/selected-network-controller`](packages/selected-network-controller) - [`@metamask/signature-controller`](packages/signature-controller) - [`@metamask/token-search-discovery-controller`](packages/token-search-discovery-controller) @@ -118,6 +119,7 @@ linkStyle default opacity:0.5 rate_limit_controller(["@metamask/rate-limit-controller"]); remote_feature_flag_controller(["@metamask/remote-feature-flag-controller"]); sample_controllers(["@metamask/sample-controllers"]); + seedless_onboarding_controller(["@metamask/seedless-onboarding-controller"]); selected_network_controller(["@metamask/selected-network-controller"]); signature_controller(["@metamask/signature-controller"]); token_search_discovery_controller(["@metamask/token-search-discovery-controller"]); diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 3bc772d825..bd3a0d7e21 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -452,6 +452,12 @@ "packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts": { "jsdoc/tag-lines": 2 }, + "packages/seedless-onboarding-controller/src/errors.ts": { + "@typescript-eslint/no-unsafe-enum-comparison": 1 + }, + "packages/seedless-onboarding-controller/jest.environment.js": { + "n/no-unsupported-features/node-builtins": 1 + }, "packages/selected-network-controller/src/SelectedNetworkController.ts": { "@typescript-eslint/prefer-readonly": 1, "prettier/prettier": 6 diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md new file mode 100644 index 0000000000..1f5875b27c --- /dev/null +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial implementation of the seedless onboarding controller. ([#5874](https://github.com/MetaMask/core/pull/5874)) + - Authenticate OAuth user using the seedless onboarding flow and determine if the user is already registered or not + - Create a new Toprf key and backup seed phrase + - Add a new seed phrase backup to the metadata store + - Add array of new seed phrase backups to the metadata store in batch (useful in multi-srp flow) + - Fetch seed phrase metadata from the metadata store + - Update the password of the seedless onboarding flow +- Support multi SRP sync using social login. ([#5875](https://github.com/MetaMask/core/pull/5875)) + - Update Metadata to support multiple types of secrets (SRP, PrivateKey). + - Add `Controller Lock` which will sync with `Keyring Lock`. + - Updated `VaultEncryptor` type in constructor args and is compulsory to provided relevant encryptor to constructor. + - Added new non-persisted states, `encryptionKey` and `encryptionSalt` to decrypt the vault when password is not available. + - Update `password` param in `fetchAllSeedPhrases` method to optional. If password is not provided, `cached EncryptionKey` will be used. +- Password sync features implementation. ([#5877](https://github.com/MetaMask/core/pull/5877)) + - checkIsPasswordOutdated to check current password is outdated compare to global password + - Add password outdated check to add SRPs / change password + - recover old password using latest global password + - sync latest global password to reset vault to use latest password and persist latest auth pubkey +- Updated `toprf-secure-backup` to `0.3.0`. ([#5880](https://github.com/MetaMask/core/pull/5880)) + - added an optional constructor param, `topfKeyDeriver` to assist the `Key derivation` in `toprf-seucre-backup` sdk and adds an additinal security + - added new state value, `recoveryRatelimitCache` to the controller to parse the `RecoveryError` correctly and synchroize the error data (numberOfAttempts) across multiple devices. + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/seedless-onboarding-controller/LICENSE b/packages/seedless-onboarding-controller/LICENSE new file mode 100644 index 0000000000..7d002dced3 --- /dev/null +++ b/packages/seedless-onboarding-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/seedless-onboarding-controller/README.md b/packages/seedless-onboarding-controller/README.md new file mode 100644 index 0000000000..3d70b3ace4 --- /dev/null +++ b/packages/seedless-onboarding-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/seedless-onboarding-controller` + +Backup and rehydrate SRP(s) using social login and password + +## Installation + +`yarn add @metamask/seedless-onboarding-controller` + +or + +`npm install @metamask/seedless-onboarding-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/seedless-onboarding-controller/jest.config.js b/packages/seedless-onboarding-controller/jest.config.js new file mode 100644 index 0000000000..0e525e1f76 --- /dev/null +++ b/packages/seedless-onboarding-controller/jest.config.js @@ -0,0 +1,29 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + + // These tests rely on the Crypto API + testEnvironment: '/jest.environment.js', +}); diff --git a/packages/seedless-onboarding-controller/jest.environment.js b/packages/seedless-onboarding-controller/jest.environment.js new file mode 100644 index 0000000000..c8cf035c3b --- /dev/null +++ b/packages/seedless-onboarding-controller/jest.environment.js @@ -0,0 +1,16 @@ +const NodeEnvironment = require('jest-environment-node'); + +/** + * SeedlessOnboardingController depends on @noble/hashes, which as of 1.7.1 relies on the + * Web Crypto API in Node and browsers. + */ +class CustomTestEnvironment extends NodeEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json new file mode 100644 index 0000000000..8d4d04ee3f --- /dev/null +++ b/packages/seedless-onboarding-controller/package.json @@ -0,0 +1,92 @@ +{ + "name": "@metamask/seedless-onboarding-controller", + "version": "0.0.0", + "description": "Backup and rehydrate SRP(s) using social login and password", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/seedless-onboarding-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/seedless-onboarding-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/seedless-onboarding-controller", + "publish:preview": "yarn npm publish --tag preview", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", + "since-latest-release": "../../scripts/since-latest-release.sh" + }, + "dependencies": { + "@metamask/auth-network-utils": "^0.3.0", + "@metamask/base-controller": "^8.0.1", + "@metamask/toprf-secure-backup": "^0.3.0", + "@metamask/utils": "^11.2.0", + "async-mutex": "^0.5.0" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/browser-passworder": "^4.3.0", + "@metamask/keyring-controller": "^22.0.0", + "@noble/ciphers": "^0.5.2", + "@noble/curves": "^1.2.0", + "@noble/hashes": "^1.4.0", + "@types/elliptic": "^6", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "jest-environment-node": "^27.5.1", + "nock": "^13.3.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "peerDependencies": { + "@metamask/keyring-controller": "^22.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "@metamask/toprf-secure-backup": true + } + } +} diff --git a/packages/seedless-onboarding-controller/src/SecretMetadata.ts b/packages/seedless-onboarding-controller/src/SecretMetadata.ts new file mode 100644 index 0000000000..355b1b07f6 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/SecretMetadata.ts @@ -0,0 +1,243 @@ +import { + base64ToBytes, + bytesToBase64, + stringToBytes, + bytesToString, +} from '@metamask/utils'; + +import { + SeedlessOnboardingControllerErrorMessage, + SecretType, + SecretMetadataVersion, +} from './constants'; +import type { SecretDataType, SecretMetadataOptions } from './types'; + +type ISecretMetadata = { + data: DataType; + timestamp: number; + type: SecretType; + version: SecretMetadataVersion; + toBytes: () => Uint8Array; +}; + +// SecretMetadata type without the data and toBytes methods +// in which the data is base64 encoded for more compacted metadata +type SecretMetadataJson = Omit< + ISecretMetadata, + 'data' | 'toBytes' +> & { + data: string; // base64 encoded string +}; + +/** + * SecretMetadata is a class that adds metadata to the secret. + * + * It contains the secret and the timestamp when it was created. + * It is used to store the secret in the metadata store. + * + * @example + * ```ts + * const secretMetadata = new SecretMetadata(secret); + * ``` + */ +export class SecretMetadata + implements ISecretMetadata +{ + readonly #data: DataType; + + readonly #timestamp: number; + + readonly #type: SecretType; + + readonly #version: SecretMetadataVersion; + + /** + * Create a new SecretMetadata instance. + * + * @param data - The secret to add metadata to. + * @param options - The options for the secret metadata. + * @param options.timestamp - The timestamp when the secret was created. + * @param options.type - The type of the secret. + */ + constructor(data: DataType, options?: Partial) { + this.#data = data; + this.#timestamp = options?.timestamp ?? Date.now(); + this.#type = options?.type ?? SecretType.Mnemonic; + this.#version = options?.version ?? SecretMetadataVersion.V1; + } + + /** + * Create an Array of SecretMetadata instances from an array of secrets. + * + * To respect the order of the secrets, we add the index to the timestamp + * so that the first secret backup will have the oldest timestamp + * and the last secret backup will have the newest timestamp. + * + * @param data - The data to add metadata to. + * @param data.value - The SeedPhrase/PrivateKey to add metadata to. + * @param data.options - The options for the seed phrase metadata. + * @returns The SecretMetadata instances. + */ + static fromBatch( + data: { + value: DataType; + options?: Partial; + }[], + ): SecretMetadata[] { + const timestamp = Date.now(); + return data.map((d, index) => { + // To respect the order of the seed phrases, we add the index to the timestamp + // so that the first seed phrase backup will have the oldest timestamp + // and the last seed phrase backup will have the newest timestamp + const backupCreatedAt = d.options?.timestamp ?? timestamp + index * 5; + return new SecretMetadata(d.value, { + timestamp: backupCreatedAt, + type: d.options?.type, + }); + }); + } + + /** + * Assert that the provided value is a valid seed phrase metadata. + * + * @param value - The value to check. + * @throws If the value is not a valid seed phrase metadata. + */ + static assertIsValidSecretMetadataJson< + DataType extends SecretDataType = Uint8Array, + >(value: unknown): asserts value is SecretMetadataJson { + if ( + typeof value !== 'object' || + !value || + !('data' in value) || + typeof value.data !== 'string' || + !('timestamp' in value) || + typeof value.timestamp !== 'number' + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidSecretMetadata, + ); + } + } + + /** + * Parse the SecretMetadata from the metadata store and return the array of SecretMetadata instances. + * + * This method also sorts the secrets by timestamp in ascending order, i.e. the oldest secret will be the first element in the array. + * + * @param secretMetadataArr - The array of SecretMetadata from the metadata store. + * @param filterType - The type of the secret to filter. + * @returns The array of SecretMetadata instances. + */ + static parseSecretsFromMetadataStore< + DataType extends SecretDataType = Uint8Array, + >( + secretMetadataArr: Uint8Array[], + filterType?: SecretType, + ): SecretMetadata[] { + const parsedSecertMetadata = secretMetadataArr.map((metadata) => + SecretMetadata.fromRawMetadata(metadata), + ); + + const secrets = SecretMetadata.sort(parsedSecertMetadata); + + if (filterType) { + return secrets.filter((secret) => secret.type === filterType); + } + + return secrets; + } + + /** + * Parse and create the SecretMetadata instance from the raw metadata bytes. + * + * @param rawMetadata - The raw metadata. + * @returns The parsed secret metadata. + */ + static fromRawMetadata( + rawMetadata: Uint8Array, + ): SecretMetadata { + const serializedMetadata = bytesToString(rawMetadata); + const parsedMetadata = JSON.parse(serializedMetadata); + + SecretMetadata.assertIsValidSecretMetadataJson(parsedMetadata); + + // if the type is not provided, we default to Mnemonic for the backwards compatibility + const type = parsedMetadata.type ?? SecretType.Mnemonic; + const version = parsedMetadata.version ?? SecretMetadataVersion.V1; + + let data: DataType; + try { + data = base64ToBytes(parsedMetadata.data) as DataType; + } catch { + data = parsedMetadata.data as DataType; + } + + return new SecretMetadata(data, { + timestamp: parsedMetadata.timestamp, + type, + version, + }); + } + + /** + * Sort the seed phrases by timestamp. + * + * @param data - The secret metadata array to sort. + * @param order - The order to sort the seed phrases. Default is `desc`. + * + * @returns The sorted secret metadata array. + */ + static sort( + data: SecretMetadata[], + order: 'asc' | 'desc' = 'asc', + ): SecretMetadata[] { + return data.sort((a, b) => { + if (order === 'asc') { + return a.timestamp - b.timestamp; + } + return b.timestamp - a.timestamp; + }); + } + + get data(): DataType { + return this.#data; + } + + get timestamp() { + return this.#timestamp; + } + + get type() { + return this.#type; + } + + get version() { + return this.#version; + } + + /** + * Serialize the secret metadata and convert it to a Uint8Array. + * + * @returns The serialized SecretMetadata value in bytes. + */ + toBytes(): Uint8Array { + let _data: unknown = this.#data; + if (this.#data instanceof Uint8Array) { + // encode the raw secret to base64 encoded string + // to create more compacted metadata + _data = bytesToBase64(this.#data); + } + + // serialize the metadata to a JSON string + const serializedMetadata = JSON.stringify({ + data: _data, + timestamp: this.#timestamp, + type: this.#type, + version: this.#version, + }); + + // convert the serialized metadata to bytes(Uint8Array) + return stringToBytes(serializedMetadata); + } +} diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts new file mode 100644 index 0000000000..9d35b3406e --- /dev/null +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -0,0 +1,3127 @@ +import { keccak256AndHexify } from '@metamask/auth-network-utils'; +import type { Messenger } from '@metamask/base-controller'; +import type { EncryptionKey } from '@metamask/browser-passworder'; +import { + encrypt, + decrypt, + decryptWithDetail, + encryptWithDetail, + decryptWithKey as decryptWithKeyBrowserPassworder, + importKey as importKeyBrowserPassworder, +} from '@metamask/browser-passworder'; +import { + TOPRFError, + type FetchAuthPubKeyResult, + type SEC1EncodedPublicKey, + type ChangeEncryptionKeyResult, + type KeyPair, + type NodeAuthTokens, + type RecoverEncryptionKeyResult, + type ToprfSecureBackup, + TOPRFErrorCode, +} from '@metamask/toprf-secure-backup'; +import { + base64ToBytes, + bytesToBase64, + stringToBytes, + bigIntToHex, +} from '@metamask/utils'; +import type { webcrypto } from 'node:crypto'; + +import { + Web3AuthNetwork, + SeedlessOnboardingControllerErrorMessage, + AuthConnection, + SecretType, + SecretMetadataVersion, +} from './constants'; +import { PasswordSyncError, RecoveryError } from './errors'; +import { SecretMetadata } from './SecretMetadata'; +import { + getDefaultSeedlessOnboardingControllerState, + SeedlessOnboardingController, +} from './SeedlessOnboardingController'; +import type { + AllowedActions, + AllowedEvents, + RecoveryErrorData, + SeedlessOnboardingControllerMessenger, + SeedlessOnboardingControllerOptions, + SeedlessOnboardingControllerState, + VaultEncryptor, +} from './types'; +import { mockSeedlessOnboardingMessenger } from '../tests/__fixtures__/mockMessenger'; +import { + handleMockSecretDataGet, + handleMockSecretDataAdd, + handleMockCommitment, + handleMockAuthenticate, +} from '../tests/__fixtures__/topfClient'; +import { + createMockSecretDataGetResponse, + MULTIPLE_MOCK_SECRET_METADATA, +} from '../tests/mocks/toprf'; +import { MockToprfEncryptorDecryptor } from '../tests/mocks/toprfEncryptor'; +import MockVaultEncryptor from '../tests/mocks/vaultEncryptor'; + +const authConnection = AuthConnection.Google; +const socialLoginEmail = 'user-test@gmail.com'; +const authConnectionId = 'seedless-onboarding'; +const groupedAuthConnectionId = 'auth-server'; +const userId = 'user-test@gmail.com'; +const idTokens = ['idToken']; + +const MOCK_NODE_AUTH_TOKENS = [ + { + authToken: 'authToken', + nodeIndex: 1, + nodePubKey: 'nodePubKey', + }, + { + authToken: 'authToken2', + nodeIndex: 2, + nodePubKey: 'nodePubKey2', + }, + { + authToken: 'authToken3', + nodeIndex: 3, + nodePubKey: 'nodePubKey3', + }, +]; + +const MOCK_KEYRING_ID = 'mock-keyring-id'; +const MOCK_SEED_PHRASE = stringToBytes( + 'horror pink muffin canal young photo magnet runway start elder patch until', +); + +const MOCK_AUTH_PUB_KEY = 'A09CwPHdl/qo2AjBOHen5d4QORaLedxOrSdgReq8IhzQ'; +const MOCK_AUTH_PUB_KEY_OUTDATED = + 'Ao2sa8imX7SD4KE4fJLoJ/iBufmaBxSFygG1qUhW2qAb'; + +type WithControllerCallback = ({ + controller, + initialState, + encryptor, + messenger, +}: { + controller: SeedlessOnboardingController; + encryptor: VaultEncryptor; + initialState: SeedlessOnboardingControllerState; + messenger: SeedlessOnboardingControllerMessenger; + baseMessenger: Messenger; + toprfClient: ToprfSecureBackup; +}) => Promise | ReturnValue; + +type WithControllerOptions = Partial< + SeedlessOnboardingControllerOptions +>; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Get the default vault encryptor for the Seedless Onboarding Controller. + * + * By default, we'll use the encryption utilities from `@metamask/browser-passworder`. + * + * @returns The default vault encryptor for the Seedless Onboarding Controller. + */ +function getDefaultSeedlessOnboardingVaultEncryptor() { + return { + encrypt, + encryptWithDetail, + decrypt, + decryptWithDetail, + decryptWithKey: decryptWithKeyBrowserPassworder as ( + key: unknown, + payload: unknown, + ) => Promise, + importKey: importKeyBrowserPassworder, + }; +} + +/** + * Builds a mock encryptor for the vault. + * + * @returns The mock encryptor. + */ +function createMockVaultEncryptor() { + return new MockVaultEncryptor(); +} + +/** + * Builds a controller based on the given options and creates a new vault + * and keychain, then calls the given function with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the options that KeyringController takes; + * the function will be called with the built controller, along with its + * preferences, encryptor and initial state. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +) { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const encryptor = new MockVaultEncryptor(); + const { messenger, baseMessenger } = mockSeedlessOnboardingMessenger(); + + const controller = new SeedlessOnboardingController({ + encryptor, + messenger, + network: Web3AuthNetwork.Devnet, + ...rest, + }); + const { toprfClient } = controller; + return await fn({ + controller, + encryptor, + initialState: controller.state, + messenger, + baseMessenger, + toprfClient, + }); +} + +/** + * Builds a mock ToprfEncryptor. + * + * @returns The mock ToprfEncryptor. + */ +function createMockToprfEncryptor() { + return new MockToprfEncryptorDecryptor(); +} + +/** + * Mocks the createLocalKey method of the ToprfSecureBackup instance. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param password - The mock password. + * + * @returns The mock createLocalKey result. + */ +function mockcreateLocalKey(toprfClient: ToprfSecureBackup, password: string) { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const encKey = mockToprfEncryptor.deriveEncKey(password); + const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(password); + const oprfKey = BigInt(0); + const seed = stringToBytes(password); + + jest.spyOn(toprfClient, 'createLocalKey').mockResolvedValueOnce({ + encKey, + authKeyPair, + oprfKey, + seed, + }); + + return { + encKey, + authKeyPair, + oprfKey, + seed, + }; +} + +/** + * Mocks the fetchAuthPubKey method of the ToprfSecureBackup instance. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param authPubKey - The mock authPubKey. + * + * @returns The mock fetchAuthPubKey result. + */ +function mockFetchAuthPubKey( + toprfClient: ToprfSecureBackup, + authPubKey: SEC1EncodedPublicKey = base64ToBytes(MOCK_AUTH_PUB_KEY), +): FetchAuthPubKeyResult { + jest.spyOn(toprfClient, 'fetchAuthPubKey').mockResolvedValue({ + authPubKey, + }); + + return { + authPubKey, + }; +} + +/** + * Mocks the recoverEncKey method of the ToprfSecureBackup instance. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param password - The mock password. + * + * @returns The mock recoverEncKey result. + */ +function mockRecoverEncKey( + toprfClient: ToprfSecureBackup, + password: string, +): RecoverEncryptionKeyResult { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const encKey = mockToprfEncryptor.deriveEncKey(password); + const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(password); + const rateLimitResetResult = Promise.resolve(); + + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + authKeyPair, + rateLimitResetResult, + keyShareIndex: 1, + }); + + return { + encKey, + authKeyPair, + rateLimitResetResult, + keyShareIndex: 1, + }; +} + +/** + * Mocks the changeEncKey method of the ToprfSecureBackup instance. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param newPassword - The new password. + * + * @returns The mock changeEncKey result. + */ +function mockChangeEncKey( + toprfClient: ToprfSecureBackup, + newPassword: string, +): ChangeEncryptionKeyResult { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const encKey = mockToprfEncryptor.deriveEncKey(newPassword); + const authKeyPair = mockToprfEncryptor.deriveAuthKeyPair(newPassword); + + jest.spyOn(toprfClient, 'changeEncKey').mockResolvedValueOnce({ + encKey, + authKeyPair, + }); + + return { encKey, authKeyPair }; +} + +/** + * Mocks the createToprfKeyAndBackupSeedPhrase method of the SeedlessOnboardingController instance. + * + * @param toprfClient - The ToprfSecureBackup instance. + * @param controller - The SeedlessOnboardingController instance. + * @param password - The mock password. + * @param seedPhrase - The mock seed phrase. + * @param keyringId - The mock keyring id. + */ +async function mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient: ToprfSecureBackup, + controller: SeedlessOnboardingController, + password: string, + seedPhrase: Uint8Array, + keyringId: string, +) { + mockcreateLocalKey(toprfClient, password); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + await controller.createToprfKeyAndBackupSeedPhrase( + password, + seedPhrase, + keyringId, + ); +} + +/** + * Creates a mock vault. + * + * @param encKey - The encryption key. + * @param authKeyPair - The authentication key pair. + * @param MOCK_PASSWORD - The mock password. + * @param authTokens - The authentication tokens. + * + * @returns The mock vault data. + */ +async function createMockVault( + encKey: Uint8Array, + authKeyPair: KeyPair, + MOCK_PASSWORD: string, + authTokens: NodeAuthTokens, +) { + const encryptor = createMockVaultEncryptor(); + + const serializedKeyData = JSON.stringify({ + authTokens, + toprfEncryptionKey: bytesToBase64(encKey), + toprfAuthKeyPair: JSON.stringify({ + sk: `0x${authKeyPair.sk.toString(16)}`, + pk: bytesToBase64(authKeyPair.pk), + }), + }); + + const { vault: encryptedMockVault, exportedKeyString } = + await encryptor.encryptWithDetail(MOCK_PASSWORD, serializedKeyData); + + return { + encryptedMockVault, + vaultEncryptionKey: exportedKeyString, + vaultEncryptionSalt: JSON.parse(encryptedMockVault).salt, + }; +} + +/** + * Decrypts the vault with the given password. + * + * @param vault - The vault. + * @param password - The password. + * + * @returns The decrypted vault. + */ +async function decryptVault(vault: string, password: string) { + const encryptor = createMockVaultEncryptor(); + + const decryptedVault = await encryptor.decrypt(password, vault); + + const deserializedVault = JSON.parse(decryptedVault as string); + + const toprfEncryptionKey = base64ToBytes( + deserializedVault.toprfEncryptionKey, + ); + const parsedToprfAuthKeyPair = JSON.parse(deserializedVault.toprfAuthKeyPair); + const toprfAuthKeyPair = { + sk: BigInt(parsedToprfAuthKeyPair.sk), + pk: base64ToBytes(parsedToprfAuthKeyPair.pk), + }; + + return { + toprfEncryptionKey, + toprfAuthKeyPair, + }; +} + +/** + * Returns the initial controller state with the optional mock state data. + * + * @param options - The options. + * @param options.withMockAuthenticatedUser - Whether to skip the authenticate method and use the mock authenticated user. + * @param options.withMockAuthPubKey - Whether to skip the checkPasswordOutdated method and use the mock authPubKey. + * @param options.authPubKey - The mock authPubKey. + * @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.recoveryRatelimitCache - The mock rate limit details cache. + * @returns The initial controller state with the mock authenticated user. + */ +function getMockInitialControllerState(options?: { + withMockAuthenticatedUser?: boolean; + withMockAuthPubKey?: boolean; + authPubKey?: string; + vault?: string; + vaultEncryptionKey?: string; + vaultEncryptionSalt?: string; + recoveryRatelimitCache?: RecoveryErrorData; +}): Partial { + const state = getDefaultSeedlessOnboardingControllerState(); + + if (options?.vault) { + state.vault = options.vault; + } + + if (options?.vaultEncryptionKey) { + state.vaultEncryptionKey = options.vaultEncryptionKey; + } + + if (options?.vaultEncryptionSalt) { + state.vaultEncryptionSalt = options.vaultEncryptionSalt; + } + + if (options?.withMockAuthenticatedUser) { + state.nodeAuthTokens = MOCK_NODE_AUTH_TOKENS; + state.authConnectionId = authConnectionId; + state.groupedAuthConnectionId = groupedAuthConnectionId; + state.userId = userId; + } + + if (options?.withMockAuthPubKey || options?.authPubKey) { + state.authPubKey = options.authPubKey ?? MOCK_AUTH_PUB_KEY; + } + + if (options?.recoveryRatelimitCache) { + state.recoveryRatelimitCache = options.recoveryRatelimitCache; + } + + return state; +} + +describe('SeedlessOnboardingController', () => { + describe('constructor', () => { + it('should be able to instantiate', () => { + const { messenger } = mockSeedlessOnboardingMessenger(); + const controller = new SeedlessOnboardingController({ + messenger, + encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), + }); + expect(controller).toBeDefined(); + expect(controller.state).toStrictEqual( + getDefaultSeedlessOnboardingControllerState(), + ); + }); + + it('should be able to instantiate with an encryptor', () => { + const { messenger } = mockSeedlessOnboardingMessenger(); + const encryptor = createMockVaultEncryptor(); + + expect( + () => + new SeedlessOnboardingController({ + messenger, + encryptor, + }), + ).not.toThrow(); + }); + + it('should be able to instantiate with a toprfKeyDeriver', async () => { + const deriveKeySpy = jest.fn(); + const MOCK_PASSWORD = 'mock-password'; + + const keyDeriver = { + deriveKey: (seed: Uint8Array, salt: Uint8Array) => { + deriveKeySpy(seed, salt); + return Promise.resolve(new Uint8Array()); + }, + }; + + await withController( + { + toprfKeyDeriver: keyDeriver, + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(deriveKeySpy).toHaveBeenCalled(); + }, + ); + }); + }); + + describe('authenticate', () => { + it('should be able to register a new user', async () => { + await withController(async ({ controller, toprfClient }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + const authResult = await controller.authenticate({ + idTokens, + authConnectionId, + userId, + authConnection, + socialLoginEmail, + }); + + expect(authResult).toBeDefined(); + expect(authResult.nodeAuthTokens).toBeDefined(); + expect(authResult.isNewUser).toBe(false); + + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + expect(controller.state.authConnectionId).toBe(authConnectionId); + expect(controller.state.userId).toBe(userId); + expect(controller.state.authConnection).toBe(authConnection); + expect(controller.state.socialLoginEmail).toBe(socialLoginEmail); + }); + }); + + it('should be able to authenticate an existing user', async () => { + await withController(async ({ controller, toprfClient }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: true, + }); + + const authResult = await controller.authenticate({ + idTokens, + authConnectionId, + userId, + authConnection, + socialLoginEmail, + }); + + expect(authResult).toBeDefined(); + expect(authResult.nodeAuthTokens).toBeDefined(); + expect(authResult.isNewUser).toBe(true); + + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + expect(controller.state.authConnectionId).toBe(authConnectionId); + expect(controller.state.userId).toBe(userId); + expect(controller.state.authConnection).toBe(authConnection); + expect(controller.state.socialLoginEmail).toBe(socialLoginEmail); + }); + }); + + it('should be able to authenticate with groupedAuthConnectionId', async () => { + await withController(async ({ controller, toprfClient }) => { + // mock the authentication method + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: true, + }); + + const authResult = await controller.authenticate({ + idTokens, + authConnectionId, + userId, + groupedAuthConnectionId, + authConnection, + socialLoginEmail, + }); + + expect(authResult).toBeDefined(); + expect(authResult.nodeAuthTokens).toBeDefined(); + expect(authResult.isNewUser).toBe(true); + + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + expect(controller.state.authConnectionId).toBe(authConnectionId); + expect(controller.state.groupedAuthConnectionId).toBe( + groupedAuthConnectionId, + ); + expect(controller.state.userId).toBe(userId); + }); + }); + + it('should throw an error if the authentication fails', async () => { + const JSONRPC_ERROR = { + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Internal error', + }, + }; + + await withController(async ({ controller }) => { + const handleCommitment = handleMockCommitment({ + status: 200, + body: JSONRPC_ERROR, + }); + const handleAuthentication = handleMockAuthenticate({ + status: 200, + body: JSONRPC_ERROR, + }); + await expect( + controller.authenticate({ + idTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + authConnection, + socialLoginEmail, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + expect(handleCommitment.isDone()).toBe(true); + expect(handleAuthentication.isDone()).toBe(false); + + expect(controller.state.nodeAuthTokens).toBeUndefined(); + expect(controller.state.authConnectionId).toBeUndefined(); + expect(controller.state.groupedAuthConnectionId).toBeUndefined(); + expect(controller.state.userId).toBeUndefined(); + }); + }); + }); + + describe('checkPasswordOutdated', () => { + it('should return false if password is not outdated (authPubKey matches)', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + const spy = jest.spyOn(toprfClient, 'fetchAuthPubKey'); + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.checkIsPasswordOutdated(); + expect(result).toBe(false); + // Call again to test cache + const result2 = await controller.checkIsPasswordOutdated(); + expect(result2).toBe(false); + // Should only call fetchAuthPubKey once due to cache + expect(spy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should return true if password is outdated (authPubKey does not match)', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: MOCK_AUTH_PUB_KEY_OUTDATED, + }), + }, + async ({ controller, toprfClient }) => { + const spy = jest.spyOn(toprfClient, 'fetchAuthPubKey'); + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.checkIsPasswordOutdated(); + expect(result).toBe(true); + // Call again to test cache + const result2 = await controller.checkIsPasswordOutdated(); + expect(result2).toBe(true); + // Should only call fetchAuthPubKey once due to cache + expect(spy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should bypass cache if skipCache is true', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + const spy = jest.spyOn(toprfClient, 'fetchAuthPubKey'); + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + const result = await controller.checkIsPasswordOutdated({ + skipCache: true, + }); + expect(result).toBe(false); + // Call again with skipCache: true, should call fetchAuthPubKey again + const result2 = await controller.checkIsPasswordOutdated({ + skipCache: true, + }); + expect(result2).toBe(false); + expect(spy).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('should throw SRPNotBackedUpError if no authPubKey in state', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller }) => { + await expect(controller.checkIsPasswordOutdated()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.SRPNotBackedUpError, + ); + }, + ); + }); + + it('should throw InsufficientAuthToken if no nodeAuthTokens in state', async () => { + await withController( + { + state: { + ...getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + nodeAuthTokens: undefined, + }, + }, + async ({ controller }) => { + await expect(controller.checkIsPasswordOutdated()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + }, + ); + }); + }); + + describe('createToprfKeyAndBackupSeedPhrase', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should be able to create a seed phrase backup', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, initialState, encryptor }) => { + const { encKey, authKeyPair } = mockcreateLocalKey( + toprfClient, + MOCK_PASSWORD, + ); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + + expect(controller.state.vault).toBeDefined(); + expect(controller.state.vault).not.toBe(initialState.vault); + expect(controller.state.vault).not.toStrictEqual({}); + + // verify the vault data + const { encryptedMockVault } = await createMockVault( + encKey, + authKeyPair, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const expectedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + encryptedMockVault, + ); + const resultedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + + expect(expectedVaultValue).toStrictEqual(resultedVaultValue); + + // should be able to get the hash of the seed phrase backup from the state + expect( + controller.getSeedPhraseBackupHash(MOCK_SEED_PHRASE), + ).toBeDefined(); + }, + ); + }); + + it('should be able to create a seed phrase backup without groupedAuthConnectionId', async () => { + await withController( + async ({ controller, toprfClient, encryptor, initialState }) => { + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.authenticate({ + idTokens, + authConnectionId, + userId, + authConnection, + socialLoginEmail, + }); + + const { encKey, authKeyPair } = mockcreateLocalKey( + toprfClient, + MOCK_PASSWORD, + ); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + + expect(controller.state.vault).toBeDefined(); + expect(controller.state.vault).not.toBe(initialState.vault); + expect(controller.state.vault).not.toStrictEqual({}); + + // verify the vault data + const { encryptedMockVault } = await createMockVault( + encKey, + authKeyPair, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const expectedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + encryptedMockVault, + ); + const resultedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + + expect(expectedVaultValue).toStrictEqual(resultedVaultValue); + + // should be able to get the hash of the seed phrase backup from the state + expect( + controller.getSeedPhraseBackupHash(MOCK_SEED_PHRASE), + ).toBeDefined(); + }, + ); + }); + + it('should throw an error if create encryption key fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, initialState }) => { + jest.spyOn(toprfClient, 'createLocalKey').mockImplementation(() => { + throw new Error('Failed to create local encryption key'); + }); + + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow('Failed to create local encryption key'); + + // verify vault is not created + expect(controller.state.vault).toBe(initialState.vault); + }, + ); + }); + + it('should throw an error if authenticated user information is not found', async () => { + await withController(async ({ controller, initialState }) => { + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + ); + + // verify vault is not created + expect(controller.state.vault).toBe(initialState.vault); + }); + }); + + it('should throw an error if user does not have the AuthToken', async () => { + await withController( + { state: { userId, authConnectionId, groupedAuthConnectionId } }, + async ({ controller, initialState }) => { + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + + // verify vault is not created + expect(controller.state.vault).toBe(initialState.vault); + }, + ); + }); + + it('should throw an error if persistLocalKey fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'persistLocalKey') + .mockRejectedValueOnce( + new Error('Failed to persist local encryption key'), + ); + + const mockSecretDataAdd = handleMockSecretDataAdd(); + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + }, + ); + }); + + it('should throw an error if failed to create seedphrase backup', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + + jest + .spyOn(toprfClient, 'addSecretDataItem') + .mockRejectedValueOnce(new Error('Failed to add secret data item')); + + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToEncryptAndStoreSeedPhraseBackup, + ); + }, + ); + }); + }); + + describe('addNewSeedPhraseBackup', () => { + const MOCK_PASSWORD = 'mock-password'; + const NEW_KEY_RING_1 = { + id: 'new-keyring-1', + seedPhrase: stringToBytes('new mock seed phrase 1'), + }; + const NEW_KEY_RING_2 = { + id: 'new-keyring-2', + seedPhrase: stringToBytes('new mock seed phrase 2'), + }; + const NEW_KEY_RING_3 = { + id: 'new-keyring-3', + seedPhrase: stringToBytes('new mock seed phrase 3'), + }; + let MOCK_VAULT = ''; + let MOCK_VAULT_ENCRYPTION_KEY = ''; + let MOCK_VAULT_ENCRYPTION_SALT = ''; + + beforeEach(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should throw an error if the controller is locked', async () => { + await withController(async ({ controller }) => { + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }); + }); + + it('should be able to add a new seed phrase backup', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await controller.submitPassword(MOCK_PASSWORD); + + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + }, + ); + }); + + it('should be able to add a new seed phrase backup to the existing seed phrase backups', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await controller.submitPassword(MOCK_PASSWORD); + + // encrypt and store the secret data + const mockSecretDataAdd = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { + id: NEW_KEY_RING_1.id, + hash: keccak256AndHexify(NEW_KEY_RING_1.seedPhrase), + }, + ]); + + // add another seed phrase backup + const mockSecretDataAdd2 = handleMockSecretDataAdd(); + await controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ); + + expect(mockSecretDataAdd2.isDone()).toBe(true); + expect(controller.state.nodeAuthTokens).toBeDefined(); + expect(controller.state.nodeAuthTokens).toStrictEqual( + MOCK_NODE_AUTH_TOKENS, + ); + + const { socialBackupsMetadata } = controller.state; + expect(socialBackupsMetadata).toStrictEqual([ + { + id: NEW_KEY_RING_1.id, + hash: keccak256AndHexify(NEW_KEY_RING_1.seedPhrase), + }, + { + id: NEW_KEY_RING_2.id, + hash: keccak256AndHexify(NEW_KEY_RING_2.seedPhrase), + }, + ]); + // should be able to get the hash of the seed phrase backup from the state + expect( + controller.getSeedPhraseBackupHash(NEW_KEY_RING_1.seedPhrase), + ).toBeDefined(); + + // should return undefined if the seed phrase is not backed up + expect( + controller.getSeedPhraseBackupHash(NEW_KEY_RING_3.seedPhrase), + ).toBeUndefined(); + }, + ); + }); + + it('should throw an error if failed to parse vault data', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, encryptor, toprfClient }) => { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await controller.submitPassword(MOCK_PASSWORD); + + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce('{ "foo": "bar"'); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidVaultData, + ); + }, + ); + }); + + it('should throw error if encryptionKey is missing', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + // @ts-expect-error intentional test case + exportedKeyString: undefined, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + }, + ); + }); + + it('should throw error if encryptionSalt is different from the one in the vault', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // intentionally mock the JSON.parse to return an object with a different salt + jest.spyOn(global.JSON, 'parse').mockReturnValueOnce({ + salt: 'different-salt', + }); + + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ExpiredCredentials, + ); + }, + ); + }); + + it('should throw error if encryptionKey is of an unexpected type', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + // @ts-expect-error intentional test case + exportedKeyString: 123, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.WrongPasswordType, + ); + }, + ); + }); + + it('should throw an error if vault unlocked has an unexpected shape', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + exportedKeyString: MOCK_VAULT_ENCRYPTION_KEY, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce({ foo: 'bar' }); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidVaultData, + ); + + jest.spyOn(encryptor, 'decryptWithKey').mockResolvedValueOnce('null'); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.VaultDataError, + ); + }, + ); + }); + + it('should throw an error if vault unlocked has invalid authentication data', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // encrypt and store the secret data + handleMockSecretDataAdd(); + + jest.spyOn(encryptor, 'encryptWithDetail').mockResolvedValueOnce({ + vault: MOCK_VAULT, + exportedKeyString: MOCK_VAULT_ENCRYPTION_KEY, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + jest + .spyOn(encryptor, 'decryptWithKey') + .mockResolvedValueOnce(MOCK_VAULT); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_2.seedPhrase, + NEW_KEY_RING_2.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.VaultDataError, + ); + }, + ); + }); + + it('should throw an error if password is outdated', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: MOCK_AUTH_PUB_KEY_OUTDATED, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + mockFetchAuthPubKey(toprfClient, base64ToBytes(MOCK_AUTH_PUB_KEY)); + await controller.submitPassword(MOCK_PASSWORD); + await expect( + controller.addNewSeedPhraseBackup( + NEW_KEY_RING_1.seedPhrase, + NEW_KEY_RING_1.id, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.OutdatedPassword, + ); + }, + ); + }); + }); + + describe('fetchAndRestoreSeedPhrase', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should be able to restore and login with a seed phrase from metadata', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, initialState, encryptor }) => { + // fetch and decrypt the secret data + const { encKey, authKeyPair } = mockRecoverEncKey( + toprfClient, + MOCK_PASSWORD, + ); + + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [MOCK_SEED_PHRASE], + MOCK_PASSWORD, + ), + }); + const secretData = + await controller.fetchAllSeedPhrases(MOCK_PASSWORD); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toBeDefined(); + expect(secretData).toStrictEqual([MOCK_SEED_PHRASE]); + + expect(controller.state.vault).toBeDefined(); + expect(controller.state.vault).not.toBe(initialState.vault); + expect(controller.state.vault).not.toStrictEqual({}); + + // verify the vault data + const { encryptedMockVault } = await createMockVault( + encKey, + authKeyPair, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const expectedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + encryptedMockVault, + ); + const resultedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + + expect(expectedVaultValue).toStrictEqual(resultedVaultValue); + }, + ); + }); + + it('should be able to restore multiple seed phrases from metadata', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + // fetch and decrypt the secret data + const { encKey, authKeyPair } = mockRecoverEncKey( + toprfClient, + MOCK_PASSWORD, + ); + + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + MULTIPLE_MOCK_SECRET_METADATA, + MOCK_PASSWORD, + ), + }); + const secretData = + await controller.fetchAllSeedPhrases(MOCK_PASSWORD); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toBeDefined(); + + // `fetchAndRestoreSeedPhraseMetadata` should sort the seed phrases by timestamp in ascending order and return the seed phrases in the correct order + // the seed phrases are sorted in ascending order, so the oldest seed phrase is the first item in the array + expect(secretData).toStrictEqual([ + stringToBytes('seedPhrase1'), + stringToBytes('seedPhrase2'), + stringToBytes('seedPhrase3'), + ]); + + // verify the vault data + const { encryptedMockVault } = await createMockVault( + encKey, + authKeyPair, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const expectedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + encryptedMockVault, + ); + const resultedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + + expect(expectedVaultValue).toStrictEqual(resultedVaultValue); + }, + ); + }); + + it('should be able to restore seed phrase backup without groupedAuthConnectionId', async () => { + await withController( + { + state: { + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + userId, + authConnectionId, + }, + }, + async ({ controller, toprfClient, initialState, encryptor }) => { + // fetch and decrypt the secret data + const { encKey, authKeyPair } = mockRecoverEncKey( + toprfClient, + MOCK_PASSWORD, + ); + + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [MOCK_SEED_PHRASE], + MOCK_PASSWORD, + ), + }); + const secretData = + await controller.fetchAllSeedPhrases(MOCK_PASSWORD); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toBeDefined(); + expect(secretData).toStrictEqual([MOCK_SEED_PHRASE]); + + expect(controller.state.vault).toBeDefined(); + expect(controller.state.vault).not.toBe(initialState.vault); + expect(controller.state.vault).not.toStrictEqual({}); + + // verify the vault data + const { encryptedMockVault } = await createMockVault( + encKey, + authKeyPair, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const expectedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + encryptedMockVault, + ); + const resultedVaultValue = await encryptor.decrypt( + MOCK_PASSWORD, + controller.state.vault as string, + ); + + expect(expectedVaultValue).toStrictEqual(resultedVaultValue); + }, + ); + }); + + it('should be able to fetch seed phrases with cached encryption key without providing password', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const MOCK_VAULT = mockResult.encryptedMockVault; + const MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + const MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [MOCK_SEED_PHRASE], + MOCK_PASSWORD, + ), + }); + + const secretData = await controller.fetchAllSeedPhrases(); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toBeDefined(); + expect(secretData).toStrictEqual([MOCK_SEED_PHRASE]); + }, + ); + }); + + it('should throw an error if the key recovery failed', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValueOnce( + new Error('Failed to recover encryption key'), + ); + + await expect( + controller.fetchAllSeedPhrases('INCORRECT_PASSWORD'), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ); + }, + ); + }); + + it('should throw an error if failed to decrypt the SeedPhraseBackup data', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockRejectedValueOnce(new Error('Failed to decrypt data')); + + await expect( + controller.fetchAllSeedPhrases('INCORRECT_PASSWORD'), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToFetchSeedPhraseMetadata, + ); + }, + ); + }); + + it('should throw an error if the restored seed phrases are not in the correct shape', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + // mock the incorrect data shape + jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockResolvedValueOnce([ + stringToBytes(JSON.stringify({ key: 'value' })), + ]); + await expect( + controller.fetchAllSeedPhrases(MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToFetchSeedPhraseMetadata, + ); + }, + ); + }); + + it('should handle TooManyLoginAttempts error', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + jest.spyOn(toprfClient, 'recoverEncKey').mockRejectedValueOnce( + new TOPRFError(1009, 'Rate limit exceeded', { + rateLimitDetails: { + remainingTime: 250, + message: 'Rate limit in effect', + lockTime: 300, + guessCount: 7, + }, + }), + ); + + await expect( + controller.fetchAllSeedPhrases(MOCK_PASSWORD), + ).rejects.toStrictEqual( + new RecoveryError( + SeedlessOnboardingControllerErrorMessage.TooManyLoginAttempts, + { + remainingTime: 250, + numberOfAttempts: 7, + }, + ), + ); + + expect(controller.state.recoveryRatelimitCache).toStrictEqual({ + remainingTime: 250, + numberOfAttempts: 7, + }); + }, + ); + }); + + it('should use cached value for TooManyLoginAttempts error', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + recoveryRatelimitCache: { + remainingTime: 30, + numberOfAttempts: 4, + }, + }), + }, + async ({ controller, toprfClient }) => { + jest.spyOn(toprfClient, 'recoverEncKey').mockRejectedValueOnce( + new TOPRFError(1009, 'Rate limit exceeded', { + rateLimitDetails: { + remainingTime: 58, // decreased by 3 seconds due to the network delay and server processing time + message: 'Rate limit in effect', + lockTime: 60, + guessCount: 5, + }, + }), + ); + + await expect( + controller.fetchAllSeedPhrases(MOCK_PASSWORD), + ).rejects.toStrictEqual( + new RecoveryError( + SeedlessOnboardingControllerErrorMessage.TooManyLoginAttempts, + { + remainingTime: 60, + numberOfAttempts: 5, + }, + ), + ); + + expect(controller.state.recoveryRatelimitCache).toStrictEqual({ + remainingTime: 60, + numberOfAttempts: 5, + }); + }, + ); + }); + + it('should handle IncorrectPassword error', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValueOnce( + new TOPRFError(1006, 'Could not derive encryption key'), + ); + + await expect( + controller.fetchAllSeedPhrases(MOCK_PASSWORD), + ).rejects.toStrictEqual( + new RecoveryError( + SeedlessOnboardingControllerErrorMessage.IncorrectPassword, + ), + ); + }, + ); + }); + + it('should handle Unexpected error during key recovery', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValueOnce( + new TOPRFError(1004, 'Insufficient valid responses'), + ); + + await expect( + controller.fetchAllSeedPhrases(MOCK_PASSWORD), + ).rejects.toStrictEqual( + new RecoveryError( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ), + ); + }, + ); + }); + }); + + describe('submitPassword', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should throw error if the vault is missing', async () => { + await withController(async ({ controller }) => { + await expect(controller.submitPassword(MOCK_PASSWORD)).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.VaultError, + ); + }); + }); + + it('should throw error if the password is invalid', async () => { + await withController( + { + state: { + vault: 'MOCK_VAULT', + }, + }, + async ({ controller }) => { + // @ts-expect-error intentional test case + await expect(controller.submitPassword(123)).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.WrongPasswordType, + ); + }, + ); + }); + }); + + describe('verifyPassword', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should not throw an error if the password is valid', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: 'MOCK_VAULT', + }), + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce('MOCK_VAULT'); + + expect(async () => { + await controller.verifyVaultPassword(MOCK_PASSWORD); + }).not.toThrow(); + }, + ); + }); + + it('should throw an error if the password is invalid', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: 'MOCK_VAULT', + }), + }, + async ({ controller, encryptor }) => { + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValueOnce(new Error('Incorrect password')); + + await expect( + controller.verifyVaultPassword(MOCK_PASSWORD), + ).rejects.toThrow('Incorrect password'); + }, + ); + }); + + it('should throw an error if the vault is missing', async () => { + await withController(async ({ controller }) => { + await expect( + controller.verifyVaultPassword(MOCK_PASSWORD), + ).rejects.toThrow(SeedlessOnboardingControllerErrorMessage.VaultError); + }); + }); + }); + + describe('updateBackupMetadataState', () => { + const MOCK_PASSWORD = 'mock-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + + beforeEach(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should be able to update the backup metadata state', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); + const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, + ]); + }, + ); + }); + + it('should not update the backup metadata state if the provided keyringId is already in the state', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); + const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, + ]); + + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, + ]); + }, + ); + }); + + it('should be able to update the backup metadata state with an array of backups', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + const MOCK_SEED_PHRASE_2 = stringToBytes('mock-seed-phrase-2'); + const MOCK_KEYRING_ID_2 = 'mock-keyring-id-2'; + + controller.updateBackupMetadataState([ + { + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }, + { + keyringId: MOCK_KEYRING_ID_2, + seedPhrase: MOCK_SEED_PHRASE_2, + }, + ]); + const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); + const MOCK_SEED_PHRASE_2_HASH = + keccak256AndHexify(MOCK_SEED_PHRASE_2); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, + { id: MOCK_KEYRING_ID_2, hash: MOCK_SEED_PHRASE_2_HASH }, + ]); + }, + ); + }); + }); + + describe('changePassword', () => { + const MOCK_PASSWORD = 'mock-password'; + const NEW_MOCK_PASSWORD = 'new-mock-password'; + const MOCK_VAULT = JSON.stringify({ foo: 'bar' }); + + it('should be able to update new password', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // verify the vault data before update password + expect(controller.state.vault).toBeDefined(); + expect(controller.state.authPubKey).toBeDefined(); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + const vaultBeforeUpdatePassword = controller.state.vault; + const { + toprfEncryptionKey: oldEncKey, + toprfAuthKeyPair: oldAuthKeyPair, + } = await decryptVault( + vaultBeforeUpdatePassword as string, + MOCK_PASSWORD, + ); + + // mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // mock the change enc key + const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = + mockChangeEncKey(toprfClient, NEW_MOCK_PASSWORD); + + await controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD); + + // verify the vault after update password + const vaultAfterUpdatePassword = controller.state.vault; + const { + toprfEncryptionKey: newEncKeyFromVault, + toprfAuthKeyPair: newAuthKeyPairFromVault, + } = await decryptVault( + vaultAfterUpdatePassword as string, + NEW_MOCK_PASSWORD, + ); + + // verify that the encryption key and auth key pair are updated + expect(newEncKeyFromVault).not.toStrictEqual(oldEncKey); + expect(newAuthKeyPairFromVault.sk).not.toStrictEqual( + oldAuthKeyPair.sk, + ); + expect(newAuthKeyPairFromVault.pk).not.toStrictEqual( + oldAuthKeyPair.pk, + ); + + // verify the vault data is updated with the new encryption key and auth key pair + expect(newEncKeyFromVault).toStrictEqual(newEncKey); + expect(newAuthKeyPairFromVault.sk).toStrictEqual(newAuthKeyPair.sk); + expect(newAuthKeyPairFromVault.pk).toStrictEqual(newAuthKeyPair.pk); + }, + ); + }); + + it('should be able to update new password without groupedAuthConnectionId', async () => { + await withController( + { + state: { + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + userId, + authConnectionId, + authPubKey: MOCK_AUTH_PUB_KEY, + }, + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // verify the vault data before update password + expect(controller.state.vault).toBeDefined(); + expect(controller.state.authPubKey).toBeDefined(); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + const vaultBeforeUpdatePassword = controller.state.vault; + const { + toprfEncryptionKey: oldEncKey, + toprfAuthKeyPair: oldAuthKeyPair, + } = await decryptVault( + vaultBeforeUpdatePassword as string, + MOCK_PASSWORD, + ); + + // mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // mock the change enc key + const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = + mockChangeEncKey(toprfClient, NEW_MOCK_PASSWORD); + + await controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD); + + // verify the vault after update password + const vaultAfterUpdatePassword = controller.state.vault; + const { + toprfEncryptionKey: newEncKeyFromVault, + toprfAuthKeyPair: newAuthKeyPairFromVault, + } = await decryptVault( + vaultAfterUpdatePassword as string, + NEW_MOCK_PASSWORD, + ); + + // verify that the encryption key and auth key pair are updated + expect(newEncKeyFromVault).not.toStrictEqual(oldEncKey); + expect(newAuthKeyPairFromVault.sk).not.toStrictEqual( + oldAuthKeyPair.sk, + ); + expect(newAuthKeyPairFromVault.pk).not.toStrictEqual( + oldAuthKeyPair.pk, + ); + + // verify the vault data is updated with the new encryption key and auth key pair + expect(newEncKeyFromVault).toStrictEqual(newEncKey); + expect(newAuthKeyPairFromVault.sk).toStrictEqual(newAuthKeyPair.sk); + expect(newAuthKeyPairFromVault.pk).toStrictEqual(newAuthKeyPair.pk); + }, + ); + }); + + it('should throw an error if the controller is locked', async () => { + await withController(async ({ controller }) => { + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }); + }); + + it('should throw error if password is outdated', async () => { + await withController( + { + state: getMockInitialControllerState({ + vault: MOCK_VAULT, + authPubKey: MOCK_AUTH_PUB_KEY_OUTDATED, + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + mockFetchAuthPubKey(toprfClient); + + // mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.OutdatedPassword, + ); + }, + ); + }); + + it('should throw an error if the old password is incorrect', async () => { + await withController( + { + state: getMockInitialControllerState({ + vault: MOCK_VAULT, + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, encryptor, baseMessenger }) => { + // unlock the controller + baseMessenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValueOnce(new Error('Incorrect password')); + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, 'INCORRECT_PASSWORD'), + ).rejects.toThrow('Incorrect password'); + }, + ); + }); + + it('should throw an error if failed to change password', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'changeEncKey') + .mockRejectedValueOnce( + new Error('Failed to change encryption key'), + ); + + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + ); + }, + ); + }); + }); + + describe('clearState', () => { + it('should clear the state', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller }) => { + const { state } = controller; + + expect(state.nodeAuthTokens).toBeDefined(); + expect(state.userId).toBeDefined(); + expect(state.authConnectionId).toBeDefined(); + + controller.clearState(); + expect(controller.state).toStrictEqual( + getDefaultSeedlessOnboardingControllerState(), + ); + }, + ); + }); + }); + + describe('vault', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should not create a vault if the user does not have encrypted seed phrase metadata', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, initialState, toprfClient }) => { + expect(initialState.vault).toBeUndefined(); + + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: { + success: true, + data: [], + }, + }); + await controller.fetchAllSeedPhrases(MOCK_PASSWORD); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(controller.state.vault).toBeUndefined(); + expect(controller.state.vault).toBe(initialState.vault); + }, + ); + }); + + it('should throw an error if the password is an empty string', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + // create the local enc key + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // mock the secret data add + const mockSecretDataAdd = handleMockSecretDataAdd(); + await expect( + controller.createToprfKeyAndBackupSeedPhrase( + '', + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + }, + ); + }); + + it('should throw an error if the passowrd is of wrong type', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + // create the local enc key + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + // mock the secret data add + const mockSecretDataAdd = handleMockSecretDataAdd(); + await expect( + // @ts-expect-error Intentionally passing wrong password type + controller.createToprfKeyAndBackupSeedPhrase(123, MOCK_SEED_PHRASE), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.WrongPasswordType, + ); + + expect(mockSecretDataAdd.isDone()).toBe(true); + }, + ); + }); + }); + + describe('lock', () => { + const MOCK_PASSWORD = 'mock-password'; + + it('should lock the controller', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + controller.setLocked(); + + await expect( + controller.addNewSeedPhraseBackup( + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }, + ); + }); + + it('should lock the controller when the keyring is locked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, baseMessenger, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + baseMessenger.publish('KeyringController:lock'); + + await expect( + controller.addNewSeedPhraseBackup( + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }, + ); + }); + + it('should unlock the controller when the keyring is unlocked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, baseMessenger }) => { + await expect( + controller.addNewSeedPhraseBackup( + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + + baseMessenger.publish('KeyringController:unlock'); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + controller.updateBackupMetadataState({ + keyringId: MOCK_KEYRING_ID, + seedPhrase: MOCK_SEED_PHRASE, + }); + + const MOCK_SEED_PHRASE_HASH = keccak256AndHexify(MOCK_SEED_PHRASE); + expect(controller.state.socialBackupsMetadata).toStrictEqual([ + { id: MOCK_KEYRING_ID, hash: MOCK_SEED_PHRASE_HASH }, + ]); + }, + ); + }); + }); + + describe('SeedPhraseMetadata', () => { + it('should be able to create a seed phrase metadata with default options', () => { + // should be able to create a SeedPhraseMetadata instance via constructor + const seedPhraseMetadata = new SecretMetadata(MOCK_SEED_PHRASE); + expect(seedPhraseMetadata.data).toBeDefined(); + expect(seedPhraseMetadata.timestamp).toBeDefined(); + expect(seedPhraseMetadata.type).toBe(SecretType.Mnemonic); + expect(seedPhraseMetadata.version).toBe(SecretMetadataVersion.V1); + + // should be able to create a SeedPhraseMetadata instance with a timestamp via constructor + const timestamp = 18_000; + const seedPhraseMetadata2 = new SecretMetadata(MOCK_SEED_PHRASE, { + timestamp, + }); + expect(seedPhraseMetadata2.data).toBeDefined(); + expect(seedPhraseMetadata2.timestamp).toBe(timestamp); + expect(seedPhraseMetadata2.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(seedPhraseMetadata2.type).toBe(SecretType.Mnemonic); + }); + + it('should be able to add metadata to a seed phrase', () => { + const timestamp = 18_000; + const seedPhraseMetadata = new SecretMetadata(MOCK_SEED_PHRASE, { + type: SecretType.PrivateKey, + timestamp, + }); + expect(seedPhraseMetadata.type).toBe(SecretType.PrivateKey); + expect(seedPhraseMetadata.timestamp).toBe(timestamp); + }); + + it('should be able to correctly create `SecretMetadata` Array for batch seedphrases', () => { + const seedPhrases = ['seed phrase 1', 'seed phrase 2', 'seed phrase 3']; + const rawSeedPhrases = seedPhrases.map((srp) => ({ + value: stringToBytes(srp), + options: { + type: SecretType.Mnemonic, + }, + })); + + const seedPhraseMetadataArray = SecretMetadata.fromBatch(rawSeedPhrases); + expect(seedPhraseMetadataArray).toHaveLength(seedPhrases.length); + + // check the timestamp, the first one should be the oldest + expect(seedPhraseMetadataArray[0].timestamp).toBeLessThan( + seedPhraseMetadataArray[1].timestamp, + ); + expect(seedPhraseMetadataArray[1].timestamp).toBeLessThan( + seedPhraseMetadataArray[2].timestamp, + ); + }); + + it('should be able to serialized and parse a seed phrase metadata', () => { + const seedPhraseMetadata = new SecretMetadata(MOCK_SEED_PHRASE); + const serializedSeedPhraseBytes = seedPhraseMetadata.toBytes(); + + const parsedSeedPhraseMetadata = SecretMetadata.fromRawMetadata( + serializedSeedPhraseBytes, + ); + expect(parsedSeedPhraseMetadata.data).toBeDefined(); + expect(parsedSeedPhraseMetadata.timestamp).toBeDefined(); + expect(parsedSeedPhraseMetadata.data).toStrictEqual(MOCK_SEED_PHRASE); + }); + + it('should be able to sort seed phrase metadata', () => { + const mockSeedPhraseMetadata1 = new SecretMetadata(MOCK_SEED_PHRASE, { + timestamp: 1000, + }); + const mockSeedPhraseMetadata2 = new SecretMetadata(MOCK_SEED_PHRASE, { + timestamp: 2000, + }); + + // sort in ascending order + const sortedSeedPhraseMetadata = SecretMetadata.sort( + [mockSeedPhraseMetadata1, mockSeedPhraseMetadata2], + 'asc', + ); + expect(sortedSeedPhraseMetadata[0].timestamp).toBeLessThan( + sortedSeedPhraseMetadata[1].timestamp, + ); + + // sort in descending order + const sortedSeedPhraseMetadataDesc = SecretMetadata.sort( + [mockSeedPhraseMetadata1, mockSeedPhraseMetadata2], + 'desc', + ); + expect(sortedSeedPhraseMetadataDesc[0].timestamp).toBeGreaterThan( + sortedSeedPhraseMetadataDesc[1].timestamp, + ); + }); + + it('should be able to overwrite the default Generic DataType', () => { + const secret1 = new SecretMetadata('private-key-1', { + type: SecretType.PrivateKey, + }); + expect(secret1.data).toBe('private-key-1'); + expect(secret1.type).toBe(SecretType.PrivateKey); + expect(secret1.version).toBe(SecretMetadataVersion.V1); + + // should be able to convert to bytes + const secret1Bytes = secret1.toBytes(); + const parsedSecret1 = + SecretMetadata.fromRawMetadata(secret1Bytes); + expect(parsedSecret1.data).toBe('private-key-1'); + expect(parsedSecret1.type).toBe(SecretType.PrivateKey); + expect(parsedSecret1.version).toBe(SecretMetadataVersion.V1); + + const secret2 = new SecretMetadata(MOCK_SEED_PHRASE, { + type: SecretType.Mnemonic, + }); + expect(secret2.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secret2.type).toBe(SecretType.Mnemonic); + + const secret2Bytes = secret2.toBytes(); + const parsedSecret2 = + SecretMetadata.fromRawMetadata(secret2Bytes); + expect(parsedSecret2.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(parsedSecret2.type).toBe(SecretType.Mnemonic); + }); + + it('should be able to parse the array of Mixed SecretMetadata', () => { + const MOCK_PRIVATE_KEY = 'private-key-1'; + const secret1 = new SecretMetadata(MOCK_PRIVATE_KEY, { + type: SecretType.PrivateKey, + }); + const secret2 = new SecretMetadata(MOCK_SEED_PHRASE, { + type: SecretType.Mnemonic, + }); + + const secrets = [secret1.toBytes(), secret2.toBytes()]; + + const parsedSecrets = + SecretMetadata.parseSecretsFromMetadataStore(secrets); + expect(parsedSecrets).toHaveLength(2); + expect(parsedSecrets[0].data).toBe(MOCK_PRIVATE_KEY); + expect(parsedSecrets[0].type).toBe(SecretType.PrivateKey); + expect(parsedSecrets[1].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(parsedSecrets[1].type).toBe(SecretType.Mnemonic); + }); + + it('should be able to filter the array of SecretMetadata by type', () => { + const MOCK_PRIVATE_KEY = 'MOCK_PRIVATE_KEY'; + const secret1 = new SecretMetadata(MOCK_PRIVATE_KEY, { + type: SecretType.PrivateKey, + }); + const secret2 = new SecretMetadata(MOCK_SEED_PHRASE, { + type: SecretType.Mnemonic, + }); + const secret3 = new SecretMetadata(MOCK_SEED_PHRASE); + + const secrets = [secret1.toBytes(), secret2.toBytes(), secret3.toBytes()]; + + const mnemonicSecrets = SecretMetadata.parseSecretsFromMetadataStore( + secrets, + SecretType.Mnemonic, + ); + expect(mnemonicSecrets).toHaveLength(2); + expect(mnemonicSecrets[0].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(mnemonicSecrets[0].type).toBe(SecretType.Mnemonic); + expect(mnemonicSecrets[1].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(mnemonicSecrets[1].type).toBe(SecretType.Mnemonic); + + const privateKeySecrets = SecretMetadata.parseSecretsFromMetadataStore( + secrets, + SecretType.PrivateKey, + ); + + expect(privateKeySecrets).toHaveLength(1); + expect(privateKeySecrets[0].data).toBe(MOCK_PRIVATE_KEY); + expect(privateKeySecrets[0].type).toBe(SecretType.PrivateKey); + }); + }); + + describe('recoverCurrentDevicePassword', () => { + const GLOBAL_PASSWORD = 'global-password'; + const RECOVERED_PASSWORD = 'recovered-password'; + + it('should recover the password for the current device', 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 + jest.spyOn(toprfClient, 'recoverPassword').mockResolvedValueOnce({ + password: RECOVERED_PASSWORD, + }); + + const result = await controller.recoverCurrentDevicePassword({ + globalPassword: GLOBAL_PASSWORD, + }); + + expect(result).toStrictEqual({ password: RECOVERED_PASSWORD }); + expect(toprfClient.recoverEncKey).toHaveBeenCalled(); + expect(toprfClient.recoverPassword).toHaveBeenCalled(); + }, + ); + }); + + it('should throw SRPNotBackedUpError if no authPubKey in state', async () => { + await withController( + { + state: getMockInitialControllerState({}), + }, + async ({ controller }) => { + await expect( + controller.recoverCurrentDevicePassword({ + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.SRPNotBackedUpError, + ); + }, + ); + }); + + it('should propagate errors from recoverEncKey', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValueOnce( + new TOPRFError( + TOPRFErrorCode.CouldNotDeriveEncryptionKey, + 'Could not derive encryption key', + ), + ); + + await expect( + controller.recoverCurrentDevicePassword({ + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toStrictEqual( + new RecoveryError( + SeedlessOnboardingControllerErrorMessage.IncorrectPassword, + ), + ); + }, + ); + }); + + it('should propagate errors from toprfClient.recoverPassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + 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, + }); + + jest + .spyOn(toprfClient, 'recoverPassword') + .mockRejectedValueOnce( + new TOPRFError( + TOPRFErrorCode.CouldNotFetchPassword, + 'Could not fetch password', + ), + ); + + await expect( + controller.recoverCurrentDevicePassword({ + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toStrictEqual( + new PasswordSyncError( + SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, + ), + ); + }, + ); + }); + + it('should not propagate unknown errors from #toprfClient.recoverPassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + 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, + }); + + jest + .spyOn(toprfClient, 'recoverPassword') + .mockRejectedValueOnce(new Error('Unknown error')); + + await expect( + controller.recoverCurrentDevicePassword({ + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toStrictEqual( + new PasswordSyncError( + SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, + ), + ); + }, + ); + }); + }); + + describe('syncLatestGlobalPassword', () => { + const OLD_PASSWORD = 'old-mock-password'; + const GLOBAL_PASSWORD = 'new-global-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + let INITIAL_AUTH_PUB_KEY: string; + let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation + let initialEncKey: Uint8Array; // Store initial encKey for vault creation + + // Generate initial keys and vault state before tests run + beforeAll(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); + initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); + INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); + + const mockResult = await createMockVault( + initialEncKey, + initialAuthKeyPair, + OLD_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + // Remove beforeEach as setup is done in beforeAll now + + it('should successfully sync the latest global password', async () => { + await withController( + { + // Pass the pre-generated state values + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, // Use the base64 encoded key + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + // Unlock controller first - requires vaultEncryptionKey/Salt or password + // Since we provide key/salt in state, submitPassword isn't strictly needed here + // but we keep it to match the method's requirement of being unlocked + // We'll use the key/salt implicitly by not providing password to unlockVaultAndGetBackupEncKey + await controller.submitPassword(OLD_PASSWORD); // Unlock using the standard method + + const verifyPasswordSpy = jest.spyOn( + controller, + 'verifyVaultPassword', + ); + const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); + const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); + + // Mock recoverEncKey for the new global password + const mockToprfEncryptor = createMockToprfEncryptor(); + const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + + recoverEncKeySpy.mockResolvedValueOnce({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // We still need verifyPassword to work conceptually, even if unlock is bypassed + // verifyPasswordSpy.mockResolvedValueOnce(); // Don't mock, let the real one run inside syncLatestGlobalPassword + + await controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }); + + // Assertions + expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { + skipLock: true, // skip lock since we already have the lock + }); + expect(recoverEncKeySpy).toHaveBeenCalledWith( + expect.objectContaining({ password: GLOBAL_PASSWORD }), + ); + + // Check if vault was re-encrypted with the new password and keys + const expectedSerializedVaultData = JSON.stringify({ + authTokens: controller.state.nodeAuthTokens, + toprfEncryptionKey: bytesToBase64(newEncKey), + toprfAuthKeyPair: JSON.stringify({ + sk: bigIntToHex(newAuthKeyPair.sk), + pk: bytesToBase64(newAuthKeyPair.pk), + }), + }); + expect(encryptorSpy).toHaveBeenCalledWith( + GLOBAL_PASSWORD, + expectedSerializedVaultData, + ); + + // Check if authPubKey was updated in state + expect(controller.state.authPubKey).toBe( + bytesToBase64(newAuthKeyPair.pk), + ); + // Check if vault content actually changed + expect(controller.state.vault).not.toBe(MOCK_VAULT); + }, + ); + }); + + it('should throw an error if the old password verification fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + const verifyPasswordSpy = jest + .spyOn(controller, 'verifyVaultPassword') + .mockRejectedValueOnce(new Error('Incorrect old password')); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: 'WRONG_OLD_PASSWORD', + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow('Incorrect old password'); + + expect(verifyPasswordSpy).toHaveBeenCalledWith('WRONG_OLD_PASSWORD', { + skipLock: true, // skip lock since we already have the lock + }); + }, + ); + }); + + it('should throw an error if recovering the encryption key for the global password fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + const verifyPasswordSpy = jest + .spyOn(controller, 'verifyVaultPassword') + .mockResolvedValueOnce(); + const recoverEncKeySpy = jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValueOnce( + new RecoveryError( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ), + ); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ); + + expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { + skipLock: true, // skip lock since we already have the lock + }); + expect(recoverEncKeySpy).toHaveBeenCalledWith( + expect.objectContaining({ password: GLOBAL_PASSWORD }), + ); + }, + ); + }); + + it('should throw an error if creating the new vault fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + const verifyPasswordSpy = jest + .spyOn(controller, 'verifyVaultPassword') + .mockResolvedValueOnce(); + const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); + const encryptorSpy = jest + .spyOn(encryptor, 'encryptWithDetail') + .mockRejectedValueOnce(new Error('Vault creation failed')); + + // Mock recoverEncKey for the new global password + const mockToprfEncryptor = createMockToprfEncryptor(); + const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + + recoverEncKeySpy.mockResolvedValueOnce({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow('Vault creation failed'); + + expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { + skipLock: true, // skip lock since we already have the lock + }); + expect(recoverEncKeySpy).toHaveBeenCalledWith( + expect.objectContaining({ password: GLOBAL_PASSWORD }), + ); + expect(encryptorSpy).toHaveBeenCalled(); + }, + ); + }); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts new file mode 100644 index 0000000000..7dffc39856 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -0,0 +1,1390 @@ +import { keccak256AndHexify } from '@metamask/auth-network-utils'; +import type { StateMetadata } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + KeyPair, + NodeAuthTokens, + RecoverEncryptionKeyResult, + SEC1EncodedPublicKey, +} from '@metamask/toprf-secure-backup'; +import { ToprfSecureBackup } from '@metamask/toprf-secure-backup'; +import { base64ToBytes, bytesToBase64, bigIntToHex } from '@metamask/utils'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Mutex } from 'async-mutex'; + +import { + type AuthConnection, + controllerName, + PASSWORD_OUTDATED_CACHE_TTL_MS, + SecretType, + SeedlessOnboardingControllerErrorMessage, + Web3AuthNetwork, +} from './constants'; +import { PasswordSyncError, RecoveryError } from './errors'; +import { projectLogger, createModuleLogger } from './logger'; +import { SecretMetadata } from './SecretMetadata'; +import type { + MutuallyExclusiveCallback, + SeedlessOnboardingControllerMessenger, + SeedlessOnboardingControllerOptions, + SeedlessOnboardingControllerState, + VaultData, + AuthenticatedUserDetails, + SocialBackupsMetadata, + SRPBackedUpUserDetails, + VaultEncryptor, +} from './types'; + +const log = createModuleLogger(projectLogger, controllerName); + +/** + * Get the default state for the Seedless Onboarding Controller. + * + * @returns The default state for the Seedless Onboarding Controller. + */ +export function getDefaultSeedlessOnboardingControllerState(): SeedlessOnboardingControllerState { + return { + socialBackupsMetadata: [], + }; +} + +/** + * Seedless Onboarding Controller State Metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const seedlessOnboardingMetadata: StateMetadata = + { + vault: { + persist: true, + anonymous: false, + }, + socialBackupsMetadata: { + persist: true, + anonymous: true, + }, + nodeAuthTokens: { + persist: true, + anonymous: true, + }, + authConnection: { + persist: true, + anonymous: true, + }, + authConnectionId: { + persist: true, + anonymous: true, + }, + groupedAuthConnectionId: { + persist: true, + anonymous: true, + }, + userId: { + persist: true, + anonymous: true, + }, + socialLoginEmail: { + persist: true, + anonymous: true, + }, + vaultEncryptionKey: { + persist: false, + anonymous: true, + }, + vaultEncryptionSalt: { + persist: false, + anonymous: true, + }, + authPubKey: { + persist: true, + anonymous: true, + }, + passwordOutdatedCache: { + persist: true, + anonymous: true, + }, + recoveryRatelimitCache: { + persist: true, + anonymous: true, + }, + }; + +export class SeedlessOnboardingController extends BaseController< + typeof controllerName, + SeedlessOnboardingControllerState, + SeedlessOnboardingControllerMessenger +> { + readonly #vaultEncryptor: VaultEncryptor; + + readonly #controllerOperationMutex = new Mutex(); + + readonly #vaultOperationMutex = new Mutex(); + + readonly toprfClient: ToprfSecureBackup; + + /** + * Controller lock state. + * + * The controller lock is synchronized with the keyring lock. + */ + #isUnlocked = false; + + /** + * Creates a new SeedlessOnboardingController instance. + * + * @param options - The options for the SeedlessOnboardingController. + * @param options.messenger - A restricted messenger. + * @param options.state - Initial state to set on this controller. + * @param options.encryptor - An optional encryptor to use for encrypting and decrypting seedless onboarding vault. + * @param options.toprfKeyDeriver - An optional key derivation interface for the TOPRF client. + * @param options.network - The network to be used for the Seedless Onboarding flow. + */ + constructor({ + messenger, + state, + encryptor, + toprfKeyDeriver, + network = Web3AuthNetwork.Mainnet, + }: SeedlessOnboardingControllerOptions) { + super({ + name: controllerName, + metadata: seedlessOnboardingMetadata, + state: { + ...getDefaultSeedlessOnboardingControllerState(), + ...state, + }, + messenger, + }); + + this.#vaultEncryptor = encryptor; + this.toprfClient = new ToprfSecureBackup({ + network, + keyDeriver: toprfKeyDeriver, + }); + + // setup subscriptions to the keyring lock event + // when the keyring is locked (wallet is locked), the controller will be cleared of its credentials + this.messagingSystem.subscribe('KeyringController:lock', () => { + this.setLocked(); + }); + this.messagingSystem.subscribe('KeyringController:unlock', () => { + this.#setUnlocked(); + }); + } + + /** + * Authenticate OAuth user using the seedless onboarding flow + * and determine if the user is already registered or not. + * + * @param params - The parameters for authenticate OAuth user. + * @param params.idTokens - The ID token(s) issued by OAuth verification service. Currently this array only contains a single idToken which is verified by all the nodes, in future we are considering to issue a unique idToken for each node. + * @param params.authConnection - The social login provider. + * @param params.authConnectionId - OAuth authConnectionId from dashboard + * @param params.userId - user email or id from Social login + * @param params.groupedAuthConnectionId - Optional grouped authConnectionId to be used for the authenticate request. + * @param params.socialLoginEmail - The user email from Social login. + * You can pass this to use aggregate multiple OAuth connections. Useful when you want user to have same account while using different OAuth connections. + * @returns A promise that resolves to the authentication result. + */ + async authenticate(params: { + idTokens: string[]; + authConnection: AuthConnection; + authConnectionId: string; + userId: string; + groupedAuthConnectionId?: string; + socialLoginEmail?: string; + }) { + return await this.#withControllerLock(async () => { + try { + const { + idTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + authConnection, + socialLoginEmail, + } = params; + + const authenticationResult = await this.toprfClient.authenticate({ + authConnectionId, + userId, + idTokens, + groupedAuthConnectionId, + }); + // update the state with the authenticated user info + this.update((state) => { + state.nodeAuthTokens = authenticationResult.nodeAuthTokens; + state.authConnectionId = authConnectionId; + state.groupedAuthConnectionId = groupedAuthConnectionId; + state.userId = userId; + state.authConnection = authConnection; + state.socialLoginEmail = socialLoginEmail; + }); + return authenticationResult; + } catch (error) { + log('Error authenticating user', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + } + }); + } + + /** + * Create a new TOPRF encryption key using given password and backups the provided seed phrase. + * + * @param password - The password used to create new wallet and seedphrase + * @param seedPhrase - The seed phrase to backup + * @param keyringId - The keyring id of the backup seed phrase + * @returns A promise that resolves to the encrypted seed phrase and the encryption key. + */ + async createToprfKeyAndBackupSeedPhrase( + password: string, + seedPhrase: Uint8Array, + keyringId: string, + ): Promise { + // to make sure that fail fast, + // assert that the user is authenticated before creating the TOPRF key and backing up the seed phrase + this.#assertIsAuthenticatedUser(this.state); + + return await this.#withControllerLock(async () => { + // locally evaluate the encryption key from the password + const { encKey, authKeyPair, oprfKey } = + await this.toprfClient.createLocalKey({ + password, + }); + + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSeedPhraseBackup({ + keyringId, + seedPhrase, + encKey, + authKeyPair, + }); + + // store/persist the encryption key shares + // We store the seed phrase metadata in the metadata store first. If this operation fails, + // we avoid persisting the encryption key shares to prevent a situation where a user appears + // to have an account but with no associated data. + await this.#persistOprfKey(oprfKey, authKeyPair.pk); + // create a new vault with the resulting authentication data + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + }); + } + + /** + * Add a new seed phrase backup to the metadata store. + * + * @param seedPhrase - The seed phrase to backup. + * @param keyringId - The keyring id of the backup seed phrase. + * @returns A promise that resolves to the success of the operation. + */ + async addNewSeedPhraseBackup( + seedPhrase: Uint8Array, + keyringId: string, + ): Promise { + return await this.#withControllerLock(async () => { + this.#assertIsUnlocked(); + await this.#assertPasswordInSync({ + skipCache: true, + skipLock: true, // skip lock since we already have the lock + }); + // verify the password and unlock the vault + const { toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSeedPhraseBackup({ + keyringId, + seedPhrase, + encKey: toprfEncryptionKey, + authKeyPair: toprfAuthKeyPair, + }); + }); + } + + /** + * Fetches all encrypted seed phrases and metadata for user's account from the metadata store. + * + * Decrypts the seed phrases and returns the decrypted seed phrases using the recovered encryption key from the password. + * + * @param password - The optional password used to create new wallet and seedphrase. If not provided, `cached Encryption Key` will be used. + * @returns A promise that resolves to the seed phrase metadata. + */ + async fetchAllSeedPhrases(password?: string): Promise { + // assert that the user is authenticated before fetching the seed phrases + this.#assertIsAuthenticatedUser(this.state); + + return await this.#withControllerLock(async () => { + let encKey: Uint8Array; + let authKeyPair: KeyPair; + + if (password) { + const recoverEncKeyResult = await this.#recoverEncKey(password); + encKey = recoverEncKeyResult.encKey; + authKeyPair = recoverEncKeyResult.authKeyPair; + } else { + this.#assertIsUnlocked(); + // verify the password and unlock the vault + const keysFromVault = await this.#unlockVaultAndGetBackupEncKey(); + encKey = keysFromVault.toprfEncryptionKey; + authKeyPair = keysFromVault.toprfAuthKeyPair; + } + + try { + const secretData = await this.toprfClient.fetchAllSecretDataItems({ + decKey: encKey, + authKeyPair, + }); + + if (secretData?.length > 0 && password) { + // if password is provided, we need to create a new vault with the auth data. (supposedly the user is trying to rehydrate the wallet) + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + } + + const secrets = SecretMetadata.parseSecretsFromMetadataStore( + secretData, + SecretType.Mnemonic, + ); + return secrets.map((secret) => secret.data); + } catch (error) { + log('Error fetching seed phrase metadata', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToFetchSeedPhraseMetadata, + ); + } + }); + } + + /** + * Update the password of the seedless onboarding flow. + * + * Changing password will also update the encryption key, metadata store and the vault with new encrypted values. + * + * @param newPassword - The new password to update. + * @param oldPassword - The old password to verify. + * @returns A promise that resolves to the success of the operation. + */ + async changePassword(newPassword: string, oldPassword: string) { + return await this.#withControllerLock(async () => { + this.#assertIsUnlocked(); + // verify the old password of the encrypted vault + await this.verifyVaultPassword(oldPassword, { + skipLock: true, // skip lock since we already have the lock + }); + await this.#assertPasswordInSync({ + skipCache: true, + skipLock: true, // skip lock since we already have the lock + }); + + try { + // update the encryption key with new password and update the Metadata Store + const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = + await this.#changeEncryptionKey(newPassword, oldPassword); + + // update and encrypt the vault with new password + await this.#createNewVaultWithAuthData({ + password: newPassword, + rawToprfEncryptionKey: newEncKey, + rawToprfAuthKeyPair: newAuthKeyPair, + }); + + this.#persistAuthPubKey({ + authPubKey: newAuthKeyPair.pk, + }); + this.#resetPasswordOutdatedCache(); + } catch (error) { + log('Error changing password', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + ); + } + }); + } + + /** + * Update the backup metadata state for the given seed phrase. + * + * @param data - The data to backup, can be a single backup or array of backups. + * @param data.keyringId - The keyring id associated with the backup seed phrase. + * @param data.seedPhrase - The seed phrase to update the backup metadata state. + */ + updateBackupMetadataState( + data: + | { + keyringId: string; + seedPhrase: Uint8Array; + } + | { + keyringId: string; + seedPhrase: Uint8Array; + }[], + ) { + this.#assertIsUnlocked(); + + this.#filterDupesAndUpdateSocialBackupsMetadata(data); + } + + /** + * Verify the password validity by decrypting the vault. + * + * @param password - The password to verify. + * @param options - Optional options object. + * @param options.skipLock - Whether to skip the lock acquisition. + * @returns A promise that resolves to the success of the operation. + * @throws {Error} If the password is invalid or the vault is not initialized. + */ + async verifyVaultPassword( + password: string, + options?: { + skipLock?: boolean; + }, + ): Promise { + const doVerify = async () => { + if (!this.state.vault) { + throw new Error(SeedlessOnboardingControllerErrorMessage.VaultError); + } + await this.#vaultEncryptor.decrypt(password, this.state.vault); + }; + return options?.skipLock + ? await doVerify() + : await this.#withControllerLock(doVerify); + } + + /** + * Get the hash of the seed phrase backup for the given seed phrase, from the state. + * + * If the given seed phrase is not backed up and not found in the state, it will return `undefined`. + * + * @param seedPhrase - The seed phrase to get the hash of. + * @returns A promise that resolves to the hash of the seed phrase backup. + */ + getSeedPhraseBackupHash( + seedPhrase: Uint8Array, + ): SocialBackupsMetadata | undefined { + const seedPhraseHash = keccak256AndHexify(seedPhrase); + return this.state.socialBackupsMetadata.find( + (backup) => backup.hash === seedPhraseHash, + ); + } + + /** + * Submit the password to the controller, verify the password validity and unlock the controller. + * + * This method will be used especially when user rehydrate/unlock the wallet. + * The provided password will be verified against the encrypted vault, encryption key will be derived and saved in the controller state. + * + * This operation is useful when user performs some actions that requires the user password/encryption key. e.g. add new srp backup + * + * @param password - The password to submit. + * @returns A promise that resolves to the success of the operation. + */ + async submitPassword(password: string): Promise { + return await this.#withControllerLock(async () => { + await this.#unlockVaultAndGetBackupEncKey(password); + this.#setUnlocked(); + }); + } + + /** + * Set the controller to locked state, and deallocate the secrets (vault encryption key and salt). + * + * When the controller is locked, the user will not be able to perform any operations on the controller/vault. + */ + setLocked() { + this.update((state) => { + delete state.vaultEncryptionKey; + delete state.vaultEncryptionSalt; + }); + + this.#isUnlocked = false; + } + + /** + * Sync the latest global password to the controller. + * reset vault with latest globalPassword, + * persist the latest global password authPubKey + * + * @param params - The parameters for syncing the latest global password. + * @param params.oldPassword - The old password to verify. + * @param params.globalPassword - The latest global password. + * @returns A promise that resolves to the success of the operation. + */ + async syncLatestGlobalPassword({ + oldPassword, + globalPassword, + }: { + oldPassword: string; + globalPassword: string; + }) { + return await this.#withControllerLock(async () => { + // verify correct old password + await this.verifyVaultPassword(oldPassword, { + skipLock: true, // skip lock since we already have the lock + }); + // update vault with latest globalPassword + const { encKey, authKeyPair } = await this.#recoverEncKey(globalPassword); + // update and encrypt the vault with new password + await this.#createNewVaultWithAuthData({ + password: globalPassword, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + // persist the latest global password authPubKey + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + this.#resetPasswordOutdatedCache(); + }); + } + + /** + * @description Fetch the password corresponding to the current authPubKey in state (current device password which is already out of sync with the current global password). + * then we use this recovered old password to unlock the vault and set the password to the new global password. + * + * @param params - The parameters for fetching the password. + * @param params.globalPassword - The latest global password. + * @returns A promise that resolves to the password corresponding to the current authPubKey in state. + */ + async recoverCurrentDevicePassword({ + globalPassword, + }: { + globalPassword: string; + }): Promise<{ password: string }> { + return await this.#withControllerLock(async () => { + const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); + const { password: currentDevicePassword } = await this.#recoverPassword({ + targetPwPubKey: currentDeviceAuthPubKey, + globalPassword, + }); + return { + password: currentDevicePassword, + }; + }); + } + + /** + * @description Fetch the password corresponding to the targetPwPubKey. + * + * @param params - The parameters for fetching the password. + * @param params.targetPwPubKey - The target public key of the password to recover. + * @param params.globalPassword - The latest global password. + * @returns A promise that resolves to the password corresponding to the current authPubKey in state. + */ + async #recoverPassword({ + targetPwPubKey, + globalPassword, + }: { + targetPwPubKey: SEC1EncodedPublicKey; + globalPassword: string; + }): Promise<{ password: string }> { + const { encKey: latestPwEncKey, authKeyPair: latestPwAuthKeyPair } = + await this.#recoverEncKey(globalPassword); + + try { + const res = await this.toprfClient.recoverPassword({ + targetPwPubKey, + curEncKey: latestPwEncKey, + curAuthKeyPair: latestPwAuthKeyPair, + }); + return res; + } catch (error) { + throw PasswordSyncError.getInstance(error); + } + } + + /** + * @description Check if the current password is outdated compare to the global password. + * + * @param options - Optional options object. + * @param options.skipCache - If true, bypass the cache and force a fresh check. + * @param options.skipLock - Whether to skip the lock acquisition. + * @returns A promise that resolves to true if the password is outdated, false otherwise. + */ + async checkIsPasswordOutdated(options?: { + skipCache?: boolean; + skipLock?: boolean; + }): Promise { + // cache result to reduce load on infra + // Check cache first unless skipCache is true + if (!options?.skipCache) { + const { passwordOutdatedCache } = this.state; + const now = Date.now(); + const isCacheValid = + passwordOutdatedCache && + now - passwordOutdatedCache.timestamp < PASSWORD_OUTDATED_CACHE_TTL_MS; + + if (isCacheValid) { + return passwordOutdatedCache.isExpiredPwd; + } + } + + const doCheck = async () => { + this.#assertIsAuthenticatedUser(this.state); + const { + nodeAuthTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + } = this.state; + + const currentDeviceAuthPubKey = this.#recoverAuthPubKey(); + + const { authPubKey: globalAuthPubKey } = + await this.toprfClient.fetchAuthPubKey({ + nodeAuthTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + }); + + // use noble lib to deserialize and compare curve point + const isExpiredPwd = !secp256k1.ProjectivePoint.fromHex( + currentDeviceAuthPubKey, + ).equals(secp256k1.ProjectivePoint.fromHex(globalAuthPubKey)); + // Cache the result in state + this.update((state) => { + state.passwordOutdatedCache = { isExpiredPwd, timestamp: Date.now() }; + }); + return isExpiredPwd; + }; + + return options?.skipLock + ? await doCheck() + : await this.#withControllerLock(doCheck); + } + + #setUnlocked(): void { + this.#isUnlocked = true; + } + + /** + * Clears the current state of the SeedlessOnboardingController. + */ + clearState() { + const defaultState = getDefaultSeedlessOnboardingControllerState(); + this.update(() => { + return defaultState; + }); + } + + /** + * Persist the encryption key for the seedless onboarding flow. + * + * @param oprfKey - The OPRF key to be splited and persisted. + * @param authPubKey - The authentication public key. + * @returns A promise that resolves to the success of the operation. + */ + async #persistOprfKey(oprfKey: bigint, authPubKey: SEC1EncodedPublicKey) { + this.#assertIsAuthenticatedUser(this.state); + const { authConnectionId, groupedAuthConnectionId, userId } = this.state; + + try { + await this.toprfClient.persistLocalKey({ + nodeAuthTokens: this.state.nodeAuthTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + oprfKey, + authPubKey, + }); + } catch (error) { + log('Error persisting local encryption key', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, + ); + } + } + + /** + * Persist the authentication public key for the seedless onboarding flow. + * convert to suitable format before persisting. + * + * @param params - The parameters for persisting the authentication public key. + * @param params.authPubKey - The authentication public key to be persisted. + */ + #persistAuthPubKey(params: { authPubKey: SEC1EncodedPublicKey }): void { + this.update((state) => { + state.authPubKey = bytesToBase64(params.authPubKey); + }); + } + + /** + * Recover the authentication public key from the state. + * convert to pubkey format before recovering. + * + * @returns The authentication public key. + */ + #recoverAuthPubKey(): SEC1EncodedPublicKey { + this.#assertIsSRPBackedUpUser(this.state); + const { authPubKey } = this.state; + + return base64ToBytes(authPubKey); + } + + /** + * Recover the encryption key from password. + * + * @param password - The password used to derive/recover the encryption key. + * @returns A promise that resolves to the encryption key and authentication key pair. + * @throws RecoveryError - If failed to recover the encryption key. + */ + async #recoverEncKey(password: string) { + return this.#withRecoveryErrorHandler(async () => { + this.#assertIsAuthenticatedUser(this.state); + + const { authConnectionId, groupedAuthConnectionId, userId } = this.state; + + const recoverEncKeyResult = await this.toprfClient.recoverEncKey({ + nodeAuthTokens: this.state.nodeAuthTokens, + password, + authConnectionId, + groupedAuthConnectionId, + userId, + }); + return recoverEncKeyResult; + }); + } + + /** + * Update the encryption key with new password and update the Metadata Store with new encryption key. + * + * @param newPassword - The new password to update. + * @param oldPassword - The old password to verify. + * @returns A promise that resolves to new encryption key and authentication key pair. + */ + async #changeEncryptionKey(newPassword: string, oldPassword: string) { + this.#assertIsAuthenticatedUser(this.state); + const { authConnectionId, groupedAuthConnectionId, userId } = this.state; + + const { + encKey, + authKeyPair, + keyShareIndex: newKeyShareIndex, + } = await this.#recoverEncKey(oldPassword); + + return await this.toprfClient.changeEncKey({ + nodeAuthTokens: this.state.nodeAuthTokens, + authConnectionId, + groupedAuthConnectionId, + userId, + oldEncKey: encKey, + oldAuthKeyPair: authKeyPair, + newKeyShareIndex, + oldPassword, + newPassword, + }); + } + + /** + * Encrypt and store the seed phrase backup in the metadata store. + * + * @param params - The parameters for encrypting and storing the seed phrase backup. + * @param params.keyringId - The keyring id of the backup seed phrase. + * @param params.seedPhrase - The seed phrase to store. + * @param params.encKey - The encryption key to store. + * @param params.authKeyPair - The authentication key pair to store. + * + * @returns A promise that resolves to the success of the operation. + */ + async #encryptAndStoreSeedPhraseBackup(params: { + keyringId: string; + seedPhrase: Uint8Array; + encKey: Uint8Array; + authKeyPair: KeyPair; + }): Promise { + try { + const { keyringId, seedPhrase, encKey, authKeyPair } = params; + + const seedPhraseMetadata = new SecretMetadata(seedPhrase); + const secretData = seedPhraseMetadata.toBytes(); + await this.#withPersistedSeedPhraseBackupsState(async () => { + await this.toprfClient.addSecretDataItem({ + encKey, + secretData, + authKeyPair, + }); + return { + keyringId, + seedPhrase, + }; + }); + } catch (error) { + log('Error encrypting and storing seed phrase backup', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToEncryptAndStoreSeedPhraseBackup, + ); + } + } + + /** + * Unlocks the encrypted vault using the provided password and returns the decrypted vault data. + * This method ensures thread-safety by using a mutex lock when accessing the vault. + * + * @param password - The optional password to unlock the vault. + * @returns A promise that resolves to an object containing: + * - nodeAuthTokens: Authentication tokens to communicate with the TOPRF service + * - toprfEncryptionKey: The decrypted TOPRF encryption key + * - toprfAuthKeyPair: The decrypted TOPRF authentication key pair + * @throws {Error} If: + * - The password is invalid or empty + * - The vault is not initialized + * - The password is incorrect (from encryptor.decrypt) + * - The decrypted vault data is malformed + */ + async #unlockVaultAndGetBackupEncKey(password?: string): Promise<{ + nodeAuthTokens: NodeAuthTokens; + toprfEncryptionKey: Uint8Array; + toprfAuthKeyPair: KeyPair; + }> { + return this.#withVaultLock(async () => { + const { + vault: encryptedVault, + vaultEncryptionKey, + vaultEncryptionSalt, + } = this.state; + + if (!encryptedVault) { + throw new Error(SeedlessOnboardingControllerErrorMessage.VaultError); + } + + if (!vaultEncryptionKey && !password) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + } + + let decryptedVaultData: unknown; + const updatedState: Partial = {}; + + if (password) { + assertIsValidPassword(password); + // Note that vault decryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const result = await this.#vaultEncryptor.decryptWithDetail( + password, + encryptedVault, + ); + decryptedVaultData = result.vault; + updatedState.vaultEncryptionKey = result.exportedKeyString; + updatedState.vaultEncryptionSalt = result.salt; + } else { + const parsedEncryptedVault = JSON.parse(encryptedVault); + + if (vaultEncryptionSalt !== parsedEncryptedVault.salt) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.ExpiredCredentials, + ); + } + + if (typeof vaultEncryptionKey !== 'string') { + throw new TypeError( + SeedlessOnboardingControllerErrorMessage.WrongPasswordType, + ); + } + + const key = await this.#vaultEncryptor.importKey(vaultEncryptionKey); + decryptedVaultData = await this.#vaultEncryptor.decryptWithKey( + key, + parsedEncryptedVault, + ); + updatedState.vaultEncryptionKey = vaultEncryptionKey; + updatedState.vaultEncryptionSalt = vaultEncryptionSalt; + } + + const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = + this.#parseVaultData(decryptedVaultData); + + // update the state with the restored nodeAuthTokens + this.update((state) => { + state.nodeAuthTokens = nodeAuthTokens; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + }); + + return { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair }; + }); + } + + /** + * Executes a callback function that creates or restores seed phrases and persists their hashes in the controller state. + * + * This method: + * 1. Executes the provided callback to create/restore seed phrases + * 2. Generates keccak256 hashes of the seed phrases + * 3. Merges new hashes with existing ones in the state, ensuring uniqueness + * 4. Updates the controller state with the combined hashes + * + * This is a wrapper method that should be used around any operation that creates + * or restores seed phrases to ensure their hashes are properly tracked. + * + * @param createSeedPhraseBackupCallback - function that returns either a single seed phrase + * or an array of seed phrases as Uint8Array(s) + * @returns The original seed phrase(s) returned by the callback + * @throws Rethrows any errors from the callback with additional logging + */ + async #withPersistedSeedPhraseBackupsState( + createSeedPhraseBackupCallback: () => Promise<{ + keyringId: string; + seedPhrase: Uint8Array; + }>, + ): Promise<{ + keyringId: string; + seedPhrase: Uint8Array; + }> { + try { + const newBackup = await createSeedPhraseBackupCallback(); + + this.#filterDupesAndUpdateSocialBackupsMetadata(newBackup); + + return newBackup; + } catch (error) { + log('Error persisting seed phrase backups', error); + throw error; + } + } + + /** + * Updates the social backups metadata state by adding new unique seed phrase backups. + * This method ensures no duplicate backups are stored by checking the hash of each seed phrase. + * + * @param data - The backup data to add to the state + * @param data.id - The identifier for the backup + * @param data.seedPhrase - The seed phrase to backup as a Uint8Array + */ + #filterDupesAndUpdateSocialBackupsMetadata( + data: + | { + keyringId: string; + seedPhrase: Uint8Array; + } + | { + keyringId: string; + seedPhrase: Uint8Array; + }[], + ) { + const currentBackupsMetadata = this.state.socialBackupsMetadata; + + const newBackupsMetadata = Array.isArray(data) ? data : [data]; + const filteredNewBackupsMetadata: SocialBackupsMetadata[] = []; + + // filter out the backed up metadata that already exists in the state + // to prevent duplicates + newBackupsMetadata.forEach((item) => { + const { keyringId, seedPhrase } = item; + const backupHash = keccak256AndHexify(seedPhrase); + + const backupStateAlreadyExisted = currentBackupsMetadata.some( + (backup) => backup.hash === backupHash, + ); + + if (!backupStateAlreadyExisted) { + filteredNewBackupsMetadata.push({ + id: keyringId, + hash: backupHash, + }); + } + }); + + if (filteredNewBackupsMetadata.length > 0) { + this.update((state) => { + state.socialBackupsMetadata = [ + ...state.socialBackupsMetadata, + ...filteredNewBackupsMetadata, + ]; + }); + } + } + + /** + * Create a new vault with the given authentication data. + * + * Serialize the authentication and key data which will be stored in the vault. + * + * @param params - The parameters for creating a new vault. + * @param params.password - The password to encrypt the vault. + * @param params.rawToprfEncryptionKey - The encryption key to encrypt the vault. + * @param params.rawToprfAuthKeyPair - The authentication key pair to encrypt the vault. + */ + async #createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey, + rawToprfAuthKeyPair, + }: { + password: string; + rawToprfEncryptionKey: Uint8Array; + rawToprfAuthKeyPair: KeyPair; + }): Promise { + this.#assertIsAuthenticatedUser(this.state); + this.#setUnlocked(); + + const { toprfEncryptionKey, toprfAuthKeyPair } = this.#serializeKeyData( + rawToprfEncryptionKey, + rawToprfAuthKeyPair, + ); + + const serializedVaultData = JSON.stringify({ + authTokens: this.state.nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + }); + + await this.#updateVault({ + password, + serializedVaultData, + }); + } + + /** + * Encrypt and update the vault with the given authentication data. + * + * @param params - The parameters for updating the vault. + * @param params.password - The password to encrypt the vault. + * @param params.serializedVaultData - The serialized authentication data to update the vault with. + * @returns A promise that resolves to the updated vault. + */ + async #updateVault({ + password, + serializedVaultData, + }: { + password: string; + serializedVaultData: string; + }): Promise { + await this.#withVaultLock(async () => { + assertIsValidPassword(password); + + // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const { vault, exportedKeyString } = + await this.#vaultEncryptor.encryptWithDetail( + password, + serializedVaultData, + ); + + this.update((state) => { + state.vault = vault; + state.vaultEncryptionKey = exportedKeyString; + state.vaultEncryptionSalt = JSON.parse(vault).salt; + }); + }); + } + + /** + * Lock the controller mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This wrapper ensures that each mutable operation that interacts with the + * controller and that changes its state is executed in a mutually exclusive way, + * preventing unsafe concurrent access that could lead to unpredictable behavior. + * + * @param callback - The function to execute while the controller mutex is locked. + * @returns The result of the function. + */ + async #withControllerLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return await withLock(this.#controllerOperationMutex, callback); + } + + /** + * Lock the vault mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This ensures that each operation that interacts with the vault + * is executed in a mutually exclusive way. + * + * @param callback - The function to execute while the vault mutex is locked. + * @returns The result of the function. + */ + async #withVaultLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return await withLock(this.#vaultOperationMutex, callback); + } + + /** + * Serialize the encryption key and authentication key pair. + * + * @param encKey - The encryption key to serialize. + * @param authKeyPair - The authentication key pair to serialize. + * @returns The serialized encryption key and authentication key pair. + */ + #serializeKeyData( + encKey: Uint8Array, + authKeyPair: KeyPair, + ): { + toprfEncryptionKey: string; + toprfAuthKeyPair: string; + } { + const b64EncodedEncKey = bytesToBase64(encKey); + const b64EncodedAuthKeyPair = JSON.stringify({ + sk: bigIntToHex(authKeyPair.sk), // Convert BigInt to hex string + pk: bytesToBase64(authKeyPair.pk), + }); + + return { + toprfEncryptionKey: b64EncodedEncKey, + toprfAuthKeyPair: b64EncodedAuthKeyPair, + }; + } + + /** + * Parse and deserialize the authentication data from the vault. + * + * @param data - The decrypted vault data. + * @returns The parsed authentication data. + * @throws If the vault data is not valid. + */ + #parseVaultData(data: unknown): { + nodeAuthTokens: NodeAuthTokens; + toprfEncryptionKey: Uint8Array; + toprfAuthKeyPair: KeyPair; + } { + if (typeof data !== 'string') { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidVaultData, + ); + } + + let parsedVaultData: unknown; + try { + parsedVaultData = JSON.parse(data); + } catch { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidVaultData, + ); + } + + this.#assertIsValidVaultData(parsedVaultData); + + const rawToprfEncryptionKey = base64ToBytes( + parsedVaultData.toprfEncryptionKey, + ); + const parsedToprfAuthKeyPair = JSON.parse(parsedVaultData.toprfAuthKeyPair); + const rawToprfAuthKeyPair = { + sk: BigInt(parsedToprfAuthKeyPair.sk), + pk: base64ToBytes(parsedToprfAuthKeyPair.pk), + }; + + return { + nodeAuthTokens: parsedVaultData.authTokens, + toprfEncryptionKey: rawToprfEncryptionKey, + toprfAuthKeyPair: rawToprfAuthKeyPair, + }; + } + + #assertIsUnlocked(): void { + if (!this.#isUnlocked) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + } + } + + /** + * Assert that the provided value contains valid authenticated user information. + * + * This method checks that the value is an object containing: + * - nodeAuthTokens: A non-empty array of authentication tokens + * - authConnectionId: A string identifier for the OAuth connection + * - groupedAuthConnectionId: A string identifier for grouped OAuth connections + * - userId: A string identifier for the authenticated user + * + * @param value - The value to validate. + * @throws {Error} If the value does not contain valid authenticated user information. + */ + #assertIsAuthenticatedUser( + value: unknown, + ): asserts value is AuthenticatedUserDetails { + if ( + !value || + typeof value !== 'object' || + !('authConnectionId' in value) || + typeof value.authConnectionId !== 'string' || + !('userId' in value) || + typeof value.userId !== 'string' + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + ); + } + + if ( + !('nodeAuthTokens' in value) || + typeof value.nodeAuthTokens !== 'object' || + !Array.isArray(value.nodeAuthTokens) || + value.nodeAuthTokens.length < 3 // At least 3 auth tokens are required for Threshold OPRF service + ) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + } + } + + #assertIsSRPBackedUpUser( + value: unknown, + ): asserts value is SRPBackedUpUserDetails { + if (!this.state.authPubKey) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.SRPNotBackedUpError, + ); + } + } + + /** + * Handle the recovery error and update the recovery error data after executing the given callback. + * + * @param recoveryCallback - The callback recovery function to execute. + * @returns The result of the callback function. + */ + async #withRecoveryErrorHandler( + recoveryCallback: () => Promise, + ): Promise { + const currentRecoveryAttempts = + this.state.recoveryRatelimitCache?.numberOfAttempts || 0; + let updatedRecoveryAttempts = currentRecoveryAttempts + 1; + let updatedRemainingTime = + this.state.recoveryRatelimitCache?.remainingTime || 0; + + try { + const result = await recoveryCallback(); + + // reset the ratelimit error data + updatedRecoveryAttempts = 0; + updatedRemainingTime = 0; + + return result; + } catch (error) { + const recoveryError = RecoveryError.getInstance(error, { + numberOfAttempts: updatedRecoveryAttempts, + remainingTime: updatedRemainingTime, + }); + + if (recoveryError.data?.numberOfAttempts) { + updatedRecoveryAttempts = recoveryError.data.numberOfAttempts; + } + + if (recoveryError.data?.remainingTime) { + updatedRemainingTime = recoveryError.data.remainingTime; + } + + throw recoveryError; + } finally { + this.update((state) => { + state.recoveryRatelimitCache = { + numberOfAttempts: updatedRecoveryAttempts, + remainingTime: updatedRemainingTime, + }; + }); + } + } + + /** + * Assert that the password is in sync with the global password. + * + * @param options - The options for asserting the password is in sync. + * @param options.skipCache - Whether to skip the cache check. + * @param options.skipLock - Whether to skip the lock acquisition. + * @throws If the password is outdated. + */ + async #assertPasswordInSync(options?: { + skipCache?: boolean; + skipLock?: boolean; + }): Promise { + const isPasswordOutdated = await this.checkIsPasswordOutdated(options); + if (isPasswordOutdated) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.OutdatedPassword, + ); + } + } + + #resetPasswordOutdatedCache(): void { + this.update((state) => { + delete state.passwordOutdatedCache; + }); + } + + /** + * Check if the provided value is a valid vault data. + * + * @param value - The value to check. + * @throws If the value is not a valid vault data. + */ + #assertIsValidVaultData(value: unknown): asserts value is VaultData { + // value is not valid vault data if any of the following conditions are true: + if ( + !value || // value is not defined + typeof value !== 'object' || // value is not an object + !('authTokens' in value) || // authTokens is not defined + typeof value.authTokens !== 'object' || // authTokens is not an object + !('toprfEncryptionKey' in value) || // toprfEncryptionKey is not defined + typeof value.toprfEncryptionKey !== 'string' || // toprfEncryptionKey is not a string + !('toprfAuthKeyPair' in value) || // toprfAuthKeyPair is not defined + typeof value.toprfAuthKeyPair !== 'string' // toprfAuthKeyPair is not a string + ) { + throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); + } + } +} + +/** + * Assert that the provided password is a valid non-empty string. + * + * @param password - The password to check. + * @throws If the password is not a valid string. + */ +function assertIsValidPassword(password: unknown): asserts password is string { + if (typeof password !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + } + + if (!password || !password.length) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + ); + } +} + +/** + * Lock the given mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * @param mutex - The mutex to lock. + * @param callback - The function to execute while the mutex is locked. + * @returns The result of the function. + */ +async function withLock( + mutex: Mutex, + callback: MutuallyExclusiveCallback, +): Promise { + const releaseLock = await mutex.acquire(); + + try { + return await callback({ releaseLock }); + } finally { + releaseLock(); + } +} diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts new file mode 100644 index 0000000000..2c39c419ce --- /dev/null +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -0,0 +1,50 @@ +export const controllerName = 'SeedlessOnboardingController'; + +export const PASSWORD_OUTDATED_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes + +export enum Web3AuthNetwork { + Mainnet = 'sapphire_mainnet', + Devnet = 'sapphire_devnet', +} + +/** + * The type of social login provider. + */ +export enum AuthConnection { + Google = 'google', + Apple = 'apple', +} + +export enum SecretType { + Mnemonic = 'mnemonic', + PrivateKey = 'privateKey', +} + +export enum SecretMetadataVersion { + V1 = 'v1', +} + +export enum SeedlessOnboardingControllerErrorMessage { + ControllerLocked = `${controllerName} - The operation cannot be completed while the controller is locked.`, + AuthenticationError = `${controllerName} - Authentication error`, + MissingAuthUserInfo = `${controllerName} - Missing authenticated user information`, + FailedToPersistOprfKey = `${controllerName} - Failed to persist OPRF key`, + LoginFailedError = `${controllerName} - Login failed`, + InsufficientAuthToken = `${controllerName} - Insufficient auth token`, + MissingCredentials = `${controllerName} - Cannot unlock vault without password and encryption key`, + ExpiredCredentials = `${controllerName} - Encryption key and salt provided are expired`, + InvalidEmptyPassword = `${controllerName} - Password cannot be empty.`, + WrongPasswordType = `${controllerName} - Password must be of type string.`, + InvalidVaultData = `${controllerName} - Invalid vault data`, + VaultDataError = `${controllerName} - The decrypted vault has an unexpected shape.`, + VaultError = `${controllerName} - Cannot unlock without a previous vault.`, + InvalidSecretMetadata = `${controllerName} - Invalid secret metadata`, + FailedToEncryptAndStoreSeedPhraseBackup = `${controllerName} - Failed to encrypt and store seed phrase backup`, + FailedToFetchSeedPhraseMetadata = `${controllerName} - Failed to fetch seed phrase metadata`, + FailedToChangePassword = `${controllerName} - Failed to change password`, + TooManyLoginAttempts = `${controllerName} - Too many login attempts`, + IncorrectPassword = `${controllerName} - Incorrect password`, + OutdatedPassword = `${controllerName} - Outdated password`, + CouldNotRecoverPassword = `${controllerName} - Could not recover password`, + SRPNotBackedUpError = `${controllerName} - SRP not backed up`, +} diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts new file mode 100644 index 0000000000..2eb5a84c3f --- /dev/null +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -0,0 +1,155 @@ +import { + type RateLimitErrorData, + TOPRFError, + TOPRFErrorCode, +} from '@metamask/toprf-secure-backup'; + +import { SeedlessOnboardingControllerErrorMessage } from './constants'; +import type { RecoveryErrorData } from './types'; + +/** + * Get the error message from the TOPRF error code. + * + * @param errorCode - The TOPRF error code. + * @param defaultMessage - The default error message if the error code is not found. + * @returns The error message. + */ +function getErrorMessageFromTOPRFErrorCode( + errorCode: TOPRFErrorCode, + defaultMessage: string, +): string { + switch (errorCode) { + case TOPRFErrorCode.RateLimitExceeded: + return SeedlessOnboardingControllerErrorMessage.TooManyLoginAttempts; + case TOPRFErrorCode.CouldNotDeriveEncryptionKey: + return SeedlessOnboardingControllerErrorMessage.IncorrectPassword; + case TOPRFErrorCode.CouldNotFetchPassword: + return SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword; + default: + return defaultMessage; + } +} + +/** + * Check if the provided error is a rate limit error triggered by too many login attempts. + * + * Return a new TooManyLoginAttemptsError if the error is a rate limit error, otherwise undefined. + * + * @param error - The error to check. + * @returns The rate limit error if the error is a rate limit error, otherwise undefined. + */ +function getRateLimitErrorData( + error: TOPRFError, +): RateLimitErrorData | undefined { + if ( + error.meta && // error metadata must be present + error.code === TOPRFErrorCode.RateLimitExceeded && + typeof error.meta.rateLimitDetails === 'object' && + error.meta.rateLimitDetails !== null && + 'remainingTime' in error.meta.rateLimitDetails && + typeof error.meta.rateLimitDetails.remainingTime === 'number' && + 'message' in error.meta.rateLimitDetails && + typeof error.meta.rateLimitDetails.message === 'string' && + 'lockTime' in error.meta.rateLimitDetails && + typeof error.meta.rateLimitDetails.lockTime === 'number' && + 'guessCount' in error.meta.rateLimitDetails && + typeof error.meta.rateLimitDetails.guessCount === 'number' + ) { + return { + remainingTime: error.meta.rateLimitDetails.remainingTime, + message: error.meta.rateLimitDetails.message, + lockTime: error.meta.rateLimitDetails.lockTime, + guessCount: error.meta.rateLimitDetails.guessCount, + }; + } + return undefined; +} + +/** + * The PasswordSyncError class is used to handle errors that occur during the password sync process. + */ +export class PasswordSyncError extends Error { + constructor(message: string) { + super(message); + this.name = 'SeedlessOnboardingController - PasswordSyncError'; + } + + /** + * Get an instance of the PasswordSyncError class. + * + * @param error - The error to get the instance of. + * @returns The instance of the PasswordSyncError class. + */ + static getInstance(error: unknown): PasswordSyncError { + if (error instanceof TOPRFError) { + const errorMessage = getErrorMessageFromTOPRFErrorCode( + error.code, + SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, + ); + return new PasswordSyncError(errorMessage); + } + return new PasswordSyncError( + SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword, + ); + } +} + +/** + * The RecoveryError class is used to handle errors that occur during the recover encryption key process from the passwrord. + * It extends the Error class and includes a data property that can be used to store additional information. + */ +export class RecoveryError extends Error { + data: RecoveryErrorData | undefined; + + constructor(message: string, data?: RecoveryErrorData) { + super(message); + this.data = data; + this.name = 'SeedlessOnboardingController - RecoveryError'; + } + + /** + * Get an instance of the RecoveryError class. + * + * @param error - The error to get the instance of. + * @param cachedErrorData - The cached error data to help synchronize the recovery error data across multiple devices. + * @returns The instance of the RecoveryError class. + */ + static getInstance( + error: unknown, + cachedErrorData?: RecoveryErrorData, + ): RecoveryError { + if (!(error instanceof TOPRFError)) { + return new RecoveryError( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ); + } + + const rateLimitErrorData = getRateLimitErrorData(error); + const recoveryErrorData = rateLimitErrorData + ? { + numberOfAttempts: rateLimitErrorData.guessCount, + remainingTime: rateLimitErrorData.remainingTime, + } + : undefined; + + if ( + rateLimitErrorData && + recoveryErrorData && + rateLimitErrorData.guessCount === cachedErrorData?.numberOfAttempts + ) { + // if the number of attempts is the same, we can assume that the previous attempt has been made from the same device. + // The `lockTime` value is the total ratelimit duration based on the `guessCount` value. + // The `remainingTime` value is the time that server acutally waits to block the recovery (count down from the `lockTime`) before the next attempt. + // However, due to the network delay and server processing time, the `remainingTime` value will be smaller than the `lockTime` value when it reaches to the client side. + // e.g. The actual remaining time is 30s, but when it reaches to the client side, it becomes less than 30s, but the `lockTime` value is still 30s. + // So, to enforce the user to follow the rate limit policy in the client side, we use the `lockTime` value to calculate the remaining time. + recoveryErrorData.remainingTime = rateLimitErrorData.lockTime; + } + + const errorMessage = getErrorMessageFromTOPRFErrorCode( + error.code, + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ); + return new RecoveryError(errorMessage, recoveryErrorData); + } +} diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts new file mode 100644 index 0000000000..611bf94b29 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -0,0 +1,23 @@ +export { + SeedlessOnboardingController, + getDefaultSeedlessOnboardingControllerState, +} from './SeedlessOnboardingController'; +export type { + AuthenticatedUserDetails, + SocialBackupsMetadata, + SeedlessOnboardingControllerState, + SeedlessOnboardingControllerOptions, + SeedlessOnboardingControllerMessenger, + SeedlessOnboardingControllerGetStateAction, + SeedlessOnboardingControllerStateChangeEvent, + SeedlessOnboardingControllerActions, + SeedlessOnboardingControllerEvents, + ToprfKeyDeriver, + RecoveryErrorData, +} from './types'; +export { + Web3AuthNetwork, + SeedlessOnboardingControllerErrorMessage, + AuthConnection, +} from './constants'; +export { RecoveryError } from './errors'; diff --git a/packages/seedless-onboarding-controller/src/logger.ts b/packages/seedless-onboarding-controller/src/logger.ts new file mode 100644 index 0000000000..ca017b5ba5 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/logger.ts @@ -0,0 +1,7 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { controllerName } from './constants'; + +export const projectLogger = createProjectLogger(controllerName); + +export { createModuleLogger }; diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts new file mode 100644 index 0000000000..5bff5bc7f9 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -0,0 +1,275 @@ +import type { RestrictedMessenger } from '@metamask/base-controller'; +import type { ControllerGetStateAction } from '@metamask/base-controller'; +import type { ControllerStateChangeEvent } from '@metamask/base-controller'; +import type { + ExportableKeyEncryptor, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { NodeAuthTokens } from '@metamask/toprf-secure-backup'; +import type { MutexInterface } from 'async-mutex'; + +import type { + AuthConnection, + controllerName, + SecretMetadataVersion, + SecretType, + Web3AuthNetwork, +} from './constants'; + +export type SocialBackupsMetadata = { + id: string; + hash: string; +}; + +export type AuthenticatedUserDetails = { + /** + * Type of social login provider. + */ + authConnection: AuthConnection; + + /** + * The node auth tokens from OAuth User authentication after the Social login. + * + * This values are used to authenticate users when they go through the Seedless Onboarding flow. + */ + nodeAuthTokens: NodeAuthTokens; + + /** + * OAuth connection id from web3auth dashboard. + */ + authConnectionId: string; + + /** + * The optional grouped authConnectionId to authenticate the user with Web3Auth network. + */ + groupedAuthConnectionId?: string; + + /** + * The user email or ID from Social login. + */ + userId: string; + + /** + * The user email from Social login. + */ + socialLoginEmail: string; +}; + +export type SRPBackedUpUserDetails = { + /** + * The public key of the authentication key pair in base64 format. + * + * This value is used to check if the password is outdated compare to the global password and find backed up old password. + */ + authPubKey: string; +}; + +/** + * The data of the recovery error. + */ +export type RecoveryErrorData = { + /** + * The remaining time in seconds before the user can try again. + */ + remainingTime: number; + + /** + * The number of attempts made by the user. + */ + numberOfAttempts: number; +}; + +// State +export type SeedlessOnboardingControllerState = + Partial & + Partial & { + /** + * Encrypted array of serialized keyrings data. + */ + vault?: string; + + /** + * The hashes of the seed phrase backups. + * + * This is to facilitate the UI to display backup status of the seed phrases. + */ + socialBackupsMetadata: SocialBackupsMetadata[]; + + /** + * The encryption key derived from the password and used to encrypt + * the vault. + */ + vaultEncryptionKey?: string; + + /** + * The salt used to derive the encryption key from the password. + */ + vaultEncryptionSalt?: string; + + /** + * Cache for checkIsPasswordOutdated result and timestamp. + */ + passwordOutdatedCache?: { isExpiredPwd: boolean; timestamp: number }; + + /** + * The cached data of the recovery error. + * + * This data is used to cache the recovery error data to retrieve the accurate ratelimit remainingTime and numberOfAttempts. + * And it also helps to synchronize the recovery error data across multiple devices. + */ + recoveryRatelimitCache?: RecoveryErrorData; + }; + +// Actions +export type SeedlessOnboardingControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + SeedlessOnboardingControllerState + >; +export type SeedlessOnboardingControllerActions = + SeedlessOnboardingControllerGetStateAction; + +export type AllowedActions = never; + +// Events +export type SeedlessOnboardingControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + SeedlessOnboardingControllerState + >; +export type SeedlessOnboardingControllerEvents = + SeedlessOnboardingControllerStateChangeEvent; + +export type AllowedEvents = + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; + +// Messenger +export type SeedlessOnboardingControllerMessenger = RestrictedMessenger< + typeof controllerName, + SeedlessOnboardingControllerActions | AllowedActions, + SeedlessOnboardingControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * Encryptor interface for encrypting and decrypting seedless onboarding vault. + */ +export type VaultEncryptor = Omit< + ExportableKeyEncryptor, + 'encryptWithKey' +>; + +/** + * Additional key deriver for the TOPRF client. + * + * This is a function that takes a seed and salt and returns a key in bytes (Uint8Array). + * It is used as an additional step during key derivation. This can be used, for example, to inject a slow key + * derivation step to protect against local brute force attacks on the password. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ +export type ToprfKeyDeriver = { + /** + * Derive a key from a seed and salt. + * + * @param seed - The seed to derive the key from. + * @param salt - The salt to derive the key from. + * @returns The derived key. + */ + deriveKey: (seed: Uint8Array, salt: Uint8Array) => Promise; +}; + +/** + * Seedless Onboarding Controller Options. + * + * @param messenger - The messenger to use for this controller. + * @param state - The initial state to set on this controller. + * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. + */ +export type SeedlessOnboardingControllerOptions = { + messenger: SeedlessOnboardingControllerMessenger; + + /** + * Initial state to set on this controller. + */ + state?: Partial; + + /** + * Encryptor to use for encrypting and decrypting seedless onboarding vault. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + encryptor: VaultEncryptor; + + /** + * Optional key derivation interface for the TOPRF client. + * + * If provided, it will be used as an additional step during + * key derivation. This can be used, for example, to inject a slow key + * derivation step to protect against local brute force attacks on the + * password. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + toprfKeyDeriver?: ToprfKeyDeriver; + + /** + * Type of Web3Auth network to be used for the Seedless Onboarding flow. + * + * @default Web3AuthNetwork.Mainnet + */ + network?: Web3AuthNetwork; +}; + +/** + * A function executed within a mutually exclusive lock, with + * a mutex releaser in its option bag. + * + * @param releaseLock - A function to release the lock. + */ +export type MutuallyExclusiveCallback = ({ + releaseLock, +}: { + releaseLock: MutexInterface.Releaser; +}) => Promise; + +/** + * The structure of the data which is serialized and stored in the vault. + */ +export type VaultData = { + /** + * The node auth tokens from OAuth User authentication after the Social login. + */ + authTokens: NodeAuthTokens; + /** + * The encryption key to encrypt the seed phrase. + */ + toprfEncryptionKey: string; + /** + * The authentication key pair to authenticate the TOPRF. + */ + toprfAuthKeyPair: string; +}; + +export type SecretDataType = Uint8Array | string | number; + +/** + * The constructor options for the seed phrase metadata. + */ +export type SecretMetadataOptions = { + /** + * The timestamp when the seed phrase was created. + */ + timestamp: number; + /** + * The type of the seed phrase. + */ + type: SecretType; + /** + * The version of the seed phrase metadata. + */ + version: SecretMetadataVersion; +}; diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts new file mode 100644 index 0000000000..01a7124e14 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts @@ -0,0 +1,57 @@ +import { Messenger } from '@metamask/base-controller'; + +import type { + AllowedActions, + AllowedEvents, + SeedlessOnboardingControllerMessenger, +} from '../../src/types'; + +/** + * creates a custom seedless onboarding messenger, in case tests need different permissions + * + * @returns base messenger, and messenger. You can pass this into the mocks below to mock messenger calls + */ +export function createCustomSeedlessOnboardingMessenger() { + const baseMessenger = new Messenger(); + const messenger = baseMessenger.getRestricted({ + name: 'SeedlessOnboardingController', + allowedActions: [], + allowedEvents: ['KeyringController:lock', 'KeyringController:unlock'], + }); + + return { + baseMessenger, + messenger, + }; +} + +type OverrideMessengers = { + baseMessenger: Messenger; + messenger: SeedlessOnboardingControllerMessenger; +}; + +/** + * Jest Mock Utility to generate a mock Seedless Onboarding Messenger + * + * @param overrideMessengers - override messengers if need to modify the underlying permissions + * @returns series of mocks to actions that can be called + */ +export function mockSeedlessOnboardingMessenger( + overrideMessengers?: OverrideMessengers, +) { + const { baseMessenger, messenger } = + overrideMessengers ?? createCustomSeedlessOnboardingMessenger(); + + const mockKeyringGetAccounts = jest.fn(); + const mockKeyringAddAccounts = jest.fn(); + + const mockAccountsListAccounts = jest.fn(); + + return { + baseMessenger, + messenger, + mockKeyringGetAccounts, + mockKeyringAddAccounts, + mockAccountsListAccounts, + }; +} diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/topfClient.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/topfClient.ts new file mode 100644 index 0000000000..7a8b4a6694 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/topfClient.ts @@ -0,0 +1,104 @@ +import nock from 'nock'; + +import { + MOCK_ACQUIRE_METADATA_LOCK_RESPONSE, + MOCK_BATCH_SECRET_DATA_ADD_RESPONSE, + MOCK_RELEASE_METADATA_LOCK_RESPONSE, + MOCK_SECRET_DATA_ADD_RESPONSE, + MOCK_SECRET_DATA_GET_RESPONSE, + MOCK_TOPRF_AUTHENTICATION_RESPONSE, + MOCK_TOPRF_COMMITMENT_RESPONSE, + TOPRF_BASE_URL, +} from '../mocks/toprf'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export const handleMockCommitment = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_TOPRF_COMMITMENT_RESPONSE, + }; + + const mockEndpoint = nock(TOPRF_BASE_URL) + .persist() + .post('/sss/jrpc') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const handleMockAuthenticate = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_TOPRF_AUTHENTICATION_RESPONSE, + }; + const mockEndpoint = nock(TOPRF_BASE_URL) + .persist() + .post('/sss/jrpc') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const handleMockSecretDataAdd = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_SECRET_DATA_ADD_RESPONSE, + }; + const mockEndpoint = nock(TOPRF_BASE_URL) + .post('/metadata/enc_account_data/set') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const handleMockBatchSecretDataAdd = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_BATCH_SECRET_DATA_ADD_RESPONSE, + }; + const mockEndpoint = nock(TOPRF_BASE_URL) + .post('/metadata/enc_account_data/batch_set') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const handleMockSecretDataGet = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_SECRET_DATA_GET_RESPONSE, + }; + const mockEndpoint = nock(TOPRF_BASE_URL) + .post('/metadata/enc_account_data/get') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const handleMockAcquireMetadataLock = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_ACQUIRE_METADATA_LOCK_RESPONSE, + }; + const mockEndpoint = nock(TOPRF_BASE_URL) + .post('/metadata/acquireLock') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const handleMockReleaseMetadataLock = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_RELEASE_METADATA_LOCK_RESPONSE, + }; + const mockEndpoint = nock(TOPRF_BASE_URL) + .post('/metadata/releaseLock') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; diff --git a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts new file mode 100644 index 0000000000..0557af5a66 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts @@ -0,0 +1,110 @@ +import { MockToprfEncryptorDecryptor } from './toprfEncryptor'; + +export const TOPRF_BASE_URL = /https:\/\/node-[1-5]\.dev-node\.web3auth\.io/u; + +export const MOCK_TOPRF_COMMITMENT_RESPONSE = { + jsonrpc: '2.0', + result: { + signature: 'MOCK_NODE_SIGNATURE', + data: 'MOCK_NODE_DATA', + nodePubX: 'MOCK_NODE_PUB_X', + nodePubY: 'MOCK_NODE_PUB_Y', + nodeIndex: '1', + }, + id: 10, +}; + +export const MOCK_TOPRF_AUTHENTICATION_RESPONSE = { + jsonrpc: '2.0', + result: { + authToken: 'MOCK_AUTH_TOKEN', + nodeIndex: 1, + pubKey: 'MOCK_USER_PUB_KEY', + keyIndex: 0, + nodePubKey: 'MOCK_NODE_PUB_KEY', + }, + id: 10, +}; + +export const MOCK_SECRET_DATA_ADD_RESPONSE = { + success: true, + message: 'Updated successfully', +}; + +export const MOCK_BATCH_SECRET_DATA_ADD_RESPONSE = { + success: true, + message: 'Updated successfully', +}; + +export const MOCK_SECRET_DATA_GET_RESPONSE = { + success: true, + data: [], + ids: [], +}; + +export const MOCK_ACQUIRE_METADATA_LOCK_RESPONSE = { + status: 1, + id: 'MOCK_METADATA_LOCK_ID', +}; + +export const MOCK_RELEASE_METADATA_LOCK_RESPONSE = { + status: 1, +}; + +export const MULTIPLE_MOCK_SECRET_METADATA = [ + { + data: new Uint8Array(Buffer.from('seedPhrase1', 'utf-8')), + timestamp: 10, + }, + { + data: new Uint8Array(Buffer.from('seedPhrase3', 'utf-8')), + timestamp: 60, + }, + { + data: new Uint8Array(Buffer.from('seedPhrase2', 'utf-8')), + timestamp: 20, + }, +]; + +/** + * Creates a mock secret data get response + * + * @param secretDataArr - The data to be returned + * @param password - The password to be used + * @returns The mock secret data get response + */ +export function createMockSecretDataGetResponse< + T extends Uint8Array | { data: Uint8Array; timestamp: number }, +>(secretDataArr: T[], password: string) { + const mockToprfEncryptor = new MockToprfEncryptorDecryptor(); + const ids: string[] = []; + + const encryptedSecretData = secretDataArr.map((secretData) => { + let b64SecretData: string; + let timestamp = Date.now(); + if (secretData instanceof Uint8Array) { + b64SecretData = Buffer.from(secretData).toString('base64'); + } else { + b64SecretData = Buffer.from(secretData.data).toString('base64'); + timestamp = secretData.timestamp; + } + + const metadata = JSON.stringify({ + data: b64SecretData, + timestamp, + }); + + return mockToprfEncryptor.encrypt( + mockToprfEncryptor.deriveEncKey(password), + new Uint8Array(Buffer.from(metadata, 'utf-8')), + ); + }); + + const jsonData = { + success: true, + data: encryptedSecretData, + ids, + }; + + return jsonData; +} diff --git a/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts b/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts new file mode 100644 index 0000000000..5476d29516 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/mocks/toprfEncryptor.ts @@ -0,0 +1,50 @@ +import type { KeyPair } from '@metamask/toprf-secure-backup'; +import { gcm } from '@noble/ciphers/aes'; +import { bytesToNumberBE } from '@noble/ciphers/utils'; +import { managedNonce } from '@noble/ciphers/webcrypto'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha256'; + +export class MockToprfEncryptorDecryptor { + readonly #HKDF_ENCRYPTION_KEY_INFO = 'encryption-key'; + + readonly #HKDF_AUTH_KEY_INFO = 'authentication-key'; + + encrypt(key: Uint8Array, data: Uint8Array): string { + const aes = managedNonce(gcm)(key); + + const cipherText = aes.encrypt(data); + return Buffer.from(cipherText).toString('base64'); + } + + decrypt(key: Uint8Array, cipherText: Uint8Array): Uint8Array { + const aes = managedNonce(gcm)(key); + const rawData = aes.decrypt(cipherText); + + return rawData; + } + + deriveEncKey(password: string): Uint8Array { + const seed = sha256(password); + const key = hkdf( + sha256, + seed, + undefined, + this.#HKDF_ENCRYPTION_KEY_INFO, + 32, + ); + return key; + } + + deriveAuthKeyPair(password: string): KeyPair { + const seed = sha256(password); + const k = hkdf(sha256, seed, undefined, this.#HKDF_AUTH_KEY_INFO, 32); // Derive 256 bit key. + + // Converting from bytes to scalar like this is OK because statistical + // distance between U(2^256) % secp256k1.n and U(secp256k1.n) is negligible. + const sk = bytesToNumberBE(k) % secp256k1.CURVE.n; + const pk = secp256k1.getPublicKey(sk, false); + return { sk, pk }; + } +} diff --git a/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts new file mode 100644 index 0000000000..e3568755c4 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/mocks/vaultEncryptor.ts @@ -0,0 +1,220 @@ +import type { + EncryptionKey, + EncryptionResult, + KeyDerivationOptions, +} from '@metamask/browser-passworder'; +import type { Json } from '@metamask/utils'; +import { webcrypto } from 'node:crypto'; + +import type { VaultEncryptor } from '../../src/types'; + +export default class MockVaultEncryptor + implements VaultEncryptor +{ + DEFAULT_DERIVATION_PARAMS: KeyDerivationOptions = { + algorithm: 'PBKDF2', + params: { + iterations: 10_000, + }, + }; + + DEFAULT_SALT = 'RANDOM_SALT'; + + async encryptWithDetail( + password: string, + dataObj: Json, + salt: string = this.DEFAULT_SALT, + keyDerivationOptions: KeyDerivationOptions = this.DEFAULT_DERIVATION_PARAMS, + ) { + const key = await this.keyFromPassword( + password, + salt, + true, + keyDerivationOptions, + ); + const exportedKeyString = await this.exportKey(key); + const vault = await this.encrypt(password, dataObj, key, salt); + + return { + vault, + exportedKeyString, + }; + } + + async decryptWithDetail(password: string, text: string) { + const payload = JSON.parse(text); + const { salt, keyMetadata } = payload; + const key = await this.keyFromPassword(password, salt, true, keyMetadata); + const exportedKeyString = await this.exportKey(key); + const vault = await this.decrypt(password, text, key); + + return { + exportedKeyString, + vault, + salt, + }; + } + + async importKey(keyString: string): Promise { + try { + const parsedKey = JSON.parse(keyString); + const key = await webcrypto.subtle.importKey( + 'jwk', + parsedKey, + 'AES-GCM', + false, + ['encrypt', 'decrypt'], + ); + return { + key, + derivationOptions: this.DEFAULT_DERIVATION_PARAMS, + }; + } catch (error) { + console.error(error); + throw new Error('Failed to import key'); + } + } + + // eslint-disable-next-line n/no-unsupported-features/node-builtins + async exportKey(cryptoKey: CryptoKey | EncryptionKey): Promise { + const key = 'key' in cryptoKey ? cryptoKey.key : cryptoKey; + const exportedKey = await webcrypto.subtle.exportKey('jwk', key); + + return JSON.stringify(exportedKey); + } + + async keyFromPassword( + password: string, + salt: string = this.DEFAULT_SALT, + exportable: boolean = true, + opts: KeyDerivationOptions = this.DEFAULT_DERIVATION_PARAMS, + ) { + const passBuffer = Buffer.from(password); + const saltBuffer = Buffer.from(salt, 'base64'); + + const key = await webcrypto.subtle.importKey( + 'raw', + passBuffer, + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'], + ); + + const encKey = await webcrypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltBuffer, + iterations: opts.params.iterations, + hash: 'SHA-256', + }, + key, + { name: 'AES-GCM', length: 256 }, + exportable, + ['encrypt', 'decrypt'], + ); + + return encKey; + } + + async encryptWithKey( + encryptionKey: EncryptionKey | webcrypto.CryptoKey, + data: unknown, + ) { + const dataString = JSON.stringify(data); + const dataBuffer = Buffer.from(dataString); + const vector = webcrypto.getRandomValues(new Uint8Array(16)); + + const key = 'key' in encryptionKey ? encryptionKey.key : encryptionKey; + const encBuff = await webcrypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: vector, + }, + key, + dataBuffer, + ); + + const buffer = new Uint8Array(encBuff); + const vectorStr = Buffer.from(vector).toString('base64'); + const vaultStr = Buffer.from(buffer).toString('base64'); + const encryptionResult: EncryptionResult = { + data: vaultStr, + iv: vectorStr, + }; + + if ('derivationOptions' in encryptionKey) { + encryptionResult.keyMetadata = encryptionKey.derivationOptions; + } + + return encryptionResult; + } + + async decryptWithKey( + encryptionKey: EncryptionKey | webcrypto.CryptoKey, + payload: string, + ) { + let encData: EncryptionResult; + if (typeof payload === 'string') { + encData = JSON.parse(payload); + } else { + encData = payload; + } + + const encryptedData = Buffer.from(encData.data, 'base64'); + const vector = Buffer.from(encData.iv, 'base64'); + const key = 'key' in encryptionKey ? encryptionKey.key : encryptionKey; + + const result = await webcrypto.subtle.decrypt( + { name: 'AES-GCM', iv: vector }, + key, + encryptedData, + ); + + const decryptedData = new Uint8Array(result); + const decryptedStr = Buffer.from(decryptedData).toString(); + const decryptedObj = JSON.parse(decryptedStr); + + return decryptedObj; + } + + async encrypt( + password: string, + dataObj: Json, + // eslint-disable-next-line n/no-unsupported-features/node-builtins + key?: EncryptionKey | CryptoKey, + salt: string = this.DEFAULT_SALT, + keyDerivationOptions = this.DEFAULT_DERIVATION_PARAMS, + ): Promise { + const cryptoKey = + key || + (await this.keyFromPassword(password, salt, false, keyDerivationOptions)); + const payload = await this.encryptWithKey(cryptoKey, dataObj); + payload.salt = salt; + return JSON.stringify(payload); + } + + async decrypt( + password: string, + text: string, + // eslint-disable-next-line n/no-unsupported-features/node-builtins + encryptionKey?: EncryptionKey | CryptoKey, + ): Promise { + const payload = JSON.parse(text); + const { salt, keyMetadata } = payload; + + let cryptoKey = encryptionKey; + if (!cryptoKey) { + cryptoKey = await this.keyFromPassword( + password, + salt, + false, + keyMetadata, + ); + } + + const key = 'key' in cryptoKey ? cryptoKey.key : cryptoKey; + + const result = await this.decryptWithKey(key, payload); + return result; + } +} diff --git a/packages/seedless-onboarding-controller/tsconfig.build.json b/packages/seedless-onboarding-controller/tsconfig.build.json new file mode 100644 index 0000000000..363d67c8df --- /dev/null +++ b/packages/seedless-onboarding-controller/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../message-manager/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/seedless-onboarding-controller/tsconfig.json b/packages/seedless-onboarding-controller/tsconfig.json new file mode 100644 index 0000000000..9167ff78a2 --- /dev/null +++ b/packages/seedless-onboarding-controller/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../message-manager" + }, + { + "path": "../keyring-controller" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/seedless-onboarding-controller/typedoc.json b/packages/seedless-onboarding-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/seedless-onboarding-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index f79b635cd0..3416e1256c 100644 --- a/teams.json +++ b/teams.json @@ -46,5 +46,6 @@ "metamask/multichain-transactions-controller": "team-sol,team-accounts", "metamask/token-search-discovery-controller": "team-portfolio", "metamask/earn-controller": "team-earn", - "metamask/error-reporting-service": "team-wallet-framework" + "metamask/error-reporting-service": "team-wallet-framework", + "metamask/seedless-onboarding-controller": "team-web3auth" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 3b4db6fe5c..37362ebeb5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -46,6 +46,7 @@ { "path": "./packages/rate-limit-controller/tsconfig.build.json" }, { "path": "./packages/remote-feature-flag-controller/tsconfig.build.json" }, { "path": "./packages/sample-controllers/tsconfig.build.json" }, + { "path": "./packages/seedless-onboarding-controller/tsconfig.build.json" }, { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/signature-controller/tsconfig.build.json" }, { diff --git a/tsconfig.json b/tsconfig.json index ca474bd2a7..7060f53819 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,7 @@ { "path": "./packages/rate-limit-controller" }, { "path": "./packages/remote-feature-flag-controller" }, { "path": "./packages/sample-controllers" }, + { "path": "./packages/seedless-onboarding-controller" }, { "path": "./packages/selected-network-controller" }, { "path": "./packages/signature-controller" }, { "path": "./packages/token-search-discovery-controller" }, diff --git a/yarn.lock b/yarn.lock index a9ff4f9909..a003955950 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2630,6 +2630,23 @@ __metadata: languageName: unknown linkType: soft +"@metamask/auth-network-utils@npm:^0.3.0": + version: 0.3.0 + resolution: "@metamask/auth-network-utils@npm:0.3.0" + dependencies: + "@noble/curves": "npm:^1.8.1" + "@noble/hashes": "npm:^1.7.1" + "@toruslabs/bs58": "npm:^1.0.0" + "@toruslabs/constants": "npm:^15.0.0" + "@toruslabs/eccrypto": "npm:^6.1.0" + bn.js: "npm:^5.2.1" + elliptic: "npm:^6.6.1" + json-stable-stringify: "npm:^1.2.1" + loglevel: "npm:^1.9.2" + checksum: 10/6239dd540cd289ef3a3d8ba2456c3968a1c25bf8b6c73459221da52cf34ce6e61922ad910434de5ccd7ab99443165f2f8bc53aff337f660124c4c65c0d6d05ff + languageName: node + linkType: hard + "@metamask/auto-changelog@npm:^3.4.4": version: 3.4.4 resolution: "@metamask/auto-changelog@npm:3.4.4" @@ -4258,6 +4275,38 @@ __metadata: languageName: node linkType: hard +"@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: + "@lavamoat/allow-scripts": "npm:^3.0.4" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auth-network-utils": "npm:^0.3.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.1" + "@metamask/browser-passworder": "npm:^4.3.0" + "@metamask/keyring-controller": "npm:^22.0.0" + "@metamask/toprf-secure-backup": "npm:^0.3.0" + "@metamask/utils": "npm:^11.2.0" + "@noble/ciphers": "npm:^0.5.2" + "@noble/curves": "npm:^1.2.0" + "@noble/hashes": "npm:^1.4.0" + "@types/elliptic": "npm:^6" + "@types/jest": "npm:^27.4.1" + async-mutex: "npm:^0.5.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + jest-environment-node: "npm:^27.5.1" + nock: "npm:^13.3.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/keyring-controller": ^22.0.0 + languageName: unknown + linkType: soft + "@metamask/selected-network-controller@npm:^22.1.0, @metamask/selected-network-controller@workspace:packages/selected-network-controller": version: 0.0.0-use.local resolution: "@metamask/selected-network-controller@workspace:packages/selected-network-controller" @@ -4480,6 +4529,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/toprf-secure-backup@npm:^0.3.0": + version: 0.3.0 + resolution: "@metamask/toprf-secure-backup@npm:0.3.0" + dependencies: + "@metamask/auth-network-utils": "npm:^0.3.0" + "@noble/ciphers": "npm:^1.2.1" + "@noble/curves": "npm:^1.8.1" + "@noble/hashes": "npm:^1.7.1" + "@sentry/core": "npm:^9.10.0" + "@toruslabs/constants": "npm:^15.0.0" + "@toruslabs/eccrypto": "npm:^6.1.0" + "@toruslabs/fetch-node-details": "npm:^15.0.0" + "@toruslabs/http-helpers": "npm:^8.1.1" + bn.js: "npm:^5.2.1" + checksum: 10/9d3b8816c25f40b9d5ed726b32cae03d15442802ecbe816eff21a1bf9e3d681b749ae709d79b937f0f6ce122170ac6a6142f435d6bca5bd9ce8cf03cd0f40eab + languageName: node + linkType: hard + "@metamask/transaction-controller@npm:^56.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" @@ -4649,6 +4716,13 @@ __metadata: languageName: node linkType: hard +"@noble/ciphers@npm:^1.2.1": + version: 1.2.1 + resolution: "@noble/ciphers@npm:1.2.1" + checksum: 10/7fa0d32529d8da6323b08afec97218f6d6bc0d1e135243bf10f7587a2819495c3f3f4a5af1f41045501bb1ade94238c76960366a5d6441970e49ba9cacb88740 + languageName: node + linkType: hard + "@noble/curves@npm:1.2.0": version: 1.2.0 resolution: "@noble/curves@npm:1.2.0" @@ -4969,10 +5043,10 @@ __metadata: languageName: node linkType: hard -"@sentry/core@npm:^9.22.0": - version: 9.22.0 - resolution: "@sentry/core@npm:9.22.0" - checksum: 10/5bf5d6b5402dca90c6ed1d6e8834c00067806f9710f1cbcd0dff3004c3f3b6ffae8e43d56592d5378fdbddb3d196eb60d8850ea50ca6eca8e31870608109df3d +"@sentry/core@npm:^9.10.0, @sentry/core@npm:^9.22.0": + version: 9.23.0 + resolution: "@sentry/core@npm:9.23.0" + checksum: 10/4ee771098d4ce4f4d2f7bd62cacb41ee2993780f4cab0eea600e73de3a3803cb953ac47ac015c23bcd7a9919e2220fd6cdc5a9a22a3663440296336d8df959b7 languageName: node linkType: hard @@ -5184,6 +5258,74 @@ __metadata: languageName: node linkType: hard +"@toruslabs/bs58@npm:^1.0.0": + version: 1.0.0 + resolution: "@toruslabs/bs58@npm:1.0.0" + peerDependencies: + "@babel/runtime": 7.x + checksum: 10/cb2db1560671ce7e87d5fb4dd2d8e2dcff38b01162fef14c9579cb6262366cbdb895f2b6a58e0e48ccb5c39ee3d0cd971c8fb29a37cf0dd6fa5c68d53314291b + languageName: node + linkType: hard + +"@toruslabs/constants@npm:^15.0.0": + version: 15.0.0 + resolution: "@toruslabs/constants@npm:15.0.0" + peerDependencies: + "@babel/runtime": 7.x + checksum: 10/82c8ecfe0ada4b0efa5972f4816befa6d732345a808ce905eec2267a35811ec80361132f56ad3244a43909a67e6c7f99c3885cb4a0a53f75408fc7ba063cbe5d + languageName: node + linkType: hard + +"@toruslabs/eccrypto@npm:^6.1.0": + version: 6.1.0 + resolution: "@toruslabs/eccrypto@npm:6.1.0" + dependencies: + elliptic: "npm:^6.6.1" + checksum: 10/8f79621ec4bd712eb12e70c0385353aa70221fe2b501ee674718c74a4147f82ede3ff38a045254b9da4bc9a5d1f891b87025904b7de8f6b8962791681ee65837 + languageName: node + linkType: hard + +"@toruslabs/fetch-node-details@npm:^15.0.0": + version: 15.0.0 + resolution: "@toruslabs/fetch-node-details@npm:15.0.0" + dependencies: + "@toruslabs/constants": "npm:^15.0.0" + "@toruslabs/fnd-base": "npm:^15.0.0" + "@toruslabs/http-helpers": "npm:^8.1.1" + loglevel: "npm:^1.9.2" + peerDependencies: + "@babel/runtime": 7.x + checksum: 10/16411ff7dc3be045784deb9c69e316bda03355c9ca3db4912677c051a1d4ebcb1e8b6116f5cfe0793dce3bd80281cc7ca2c5b02479f86621e628b4c3ca4f2d7b + languageName: node + linkType: hard + +"@toruslabs/fnd-base@npm:^15.0.0": + version: 15.0.0 + resolution: "@toruslabs/fnd-base@npm:15.0.0" + dependencies: + "@toruslabs/constants": "npm:^15.0.0" + peerDependencies: + "@babel/runtime": 7.x + checksum: 10/1f4998b8b8a1311978551dc21c761b9baa3d928254be6a3fc350400c48fccc15b9cc787cf2660594e8662fffe1385aaf3b6fa7580eea525782ab27b87c94733c + languageName: node + linkType: hard + +"@toruslabs/http-helpers@npm:^8.1.1": + version: 8.1.1 + resolution: "@toruslabs/http-helpers@npm:8.1.1" + dependencies: + deepmerge: "npm:^4.3.1" + loglevel: "npm:^1.9.2" + peerDependencies: + "@babel/runtime": ^7.x + "@sentry/core": ^9.x + peerDependenciesMeta: + "@sentry/core": + optional: true + checksum: 10/bae7821b8a30a40dff4752bb41bb93d0fa6d41e766e3cdb998462bb59338e3fa8b2a491ccc97cbe371b25d155b2bea8e69ecbd4b177cb42af6aba9b34af7aba8 + languageName: node + linkType: hard + "@ts-bridge/cli@npm:^0.6.1": version: 0.6.1 resolution: "@ts-bridge/cli@npm:0.6.1" @@ -5277,12 +5419,12 @@ __metadata: languageName: node linkType: hard -"@types/bn.js@npm:^5.1.0, @types/bn.js@npm:^5.1.5": - version: 5.1.5 - resolution: "@types/bn.js@npm:5.1.5" +"@types/bn.js@npm:*, @types/bn.js@npm:^5.1.0, @types/bn.js@npm:^5.1.5": + version: 5.1.6 + resolution: "@types/bn.js@npm:5.1.6" dependencies: "@types/node": "npm:*" - checksum: 10/9719330c86aeae0a6a447c974cf0f853ba3660ede20de61f435b03d699e30e6d8b35bf71a8dc9fdc8317784438e83177644ba068ed653d0ae0106e1ecbfe289e + checksum: 10/db565b5a2af59b09459d74441153bf23a0e80f1fb2d070330786054e7ce1a7285dc40afcd8f289426c61a83166bdd70814f70e2d439744686aac5d3ea75daf13 languageName: node linkType: hard @@ -5321,6 +5463,15 @@ __metadata: languageName: node linkType: hard +"@types/elliptic@npm:^6": + version: 6.4.18 + resolution: "@types/elliptic@npm:6.4.18" + dependencies: + "@types/bn.js": "npm:*" + checksum: 10/06493e18167a581fa48d3c0f7034b9ad107993610767d5251ae2788be4bc5bdeda292d9ae18bbf366faa4a492eb669fc31060392f79bd5fdccb4efbd729ae66a + languageName: node + linkType: hard + "@types/emscripten@npm:^1.39.6": version: 1.39.13 resolution: "@types/emscripten@npm:1.39.13" @@ -7249,16 +7400,35 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.7": - version: 1.0.7 - resolution: "call-bind@npm:1.0.7" +"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" dependencies: - es-define-property: "npm:^1.0.0" es-errors: "npm:^1.3.0" function-bind: "npm:^1.1.2" + checksum: 10/00482c1f6aa7cfb30fb1dbeb13873edf81cfac7c29ed67a5957d60635a56b2a4a480f1016ddbdb3395cc37900d46037fb965043a51c5c789ffeab4fc535d18b5 + languageName: node + linkType: hard + +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": + version: 1.0.8 + resolution: "call-bind@npm:1.0.8" + dependencies: + call-bind-apply-helpers: "npm:^1.0.0" + es-define-property: "npm:^1.0.0" get-intrinsic: "npm:^1.2.4" - set-function-length: "npm:^1.2.1" - checksum: 10/cd6fe658e007af80985da5185bff7b55e12ef4c2b6f41829a26ed1eef254b1f1c12e3dfd5b2b068c6ba8b86aba62390842d81752e67dcbaec4f6f76e7113b6b7 + set-function-length: "npm:^1.2.2" + checksum: 10/659b03c79bbfccf0cde3a79e7d52570724d7290209823e1ca5088f94b52192dc1836b82a324d0144612f816abb2f1734447438e38d9dafe0b3f82c2a1b9e3bce + languageName: node + linkType: hard + +"call-bound@npm:^1.0.3": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10/ef2b96e126ec0e58a7ff694db43f4d0d44f80e641370c21549ed911fecbdbc2df3ebc9bddad918d6bbdefeafb60bb3337902006d5176d72bcd2da74820991af7 languageName: node linkType: hard @@ -7869,7 +8039,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 @@ -8094,6 +8264,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10/5add88a3d68d42d6e6130a0cac450b7c2edbe73364bbd2fc334564418569bea97c6943a8fcd70e27130bf32afc236f30982fc4905039b703f23e9e0433c29934 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -8115,7 +8296,7 @@ __metadata: languageName: node linkType: hard -"elliptic@npm:6.6.1, elliptic@npm:^6.5.7": +"elliptic@npm:6.6.1, elliptic@npm:^6.5.7, elliptic@npm:^6.6.1": version: 6.6.1 resolution: "elliptic@npm:6.6.1" dependencies: @@ -8233,12 +8414,10 @@ __metadata: languageName: node linkType: hard -"es-define-property@npm:^1.0.0": - version: 1.0.0 - resolution: "es-define-property@npm:1.0.0" - dependencies: - get-intrinsic: "npm:^1.2.4" - checksum: 10/f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10/f8dc9e660d90919f11084db0a893128f3592b781ce967e4fccfb8f3106cb83e400a4032c559184ec52ee1dbd4b01e7776c7cd0b3327b1961b1a4a7008920fe78 languageName: node linkType: hard @@ -8256,6 +8435,15 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10/54fe77de288451dae51c37bfbfe3ec86732dc3778f98f3eb3bdb4bf48063b2c0b8f9c93542656986149d08aa5be3204286e2276053d19582b76753f1a2728867 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -9304,16 +9492,21 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4": - version: 1.2.4 - resolution: "get-intrinsic@npm:1.2.4" +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.3.0": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" function-bind: "npm:^1.1.2" - has-proto: "npm:^1.0.1" - has-symbols: "npm:^1.0.3" - hasown: "npm:^2.0.0" - checksum: 10/85bbf4b234c3940edf8a41f4ecbd4e25ce78e5e6ad4e24ca2f77037d983b9ef943fd72f00f3ee97a49ec622a506b67db49c36246150377efcda1c9eb03e5f06d + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10/6e9dd920ff054147b6f44cb98104330e87caafae051b6d37b13384a45ba15e71af33c3baeac7cb630a0aaa23142718dcf25b45cfdd86c184c5dcb4e56d953a10 languageName: node linkType: hard @@ -9331,6 +9524,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10/4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stdin@npm:^9.0.0": version: 9.0.0 resolution: "get-stdin@npm:9.0.0" @@ -9516,12 +9719,10 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.0.1": - version: 1.0.1 - resolution: "gopd@npm:1.0.1" - dependencies: - get-intrinsic: "npm:^1.1.3" - checksum: 10/5fbc7ad57b368ae4cd2f41214bd947b045c1a4be2f194a7be1778d71f8af9dbf4004221f3b6f23e30820eb0d052b4f819fe6ebe8221e2a3c6f0ee4ef173421ca +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10/94e296d69f92dc1c0768fcfeecfb3855582ab59a7c75e969d5f96ce50c3d201fd86d5a2857c22565764d5bb8a816c7b1e58f133ec318cd56274da36c5e3fb1a1 languageName: node linkType: hard @@ -9581,17 +9782,10 @@ __metadata: languageName: node linkType: hard -"has-proto@npm:^1.0.1": - version: 1.0.3 - resolution: "has-proto@npm:1.0.3" - checksum: 10/0b67c2c94e3bea37db3e412e3c41f79d59259875e636ba471e94c009cdfb1fa82bf045deeffafc7dbb9c148e36cae6b467055aaa5d9fad4316e11b41e3ba551a - languageName: node - linkType: hard - -"has-symbols@npm:^1.0.3": - version: 1.0.3 - resolution: "has-symbols@npm:1.0.3" - checksum: 10/464f97a8202a7690dadd026e6d73b1ceeddd60fe6acfd06151106f050303eaa75855aaa94969df8015c11ff7c505f196114d22f7386b4a471038da5874cf5e9b +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10/959385c98696ebbca51e7534e0dc723ada325efa3475350951363cce216d27373e0259b63edb599f72eb94d6cde8577b4b2375f080b303947e560f85692834fa languageName: node linkType: hard @@ -9625,7 +9819,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.0, hasown@npm:^2.0.2": +"hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" dependencies: @@ -10205,6 +10399,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:^2.0.5": + version: 2.0.5 + resolution: "isarray@npm:2.0.5" + checksum: 10/1d8bc7911e13bb9f105b1b3e0b396c787a9e63046af0b8fe0ab1414488ab06b2b099b87a2d8a9e31d21c9a6fad773c7fc8b257c4880f2d957274479d28ca3414 + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -11112,6 +11313,19 @@ __metadata: languageName: node linkType: hard +"json-stable-stringify@npm:^1.2.1": + version: 1.2.1 + resolution: "json-stable-stringify@npm:1.2.1" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + isarray: "npm:^2.0.5" + jsonify: "npm:^0.0.1" + object-keys: "npm:^1.1.1" + checksum: 10/f4600d34605e1da81a615ddf7dc62f021a5a5c822aee38b3c878e9a703bbd72623402944dbd7848140602c9ec54bfa2df65dfe75cc40afcfd79f3f072ca5307b + languageName: node + linkType: hard + "json-stringify-safe@npm:^5.0.1": version: 5.0.1 resolution: "json-stringify-safe@npm:5.0.1" @@ -11148,6 +11362,13 @@ __metadata: languageName: node linkType: hard +"jsonify@npm:^0.0.1": + version: 0.0.1 + resolution: "jsonify@npm:0.0.1" + checksum: 10/7b86b6f4518582ff1d8b7624ed6c6277affd5246445e864615dbdef843a4057ac58587684faf129ea111eeb80e01c15f0a4d9d03820eb3f3985fa67e81b12398 + languageName: node + linkType: hard + "jsonschema@npm:^1.4.1": version: 1.4.1 resolution: "jsonschema@npm:1.4.1" @@ -11288,10 +11509,10 @@ __metadata: languageName: node linkType: hard -"loglevel@npm:^1.8.1": - version: 1.9.1 - resolution: "loglevel@npm:1.9.1" - checksum: 10/863cbbcddf850a937482c604e2d11586574a5110b746bb49c7cc04739e01f6035f6db841d25377106dd330bca7142d74995f15a97c5f3ea0af86d9472d4a99f4 +"loglevel@npm:^1.8.1, loglevel@npm:^1.9.2": + version: 1.9.2 + resolution: "loglevel@npm:1.9.2" + checksum: 10/6153d8db308323f7ee20130bc40309e7a976c30a10379d8666b596d9c6441965c3e074c8d7ee3347fe5cfc059c0375b6f3e8a10b93d5b813cc5547f5aa412a29 languageName: node linkType: hard @@ -11422,6 +11643,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd + languageName: node + linkType: hard + "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -13124,7 +13352,7 @@ __metadata: languageName: node linkType: hard -"set-function-length@npm:^1.2.1": +"set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" dependencies: