diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index e62e3fd92..702519919 100644 --- a/.github/workflows/validate-pr-title.yml +++ b/.github/workflows/validate-pr-title.yml @@ -44,10 +44,10 @@ jobs: keyring-api keyring-eth-hd keyring-eth-money + keyring-eth-money-mpc keyring-eth-ledger-bridge keyring-eth-simple keyring-eth-trezor - keyring-eth-mpc keyring-internal-api keyring-internal-snap-client keyring-sdk diff --git a/README.md b/README.md index d67e81de4..14571eef9 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/eth-hd-keyring`](packages/keyring-eth-hd) - [`@metamask/eth-ledger-bridge-keyring`](packages/keyring-eth-ledger-bridge) - [`@metamask/eth-money-keyring`](packages/keyring-eth-money) -- [`@metamask/eth-mpc-keyring`](packages/keyring-eth-mpc) +- [`@metamask/eth-money-mpc-keyring`](packages/keyring-eth-money-mpc) - [`@metamask/eth-qr-keyring`](packages/keyring-eth-qr) - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) @@ -53,7 +53,7 @@ linkStyle default opacity:0.5 eth_hd_keyring(["@metamask/eth-hd-keyring"]); eth_ledger_bridge_keyring(["@metamask/eth-ledger-bridge-keyring"]); eth_money_keyring(["@metamask/eth-money-keyring"]); - eth_mpc_keyring(["@metamask/eth-mpc-keyring"]); + eth_money_mpc_keyring(["@metamask/eth-money-mpc-keyring"]); eth_qr_keyring(["@metamask/eth-qr-keyring"]); eth_simple_keyring(["@metamask/eth-simple-keyring"]); eth_trezor_keyring(["@metamask/eth-trezor-keyring"]); @@ -81,9 +81,9 @@ linkStyle default opacity:0.5 eth_money_keyring --> keyring_api; eth_money_keyring --> keyring_sdk; eth_money_keyring --> keyring_utils; - eth_mpc_keyring --> keyring_api; - eth_mpc_keyring --> keyring_sdk; - eth_mpc_keyring --> keyring_utils; + eth_money_mpc_keyring --> keyring_api; + eth_money_mpc_keyring --> keyring_sdk; + eth_money_mpc_keyring --> keyring_utils; eth_qr_keyring --> keyring_api; eth_qr_keyring --> keyring_sdk; eth_qr_keyring --> keyring_utils; diff --git a/jest.config.packages.js b/jest.config.packages.js index e1bb76d07..84a10f907 100644 --- a/jest.config.packages.js +++ b/jest.config.packages.js @@ -86,6 +86,10 @@ module.exports = { // Here we ensure that Jest resolves `@metamask/*` imports to the uncompiled source code for packages that live in this repo. // NOTE: This must be synchronized with the `paths` option in `tsconfig.packages.json`. moduleNameMapper: { + // Listed before the `@metamask/*` wildcard so the AES-GCM subpath is not + // rewritten to a workspace source directory. + '^@metamask/cryptography/aes-gcm$': + '/../../node_modules/@metamask/cryptography/dist/aes-gcm.js', '^@metamask/(.+)/v2$': [ '/../$1/src/v2', // Some @metamask/* packages we are referencing aren't in this monorepo, @@ -97,6 +101,12 @@ module.exports = { // Some @metamask/* packages we are referencing aren't in this monorepo, // so in that case use their published versions '/../../node_modules/@metamask/$1', + // Some published @metamask/* packages are ESM-only and have no `main` + // field, so resolving their package directory fails. In that case, use + // their ESM entrypoint directly. This is only safe for modules that are + // mocked with `jest.mock`, since Jest's CommonJS runtime cannot execute + // native ESM. + '/../../node_modules/@metamask/$1/dist/index.js', ], }, diff --git a/packages/keyring-eth-money-mpc/CHANGELOG.md b/packages/keyring-eth-money-mpc/CHANGELOG.md new file mode 100644 index 000000000..250c22484 --- /dev/null +++ b/packages/keyring-eth-money-mpc/CHANGELOG.md @@ -0,0 +1,15 @@ +# 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 release of `@metamask/eth-money-mpc-keyring` ([#627](https://github.com/MetaMask/accounts/pull/627)) +- Skip MFA for whitelisted signature requests, and retry with a 2FA token when the backend returns `mfa_required` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-mpc/LICENSE b/packages/keyring-eth-money-mpc/LICENSE similarity index 100% rename from packages/keyring-eth-mpc/LICENSE rename to packages/keyring-eth-money-mpc/LICENSE diff --git a/packages/keyring-eth-money-mpc/README.md b/packages/keyring-eth-money-mpc/README.md new file mode 100644 index 000000000..d30d4f9b2 --- /dev/null +++ b/packages/keyring-eth-money-mpc/README.md @@ -0,0 +1,23 @@ +# MPC Money Keyring + +A Keyring for Ethereum money accounts that uses Multi-Party Computation (MPC) for key management and signing, built on top of the [MFA Wallet SDK](https://github.com/MetaMask/mfa-wallet-sdk). + +The default export is the V1 `MpcMoneyKeyring` implementation. A V2 adapter that wraps it is available from `@metamask/eth-money-mpc-keyring/v2`. + +## MFA + +Pass `getMfaWhitelistConfig` to let signature requests skip MFA when they match [`@metamask/money-account-mfa-whitelist`](https://github.com/MetaMask/core/tree/main/packages/money-account-mfa-whitelist). The keyring sends the original request with the hash. The backend repeats the check with its own account, hash, and pinned configuration. When that still requires MFA, `/sign` responds with `{ "code": "mfa_required" }`. The keyring then requests a 2FA profile token and calls `/sign` once more. + +Key generation, import, share rotation, share sync, and transaction signing always use a 2FA token. Omitting `getMfaWhitelistConfig` does too. + +## Installation + +`yarn add @metamask/eth-money-mpc-keyring` + +or + +`npm install @metamask/eth-money-mpc-keyring` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). diff --git a/packages/keyring-eth-mpc/jest.config.js b/packages/keyring-eth-money-mpc/jest.config.cjs similarity index 61% rename from packages/keyring-eth-mpc/jest.config.js rename to packages/keyring-eth-money-mpc/jest.config.cjs index afeeab368..a6f0470d6 100644 --- a/packages/keyring-eth-mpc/jest.config.js +++ b/packages/keyring-eth-money-mpc/jest.config.cjs @@ -14,6 +14,22 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // Source files use Node16-style `.js` specifiers. Map them back to the + // TypeScript sources so Jest can load the modules under test. + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + + // `@metamask/cryptography` is ESM-only. Transform it so Jest's CommonJS + // runtime can load the AES-GCM helpers. + transformIgnorePatterns: ['/node_modules/(?!@metamask/cryptography/)'], + transform: { + '/@metamask/cryptography/.+\\.js$': path.join( + __dirname, + 'jest.cryptography-transform.cjs', + ), + }, + // An array of regexp pattern strings used to skip coverage collection coveragePathIgnorePatterns: ['./src/tests'], diff --git a/packages/keyring-eth-money-mpc/jest.cryptography-transform.cjs b/packages/keyring-eth-money-mpc/jest.cryptography-transform.cjs new file mode 100644 index 000000000..68037ecaf --- /dev/null +++ b/packages/keyring-eth-money-mpc/jest.cryptography-transform.cjs @@ -0,0 +1,33 @@ +/** + * Jest runs this package as CommonJS, and `@metamask/cryptography` ships + * ESM only. Transpile those files when the test runtime loads them. + * + * This file is CommonJS because Jest loads transformers without the + * package's TypeScript build. + */ +const ts = require('typescript'); + +/** + * @type {import('@jest/transform').Transformer} + */ +module.exports = { + /** + * Transpile an ESM file to CommonJS. + * + * @param {string} sourceText - ESM source from `@metamask/cryptography`. + * @param {string} sourcePath - Absolute path of the source file. + * @returns {{ code: string }} CommonJS source Jest can execute. + */ + process(sourceText, sourcePath) { + const result = ts.transpileModule(sourceText, { + compilerOptions: { + esModuleInterop: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + fileName: sourcePath, + }); + + return { code: result.outputText }; + }, +}; diff --git a/packages/keyring-eth-mpc/package.json b/packages/keyring-eth-money-mpc/package.json similarity index 58% rename from packages/keyring-eth-mpc/package.json rename to packages/keyring-eth-money-mpc/package.json index d88821b82..3b317535e 100644 --- a/packages/keyring-eth-mpc/package.json +++ b/packages/keyring-eth-money-mpc/package.json @@ -1,12 +1,12 @@ { - "name": "@metamask/eth-mpc-keyring", + "name": "@metamask/eth-money-mpc-keyring", "version": "0.0.0", - "description": "A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing", + "description": "A Keyring for Ethereum money accounts that uses Multi-Party Computation (MPC) for key management and signing", "keywords": [ "ethereum", "keyring" ], - "homepage": "https://github.com/MetaMask/accounts/tree/main/packages/keyring-eth-mpc#readme", + "homepage": "https://github.com/MetaMask/accounts/tree/main/packages/keyring-eth-money-mpc#readme", "bugs": { "url": "https://github.com/MetaMask/accounts/issues" }, @@ -16,32 +16,18 @@ "url": "https://github.com/MetaMask/accounts.git" }, "files": [ - "dist/", - "v2.js" + "dist/" ], + "type": "module", "sideEffects": false, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" }, "./v2": { - "import": { - "types": "./dist/v2/index.d.mts", - "default": "./dist/v2/index.mjs" - }, - "require": { - "types": "./dist/v2/index.d.cts", - "default": "./dist/v2/index.cjs" - } + "types": "./dist/v2/index.d.mts", + "default": "./dist/v2/index.mjs" }, "./package.json": "./package.json" }, @@ -50,33 +36,40 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references --format module", "build:clean": "yarn build --clean", "build:docs": "typedoc", - "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-mpc-keyring", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-mpc-keyring", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-money-mpc-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-money-mpc-keyring", "publish:preview": "yarn npm publish --tag preview", "test": "yarn test:source && yarn test:types", - "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", - "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "test:clean": "jest --clearCache", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean --format module", "since-latest-release": "../../scripts/since-latest-release.sh", - "test:source": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:source": "jest --reporters=jest-silent-reporter", "test:types": "../../scripts/tsd-test.sh ./src", - "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", - "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/util": "^9.1.0", + "@metamask/cryptography": "^1.1.2", + "@metamask/eth-sig-util": "^9.0.0", "@metamask/keyring-api": "^24.2.0", "@metamask/keyring-sdk": "^4.0.0", "@metamask/keyring-utils": "^5.0.0", + "@metamask/mfa-wallet-cl24": "^0.1.0", + "@metamask/mfa-wallet-dkls23": "^0.1.0", + "@metamask/mfa-wallet-network": "^0.1.0", + "@metamask/money-account-mfa-whitelist": "npm:@metamask-previews/money-account-mfa-whitelist@0.0.0-preview-6ccf7a055", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0" }, "devDependencies": { - "@ethereumjs/tx": "^5.4.0", "@lavamoat/allow-scripts": "^3.2.1", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", - "@metamask/eth-sig-util": "^9.0.0", + "@metamask/mfa-wallet-interface": "^0.1.0", "@ts-bridge/cli": "^0.6.3", "@types/jest": "^29.5.12", "deepmerge": "^4.2.2", @@ -88,7 +81,8 @@ }, "lavamoat": { "allowScripts": { - "@lavamoat/preinstall-always-fail": false + "@lavamoat/preinstall-always-fail": false, + "@metamask/mfa-wallet-network>centrifuge>protobufjs": false } } } diff --git a/packages/keyring-eth-money-mpc/src/cloud.test.ts b/packages/keyring-eth-money-mpc/src/cloud.test.ts new file mode 100644 index 000000000..f8181f042 --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/cloud.test.ts @@ -0,0 +1,543 @@ +import { bytesToBase64 } from '@metamask/utils'; + +import { + checkKeyShare, + createKey, + getNetId, + loadKeyShareBackup, + registerClient, + rotateKeyShares, + setActiveEpoch, + sign, + storeKeyShareBackup, +} from './cloud.js'; + +describe('cloud helpers', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + const okJson = (body: unknown): void => { + fetchSpy.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(JSON.stringify(body)), + } as never); + }; + + const okEmpty = (): void => { + fetchSpy.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(''), + } as never); + }; + + it('gets the server network id', async () => { + okJson({ netId: 'server-1' }); + + expect( + await getNetId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toBe('server-1'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/net-id', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token-1', + }, + body: '{}', + }), + ); + }); + + it('rejects cloud requests that time out', async () => { + jest.useFakeTimers(); + try { + fetchSpy.mockImplementation(async (_url: string, init?: RequestInit) => { + const { signal } = init ?? {}; + if (!signal) { + throw new Error('missing abort signal'); + } + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject( + new DOMException('The operation was aborted.', 'AbortError'), + ); + }); + }); + }); + + const pending = getNetId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }); + await Promise.all([ + expect(pending).rejects.toThrow( + 'Failed to get server network id: timed out after 30000ms', + ), + jest.advanceTimersByTimeAsync(30_000), + ]); + } finally { + jest.useRealTimers(); + } + }); + + it('rethrows cloud network errors that are not timeouts', async () => { + fetchSpy.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect( + loadKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('rejects a server network id response with the wrong shape', async () => { + okJson({ netId: '' }); + + await expect( + getNetId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow( + /^Failed to get server network id: bad response format$/u, + ); + }); + + it('throws when getting the server network id fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as never); + + await expect( + getNetId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to get server network id: Unauthorized'); + }); + + it('starts cloud key generation', async () => { + okEmpty(); + + await createKey({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/create-key', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when cloud key generation initialization fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as never); + + await expect( + createKey({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + }), + ).rejects.toThrow( + 'Failed to initialize cloud keygen session: Unauthorized', + ); + }); + + it('registers a client network id', async () => { + okEmpty(); + + await registerClient({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/register-client', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when client registration fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + } as never); + + await expect( + registerClient({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + }), + ).rejects.toThrow('Failed to register client: Forbidden'); + }); + + it('starts a cloud sign session with epoch and base64-encoded message', async () => { + okEmpty(); + const data = new Uint8Array([104, 105]); // "hi" + + expect( + await sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data, + shareEpoch: 1, + }), + ).toBe('ok'); + + const init = fetchSpy.mock.calls[0]?.[1] as { + body: string; + headers: Record; + }; + expect(init.headers.Authorization).toBe('Bearer token-1'); + const body = JSON.parse(init.body) as { + data: string; + shareEpoch: number; + token?: string; + }; + expect(body.data).toBe('aGk='); + expect(body.shareEpoch).toBe(1); + expect(body.token).toBeUndefined(); + expect(body).not.toHaveProperty('request'); + }); + + it('sends the signature request with the hash', async () => { + okEmpty(); + const request = { + method: 'signPersonalMessage' as const, + address: '0x1111111111111111111111111111111111111111' as const, + message: '0x68656c6c6f' as const, + }; + + await sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data: new Uint8Array([1]), + shareEpoch: 1, + request, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { request: unknown }; + expect(body.request).toStrictEqual(request); + }); + + it('returns mfa_required when the backend requires MFA', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + text: jest + .fn() + .mockResolvedValue( + JSON.stringify({ code: 'mfa_required', reason: 'stale config' }), + ), + } as never); + + expect( + await sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data: new Uint8Array([1]), + shareEpoch: 1, + }), + ).toBe('mfa_required'); + }); + + it('returns mfa_required from a successful response body', async () => { + okJson({ code: 'mfa_required' }); + + expect( + await sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data: new Uint8Array([1]), + shareEpoch: 1, + }), + ).toBe('mfa_required'); + }); + + it('throws when a sign error body is not mfa_required', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + text: jest.fn().mockResolvedValue(JSON.stringify({ code: 'forbidden' })), + } as never); + + await expect( + sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data: new Uint8Array([1]), + shareEpoch: 1, + }), + ).rejects.toThrow('Failed to initialize cloud sign session: Forbidden'); + }); + + it('throws when cloud sign initialization fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Server Error', + } as never); + + await expect( + sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data: new Uint8Array([1]), + shareEpoch: 1, + }), + ).rejects.toThrow('Failed to initialize cloud sign session: Server Error'); + }); + + it('starts a cloud key rotation session', async () => { + okEmpty(); + + await rotateKeyShares({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + expectedActiveEpoch: 1, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { expectedActiveEpoch: number }; + expect(body.expectedActiveEpoch).toBe(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/rotate-key-shares', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when cloud key rotation initialization fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + } as never); + + await expect( + rotateKeyShares({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + expectedActiveEpoch: 1, + }), + ).rejects.toThrow( + 'Failed to initialize cloud key rotation session: Forbidden', + ); + }); + + it('stores an encrypted key share backup for an epoch', async () => { + okEmpty(); + const encryptedKeyShare = new Uint8Array([1, 2, 3]); + + await storeKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 2, + attemptNonce: '0xnonce', + encryptedKeyShare, + }); + + const init = fetchSpy.mock.calls[0]?.[1] as { + body: string; + headers: Record; + }; + expect(init.headers.Authorization).toBe('Bearer token-1'); + const body = JSON.parse(init.body) as { + encryptedKeyShare: string; + epoch: number; + attemptNonce: string; + token?: string; + }; + expect(body.epoch).toBe(2); + expect(body.attemptNonce).toBe('0xnonce'); + expect(body.encryptedKeyShare).toBe(bytesToBase64(encryptedKeyShare)); + expect(body.token).toBeUndefined(); + }); + + it('throws when storing a key share backup fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Bad Request', + } as never); + + await expect( + storeKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 1, + attemptNonce: '0xnonce', + encryptedKeyShare: new Uint8Array([1]), + }), + ).rejects.toThrow('Failed to store key share backup: Bad Request'); + }); + + it('checks key share epochs', async () => { + okJson({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + + expect( + await checkKeyShare({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toStrictEqual({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + }); + + it('rejects key share epoch metadata with the wrong shape', async () => { + okJson({ activeEpoch: '1' }); + + await expect( + checkKeyShare({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow(/^Failed to check key share: bad response format$/u); + }); + + it('throws when checking key share fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as never); + + await expect( + checkKeyShare({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to check key share: Not Found'); + }); + + it('sets the active epoch', async () => { + okEmpty(); + + await setActiveEpoch({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 2, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { epoch: number }; + expect(body.epoch).toBe(2); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/set-active-epoch', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when setting the active epoch fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Conflict', + } as never); + + await expect( + setActiveEpoch({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 2, + }), + ).rejects.toThrow('Failed to set active epoch: Conflict'); + }); + + it('loads an encrypted key share backup', async () => { + const encryptedKeyShare = new Uint8Array([9, 8, 7]); + okJson({ + encryptedKeyShare: bytesToBase64(encryptedKeyShare), + epoch: 3, + }); + + expect( + await loadKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toStrictEqual({ + encryptedKeyShare, + epoch: 3, + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/load-key-share-backup', + expect.objectContaining({ + method: 'GET', + headers: { Authorization: 'Bearer token-1' }, + }), + ); + }); + + it('rejects a key share backup response without including the body', async () => { + okJson({ encryptedKeyShare: 'secret-ciphertext', epoch: 'nope' }); + + await expect( + loadKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow( + /^Failed to load key share backup: bad response format$/u, + ); + }); + + it('throws when loading a key share backup fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as never); + + await expect( + loadKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to load key share backup: Unauthorized'); + }); +}); diff --git a/packages/keyring-eth-money-mpc/src/cloud.ts b/packages/keyring-eth-money-mpc/src/cloud.ts new file mode 100644 index 000000000..7eb743f5f --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/cloud.ts @@ -0,0 +1,469 @@ +import type { PartyId } from '@metamask/mfa-wallet-interface'; +import type { MoneyAccountSignatureRequest } from '@metamask/money-account-mfa-whitelist'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { + exactOptional, + integer, + is, + literal, + min, + nonempty, + sensitive, + string, + type, +} from '@metamask/superstruct'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +import { MFA_REQUIRED_CODE } from './types.js'; + +export type LoadKeyShareBackupResult = { + encryptedKeyShare: Uint8Array; + epoch: number; +}; + +const shareEpochStruct = min(integer(), 1); + +const NetIdResponseStruct = type({ + netId: nonempty(string()), +}); + +const CheckKeyShareResponseStruct = type({ + latestShareEpoch: exactOptional(shareEpochStruct), + latestBackupEpoch: exactOptional(shareEpochStruct), + activeEpoch: exactOptional(shareEpochStruct), +}); + +export type CheckKeyShareResult = Infer; + +const LoadKeyShareBackupResponseStruct = type({ + encryptedKeyShare: sensitive(nonempty(string())), + epoch: shareEpochStruct, +}); + +/** + * Accept `value` when it matches `struct`. + * + * Failures use a fixed message so the response body is not included. + * + * @param value - Parsed JSON body. + * @param struct - Expected response shape. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The validated value. + */ +function readStruct( + value: unknown, + struct: Struct, + errorPrefix: string, +): Schema { + if (!is(value, struct)) { + throw new Error(`${errorPrefix}: bad response format`); + } + return value; +} + +/** + * Cloud HTTP calls must finish within this window. Keyring methods run under + * the controller mutex, so a backend that never responds would block every + * keyring operation until the client restarts. + */ +const CLOUD_REQUEST_TIMEOUT_MS = 30_000; + +/** + * Fetch a URL, aborting if the backend does not respond in time. + * + * @param url - The request URL. + * @param init - Fetch options. `signal` is set by this function. + * @param errorPrefix - Prefix for the thrown timeout error. + * @returns The fetch response. + * @throws If the request exceeds `CLOUD_REQUEST_TIMEOUT_MS`. + */ +async function fetchWithTimeout( + url: string, + init: RequestInit, + errorPrefix: string, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, CLOUD_REQUEST_TIMEOUT_MS); + + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error( + `${errorPrefix}: timed out after ${CLOUD_REQUEST_TIMEOUT_MS}ms`, + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Parse a fetch response as JSON, throwing on non-OK status. + * + * @param response - The fetch response. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The parsed JSON body, or `undefined` when the response is empty. + */ +async function parseJsonResponse( + response: Response, + errorPrefix: string, +): Promise { + if (!response.ok) { + throw new Error(`${errorPrefix}: ${response.statusText}`); + } + + const text = await response.text(); + if (text.length === 0) { + return undefined as Result; + } + return JSON.parse(text) as Result; +} + +/** + * GET JSON from the MPC backend, throwing on non-OK responses. + * + * @param url - The request URL. + * @param token - Profile token sent as a Bearer header. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The parsed JSON body, or `undefined` when the response is empty. + */ +async function getJson( + url: string, + token: string, + errorPrefix: string, +): Promise { + const response = await fetchWithTimeout( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }, + errorPrefix, + ); + return parseJsonResponse(response, errorPrefix); +} + +/** + * POST JSON to the MPC backend, throwing on non-OK responses. + * + * @param url - The request URL. + * @param token - Profile token sent as a Bearer header. + * @param body - The JSON request body. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The parsed JSON body, or `undefined` when the response is empty. + */ +async function postJson( + url: string, + token: string, + body: Record, + errorPrefix: string, +): Promise { + const response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }, + errorPrefix, + ); + return parseJsonResponse(response, errorPrefix); +} + +/** + * Return the server network id for this profile, creating one if needed. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token. + * @returns The server network id. + */ +export async function getNetId(opts: { + baseURL: string; + token: string; +}): Promise { + const data = readStruct( + await postJson( + `${opts.baseURL}/net-id`, + opts.token, + {}, + 'Failed to get server network id', + ), + NetIdResponseStruct, + 'Failed to get server network id', + ); + return data.netId; +} + +/** + * Start DKG on the backend for a new key (appends epoch 1; does not activate). + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.clientNetId - Client network id. + * @param opts.nonce - Client session nonce. + */ +export async function createKey(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; + nonce: string; +}): Promise { + await postJson( + `${opts.baseURL}/create-key`, + opts.token, + { + clientNetId: opts.clientNetId, + nonce: opts.nonce, + }, + 'Failed to initialize cloud keygen session', + ); +} + +/** + * Authorize this client network id to use the existing key share. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.clientNetId - Client network id. + */ +export async function registerClient(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; +}): Promise { + await postJson( + `${opts.baseURL}/register-client`, + opts.token, + { + clientNetId: opts.clientNetId, + }, + 'Failed to register client', + ); +} + +const signErrorPrefix = 'Failed to initialize cloud sign session'; + +const MfaRequiredBodyStruct = type({ + code: literal(MFA_REQUIRED_CODE), +}); + +export type SignResult = 'ok' | typeof MFA_REQUIRED_CODE; + +/** + * Read a JSON body, or `undefined` when the body is empty or not JSON. + * + * @param response - The fetch response. + * @returns The parsed body, or `undefined`. + */ +async function readJsonBody(response: Response): Promise { + try { + const text = await response.text(); + if (text.length === 0) { + return undefined; + } + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +/** + * Start a backend signing session for the active share epoch. + * + * A response body of `{ "code": "mfa_required" }` means the backend still + * requires MFA. Every other non-OK response is an error. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token. 2FA is included when the client requires MFA. + * @param opts.data - Message hash to sign. + * @param opts.clientNetId - Client network id. + * @param opts.nonce - Client session nonce. + * @param opts.shareEpoch - Local share epoch (must equal backend activeEpoch). + * @param opts.request - Signature request the hash was computed from. + * @returns `ok` when the session starts, or `mfa_required` when the backend requires MFA. + */ +export async function sign(opts: { + baseURL: string; + token: string; + data: Uint8Array; + clientNetId: PartyId; + nonce: string; + shareEpoch: number; + request?: MoneyAccountSignatureRequest; +}): Promise { + const response = await fetchWithTimeout( + `${opts.baseURL}/sign`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${opts.token}`, + }, + body: JSON.stringify({ + data: bytesToBase64(opts.data), + clientNetId: opts.clientNetId, + nonce: opts.nonce, + shareEpoch: opts.shareEpoch, + ...(opts.request === undefined ? {} : { request: opts.request }), + }), + }, + signErrorPrefix, + ); + + const body = await readJsonBody(response); + if (is(body, MfaRequiredBodyStruct)) { + return MFA_REQUIRED_CODE; + } + if (!response.ok) { + throw new Error(`${signErrorPrefix}: ${response.statusText}`); + } + return 'ok'; +} + +/** + * Start a backend share-rotation session (appends next epoch; does not activate). + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.clientNetId - Client network id. + * @param opts.nonce - Client session nonce. + * @param opts.expectedActiveEpoch - Current active epoch on client and server. + */ +export async function rotateKeyShares(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; + nonce: string; + expectedActiveEpoch: number; +}): Promise { + await postJson( + `${opts.baseURL}/rotate-key-shares`, + opts.token, + { + clientNetId: opts.clientNetId, + nonce: opts.nonce, + expectedActiveEpoch: opts.expectedActiveEpoch, + }, + 'Failed to initialize cloud key rotation session', + ); +} + +/** + * Store an encrypted key-share backup for a share epoch. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.epoch - Share epoch this backup belongs to. + * @param opts.attemptNonce - DKG client nonce for that epoch (rejects stale backups). + * @param opts.encryptedKeyShare - Encrypted key share ciphertext. + */ +export async function storeKeyShareBackup(opts: { + baseURL: string; + token: string; + epoch: number; + attemptNonce: string; + encryptedKeyShare: Uint8Array; +}): Promise { + await postJson( + `${opts.baseURL}/store-key-share-backup`, + opts.token, + { + epoch: opts.epoch, + attemptNonce: opts.attemptNonce, + encryptedKeyShare: bytesToBase64(opts.encryptedKeyShare), + }, + 'Failed to store key share backup', + ); +} + +/** + * Return the latest share/backup epochs and the active epoch from the backend. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token (2FA not required). + * @returns Epoch metadata from the backend. + */ +export async function checkKeyShare(opts: { + baseURL: string; + token: string; +}): Promise { + return readStruct( + await postJson( + `${opts.baseURL}/check-key-share`, + opts.token, + {}, + 'Failed to check key share', + ), + CheckKeyShareResponseStruct, + 'Failed to check key share', + ); +} + +/** + * Activate a share epoch after its share and backup are present. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.epoch - Epoch to activate. + */ +export async function setActiveEpoch(opts: { + baseURL: string; + token: string; + epoch: number; +}): Promise { + await postJson( + `${opts.baseURL}/set-active-epoch`, + opts.token, + { + epoch: opts.epoch, + }, + 'Failed to set active epoch', + ); +} + +/** + * Load the encrypted key-share backup for the active epoch. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @returns The ciphertext and share epoch. + */ +export async function loadKeyShareBackup(opts: { + baseURL: string; + token: string; +}): Promise { + const data = readStruct( + await getJson( + `${opts.baseURL}/load-key-share-backup`, + opts.token, + 'Failed to load key share backup', + ), + LoadKeyShareBackupResponseStruct, + 'Failed to load key share backup', + ); + return { + encryptedKeyShare: base64ToBytes(data.encryptedKeyShare), + epoch: data.epoch, + }; +} diff --git a/packages/keyring-eth-money-mpc/src/index.ts b/packages/keyring-eth-money-mpc/src/index.ts new file mode 100644 index 000000000..3e5f37766 --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/index.ts @@ -0,0 +1,11 @@ +export { MpcMoneyKeyring } from './mpc-money-keyring.js'; + +export { MFA_REQUIRED_CODE, MpcMoneyKeyringSetupMode } from './types.js'; +export type { MfaWhitelistConfig } from '@metamask/money-account-mfa-whitelist'; +export type { + MpcMoneyKeyringOpts, + MpcMoneyKeyringSerializer, + MpcMoneyKeyringSetupParams, + MpcMoneyKeyringState, + ProfileTokenOpts, +} from './types.js'; diff --git a/packages/keyring-eth-money-mpc/src/mpc-money-keyring.test.ts b/packages/keyring-eth-money-mpc/src/mpc-money-keyring.test.ts new file mode 100644 index 000000000..a54faca4b --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/mpc-money-keyring.test.ts @@ -0,0 +1,1209 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { hashPersonalMessage } from '@ethereumjs/util'; +import type { MfaWhitelistConfig } from '@metamask/money-account-mfa-whitelist'; +import { bytesToHex } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; + +import { MpcMoneyKeyring } from './mpc-money-keyring.js'; +import { MFA_REQUIRED_CODE } from './types.js'; + +const mockCreateKey = jest.fn(); +const mockRotateKeyShares = jest.fn(); +const mockDklsSetup = jest.fn(); +const mockDklsSign = jest.fn(); +const mockCreateIdentity = jest.fn(); +const mockCreateSession = jest.fn(); +const mockCreateScopedSessionId = jest.fn(); +const mockThresholdKeyToJson = jest.fn(); +const mockThresholdKeyFromJson = jest.fn(); +const mockNetworkIdentityToJson = jest.fn(); +const mockNetworkIdentityFromJson = jest.fn(); +const mockGetNetId = jest.fn(); +const mockStartCreateKey = jest.fn(); +const mockRegisterClient = jest.fn(); +const mockStartSign = jest.fn(); +const mockStartRotateKeyShares = jest.fn(); +const mockStoreKeyShareBackup = jest.fn(); +const mockCheckKeyShare = jest.fn(); +const mockSetActiveEpoch = jest.fn(); +const mockLoadKeyShareBackup = jest.fn(); +const mockGetMfaRequirement = jest.fn(); +let lastNetworkManagerOptions: Record | undefined; + +const mockDerivedAddress = '0x1111111111111111111111111111111111111111' as Hex; +const mockSessionNonce = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const mockShareEpoch = 1; +const mockTypedDataHash = new Uint8Array([9, 8, 7, 6]); +const mockEthSignature = new Uint8Array(65); +mockEthSignature[64] = 27; +const mockBackupKey = new Uint8Array(32).fill(7); + +jest.mock('@metamask/mfa-wallet-cl24', () => { + class MockCL24DKM { + createKey(...args: unknown[]) { + return mockCreateKey(...args); + } + + rotateKeyShares(...args: unknown[]) { + return mockRotateKeyShares(...args); + } + } + + class MockCL24ThresholdKeySerializer { + toJson(value: unknown) { + return mockThresholdKeyToJson(value); + } + + fromJson(value: unknown) { + return mockThresholdKeyFromJson(value); + } + } + + return { + CL24DKM: MockCL24DKM, + CL24ThresholdKeySerializer: MockCL24ThresholdKeySerializer, + }; +}); + +jest.mock('@metamask/mfa-wallet-network', () => { + class MockCentrifugeNetworkManager { + constructor(opts: Record) { + lastNetworkManagerOptions = opts; + } + + createIdentity(...args: unknown[]) { + return mockCreateIdentity(...args); + } + + createSession(...args: unknown[]) { + return mockCreateSession(...args); + } + } + + class MockCentrifugeIdentitySerializer { + toJson(value: unknown) { + return mockNetworkIdentityToJson(value); + } + + fromJson(value: unknown) { + return mockNetworkIdentityFromJson(value); + } + } + + return { + CentrifugeNetworkManager: MockCentrifugeNetworkManager, + CentrifugeIdentitySerializer: MockCentrifugeIdentitySerializer, + createScopedSessionId: (...args: unknown[]) => + mockCreateScopedSessionId(...args), + }; +}); + +jest.mock('@metamask/mfa-wallet-dkls23', () => { + class MockDkls23TssLib { + setup(...args: unknown[]) { + return mockDklsSetup(...args); + } + + sign(...args: unknown[]) { + return mockDklsSign(...args); + } + } + + return { Dkls23TssLib: MockDkls23TssLib }; +}); + +jest.mock('@metamask/money-account-mfa-whitelist', () => ({ + getMfaRequirement: (...args: unknown[]) => mockGetMfaRequirement(...args), +})); + +jest.mock('./cloud', () => ({ + getNetId: (...args: unknown[]) => mockGetNetId(...args), + createKey: (...args: unknown[]) => mockStartCreateKey(...args), + registerClient: (...args: unknown[]) => mockRegisterClient(...args), + sign: (...args: unknown[]) => mockStartSign(...args), + rotateKeyShares: (...args: unknown[]) => mockStartRotateKeyShares(...args), + storeKeyShareBackup: (...args: unknown[]) => mockStoreKeyShareBackup(...args), + checkKeyShare: (...args: unknown[]) => mockCheckKeyShare(...args), + setActiveEpoch: (...args: unknown[]) => mockSetActiveEpoch(...args), + loadKeyShareBackup: (...args: unknown[]) => mockLoadKeyShareBackup(...args), +})); + +jest.mock('./util', () => { + const actual = jest.requireActual('./util'); + return { + ...actual, + generateSessionNonce: jest.fn(() => mockSessionNonce), + encryptBytes: jest.fn(async (_key: Uint8Array, plaintext: Uint8Array) => { + return plaintext; + }), + decryptBytes: jest.fn(async (_key: Uint8Array, ciphertext: Uint8Array) => { + return ciphertext; + }), + getSignedTypedDataHash: jest.fn(() => mockTypedDataHash), + publicKeyToAddressHex: jest.fn(() => mockDerivedAddress), + toEthSig: jest.fn(() => mockEthSignature), + }; +}); + +const makeThresholdKey = () => { + return { + threshold: 2, + shareIndex: 0, + publicKey: new Uint8Array([4, 1, 2, 3]), + privateKeyShare: new Uint8Array([5, 6, 7]), + coefficientCommitments: [new Uint8Array([4, 1, 2, 3]), new Uint8Array([8])], + xCoordinates: [1, 2], + partyMapping: [ + { networkId: 'local-user', protocolId: 1 }, + { networkId: 'cloud-user', protocolId: 2 }, + ], + }; +}; + +const makeSerializedState = (overrides: Record = {}) => ({ + netCreds: { partyId: 'local-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + shareEpoch: mockShareEpoch, + tssSetup: '0x0102', + ...overrides, +}); + +const encodeText = (value: string): Uint8Array => + new TextEncoder().encode(value); + +const makeRootSession = (statusPayload = 'data persisted') => { + const session = { + sendMessage: jest.fn(), + receiveMessage: jest + .fn() + .mockImplementation(async (_peer: string, type: string) => { + if (type === 'status') { + return Promise.resolve(encodeText(statusPayload)); + } + return Promise.resolve(encodeText(JSON.stringify({ haveSetup: true }))); + }), + createSubsession: jest + .fn() + .mockImplementation((label: string) => ({ label })), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + return session; +}; + +const mockWhitelistConfig = { + chainId: '0x8f', +} as unknown as MfaWhitelistConfig; + +const makeKeyring = ( + getProfileToken = jest.fn().mockResolvedValue('token'), + getBackupEncryptionKey = jest.fn().mockResolvedValue(mockBackupKey), + getMfaWhitelistConfig?: () => + | MfaWhitelistConfig + | Promise, +) => + new MpcMoneyKeyring({ + cl24Lib: {} as never, + dkls23Lib: {} as never, + cloudURL: 'https://cloud.example', + relayerURL: 'https://relayer.example', + getProfileToken, + getBackupEncryptionKey, + ...(getMfaWhitelistConfig === undefined ? {} : { getMfaWhitelistConfig }), + }); + +const deserializeState = async ( + keyring: MpcMoneyKeyring, + state = makeSerializedState(), +) => { + await keyring.deserialize(state as unknown as Json); +}; + +describe('MpcMoneyKeyring', () => { + beforeEach(() => { + lastNetworkManagerOptions = undefined; + + const mockedUtil = jest.requireMock('./util'); + + mockedUtil.generateSessionNonce.mockReturnValue(mockSessionNonce); + mockedUtil.encryptBytes.mockImplementation( + async (_key: Uint8Array, plaintext: Uint8Array) => plaintext, + ); + mockedUtil.decryptBytes.mockImplementation( + async (_key: Uint8Array, ciphertext: Uint8Array) => ciphertext, + ); + mockedUtil.getSignedTypedDataHash.mockReturnValue(mockTypedDataHash); + mockedUtil.publicKeyToAddressHex.mockReturnValue(mockDerivedAddress); + mockedUtil.toEthSig.mockReturnValue(mockEthSignature); + + mockCreateScopedSessionId.mockImplementation( + (partyIds: string[], nonce: string) => + `session:${partyIds.join('|')}:${nonce}`, + ); + mockThresholdKeyToJson.mockImplementation((value) => value); + mockThresholdKeyFromJson.mockImplementation((value) => value); + mockNetworkIdentityToJson.mockImplementation((value) => value); + mockNetworkIdentityFromJson.mockImplementation((value) => value); + mockGetNetId.mockResolvedValue('cloud-user'); + mockStartCreateKey.mockResolvedValue(undefined); + mockRegisterClient.mockResolvedValue(undefined); + mockStartSign.mockResolvedValue('ok'); + mockGetMfaRequirement.mockReturnValue({ + mfaRequired: true, + reason: 'test', + }); + mockStartRotateKeyShares.mockResolvedValue(undefined); + mockStoreKeyShareBackup.mockResolvedValue(undefined); + mockCheckKeyShare.mockResolvedValue({ + latestShareEpoch: mockShareEpoch, + latestBackupEpoch: mockShareEpoch, + activeEpoch: mockShareEpoch, + }); + mockSetActiveEpoch.mockResolvedValue(undefined); + mockLoadKeyShareBackup.mockResolvedValue({ + encryptedKeyShare: new TextEncoder().encode(JSON.stringify({ ok: true })), + epoch: mockShareEpoch, + }); + mockDklsSetup.mockResolvedValue(new Uint8Array([1, 2, 3])); + mockDklsSign.mockResolvedValue({ signature: new Uint8Array(64).fill(9) }); + }); + + it('exposes the expected type constant', () => { + const keyring = makeKeyring(); + expect(MpcMoneyKeyring.type).toBe('MPC Money Keyring'); + expect(keyring.type).toBe('MPC Money Keyring'); + }); + + it('wires network manager options', () => { + const transportToken = jest.fn().mockResolvedValue('transport-token'); + const webSocket = { send: jest.fn() }; + + const keyring = new MpcMoneyKeyring({ + cl24Lib: {} as never, + dkls23Lib: {} as never, + cloudURL: 'https://cloud.example', + relayerURL: 'https://relayer.example', + getProfileToken: jest.fn().mockResolvedValue('verifier-token'), + getBackupEncryptionKey: jest.fn().mockResolvedValue(mockBackupKey), + getTransportToken: transportToken, + webSocket, + }); + expect(keyring.type).toBe('MPC Money Keyring'); + + expect(lastNetworkManagerOptions).toStrictEqual( + expect.objectContaining({ + url: 'https://relayer.example', + getToken: transportToken, + websocket: webSocket, + }), + ); + }); + + it('serializes to an empty object before initialization', async () => { + const keyring = makeKeyring(); + expect(await keyring.serialize()).toStrictEqual({}); + expect(await keyring.getAccounts()).toStrictEqual([]); + }); + + it('deserializes and re-serializes valid state', async () => { + const keyring = makeKeyring(); + const state = makeSerializedState(); + + await deserializeState(keyring, state); + + expect(await keyring.serialize()).toStrictEqual(state); + expect(await keyring.getAccounts()).toStrictEqual([mockDerivedAddress]); + }); + + it('round-trips null tssSetup', async () => { + const keyring = makeKeyring(); + const state = makeSerializedState({ tssSetup: null }); + await deserializeState(keyring, state); + expect(await keyring.serialize()).toStrictEqual(state); + }); + + it('throws on invalid deserialize input', async () => { + const keyring = makeKeyring(); + await expect(keyring.deserialize(null as never)).rejects.toThrow( + 'Invalid state', + ); + }); + + it('throws on invalid initialized state', async () => { + const keyring = makeKeyring(); + + await expect( + keyring.deserialize(makeSerializedState({ shareEpoch: 0 }) as never), + ).rejects.toThrow('Invalid state'); + }); + + it('throws on invalid initialized state that also includes a setup mode', async () => { + const keyring = makeKeyring(); + + await expect( + keyring.deserialize( + makeSerializedState({ shareEpoch: 0, mode: 'create' }) as never, + ), + ).rejects.toThrow('Invalid state'); + }); + + it('throws on invalid setup params in deserialize', async () => { + const keyring = makeKeyring(); + + await expect( + keyring.deserialize({ + mode: 'join', + } as never), + ).rejects.toThrow("Invalid setup mode: expected 'create' or 'import'"); + }); + + it('does not initialize from incomplete serialized state', async () => { + const keyring = makeKeyring(); + await keyring.deserialize({ shareEpoch: 1 }); + await expect(keyring.checkKeyShare()).rejects.toThrow( + 'Keyring not initialized', + ); + }); + + it('creates a key via deserialize(init args) + init(create mode)', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + const rootSession = makeRootSession(); + + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + + await keyring.deserialize({ mode: 'create' }); + expect(await keyring.serialize()).toStrictEqual({ mode: 'create' }); + await keyring.init(); + + expect(getProfileToken).toHaveBeenCalledWith({ twoFactor: true }); + expect(mockGetNetId).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + expect(mockStartCreateKey).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'local-user', + nonce: mockSessionNonce, + }); + expect(mockCreateScopedSessionId).toHaveBeenCalledWith( + ['cloud-user', 'local-user'], + mockSessionNonce, + ); + expect(mockCreateKey).toHaveBeenCalledWith( + expect.objectContaining({ + custodians: ['local-user', 'cloud-user'], + threshold: 2, + networkSession: { label: 'dkg-create' }, + }), + ); + expect(mockDklsSetup).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-setup' }, + }), + ); + expect(rootSession.createSubsession).toHaveBeenCalledWith('dkg-create'); + expect(rootSession.createSubsession).toHaveBeenCalledWith('tss-setup'); + expect(rootSession.receiveMessage).toHaveBeenCalledWith( + 'cloud-user', + 'status', + ); + expect(rootSession.disconnect).toHaveBeenCalled(); + expect(mockStoreKeyShareBackup).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'token', + epoch: 1, + attemptNonce: mockSessionNonce, + }), + ); + expect(mockCheckKeyShare).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + expect(mockSetActiveEpoch).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + epoch: 1, + }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + shareEpoch: 1, + tssSetup: '0x010203', + }); + }); + + it('throws when create cannot activate an incomplete epoch', async () => { + const keyring = makeKeyring(); + const rootSession = makeRootSession(); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + mockCheckKeyShare.mockResolvedValueOnce({ + latestShareEpoch: 1, + latestBackupEpoch: undefined, + activeEpoch: undefined, + }); + + await expect(keyring.init('create')).rejects.toThrow( + 'Share epoch 1 is not ready', + ); + expect(mockSetActiveEpoch).not.toHaveBeenCalled(); + expect(await keyring.serialize()).toStrictEqual({}); + }); + + it('imports a key from the backend backup', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'imported-user' }); + mockThresholdKeyFromJson.mockReturnValueOnce(makeThresholdKey()); + + await keyring.deserialize({ mode: 'import' }); + expect(await keyring.serialize()).toStrictEqual({ mode: 'import' }); + await keyring.init(); + + expect(mockLoadKeyShareBackup).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + expect(mockRegisterClient).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'imported-user', + }); + expect(mockDklsSetup).not.toHaveBeenCalled(); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'imported-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + shareEpoch: mockShareEpoch, + tssSetup: null, + }); + }); + + it('init is a no-op when already initialized', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + expect(await keyring.init()).toBeUndefined(); + expect(mockStartCreateKey).not.toHaveBeenCalled(); + expect(mockLoadKeyShareBackup).not.toHaveBeenCalled(); + }); + + it('init is a no-op without setup params or mode', async () => { + const keyring = makeKeyring(); + expect(await keyring.init()).toBeUndefined(); + expect(mockStartCreateKey).not.toHaveBeenCalled(); + }); + + it('init can take create mode without prior deserialize', async () => { + const keyring = makeKeyring(); + const rootSession = makeRootSession(); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + + await keyring.init('create'); + expect(mockStartCreateKey).toHaveBeenCalledTimes(1); + expect(await keyring.getAccounts()).toStrictEqual([mockDerivedAddress]); + }); + + it('runs setup once when init is called concurrently', async () => { + const keyring = makeKeyring(); + const rootSession = makeRootSession(); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + + await Promise.all([keyring.init('create'), keyring.init('create')]); + + expect(mockCreateIdentity).toHaveBeenCalledTimes(1); + expect(mockStartCreateKey).toHaveBeenCalledTimes(1); + expect(mockCreateKey).toHaveBeenCalledTimes(1); + expect(await keyring.getAccounts()).toStrictEqual([mockDerivedAddress]); + }); + + it('throws when create status is not data persisted', async () => { + const keyring = makeKeyring(); + const rootSession = makeRootSession(); + rootSession.receiveMessage.mockImplementation( + async (_peer: string, type: string) => { + if (type === 'status') { + return encodeText('running'); + } + return encodeText(JSON.stringify({ haveSetup: true })); + }, + ); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + + await expect(keyring.init('create')).rejects.toThrow( + 'Expected status data persisted, received running', + ); + expect(mockStoreKeyShareBackup).not.toHaveBeenCalled(); + }); + + it('rotates key shares, activates the next epoch, and clears tssSetup', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const rotatedKey = { + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([9, 9, 9]), + }; + + const rootSession = makeRootSession(); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockRotateKeyShares.mockResolvedValueOnce(rotatedKey); + mockCheckKeyShare.mockResolvedValueOnce({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + + await keyring.rotateKeyShares(); + + expect(getProfileToken).toHaveBeenCalledWith({ twoFactor: true }); + expect(mockStartRotateKeyShares).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'local-user', + nonce: mockSessionNonce, + expectedActiveEpoch: 1, + }); + expect(mockRotateKeyShares).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'dkg-rotate' }, + }), + ); + expect(rootSession.createSubsession).toHaveBeenCalledWith('dkg-rotate'); + expect(rootSession.receiveMessage).toHaveBeenCalledWith( + 'cloud-user', + 'status', + ); + expect(rootSession.disconnect).toHaveBeenCalled(); + expect(mockDklsSetup).not.toHaveBeenCalled(); + expect(mockStoreKeyShareBackup).toHaveBeenCalledWith( + expect.objectContaining({ + epoch: 2, + token: 'token', + attemptNonce: mockSessionNonce, + }), + ); + expect(mockSetActiveEpoch).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + epoch: 2, + }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: rotatedKey, + serverNetId: 'cloud-user', + shareEpoch: 2, + tssSetup: null, + }); + }); + + it('checks whether local and backend epochs match', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + expect(await keyring.checkKeyShare()).toBe(true); + expect(getProfileToken).toHaveBeenCalledWith(); + expect(mockCheckKeyShare).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + + mockCheckKeyShare.mockResolvedValueOnce({ + latestShareEpoch: 2, + latestBackupEpoch: 1, + activeEpoch: 1, + }); + expect(await keyring.checkKeyShare()).toBe(false); + }); + + it('syncs key share and epoch from the backend and clears tssSetup', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const syncedKey = { + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([1, 1, 1]), + }; + mockLoadKeyShareBackup.mockResolvedValueOnce({ + encryptedKeyShare: new TextEncoder().encode(JSON.stringify({ ok: true })), + epoch: 3, + }); + mockThresholdKeyFromJson.mockReturnValueOnce(syncedKey); + + await keyring.syncKeyShare(); + + expect(getProfileToken).toHaveBeenCalledWith({ twoFactor: true }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: syncedKey, + serverNetId: 'cloud-user', + shareEpoch: 3, + tssSetup: null, + }); + }); + + it('signs personal messages through the MPC flow', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const signSession = makeRootSession('signing completed'); + mockCreateSession.mockResolvedValue(signSession); + + const messageHex = '0x68656c6c6f'; + const signatureHex = await keyring.signPersonalMessage( + mockDerivedAddress, + messageHex, + ); + expect(signatureHex).toBe(bytesToHex(mockEthSignature)); + const messageHash = hashPersonalMessage(new TextEncoder().encode('hello')); + expect(getProfileToken).toHaveBeenCalledWith({ + twoFactor: true, + challenge: messageHash, + }); + expect(mockGetMfaRequirement).not.toHaveBeenCalled(); + expect(mockStartSign).toHaveBeenCalledWith( + expect.objectContaining({ + clientNetId: 'local-user', + token: 'token', + data: messageHash, + shareEpoch: 1, + request: { + method: 'signPersonalMessage', + address: mockDerivedAddress, + message: messageHex, + }, + }), + ); + expect(mockDklsSetup).not.toHaveBeenCalled(); + expect(mockDklsSign).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-sign' }, + }), + ); + expect(signSession.createSubsession).toHaveBeenCalledWith('tss-sign'); + expect(signSession.createSubsession).not.toHaveBeenCalledWith('tss-setup'); + expect(signSession.receiveMessage).toHaveBeenCalledWith( + 'cloud-user', + 'status', + ); + }); + + it('returns the signature when sign status is not signing completed', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + const signSession = makeRootSession(); + signSession.receiveMessage.mockImplementation( + async (_peer: string, type: string) => { + if (type === 'status') { + return encodeText('running'); + } + return encodeText(JSON.stringify({ haveSetup: true })); + }, + ); + mockCreateSession.mockResolvedValue(signSession); + + const signatureHex = await keyring.signPersonalMessage( + mockDerivedAddress, + '0x68656c6c6f', + ); + + expect(signatureHex).toBe(bytesToHex(mockEthSignature)); + expect(signSession.disconnect).toHaveBeenCalled(); + expect(await keyring.serialize()).toStrictEqual( + expect.objectContaining({ tssSetup: '0x0102' }), + ); + }); + + it('signs EIP-7702 authorizations through the MPC flow', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const signSession = makeRootSession('signing completed'); + mockCreateSession.mockResolvedValue(signSession); + + const { hashEIP7702Authorization } = jest.requireActual( + '@metamask/eth-sig-util', + ); + const authorization = [ + 1, + '0x1234567890abcdef1234567890abcdef12345678', + 1, + ] as const; + const expectedHash = new Uint8Array( + hashEIP7702Authorization(authorization), + ); + + const signatureHex = await keyring.signEip7702Authorization( + mockDerivedAddress, + [...authorization], + ); + + expect(signatureHex).toBe(bytesToHex(mockEthSignature)); + expect(getProfileToken).toHaveBeenCalledWith({ + twoFactor: true, + challenge: expectedHash, + }); + expect(mockStartSign).toHaveBeenCalledWith( + expect.objectContaining({ + clientNetId: 'local-user', + token: 'token', + data: expectedHash, + shareEpoch: 1, + request: { + method: 'signEip7702Authorization', + address: mockDerivedAddress, + authorization, + }, + }), + ); + expect(mockDklsSign).toHaveBeenCalledWith( + expect.objectContaining({ + message: expectedHash, + networkSession: { label: 'tss-sign' }, + }), + ); + }); + + it('skips MFA for a whitelisted signature request', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const getMfaWhitelistConfig = jest + .fn() + .mockResolvedValue(mockWhitelistConfig); + const keyring = makeKeyring( + getProfileToken, + undefined, + getMfaWhitelistConfig, + ); + await deserializeState(keyring); + mockCreateSession.mockResolvedValue(makeRootSession('signing completed')); + mockGetMfaRequirement.mockReturnValue({ + mfaRequired: false, + rule: 'chomp-authentication', + }); + + const messageHex = '0x68656c6c6f'; + const messageHash = hashPersonalMessage(new TextEncoder().encode('hello')); + expect( + await keyring.signPersonalMessage(mockDerivedAddress, messageHex), + ).toBe(bytesToHex(mockEthSignature)); + + expect(mockGetMfaRequirement).toHaveBeenCalledWith( + { + method: 'signPersonalMessage', + address: mockDerivedAddress, + message: messageHex, + }, + { + address: mockDerivedAddress, + hash: bytesToHex(messageHash), + config: mockWhitelistConfig, + }, + ); + expect(getProfileToken).toHaveBeenCalledTimes(1); + expect(getProfileToken).toHaveBeenCalledWith({ + twoFactor: false, + challenge: messageHash, + }); + expect(mockStartSign).toHaveBeenCalledTimes(1); + expect(mockStartSign).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'token', + request: { + method: 'signPersonalMessage', + address: mockDerivedAddress, + message: messageHex, + }, + }), + ); + }); + + it('prompts for MFA when the backend returns mfa_required', async () => { + const getProfileToken = jest + .fn() + .mockResolvedValueOnce('session-token') + .mockResolvedValueOnce('mfa-token'); + const keyring = makeKeyring( + getProfileToken, + undefined, + jest.fn().mockReturnValue(mockWhitelistConfig), + ); + await deserializeState(keyring); + mockCreateSession.mockResolvedValue(makeRootSession('signing completed')); + mockGetMfaRequirement.mockReturnValue({ + mfaRequired: false, + rule: 'chomp-authentication', + }); + mockStartSign + .mockResolvedValueOnce(MFA_REQUIRED_CODE) + .mockResolvedValueOnce('ok'); + const mockedUtil = jest.requireMock('./util'); + mockedUtil.generateSessionNonce + .mockReturnValueOnce('0x1111') + .mockReturnValueOnce('0x2222'); + + const messageHash = hashPersonalMessage(new TextEncoder().encode('hello')); + expect( + await keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + ).toBe(bytesToHex(mockEthSignature)); + + expect(getProfileToken).toHaveBeenNthCalledWith(1, { + twoFactor: false, + challenge: messageHash, + }); + expect(getProfileToken).toHaveBeenNthCalledWith(2, { + twoFactor: true, + challenge: messageHash, + }); + expect(mockStartSign).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + token: 'session-token', + nonce: '0x1111', + request: expect.objectContaining({ method: 'signPersonalMessage' }), + }), + ); + expect(mockStartSign).toHaveBeenNthCalledWith( + 2, + expect.not.objectContaining({ + request: expect.anything(), + }), + ); + expect(mockStartSign).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + token: 'mfa-token', + nonce: '0x2222', + }), + ); + expect(mockCreateSession).toHaveBeenCalledWith( + expect.anything(), + 'session:cloud-user|local-user:0x2222', + ); + }); + + it('requires MFA when whitelist configuration throws', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring( + getProfileToken, + undefined, + jest.fn().mockRejectedValue(new Error('config unavailable')), + ); + await deserializeState(keyring); + mockCreateSession.mockResolvedValue(makeRootSession('signing completed')); + + await keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'); + + expect(mockGetMfaRequirement).not.toHaveBeenCalled(); + expect(getProfileToken).toHaveBeenCalledWith({ + twoFactor: true, + challenge: hashPersonalMessage(new TextEncoder().encode('hello')), + }); + }); + + it('requires MFA when the whitelist check throws', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring( + getProfileToken, + undefined, + jest.fn().mockReturnValue(mockWhitelistConfig), + ); + await deserializeState(keyring); + mockCreateSession.mockResolvedValue(makeRootSession('signing completed')); + mockGetMfaRequirement.mockImplementation(() => { + throw new Error('whitelist failed'); + }); + + await keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'); + + expect(getProfileToken).toHaveBeenCalledWith({ + twoFactor: true, + challenge: hashPersonalMessage(new TextEncoder().encode('hello')), + }); + }); + + it('throws when the backend requires MFA for a 2FA token', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + mockStartSign.mockResolvedValue(MFA_REQUIRED_CODE); + + await expect( + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + ).rejects.toThrow('MFA required'); + + expect(getProfileToken).toHaveBeenCalledTimes(1); + expect(mockStartSign).toHaveBeenCalledTimes(1); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); + + it('throws when the MFA retry is still rejected', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring( + getProfileToken, + undefined, + jest.fn().mockReturnValue(mockWhitelistConfig), + ); + await deserializeState(keyring); + mockGetMfaRequirement.mockReturnValue({ + mfaRequired: false, + rule: 'chomp-authentication', + }); + mockStartSign.mockResolvedValue(MFA_REQUIRED_CODE); + + await expect( + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + ).rejects.toThrow('MFA required'); + + expect(getProfileToken).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ twoFactor: true }), + ); + expect(mockStartSign).toHaveBeenCalledTimes(2); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); + + it('runs TSS setup when the peer has discarded setup', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + const signSession = makeRootSession('signing completed'); + signSession.receiveMessage.mockResolvedValueOnce( + new TextEncoder().encode(JSON.stringify({ haveSetup: false })), + ); + mockCreateSession.mockResolvedValue(signSession); + mockDklsSetup.mockResolvedValueOnce(new Uint8Array([9, 9, 9])); + + await keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'); + + expect(mockDklsSetup).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-setup' }, + }), + ); + expect(mockDklsSign).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-sign' }, + }), + ); + expect(signSession.createSubsession).toHaveBeenCalledWith('tss-setup'); + expect(signSession.createSubsession).toHaveBeenCalledWith('tss-sign'); + expect(await keyring.serialize()).toStrictEqual( + expect.objectContaining({ tssSetup: '0x090909' }), + ); + }); + + it('runs TSS setup on first sign after import', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring, makeSerializedState({ tssSetup: null })); + + const signSession = makeRootSession('signing completed'); + mockCreateSession.mockResolvedValue(signSession); + + await keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'); + expect(mockDklsSetup).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-setup' }, + }), + ); + expect(signSession.createSubsession).toHaveBeenCalledWith('tss-setup'); + expect(signSession.createSubsession).toHaveBeenCalledWith('tss-sign'); + }); + + it('discards tssSetup when TSS.sign fails', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + const signSession = makeRootSession(); + mockCreateSession.mockResolvedValue(signSession); + mockDklsSign.mockRejectedValueOnce(new Error('sign failed')); + + await expect( + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + ).rejects.toThrow('sign failed'); + + expect(await keyring.serialize()).toStrictEqual( + expect.objectContaining({ tssSetup: null }), + ); + }); + + it('serializes concurrent sign calls', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + let inFlight = 0; + let maxInFlight = 0; + mockDklsSign.mockImplementation(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 20)); + inFlight -= 1; + return { signature: new Uint8Array(64).fill(9) }; + }); + mockCreateSession.mockImplementation(async () => + makeRootSession('signing completed'), + ); + + await Promise.all([ + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + ]); + + expect(maxInFlight).toBe(1); + }); + + it('serializes sign, rotate, and sync on one op queue', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + const order: string[] = []; + let inFlight = 0; + let maxInFlight = 0; + + const track = async (label: string, work: () => Promise) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(`${label}-start`); + await new Promise((resolve) => setTimeout(resolve, 20)); + await work(); + order.push(`${label}-end`); + inFlight -= 1; + }; + + mockCreateSession.mockImplementation(async () => makeRootSession()); + mockDklsSign.mockImplementation(async () => { + await track('sign', async () => undefined); + return { signature: new Uint8Array(64).fill(9) }; + }); + mockRotateKeyShares.mockImplementation(async () => { + await track('rotate', async () => undefined); + return { + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([9, 9, 9]), + }; + }); + mockCheckKeyShare.mockResolvedValue({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + mockLoadKeyShareBackup.mockImplementation(async () => { + await track('sync', async () => undefined); + return { + encryptedKeyShare: new TextEncoder().encode( + JSON.stringify({ ok: true }), + ), + epoch: 2, + }; + }); + mockThresholdKeyFromJson.mockReturnValue({ + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([1, 1, 1]), + }); + + await Promise.all([ + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + keyring.rotateKeyShares(), + keyring.syncKeyShare(), + ]); + + expect(maxInFlight).toBe(1); + expect(order).toStrictEqual([ + 'sign-start', + 'sign-end', + 'rotate-start', + 'rotate-end', + 'sync-start', + 'sync-end', + ]); + expect(await keyring.serialize()).toStrictEqual( + expect.objectContaining({ + shareEpoch: 2, + tssSetup: null, + }), + ); + }); + + it('signs typed data and validates signer constraints', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + mockCreateSession.mockResolvedValue(makeRootSession('signing completed')); + + const signature = await keyring.signTypedData( + mockDerivedAddress, + [{ type: 'string', name: 'message', value: 'hello' }], + {}, + ); + expect(signature).toBe(bytesToHex(mockEthSignature)); + }); + + it('throws for signing with an unknown account', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + await expect( + keyring.signPersonalMessage( + '0x2222222222222222222222222222222222222222', + '0x68656c6c6f', + ), + ).rejects.toThrow( + 'account 0x2222222222222222222222222222222222222222 not found', + ); + }); + + describe('addAccounts', () => { + it('throws if called before initialization', async () => { + const keyring = makeKeyring(); + + await expect(keyring.addAccounts()).rejects.toThrow( + 'MpcMoneyKeyring: has no account', + ); + }); + + it('throws if numberOfAccounts is not 1', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + await expect(keyring.addAccounts(2)).rejects.toThrow( + 'MpcMoneyKeyring: supports adding exactly one account', + ); + }); + + it('returns the existing account after initialization', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + expect(await keyring.addAccounts()).toStrictEqual([mockDerivedAddress]); + expect(await keyring.getAccounts()).toStrictEqual([mockDerivedAddress]); + }); + }); + + it('throws for unsupported account APIs that are not implemented', async () => { + const keyring = makeKeyring(); + + await expect( + keyring.getAppKeyAddress(mockDerivedAddress, 'example.com'), + ).rejects.toThrow( + `getAppKeyAddress(${mockDerivedAddress}, example.com): not implemented`, + ); + }); +}); diff --git a/packages/keyring-eth-money-mpc/src/mpc-money-keyring.ts b/packages/keyring-eth-money-mpc/src/mpc-money-keyring.ts new file mode 100644 index 000000000..fc8548c49 --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/mpc-money-keyring.ts @@ -0,0 +1,927 @@ +import { hashPersonalMessage } from '@ethereumjs/util'; +import type { + TypedDataV1, + TypedMessage, + SignTypedDataVersion, + MessageTypes, + EIP7702Authorization, +} from '@metamask/eth-sig-util'; +import { hashEIP7702Authorization } from '@metamask/eth-sig-util'; +import { CL24DKM, CL24ThresholdKeySerializer } from '@metamask/mfa-wallet-cl24'; +import type { CL24ThresholdKey } from '@metamask/mfa-wallet-cl24'; +import { Dkls23TssLib } from '@metamask/mfa-wallet-dkls23'; +import type { + PartyId, + RootNetworkSession, + ShareBinding, +} from '@metamask/mfa-wallet-interface'; +import type { CentrifugeIdentity } from '@metamask/mfa-wallet-network'; +import { + CentrifugeIdentitySerializer, + CentrifugeNetworkManager, + createScopedSessionId, +} from '@metamask/mfa-wallet-network'; +import { getMfaRequirement } from '@metamask/money-account-mfa-whitelist'; +import type { MoneyAccountSignatureRequest } from '@metamask/money-account-mfa-whitelist'; +import { + enums, + integer, + is, + min, + nonempty, + nullable, + string, + type, +} from '@metamask/superstruct'; +import { bytesToHex, hexToBytes, JsonStruct } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; + +import type { CheckKeyShareResult } from './cloud.js'; +import { + checkKeyShare as checkKeyShareRemote, + createKey as startCreateKey, + getNetId, + loadKeyShareBackup, + registerClient, + rotateKeyShares as startRotateKeyShares, + setActiveEpoch, + sign as startSign, + storeKeyShareBackup, +} from './cloud.js'; +import { MFA_REQUIRED_CODE } from './types.js'; +import type { + MpcMoneyKeyring as MpcMoneyKeyringContract, + MpcMoneyKeyringOpts, + MpcMoneyKeyringSerializer, + MpcMoneyKeyringSetupParams, + MpcMoneyKeyringState, + MpcMoneyKeyringStorageState, + ProfileTokenOpts, +} from './types.js'; +import { + AES_GCM_IV_LENGTH, + decryptBytes, + encryptBytes, + equalAddresses, + generateSessionNonce, + getSignedTypedDataHash, + parseSignedTypedDataVersion, + publicKeyToAddressHex, + toEthSig, +} from './util.js'; + +const mpcMoneyKeyringType = 'MPC Money Keyring'; +const TSS_HAVE_SETUP_MESSAGE_TYPE = 'tss-have-setup'; +const STATUS_MESSAGE_TYPE = 'status'; +const STATUS_DATA_PERSISTED_PAYLOAD = 'data persisted'; +const STATUS_SIGNING_COMPLETED_PAYLOAD = 'signing completed'; +const CLIENT_SHARE_INDEX = 0; +const SERVER_SHARE_INDEX = 1; +const SIGNING_THRESHOLD = 2; +const INITIAL_SHARE_EPOCH = 1; + +const SetupParamsStruct = type({ + mode: enums(['create', 'import'] as const), +}); + +const InitializedStorageStruct = type({ + netCreds: JsonStruct, + keyShare: JsonStruct, + serverNetId: nonempty(string()), + shareEpoch: min(integer(), 1), + tssSetup: nullable(string()), +}); + +/** + * Whether `state` carries the initialized-keyring fields. + * + * A payload with these fields that fails {@link InitializedStorageStruct} + * is rejected, instead of being treated as missing state. + * + * @param state - Candidate persisted state. + * @returns True when every initialized field is present. + */ +function hasInitializedFields(state: object): boolean { + return ( + 'netCreds' in state && + 'keyShare' in state && + 'serverNetId' in state && + 'shareEpoch' in state && + 'tssSetup' in state + ); +} + +/** + * Wait for a backend `status` message and assert it matches `expectedStatus`. + * + * @param networkSession - Root network session shared with the backend. + * @param peerNetId - Server network id. + * @param expectedStatus - Payload the backend must send for this operation. + */ +async function waitForDoneStatus( + networkSession: RootNetworkSession, + peerNetId: PartyId, + expectedStatus: string, +): Promise { + const status = new TextDecoder().decode( + await networkSession.receiveMessage(peerNetId, STATUS_MESSAGE_TYPE), + ); + if (status !== expectedStatus) { + throw new Error(`Expected status ${expectedStatus}, received ${status}`); + } +} + +/** + * Assert that the latest share and backup epochs both equal `expectedEpoch`. + * + * @param check - Backend epoch metadata. + * @param expectedEpoch - Epoch that must be ready for activation. + */ +function assertEpochReady( + check: CheckKeyShareResult, + expectedEpoch: number, +): void { + if ( + check.latestShareEpoch !== expectedEpoch || + check.latestBackupEpoch !== expectedEpoch + ) { + throw new Error( + `Share epoch ${expectedEpoch} is not ready (latestShareEpoch=${String( + check.latestShareEpoch, + )}, latestBackupEpoch=${String(check.latestBackupEpoch)})`, + ); + } +} + +/** + * Party net ids indexed by 0-based share slot. + * + * @param clientNetId - Client (share 0) network id. + * @param serverNetId - Server (share 1) network id. + * @returns Net ids in share-slot order. + */ +function partyNetIds(clientNetId: PartyId, serverNetId: PartyId): PartyId[] { + const netIds: PartyId[] = []; + netIds[CLIENT_SHARE_INDEX] = clientNetId; + netIds[SERVER_SHARE_INDEX] = serverNetId; + return netIds; +} + +/** + * Share bindings for the client/server pair. + * + * @param clientNetId - Client (share 0) network id. + * @param serverNetId - Server (share 1) network id. + * @returns Bindings with fixed share indexes. + */ +function shareBindings( + clientNetId: PartyId, + serverNetId: PartyId, +): ShareBinding[] { + return [ + { netId: clientNetId, shareIndex: CLIENT_SHARE_INDEX }, + { netId: serverNetId, shareIndex: SERVER_SHARE_INDEX }, + ]; +} + +/** + * 2-of-2 Ethereum money keyring. The client holds one DKLS23 share and signs + * with a cloud peer. Shares are stored as encrypted epochs on the backend. + * + * The controller keyring type is `'MPC Money Keyring'`. + */ +export class MpcMoneyKeyring implements MpcMoneyKeyringContract { + /** + * Keyring type registered with the controller. + */ + static type: string = mpcMoneyKeyringType; + + readonly type: string = mpcMoneyKeyringType; + + readonly #networkManager: CentrifugeNetworkManager; + + readonly #tss: Dkls23TssLib; + + readonly #dkm: CL24DKM; + + #state?: MpcMoneyKeyringStorageState; + + readonly #cloudURL: string; + + readonly #serializer: MpcMoneyKeyringSerializer; + + readonly #getProfileToken: (opts?: ProfileTokenOpts) => Promise; + + readonly #getMfaWhitelistConfig: MpcMoneyKeyringOpts['getMfaWhitelistConfig']; + + readonly #getBackupEncryptionKey: () => Promise; + + #opQueue: Promise = Promise.resolve(); + + /** + * Create a keyring bound to the given cloud and relayer endpoints. + * + * The keyring stays uninitialized until {@link MpcMoneyKeyring.init} or + * {@link MpcMoneyKeyring.deserialize}. + * + * @param opts - TSS libraries, backend URLs, and token callbacks. + */ + constructor(opts: MpcMoneyKeyringOpts) { + this.#dkm = new CL24DKM(opts.cl24Lib); + this.#tss = new Dkls23TssLib(opts.dkls23Lib); + this.#cloudURL = opts.cloudURL; + this.#serializer = { + thresholdKey: new CL24ThresholdKeySerializer(), + networkIdentity: new CentrifugeIdentitySerializer(), + }; + this.#networkManager = new CentrifugeNetworkManager({ + url: opts.relayerURL, + ...(opts.getTransportToken && { + getToken: opts.getTransportToken, + }), + ...(opts.webSocket === undefined ? {} : { websocket: opts.webSocket }), + }); + this.#getProfileToken = opts.getProfileToken; + this.#getMfaWhitelistConfig = opts.getMfaWhitelistConfig; + this.#getBackupEncryptionKey = opts.getBackupEncryptionKey; + } + + /** + * Return the serialized state of the keyring. + * + * @returns The serialized state of the keyring. + */ + async serialize(): Promise { + if (!this.#state) { + return {}; + } + if (this.#state.status === 'uninitialized') { + return this.#state.setup; + } + + const { netCreds, keyShare, serverNetId, shareEpoch, tssSetup } = + this.#state; + return { + netCreds: this.#serializer.networkIdentity.toJson(netCreds), + keyShare: this.#serializer.thresholdKey.toJson(keyShare), + serverNetId, + shareEpoch, + tssSetup: tssSetup === null ? null : bytesToHex(tssSetup), + }; + } + + /** + * Initialize the keyring with the given serialized state. + * + * @param state - The serialized state of the keyring. + */ + async deserialize(state: Json): Promise { + if (!state || typeof state !== 'object') { + throw new Error('Invalid state'); + } + + if (is(state, InitializedStorageStruct)) { + this.#state = { + status: 'initialized', + netCreds: this.#serializer.networkIdentity.fromJson(state.netCreds), + keyShare: this.#serializer.thresholdKey.fromJson(state.keyShare), + serverNetId: state.serverNetId, + shareEpoch: state.shareEpoch, + tssSetup: state.tssSetup === null ? null : hexToBytes(state.tssSetup), + }; + return; + } + + // `type()` ignores unknown keys, so a payload can match `mode` and still + // carry initialized fields. Reject that before treating it as setup. + if (hasInitializedFields(state)) { + throw new Error('Invalid state'); + } + + if (is(state, SetupParamsStruct)) { + this.#state = { + status: 'uninitialized', + setup: { mode: state.mode }, + }; + return; + } + + if ('mode' in state) { + throw new Error("Invalid setup mode: expected 'create' or 'import'"); + } + } + + /** + * Run key generation or import. `mode` may be passed directly, or taken + * from setup params previously stored via {@link deserialize}. + * Serialized with sign, rotate and sync so overlapping `init` calls run + * setup once: the second call waits, sees the initialized state and returns. + * + * @param mode - Create a new key or import from the backend backup. + * @returns Resolves when the keyring is initialized. + */ + async init(mode?: MpcMoneyKeyringSetupParams['mode']): Promise { + return this.#serializeOp(async () => { + if (this.#state?.status === 'initialized') { + return; + } + + const resolvedMode = + mode ?? + (this.#state?.status === 'uninitialized' + ? this.#state.setup.mode + : undefined); + if (resolvedMode === undefined) { + return; + } + + if (resolvedMode === 'create') { + await this.#setupCreate(); + } else { + await this.#setupImport(); + } + }); + } + + /** + * Rotate client and server shares to the next epoch, then activate it. + * Clears local TSS setup so it is rebuilt against the new shares. + * Serialized with sign and sync so mid-flight state writes cannot race. + * + * @returns Resolves when rotation and activation complete. + */ + async rotateKeyShares(): Promise { + return this.#serializeOp(async () => { + const state = this.#assertState(); + const { netCreds, serverNetId, shareEpoch } = state; + let { keyShare } = state; + const nextEpoch = shareEpoch + 1; + + const token = await this.#getProfileToken({ twoFactor: true }); + const nonce = generateSessionNonce(); + await startRotateKeyShares({ + baseURL: this.#cloudURL, + token, + clientNetId: netCreds.partyId, + nonce, + expectedActiveEpoch: shareEpoch, + }); + + const netSession = await this.#createNetworkSession( + netCreds, + serverNetId, + nonce, + ); + try { + const custodians = partyNetIds(netCreds.partyId, serverNetId); + const bindings = shareBindings(netCreds.partyId, serverNetId); + keyShare = await this.#dkm.rotateKeyShares({ + key: keyShare, + dealers: bindings, + custodians, + networkSession: netSession.createSubsession('dkg-rotate'), + }); + await waitForDoneStatus( + netSession, + serverNetId, + STATUS_DATA_PERSISTED_PAYLOAD, + ); + } finally { + await netSession.disconnect(); + } + + await storeKeyShareBackup({ + baseURL: this.#cloudURL, + token, + epoch: nextEpoch, + attemptNonce: nonce, + encryptedKeyShare: await this.#encryptKeyShare(keyShare), + }); + + assertEpochReady( + await checkKeyShareRemote({ + baseURL: this.#cloudURL, + token, + }), + nextEpoch, + ); + + await setActiveEpoch({ + baseURL: this.#cloudURL, + token, + epoch: nextEpoch, + }); + + this.#applyKeyState({ + ...state, + keyShare, + shareEpoch: nextEpoch, + tssSetup: null, + }); + }); + } + + /** + * Compare the local share epoch with backend share/backup/active epochs. + * + * @returns Whether all remote epochs match the local share epoch. + */ + async checkKeyShare(): Promise { + const { shareEpoch } = this.#assertState(); + const token = await this.#getProfileToken(); + const check = await checkKeyShareRemote({ + baseURL: this.#cloudURL, + token, + }); + return ( + check.latestShareEpoch === shareEpoch && + check.latestBackupEpoch === shareEpoch && + check.activeEpoch === shareEpoch + ); + } + + /** + * Refresh `keyShare` and `shareEpoch` from the active-epoch backend backup. + * Clears `tssSetup`; `netCreds` and `serverNetId` are unchanged. + * Serialized with sign and rotate so mid-flight state writes cannot race. + * + * @returns Resolves when the local share has been refreshed. + */ + async syncKeyShare(): Promise { + return this.#serializeOp(async () => { + const state = this.#assertState(); + const token = await this.#getProfileToken({ twoFactor: true }); + const { encryptedKeyShare, epoch } = await loadKeyShareBackup({ + baseURL: this.#cloudURL, + token, + }); + const keyShare = await this.#decryptKeyShare(encryptedKeyShare); + this.#applyKeyState({ + ...state, + keyShare, + shareEpoch: epoch, + tssSetup: null, + }); + }); + } + + /** + * Return the single MPC account. The account is created during + * {@link MpcMoneyKeyring.init}, not by this method. + * + * @param numberOfAccounts - Must be 1 (the only supported value). + * @returns The address of the existing account. + * @throws If `numberOfAccounts` is not 1 or the keyring has no account. + */ + async addAccounts(numberOfAccounts = 1): Promise { + if (numberOfAccounts !== 1) { + throw new Error('MpcMoneyKeyring: supports adding exactly one account'); + } + + const accounts = await this.getAccounts(); + const account = accounts[0]; + if (!account) { + throw new Error('MpcMoneyKeyring: has no account'); + } + + return [account]; + } + + /** + * Get the addresses of all accounts in the keyring. + * + * @returns The addresses of all accounts in the keyring. + */ + async getAccounts(): Promise { + if (!this.#state || this.#state.status !== 'initialized') { + return []; + } + + return [this.#address()]; + } + + /** + * Get the public address of the account for the given app key origin. + * + * @param address - The address of the account. + * @param origin - The origin of the app requesting the account. + * @returns The public address of the account. + */ + async getAppKeyAddress(address: Hex, origin: string): Promise { + throw new Error(`getAppKeyAddress(${address}, ${origin}): not implemented`); + } + + /** + * Sign a personal message using the specified account. + * This method is compatible with the `personal_sign` RPC method. + * + * @param address - The address of the account. + * @param msgHex - The message to sign. + * @param _opts - The options for signing the message. + * @returns The signature of the message. + */ + async signPersonalMessage( + address: Hex, + msgHex: string, + _opts?: Record, + ): Promise { + const rawMsg = hexToBytes(msgHex); + const msgHash = hashPersonalMessage(rawMsg); + + const signature = await this.#signHash(address, msgHash, { + method: 'signPersonalMessage', + address, + message: msgHex as Hex, + }); + return bytesToHex(signature); + } + + /** + * Sign a typed message using the specified account. + * This method is compatible with the `eth_signTypedData` RPC method. + * + * @param address - The address of the account. + * @param data - The typed data to sign. + * @param options - The options for signing the message. + * @returns The signature of the message. + */ + async signTypedData< + Version extends SignTypedDataVersion, + Types extends MessageTypes, + Options extends { version?: Version }, + >( + address: Hex, + data: Version extends 'V1' ? TypedDataV1 : TypedMessage, + options?: Options, + ): Promise { + const version = parseSignedTypedDataVersion(options); + + const messageHash = getSignedTypedDataHash(data, version); + + const signature = await this.#signHash(address, messageHash, { + method: 'signTypedData', + address, + version, + data, + }); + return bytesToHex(signature); + } + + /** + * Sign an EIP-7702 authorization using the specified account. + * + * @param address - The address of the account. + * @param authorization - The EIP-7702 authorization to sign. + * @param _opts - The options for signing the authorization. + * @returns The signature of the authorization. + */ + async signEip7702Authorization( + address: Hex, + authorization: EIP7702Authorization, + _opts?: Record, + ): Promise { + const messageHash = new Uint8Array(hashEIP7702Authorization(authorization)); + const [chainId, contractAddress, nonce] = authorization; + const signature = await this.#signHash(address, messageHash, { + method: 'signEip7702Authorization', + address, + authorization: [chainId, contractAddress as Hex, nonce], + }); + return bytesToHex(signature); + } + + async #setupCreate(): Promise { + const token = await this.#getProfileToken({ twoFactor: true }); + const netCreds = await this.#networkManager.createIdentity(); + const serverNetId = await getNetId({ + baseURL: this.#cloudURL, + token, + }); + + const nonce = generateSessionNonce(); + await startCreateKey({ + baseURL: this.#cloudURL, + token, + clientNetId: netCreds.partyId, + nonce, + }); + + const netSession = await this.#createNetworkSession( + netCreds, + serverNetId, + nonce, + ); + let keyShare: CL24ThresholdKey; + let tssSetup: Uint8Array; + try { + const custodians = partyNetIds(netCreds.partyId, serverNetId); + const bindings = shareBindings(netCreds.partyId, serverNetId); + const createKeySession = netSession.createSubsession('dkg-create'); + const tssSetupSession = netSession.createSubsession('tss-setup'); + [keyShare, tssSetup] = await Promise.all([ + this.#dkm.createKey({ + custodians, + threshold: SIGNING_THRESHOLD, + networkSession: createKeySession, + }), + this.#tss.setup({ + signers: bindings, + networkSession: tssSetupSession, + }), + ]); + await waitForDoneStatus( + netSession, + serverNetId, + STATUS_DATA_PERSISTED_PAYLOAD, + ); + } finally { + await netSession.disconnect(); + } + + await storeKeyShareBackup({ + baseURL: this.#cloudURL, + token, + epoch: INITIAL_SHARE_EPOCH, + attemptNonce: nonce, + encryptedKeyShare: await this.#encryptKeyShare(keyShare), + }); + + assertEpochReady( + await checkKeyShareRemote({ + baseURL: this.#cloudURL, + token, + }), + INITIAL_SHARE_EPOCH, + ); + + await setActiveEpoch({ + baseURL: this.#cloudURL, + token, + epoch: INITIAL_SHARE_EPOCH, + }); + + this.#applyKeyState({ + keyShare, + netCreds, + serverNetId, + shareEpoch: INITIAL_SHARE_EPOCH, + tssSetup, + }); + } + + async #setupImport(): Promise { + const token = await this.#getProfileToken({ twoFactor: true }); + const netCreds = await this.#networkManager.createIdentity(); + const serverNetId = await getNetId({ + baseURL: this.#cloudURL, + token, + }); + + const loaded = await loadKeyShareBackup({ + baseURL: this.#cloudURL, + token, + }); + const keyShare = await this.#decryptKeyShare(loaded.encryptedKeyShare); + + await registerClient({ + baseURL: this.#cloudURL, + token, + clientNetId: netCreds.partyId, + }); + + this.#applyKeyState({ + keyShare, + netCreds, + serverNetId, + shareEpoch: loaded.epoch, + tssSetup: null, + }); + } + + /** + * Whether signing `request` requires MFA. + * + * A missing request, missing configuration, or a thrown check requires MFA. + * + * @param request - The signature request, when this operation has one. + * @param hash - The hash the keyring will sign. + * @returns Whether the profile token must include 2FA. + */ + async #mfaRequired( + request: MoneyAccountSignatureRequest | undefined, + hash: Uint8Array, + ): Promise { + if (request === undefined || this.#getMfaWhitelistConfig === undefined) { + return true; + } + + try { + const config = await this.#getMfaWhitelistConfig(); + return getMfaRequirement(request, { + address: this.#address(), + hash: bytesToHex(hash), + config, + }).mfaRequired; + } catch { + return true; + } + } + + async #signHash( + address: Hex, + hash: Uint8Array, + request: MoneyAccountSignatureRequest | undefined, + ): Promise { + return this.#serializeOp(async () => { + const state = this.#assertState(); + const { keyShare, netCreds, serverNetId, shareEpoch } = state; + let { tssSetup } = state; + + const addr = this.#address(); + if (!equalAddresses(address, addr)) { + throw new Error(`account ${address} not found`); + } + + const mfaRequired = await this.#mfaRequired(request, hash); + let token = await this.#getProfileToken({ + twoFactor: mfaRequired, + challenge: hash, + }); + let nonce = generateSessionNonce(); + let signResult = await startSign({ + baseURL: this.#cloudURL, + token, + data: hash, + clientNetId: netCreds.partyId, + nonce, + shareEpoch, + ...(request === undefined ? {} : { request }), + }); + if (signResult === MFA_REQUIRED_CODE) { + if (mfaRequired) { + throw new Error('MFA required'); + } + token = await this.#getProfileToken({ + twoFactor: true, + challenge: hash, + }); + nonce = generateSessionNonce(); + signResult = await startSign({ + baseURL: this.#cloudURL, + token, + data: hash, + clientNetId: netCreds.partyId, + nonce, + shareEpoch, + }); + if (signResult === MFA_REQUIRED_CODE) { + throw new Error('MFA required'); + } + } + + const netSession = await this.#createNetworkSession( + netCreds, + serverNetId, + nonce, + ); + const bindings = shareBindings(netCreds.partyId, serverNetId); + + try { + tssSetup = await this.#ensureTssSetup( + netSession, + serverNetId, + bindings, + tssSetup, + ); + this.#applyKeyState({ ...state, tssSetup }); + + let ethSignature: Uint8Array; + try { + const { signature } = await this.#tss.sign({ + key: keyShare, + signers: bindings, + message: hash, + networkSession: netSession.createSubsession('tss-sign'), + setup: tssSetup, + }); + ethSignature = toEthSig(signature, hash, keyShare.publicKey); + } catch (error) { + // Any error drops the cached TSS setup. A failed sign may have + // consumed it, including a transport failure, so the next sign + // rebuilds setup with the peer instead of reusing it. + this.#applyKeyState({ ...state, tssSetup: null }); + throw error; + } + try { + await waitForDoneStatus( + netSession, + serverNetId, + STATUS_SIGNING_COMPLETED_PAYLOAD, + ); + } catch { + // Signing does not mutate shares, so a late status failure must not drop the signature. + } + return ethSignature; + } finally { + await netSession.disconnect(); + } + }); + } + + async #ensureTssSetup( + netSession: RootNetworkSession, + peerNetId: PartyId, + bindings: ShareBinding[], + storedSetup: Uint8Array | null, + ): Promise { + const haveSetup = storedSetup !== null; + netSession.sendMessage( + peerNetId, + TSS_HAVE_SETUP_MESSAGE_TYPE, + new TextEncoder().encode(JSON.stringify({ haveSetup })), + ); + const peerBytes = await netSession.receiveMessage( + peerNetId, + TSS_HAVE_SETUP_MESSAGE_TYPE, + ); + const peerPayload = JSON.parse(new TextDecoder().decode(peerBytes)) as { + haveSetup?: unknown; + }; + const peerHaveSetup = peerPayload.haveSetup === true; + if (haveSetup && peerHaveSetup) { + return storedSetup; + } + + return this.#tss.setup({ + signers: bindings, + networkSession: netSession.createSubsession('tss-setup'), + }); + } + + async #createNetworkSession( + netCreds: CentrifugeIdentity, + serverNetId: PartyId, + nonce: string, + ): Promise { + const sessionId = createScopedSessionId( + [serverNetId, netCreds.partyId], + nonce, + ); + return this.#networkManager.createSession(netCreds, sessionId); + } + + async #encryptKeyShare(keyShare: CL24ThresholdKey): Promise { + const key = await this.#getBackupEncryptionKey(); + const plaintext = new TextEncoder().encode( + JSON.stringify(this.#serializer.thresholdKey.toJson(keyShare)), + ); + const iv = globalThis.crypto.getRandomValues( + new Uint8Array(AES_GCM_IV_LENGTH), + ); + return encryptBytes(key, plaintext, iv); + } + + async #decryptKeyShare( + encryptedKeyShare: Uint8Array, + ): Promise { + const key = await this.#getBackupEncryptionKey(); + const plaintext = await decryptBytes(key, encryptedKeyShare); + return this.#serializer.thresholdKey.fromJson( + JSON.parse(new TextDecoder().decode(plaintext)) as Json, + ); + } + + async #serializeOp( + operation: () => Promise, + ): Promise { + const previous = this.#opQueue; + let release!: () => void; + this.#opQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } + + #applyKeyState(state: MpcMoneyKeyringState): void { + this.#state = { + status: 'initialized', + ...state, + }; + } + + #assertState(): MpcMoneyKeyringState { + if (!this.#state || this.#state.status !== 'initialized') { + throw new Error('Keyring not initialized'); + } + return this.#state; + } + + #address(): Hex { + return publicKeyToAddressHex(this.#assertState().keyShare.publicKey); + } +} diff --git a/packages/keyring-eth-money-mpc/src/types.ts b/packages/keyring-eth-money-mpc/src/types.ts new file mode 100644 index 000000000..2ac67f5e1 --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/types.ts @@ -0,0 +1,116 @@ +import type { EthKeyring } from '@metamask/keyring-utils'; +import type { CL24DKM, CL24ThresholdKey } from '@metamask/mfa-wallet-cl24'; +import type { Dkls23TssLib } from '@metamask/mfa-wallet-dkls23'; +import type { CentrifugeIdentity } from '@metamask/mfa-wallet-network'; +import type { MfaWhitelistConfig } from '@metamask/money-account-mfa-whitelist'; +import type { Json } from '@metamask/utils'; + +export type ProfileTokenOpts = { + twoFactor?: boolean; + challenge?: Uint8Array; +}; + +/** + * `/sign` response code when the backend still requires MFA. + * + * The keyring prompts for a 2FA token and retries once when it receives this + * after trying to sign without MFA. A second response, or this response to a + * token that already has 2FA, is an error. + */ +export const MFA_REQUIRED_CODE = 'mfa_required' as const; + +export type Cl24Lib = ConstructorParameters[0]; +export type Dkls23Lib = ConstructorParameters[0]; + +export type MpcMoneyKeyringOpts = { + cl24Lib: Cl24Lib; + dkls23Lib: Dkls23Lib; + cloudURL: string; + relayerURL: string; + getTransportToken?: () => Promise; + getProfileToken: (opts?: ProfileTokenOpts) => Promise; + /** + * Pinned whitelist configuration for the current signature. + * + * When this is omitted or throws, every signature requires MFA. + */ + getMfaWhitelistConfig?: () => + | MfaWhitelistConfig + | Promise; + getBackupEncryptionKey: () => Promise; + webSocket?: unknown; +}; + +export type MpcMoneyKeyringState = { + keyShare: CL24ThresholdKey; + shareEpoch: number; + netCreds: CentrifugeIdentity; + serverNetId: string; + tssSetup: Uint8Array | null; +}; + +/** The MPC money keyring setup modes. */ +export const MpcMoneyKeyringSetupMode = { + Create: 'create', + Import: 'import', +} as const; + +export type MpcMoneyKeyringSetupMode = + `${(typeof MpcMoneyKeyringSetupMode)[keyof typeof MpcMoneyKeyringSetupMode]}`; + +export type MpcMoneyKeyringSetupParams = { + mode: MpcMoneyKeyringSetupMode; +}; + +type JsonSerializer = { + toJson: (value: Value) => Json; + fromJson: (value: Json) => Value; +}; + +export type MpcMoneyKeyringInitializedState = { + status: 'initialized'; +} & MpcMoneyKeyringState; + +export type MpcMoneyKeyringUninitializedState = { + status: 'uninitialized'; + setup: MpcMoneyKeyringSetupParams; +}; + +export type MpcMoneyKeyringStorageState = + | MpcMoneyKeyringInitializedState + | MpcMoneyKeyringUninitializedState; + +export type MpcMoneyKeyringSerializer = { + thresholdKey: JsonSerializer; + networkIdentity: JsonSerializer; +}; + +/** + * The MPC money keyring contract implemented by the V1 keyring class and consumed + * by the V2 adapter. + */ +export type MpcMoneyKeyring = EthKeyring & { + /** + * Run key generation or import. + * + * @param mode - The MPC setup mode. + */ + init(mode?: MpcMoneyKeyringSetupMode): Promise; + + /** + * Rotate the MPC key shares. + */ + rotateKeyShares(): Promise; + + /** + * Check the MPC key share state. + * + * @returns Whether the key share is up to date. + */ + checkKeyShare(): Promise; + + /** + * Synchronize the MPC key share. + */ + syncKeyShare(): Promise; +}; diff --git a/packages/keyring-eth-money-mpc/src/util.test.ts b/packages/keyring-eth-money-mpc/src/util.test.ts new file mode 100644 index 000000000..ffd74b162 --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/util.test.ts @@ -0,0 +1,227 @@ +import { ecsign, privateToAddress, privateToPublic } from '@ethereumjs/util'; +import type { ECDSASignature } from '@ethereumjs/util'; +import { + SignTypedDataVersion, + TypedDataUtils, + typedSignatureHash, +} from '@metamask/eth-sig-util'; +import { bytesToHex, hexToBytes } from '@metamask/utils'; + +import { + AES_GCM_IV_LENGTH, + decryptBytes, + encryptBytes, + equalAddresses, + generateSessionNonce, + getSignedTypedDataHash, + normalizeAddress, + parseSignedTypedDataVersion, + publicKeyToAddressHex, + toEthSig, +} from './util.js'; + +describe('util', () => { + const privateKey = hexToBytes( + '0x6969696969696969696969696969696969696969696969696969696969696969', + ); + const publicKey = privateToPublic(privateKey); + const address = bytesToHex(privateToAddress(privateKey)); + + const SECP256K1_N = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', + ); + const SECP256K1_HALF_N = SECP256K1_N / 2n; + + const toBytes32 = (value: bigint): Uint8Array => { + return hexToBytes(`0x${value.toString(16).padStart(64, '0')}`); + }; + + const toCompactSignature = (signature: ECDSASignature): Uint8Array => { + const compact = new Uint8Array(64); + compact.set(signature.r, 0); + compact.set(signature.s, 32); + return compact; + }; + + const findSignatureByV = ( + targetV: bigint, + ): { hash: Uint8Array; signature: ECDSASignature } => { + for (let index = 0; index < 512; index += 1) { + const hash = new Uint8Array(32); + hash[31] = index; + const signature = ecsign(hash, privateKey); + if (signature.v === targetV) { + return { hash, signature }; + } + } + + throw new Error(`Could not find signature with v=${targetV.toString()}`); + }; + + it('generates a random 32-byte hex session nonce', () => { + const nonce = generateSessionNonce(); + expect(nonce).toMatch(/^0x[0-9a-f]{64}$/u); + expect(generateSessionNonce()).not.toBe(nonce); + }); + + it('converts public keys to address hex', () => { + expect(publicKeyToAddressHex(publicKey)).toBe(address); + }); + + it('normalizes and compares addresses', () => { + expect(normalizeAddress('0x1111111111111111111111111111111111111111')).toBe( + '0x1111111111111111111111111111111111111111', + ); + expect( + equalAddresses( + '0x1111111111111111111111111111111111111111', + '0x1111111111111111111111111111111111111111', + ), + ).toBe(true); + expect( + equalAddresses( + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + ), + ).toBe(false); + }); + + it('converts compact signatures to ethereum signatures with both parities', () => { + const sigWithParity0 = findSignatureByV(27n); + const ethSignature0 = toEthSig( + toCompactSignature(sigWithParity0.signature), + sigWithParity0.hash, + publicKey, + ); + expect(ethSignature0).toHaveLength(65); + expect(ethSignature0[64]).toBe(27); + + const sigWithParity1 = findSignatureByV(28n); + const ethSignature1 = toEthSig( + toCompactSignature(sigWithParity1.signature), + sigWithParity1.hash, + publicKey, + ); + expect(ethSignature1).toHaveLength(65); + expect(ethSignature1[64]).toBe(28); + }); + + it('enforces low-s for high-s compact signatures', () => { + const { hash, signature } = findSignatureByV(27n); + const highS = SECP256K1_N - BigInt(bytesToHex(signature.s)); + expect(highS).toBeGreaterThan(SECP256K1_HALF_N); + + const compactSignature = new Uint8Array(64); + compactSignature.set(signature.r, 0); + compactSignature.set(toBytes32(highS), 32); + + const ethSignature = toEthSig(compactSignature, hash, publicKey); + const normalizedS = BigInt(bytesToHex(ethSignature.slice(32, 64))); + + expect(ethSignature).toHaveLength(65); + expect(normalizedS).toBeLessThanOrEqual(SECP256K1_HALF_N); + }); + + it('pads normalized s to 32 bytes when needed', () => { + const { hash, signature } = findSignatureByV(27n); + const compactSignature = new Uint8Array(64); + compactSignature.set(signature.r, 0); + compactSignature.set(toBytes32(SECP256K1_N - 1n), 32); + + const ethSignature = toEthSig(compactSignature, hash, publicKey); + expect(ethSignature.slice(32, 64)).toStrictEqual(toBytes32(1n)); + }); + + it('falls back to parity=1 if public key recovery throws', () => { + const signature = new Uint8Array(64); + const invalidHash = new Uint8Array(32).fill(8); + const ethSignature = toEthSig(signature, invalidHash, publicKey); + expect(ethSignature).toHaveLength(65); + expect(ethSignature[64]).toBe(28); + }); + + it('throws for invalid compact signature lengths', () => { + expect(() => + toEthSig(new Uint8Array(63), new Uint8Array(32), new Uint8Array([4])), + ).toThrow('Invalid signature length'); + }); + + it('parses signed typed data versions with sane defaults', () => { + expect(parseSignedTypedDataVersion()).toBe(SignTypedDataVersion.V1); + expect(parseSignedTypedDataVersion({ version: 'NOPE' })).toBe( + SignTypedDataVersion.V1, + ); + expect( + parseSignedTypedDataVersion({ version: SignTypedDataVersion.V4 }), + ).toBe(SignTypedDataVersion.V4); + }); + + it('hashes signed typed data for V1 and EIP-712 versions', () => { + const typedDataV1 = [{ name: 'value', type: 'string', value: 'hello' }]; + + const v1Hash = getSignedTypedDataHash(typedDataV1, SignTypedDataVersion.V1); + expect(v1Hash).toStrictEqual(hexToBytes(typedSignatureHash(typedDataV1))); + + const typedDataV4 = { + types: { EIP712Domain: [] }, + domain: {}, + primaryType: 'EIP712Domain', + message: {}, + } as const; + + const v4Hash = getSignedTypedDataHash( + typedDataV4 as never, + SignTypedDataVersion.V4, + ); + expect(v4Hash).toStrictEqual( + new Uint8Array( + TypedDataUtils.eip712Hash( + typedDataV4 as never, + SignTypedDataVersion.V4, + ), + ), + ); + }); + + it('encrypts and decrypts bytes with AES-GCM', async () => { + const key = new Uint8Array(32).fill(9); + const iv = new Uint8Array(AES_GCM_IV_LENGTH).fill(1); + const plaintext = new TextEncoder().encode('key-share'); + + const payload = await encryptBytes(key, plaintext, iv); + expect(payload.slice(0, AES_GCM_IV_LENGTH)).toStrictEqual(iv); + + const decrypted = await decryptBytes(key, payload); + expect(decrypted).toStrictEqual(plaintext); + }); + + it('encrypts with a 16-byte AES key', async () => { + const key = new Uint8Array(16).fill(3); + const iv = new Uint8Array(AES_GCM_IV_LENGTH).fill(2); + const plaintext = new Uint8Array([4, 5, 6]); + + const decrypted = await decryptBytes( + key, + await encryptBytes(key, plaintext, iv), + ); + expect(decrypted).toStrictEqual(plaintext); + }); + + it('throws for invalid AES-GCM parameters', async () => { + const key = new Uint8Array(32).fill(1); + const plaintext = new Uint8Array([1]); + + await expect( + encryptBytes(key, plaintext, new Uint8Array(11)), + ).rejects.toThrow('Invalid IV length'); + await expect( + encryptBytes(new Uint8Array(15), plaintext, new Uint8Array(12)), + ).rejects.toThrow('Invalid backup encryption key length'); + await expect(decryptBytes(key, new Uint8Array(12))).rejects.toThrow( + 'Invalid ciphertext', + ); + await expect( + decryptBytes(new Uint8Array(15), new Uint8Array(20)), + ).rejects.toThrow('Invalid backup encryption key length'); + }); +}); diff --git a/packages/keyring-eth-money-mpc/src/util.ts b/packages/keyring-eth-money-mpc/src/util.ts new file mode 100644 index 000000000..011b6651f --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/util.ts @@ -0,0 +1,227 @@ +import { + bigIntToBytes, + concatBytes, + ecrecover, + pubToAddress, +} from '@ethereumjs/util'; +import { decrypt, encrypt, IV_LENGTH } from '@metamask/cryptography/aes-gcm'; +import type { + MessageTypes, + TypedDataV1, + TypedMessage, +} from '@metamask/eth-sig-util'; +import { + normalize, + SignTypedDataVersion, + TypedDataUtils, + typedSignatureHash, +} from '@metamask/eth-sig-util'; +import type { Hex } from '@metamask/utils'; +import { add0x, assert, bytesToHex, hexToBytes } from '@metamask/utils'; + +const SESSION_NONCE_BYTE_LENGTH = 32; +export const AES_GCM_IV_LENGTH = IV_LENGTH; + +/** + * Generate a session nonce: random bytes from the platform RNG, hex-encoded. + * + * @returns Hex-encoded 32-byte random nonce. + */ +export function generateSessionNonce(): Hex { + return bytesToHex( + globalThis.crypto.getRandomValues( + new Uint8Array(SESSION_NONCE_BYTE_LENGTH), + ), + ); +} + +/** + * Encrypt plaintext with AES-GCM. The IV is prepended to the ciphertext. + * + * @param key - 16- or 32-byte AES key. + * @param plaintext - Bytes to encrypt. + * @param iv - 12-byte IV. + * @returns `iv || ciphertext || tag`. + */ +export async function encryptBytes( + key: Uint8Array, + plaintext: Uint8Array, + iv: Uint8Array, +): Promise { + if (iv.length !== AES_GCM_IV_LENGTH) { + throw new Error('Invalid IV length'); + } + if (key.length !== 16 && key.length !== 32) { + throw new Error('Invalid backup encryption key length'); + } + + const { ciphertext } = await encrypt(key, plaintext, { unsafeIv: iv }); + return concatBytes(iv, ciphertext); +} + +/** + * Decrypt a payload produced by {@link encryptBytes}. + * + * @param key - 16- or 32-byte AES key. + * @param payload - `iv || ciphertext || tag`. + * @returns The plaintext bytes. + */ +export async function decryptBytes( + key: Uint8Array, + payload: Uint8Array, +): Promise { + if (payload.length <= AES_GCM_IV_LENGTH) { + throw new Error('Invalid ciphertext'); + } + if (key.length !== 16 && key.length !== 32) { + throw new Error('Invalid backup encryption key length'); + } + + const iv = payload.slice(0, AES_GCM_IV_LENGTH); + const ciphertext = payload.slice(AES_GCM_IV_LENGTH); + return decrypt(key, iv, ciphertext); +} + +const SECP256K1_N = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', +); +const SECP256K1_HALF_N = SECP256K1_N / 2n; + +/** + * Normalize an address. + * + * @param address - The address to normalize. + * @returns The normalized address. + */ +export function normalizeAddress(address: string): Hex { + const normalized = normalize(address); + assert(normalized, 'Expected address to be set'); + return add0x(normalized); +} + +/** + * Check if two addresses are equal. + * + * @param address1 - The first address. + * @param address2 - The second address. + * @returns Whether the addresses are equal. + */ +export function equalAddresses(address1: string, address2: string): boolean { + return normalizeAddress(address1) === normalizeAddress(address2); +} + +/** + * Convert an ECDSA signature in compact format (64 bytes) to a signature in + * Ethereum extended format (65 bytes). + * + * @param signature - The signature to convert. + * @param hash - The hash of the message. + * @param pubKey - The public key of the signer. + * @returns The Ethereum signature. + */ +export function toEthSig( + signature: Uint8Array, + hash: Uint8Array, + pubKey: Uint8Array, +): Uint8Array { + if (signature.length !== 64) { + throw new Error('Invalid signature length'); + } + + // Enforce low `s` + + const rBuf = signature.slice(0, 32); + let sBuf = signature.slice(32, 64); + + const sInt = BigInt(add0x(bytesToHex(sBuf))); + if (sInt > SECP256K1_HALF_N) { + const newSInt = SECP256K1_N - sInt; + const newSBytes = bigIntToBytes(newSInt); + + if (newSBytes.length < 32) { + sBuf = new Uint8Array(32); + sBuf.set(newSBytes, 32 - newSBytes.length); + } else { + sBuf = new Uint8Array(newSBytes); + } + } + + // Compute `v` + // --------------------------------------------------------------------------- + // NOTE: If the signing library provided the parity of R.y, we could compute + // `v` directly and skip the costly ecrecover operation. + // --------------------------------------------------------------------------- + + const expectedAddr = publicKeyToAddressHex(pubKey); + + const checkParity = (parity: bigint): boolean => { + try { + const candidatePubKey = ecrecover(hash, parity, rBuf, sBuf); + return publicKeyToAddressHex(candidatePubKey) === expectedAddr; + } catch { + return false; + } + }; + + const parity = checkParity(0n) ? 0n : 1n; + + // Ethereum's recovery value: `v = parity(R.y) + 27` + const vInt = parity + 27n; + + // Ethereum's extended signature format: `[r | s | v]` + return concatBytes(rBuf, sBuf, bigIntToBytes(vInt)); +} + +/** + * Parse the version of a signed typed data object. + * + * @param opts - The options object. + * @returns The version of the signed typed data object. + */ +export function parseSignedTypedDataVersion( + opts?: Record, +): SignTypedDataVersion { + let version = opts?.version as SignTypedDataVersion | undefined; + if (!version || !Object.keys(SignTypedDataVersion).includes(version)) { + version = SignTypedDataVersion.V1; + } + return version; +} + +/** + * Get the hash of a signed typed data object. + * + * @param data - The data to hash. + * @param version - The version of the signed typed data object. + * @returns The hash of the signed typed data object. + */ +export function getSignedTypedDataHash< + Version extends SignTypedDataVersion, + MessageType extends MessageTypes, +>( + data: Version extends 'V1' ? TypedDataV1 : TypedMessage, + version: Version, +): Uint8Array { + if (version === SignTypedDataVersion.V1) { + const hash = typedSignatureHash(data as unknown as TypedDataV1); + return hexToBytes(hash); + } + + const hash = TypedDataUtils.eip712Hash( + data as TypedMessage, + version, + ); + return new Uint8Array(hash); +} + +/** + * Convert a public key to an address. + * + * Accepts a raw 64-byte key, or a compressed or uncompressed SEC1 key. + * + * @param publicKey - The public key to convert. + * @returns The address. + */ +export function publicKeyToAddressHex(publicKey: Uint8Array): Hex { + return bytesToHex(pubToAddress(publicKey, true)); +} diff --git a/packages/keyring-eth-money-mpc/src/v2/index.ts b/packages/keyring-eth-money-mpc/src/v2/index.ts new file mode 100644 index 000000000..d5f85d1b5 --- /dev/null +++ b/packages/keyring-eth-money-mpc/src/v2/index.ts @@ -0,0 +1,5 @@ +export { + MpcMoneyKeyring, + type MpcMoneyCreateAccountOptions, + type MpcMoneyKeyringOptions, +} from './mpc-money-keyring.js'; diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts b/packages/keyring-eth-money-mpc/src/v2/mpc-money-keyring.test.ts similarity index 87% rename from packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts rename to packages/keyring-eth-money-mpc/src/v2/mpc-money-keyring.test.ts index 16145ccf5..2ea9696c2 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts +++ b/packages/keyring-eth-money-mpc/src/v2/mpc-money-keyring.test.ts @@ -1,4 +1,3 @@ -import type { TypedTxData } from '@ethereumjs/tx'; import { SignTypedDataVersion } from '@metamask/eth-sig-util'; import { EthAccountType, @@ -12,23 +11,26 @@ import { EthKeyringMethod } from '@metamask/keyring-sdk/v2'; import type { AccountId } from '@metamask/keyring-utils'; import type { Hex, Json } from '@metamask/utils'; -import type { MpcKeyring as MpcKeyringV1 } from '../types'; -import { MPC_KEYRING_METHODS, MpcKeyring } from './mpc-keyring'; -import type { MpcCreateAccountOptions } from './mpc-keyring'; +import type { MpcMoneyKeyring as MpcMoneyKeyringV1 } from '../types.js'; +import { + MPC_MONEY_KEYRING_METHODS, + MpcMoneyKeyring, +} from './mpc-money-keyring.js'; +import type { MpcMoneyCreateAccountOptions } from './mpc-money-keyring.js'; const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; const OTHER_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; /** - * Expected methods supported by MPC keyring accounts. + * Expected methods supported by MPC money keyring accounts. */ -const EXPECTED_METHODS = MPC_KEYRING_METHODS; +const EXPECTED_METHODS = MPC_MONEY_KEYRING_METHODS; /** - * The legacy MPC keyring methods that the V2 wrapper delegates to, mocked + * The legacy MPC money keyring methods that the V2 wrapper delegates to, mocked * for testing. */ -type MockMpcKeyringV1 = { +type MockMpcMoneyKeyringV1 = { type: string; getAccounts: jest.Mock; init: jest.Mock; @@ -36,7 +38,6 @@ type MockMpcKeyringV1 = { deserialize: jest.Mock; addAccounts: jest.Mock; getAppKeyAddress: jest.Mock; - signTransaction: jest.Mock; signPersonalMessage: jest.Mock; signTypedData: jest.Mock; signEip7702Authorization: jest.Mock; @@ -46,21 +47,20 @@ type MockMpcKeyringV1 = { }; /** - * Create a mock of the legacy MPC keyring with all the methods the V2 + * Create a mock of the legacy MPC money keyring with all the methods the V2 * wrapper delegates to. * * @returns The mocked inner keyring. */ -function createInner(): MockMpcKeyringV1 { +function createInner(): MockMpcMoneyKeyringV1 { return { - type: 'MPC Keyring', + type: 'MPC Money Keyring', getAccounts: jest.fn().mockResolvedValue([] as Hex[]), init: jest.fn().mockResolvedValue(undefined), serialize: jest.fn().mockResolvedValue({}), deserialize: jest.fn().mockResolvedValue(undefined), addAccounts: jest.fn(), getAppKeyAddress: jest.fn(), - signTransaction: jest.fn(), signPersonalMessage: jest.fn(), signTypedData: jest.fn(), signEip7702Authorization: jest.fn(), @@ -71,7 +71,7 @@ function createInner(): MockMpcKeyringV1 { } /** - * Create a V2 wrapper around a mocked legacy MPC keyring. + * Create a V2 wrapper around a mocked legacy MPC money keyring. * * @param args - Setup arguments. * @param args.accounts - The addresses reported by the inner keyring. @@ -82,14 +82,14 @@ function setup({ }: { accounts?: Hex[]; } = {}): { - wrapper: MpcKeyring; - inner: MockMpcKeyringV1; + wrapper: MpcMoneyKeyring; + inner: MockMpcMoneyKeyringV1; } { const inner = createInner(); inner.getAccounts.mockResolvedValue(accounts); - const wrapper = new MpcKeyring({ - legacyKeyring: inner as unknown as MpcKeyringV1, + const wrapper = new MpcMoneyKeyring({ + legacyKeyring: inner as unknown as MpcMoneyKeyringV1, }); return { wrapper, inner }; @@ -102,8 +102,8 @@ function setup({ * @returns The wrapper, the mocked inner keyring, and the account ID. */ async function createWrapperWithAccount(): Promise<{ - wrapper: MpcKeyring; - inner: MockMpcKeyringV1; + wrapper: MpcMoneyKeyring; + inner: MockMpcMoneyKeyringV1; accountId: AccountId; }> { const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); @@ -137,7 +137,7 @@ function createMockRequest( }; } -describe('MpcKeyring (v2 wrapper)', () => { +describe('MpcMoneyKeyring (v2 wrapper)', () => { describe('constructor', () => { it('creates a wrapper with the correct type and capabilities', () => { const { wrapper } = setup(); @@ -194,7 +194,7 @@ describe('MpcKeyring (v2 wrapper)', () => { inner.getAccounts.mockResolvedValue([MOCK_ADDRESS, OTHER_ADDRESS]); await expect(wrapper.getAccounts()).rejects.toThrow( - 'MpcKeyring: supports at most one account', + 'MpcMoneyKeyring: supports at most one account', ); }); }); @@ -262,7 +262,7 @@ describe('MpcKeyring (v2 wrapper)', () => { await expect( wrapper.createAccounts({ type: 'custom', mode: 'create' }), - ).rejects.toThrow('MpcKeyring: account creation failed'); + ).rejects.toThrow('MpcMoneyKeyring: account creation failed'); expect(inner.init).toHaveBeenCalledWith('create'); }); @@ -274,10 +274,10 @@ describe('MpcKeyring (v2 wrapper)', () => { type: 'bip44:derive-index', entropySource: 'entropy-source', groupIndex: 0, - } as unknown as MpcCreateAccountOptions; + } as unknown as MpcMoneyCreateAccountOptions; await expect(wrapper.createAccounts(invalidOptions)).rejects.toThrow( - 'MpcKeyring: unsupported account creation type: bip44:derive-index', + 'MpcMoneyKeyring: unsupported account creation type: bip44:derive-index', ); expect(inner.init).not.toHaveBeenCalled(); }); @@ -288,10 +288,10 @@ describe('MpcKeyring (v2 wrapper)', () => { const invalidOptions = { type: 'custom', mode: 'bogus', - } as unknown as MpcCreateAccountOptions; + } as unknown as MpcMoneyCreateAccountOptions; await expect(wrapper.createAccounts(invalidOptions)).rejects.toThrow( - 'MpcKeyring: invalid mode: bogus', + 'MpcMoneyKeyring: invalid mode: bogus', ); expect(inner.init).not.toHaveBeenCalled(); }); @@ -327,7 +327,7 @@ describe('MpcKeyring (v2 wrapper)', () => { await expect( wrapper.deleteAccount(account?.id ?? ('' as AccountId)), - ).rejects.toThrow('MpcKeyring: deleting accounts is not supported'); + ).rejects.toThrow('MpcMoneyKeyring: deleting accounts is not supported'); }); }); @@ -445,30 +445,17 @@ describe('MpcKeyring (v2 wrapper)', () => { ); }); - it('signs a transaction via the inner keyring', async () => { + it('rejects transaction signing', async () => { const { wrapper, inner, accountId } = await createWrapperWithAccount(); - const signedTx = { serialized: '0xdeadbeef' }; - inner.signTransaction.mockResolvedValue(signedTx); - - const txParams: TypedTxData = { - nonce: '0x00', - gasPrice: '0x09184e72a000', - gasLimit: '0x2710', - to: '0x0000000000000000000000000000000000000001', - value: '0x1000', - }; - - const result = await wrapper.submitRequest( - createMockRequest(accountId, EthMethod.SignTransaction, [ - txParams as unknown as Json, - ]), - ); - expect(result).toStrictEqual(signedTx); - expect(inner.signTransaction).toHaveBeenCalledWith( - MOCK_ADDRESS, - expect.anything(), + await expect( + wrapper.submitRequest( + createMockRequest(accountId, EthMethod.SignTransaction), + ), + ).rejects.toThrow( + `Account ${accountId} cannot handle method: ${EthMethod.SignTransaction}`, ); + expect(inner.signPersonalMessage).not.toHaveBeenCalled(); }); it('signs an EIP-7702 authorization via the inner keyring', async () => { diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts b/packages/keyring-eth-money-mpc/src/v2/mpc-money-keyring.ts similarity index 72% rename from packages/keyring-eth-mpc/src/v2/mpc-keyring.ts rename to packages/keyring-eth-money-mpc/src/v2/mpc-money-keyring.ts index 4e6b13608..a052d63a2 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts +++ b/packages/keyring-eth-money-mpc/src/v2/mpc-money-keyring.ts @@ -11,15 +11,15 @@ import { EthKeyringMethod, EthKeyringWrapper } from '@metamask/keyring-sdk/v2'; import type { AccountId } from '@metamask/keyring-utils'; import type { Hex } from '@metamask/utils'; -import { MpcKeyringSetupMode } from '../types'; -import type { MpcKeyring as MpcKeyringV1 } from '../types'; +import { MpcMoneyKeyringSetupMode } from '../types.js'; +import type { MpcMoneyKeyring as MpcMoneyKeyringV1 } from '../types.js'; /** - * Methods supported by MPC keyring EOA accounts. - * MPC keyrings support signing methods, but not encryption or app keys. + * Methods supported by MPC money keyring EOA accounts. + * MPC money keyrings support message signing, but not transactions, + * encryption, or app keys. */ -export const MPC_KEYRING_METHODS = [ - EthMethod.SignTransaction, +export const MPC_MONEY_KEYRING_METHODS = [ EthMethod.PersonalSign, EthMethod.SignTypedDataV1, EthMethod.SignTypedDataV3, @@ -28,9 +28,9 @@ export const MPC_KEYRING_METHODS = [ ]; /** - * Capabilities for the MPC keyring. + * Capabilities for the MPC money keyring. */ -const mpcKeyringCapabilities: KeyringCapabilities = { +const mpcMoneyKeyringCapabilities: KeyringCapabilities = { scopes: [EthScope.Eoa], custom: { createAccounts: true, @@ -38,9 +38,9 @@ const mpcKeyringCapabilities: KeyringCapabilities = { }; /** - * Options for creating an account in the MPC keyring. + * Options for creating an account in the MPC money keyring. */ -export type MpcCreateAccountOptions = { +export type MpcMoneyCreateAccountOptions = { /** * The type of the options. */ @@ -49,35 +49,35 @@ export type MpcCreateAccountOptions = { /** * The MPC setup mode. */ - mode?: MpcKeyringSetupMode; + mode?: MpcMoneyKeyringSetupMode; }; /** - * Options for constructing the V2 {@link MpcKeyring}. + * Options for constructing the V2 {@link MpcMoneyKeyring}. */ -export type MpcKeyringOptions = { +export type MpcMoneyKeyringOptions = { /** * The underlying "old" keyring instance that this wrapper adapts. */ - legacyKeyring: MpcKeyringV1; + legacyKeyring: MpcMoneyKeyringV1; }; /** - * Concrete {@link Keyring} adapter for a {@link MpcKeyringV1} + * Concrete {@link Keyring} adapter for a {@link MpcMoneyKeyringV1} * implementation. * * This wrapper exposes the accounts and signing capabilities of the legacy - * MPC keyring via the unified V2 interface. + * MPC money keyring via the unified V2 interface. */ -export class MpcKeyring - extends EthKeyringWrapper +export class MpcMoneyKeyring + extends EthKeyringWrapper implements Keyring { - constructor(options: MpcKeyringOptions) { + constructor(options: MpcMoneyKeyringOptions) { super({ type: KeyringType.Mpc, inner: options.legacyKeyring, - capabilities: mpcKeyringCapabilities, + capabilities: mpcMoneyKeyringCapabilities, }); } @@ -90,7 +90,7 @@ export class MpcKeyring const addresses = await this.inner.getAccounts(); if (addresses.length > 1) { - throw new Error('MpcKeyring: supports at most one account'); + throw new Error('MpcMoneyKeyring: supports at most one account'); } const [address] = addresses; @@ -112,7 +112,7 @@ export class MpcKeyring type: EthAccountType.Eoa, address, scopes: [...this.capabilities.scopes], - methods: [...MPC_KEYRING_METHODS], + methods: [...MPC_MONEY_KEYRING_METHODS], options: { entropy: { type: KeyringAccountEntropyTypeOption.Mpc, @@ -147,12 +147,12 @@ export class MpcKeyring * account. */ async createAccounts( - options: MpcCreateAccountOptions, + options: MpcMoneyCreateAccountOptions, ): Promise { return this.withLock(async () => { if (options.type !== 'custom') { throw new Error( - `MpcKeyring: unsupported account creation type: ${String( + `MpcMoneyKeyring: unsupported account creation type: ${String( options.type, )}`, ); @@ -161,10 +161,10 @@ export class MpcKeyring const { mode } = options; if ( mode !== undefined && - mode !== MpcKeyringSetupMode.Create && - mode !== MpcKeyringSetupMode.Import + mode !== MpcMoneyKeyringSetupMode.Create && + mode !== MpcMoneyKeyringSetupMode.Import ) { - throw new Error(`MpcKeyring: invalid mode: ${String(mode)}`); + throw new Error(`MpcMoneyKeyring: invalid mode: ${String(mode)}`); } const accounts = await this.getAccounts(); @@ -176,7 +176,7 @@ export class MpcKeyring const [createdAccount] = await this.getAccounts(); if (!createdAccount) { - throw new Error('MpcKeyring: account creation failed'); + throw new Error('MpcMoneyKeyring: account creation failed'); } return [createdAccount]; @@ -189,7 +189,7 @@ export class MpcKeyring * @param _accountId - The account ID to delete. */ async deleteAccount(_accountId: AccountId): Promise { - throw new Error('MpcKeyring: deleting accounts is not supported'); + throw new Error('MpcMoneyKeyring: deleting accounts is not supported'); } /** diff --git a/packages/keyring-eth-mpc/tsconfig.build.json b/packages/keyring-eth-money-mpc/tsconfig.build.json similarity index 64% rename from packages/keyring-eth-mpc/tsconfig.build.json rename to packages/keyring-eth-money-mpc/tsconfig.build.json index 050cb1fd6..985e1f884 100644 --- a/packages/keyring-eth-mpc/tsconfig.build.json +++ b/packages/keyring-eth-money-mpc/tsconfig.build.json @@ -3,7 +3,10 @@ "compilerOptions": { "baseUrl": "./", "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + // NOTE: @metamask/mfa-wallet-network uses Uint8Array which requires TypeScript 5.7+. + // skipLibCheck bypasses type-checking of .d.ts files in node_modules. + "skipLibCheck": true }, "references": [ { diff --git a/packages/keyring-eth-mpc/tsconfig.json b/packages/keyring-eth-money-mpc/tsconfig.json similarity index 56% rename from packages/keyring-eth-mpc/tsconfig.json rename to packages/keyring-eth-money-mpc/tsconfig.json index 222170f96..74c540af0 100644 --- a/packages/keyring-eth-mpc/tsconfig.json +++ b/packages/keyring-eth-money-mpc/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + // NOTE: @metamask/mfa-wallet-network uses Uint8Array which requires TypeScript 5.7+. + // skipLibCheck bypasses type-checking of .d.ts files in node_modules. + "skipLibCheck": true }, "references": [ { diff --git a/packages/keyring-eth-mpc/CHANGELOG.md b/packages/keyring-eth-mpc/CHANGELOG.md deleted file mode 100644 index 1e4a81d63..000000000 --- a/packages/keyring-eth-mpc/CHANGELOG.md +++ /dev/null @@ -1,25 +0,0 @@ -# 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 release of `@metamask/eth-mpc-keyring` ([#641](https://github.com/MetaMask/accounts/pull/641)) - - The V1 MPC keyring implementation is not included yet. -- Add a V2 keyring implementation, available via the `./v2` export ([#641](https://github.com/MetaMask/accounts/pull/641)), ([#653](https://github.com/MetaMask/accounts/pull/653)) - - `MpcKeyring` (V2) implements the unified V2 `Keyring` interface with type `KeyringType.Mpc`. - - Account creation uses custom options: `{ type: 'custom', mode: 'create' | 'import' }`, and the keyring declares `custom.createAccounts` in its capabilities. - - Account are created with `options: { entropy: { type: 'mpc' }}`. - - Exposes `rotateKeyShares`, `checkKeyShare`, and `syncKeyShare` maintenance operations. - -### Changed - -- Bump `@metamask/keyring-api` from `^24.1.0` to `^24.2.0` ([#660](https://github.com/MetaMask/accounts/pull/660)) -- Bump `@metamask/keyring-sdk` from `^3.1.0` to `^4.0.0` ([#664](https://github.com/MetaMask/accounts/pull/664)) - -[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-mpc/README.md b/packages/keyring-eth-mpc/README.md deleted file mode 100644 index 35a219bfb..000000000 --- a/packages/keyring-eth-mpc/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# MPC Keyring - -A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing, built on top of the [MFA Wallet SDK](https://github.com/MetaMask/mfa-wallet-sdk). - -> [!NOTE] -> The full V1 MPC keyring implementation is not included yet. Implement the `MpcKeyringV1` contract yourself and pass it to the V2 keyring; the official implementation will be provided in a later release, once the MFA Wallet SDK packages are releasable. - -## Installation - -`yarn add @metamask/eth-mpc-keyring` - -or - -`npm install @metamask/eth-mpc-keyring` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). diff --git a/packages/keyring-eth-mpc/src/index.ts b/packages/keyring-eth-mpc/src/index.ts deleted file mode 100644 index f78f4c1a6..000000000 --- a/packages/keyring-eth-mpc/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { MpcKeyringSetupMode } from './types'; -export type { MpcKeyring } from './types'; diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts deleted file mode 100644 index 736bde2f3..000000000 --- a/packages/keyring-eth-mpc/src/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { EthKeyring } from '@metamask/keyring-utils'; - -/** The MPC keyring setup modes. */ -export const MpcKeyringSetupMode = { - Create: 'create', - Import: 'import', -} as const; -export type MpcKeyringSetupMode = - `${(typeof MpcKeyringSetupMode)[keyof typeof MpcKeyringSetupMode]}`; - -/** - * The MPC keyring contract. - */ -export type MpcKeyring = EthKeyring & { - /** - * Run key generation or import. - * - * @param mode - The MPC setup mode. - */ - init(mode?: MpcKeyringSetupMode): Promise; - - /** - * Rotate the MPC key shares. - */ - rotateKeyShares(): Promise; - - /** - * Check the MPC key share state. - * - * @returns Whether the key share is up to date. - */ - checkKeyShare(): Promise; - - /** - * Synchronize the MPC key share. - */ - syncKeyShare(): Promise; -}; diff --git a/packages/keyring-eth-mpc/src/v2/index.ts b/packages/keyring-eth-mpc/src/v2/index.ts deleted file mode 100644 index 0a8efe443..000000000 --- a/packages/keyring-eth-mpc/src/v2/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - MpcKeyring, - type MpcCreateAccountOptions, - type MpcKeyringOptions, -} from './mpc-keyring'; diff --git a/packages/keyring-eth-mpc/v2.js b/packages/keyring-eth-mpc/v2.js deleted file mode 100644 index faf7abd23..000000000 --- a/packages/keyring-eth-mpc/v2.js +++ /dev/null @@ -1,3 +0,0 @@ -// Re-exported for compatibility with Browserify. -// eslint-disable-next-line -module.exports = require('./dist/v2/index.cjs'); diff --git a/tsconfig.build.json b/tsconfig.build.json index 823d82643..493585fa3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,7 +6,7 @@ { "path": "./packages/keyring-eth-hd/tsconfig.build.json" }, { "path": "./packages/keyring-eth-money/tsconfig.build.json" }, { "path": "./packages/keyring-eth-ledger-bridge/tsconfig.build.json" }, - { "path": "./packages/keyring-eth-mpc/tsconfig.build.json" }, + { "path": "./packages/keyring-eth-money-mpc/tsconfig.build.json" }, { "path": "./packages/keyring-eth-qr/tsconfig.build.json" }, { "path": "./packages/keyring-eth-simple/tsconfig.build.json" }, { "path": "./packages/keyring-eth-trezor/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 390d1290e..0727351dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ { "path": "./packages/keyring-api" }, { "path": "./packages/keyring-eth-hd" }, { "path": "./packages/keyring-eth-ledger-bridge" }, - { "path": "./packages/keyring-eth-mpc" }, + { "path": "./packages/keyring-eth-money-mpc" }, { "path": "./packages/keyring-eth-simple" }, { "path": "./packages/keyring-eth-trezor" }, { "path": "./packages/keyring-internal-api" }, diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 9a395f32e..6442e0ec4 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -28,6 +28,9 @@ * `jest.config.packages.js`. */ "paths": { + "@metamask/cryptography/aes-gcm": [ + "../../node_modules/@metamask/cryptography/dist/aes-gcm.d.ts" + ], "@metamask/*/v2": ["../*/src/v2"], "@metamask/*": ["../*/src"] } diff --git a/yarn.config.cjs b/yarn.config.cjs index 10ab49a76..875c250d6 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -35,6 +35,20 @@ const ALLOWED_INCONSISTENT_DEPENDENCIES = {}; */ const PINNED_VERSION_EXCEPTIONS = []; +/** + * FIXME: These packages are ESM-only, which is not yet the standard package + * format across this monorepo. They deviate from the CommonJS-compatible + * (dual-format) package shape enforced by these constraints: ESM-only + * `exports` without a CommonJS fallback, a `ts-bridge` build using + * `--format module`, and Jest scripts without + * `NODE_OPTIONS=--experimental-vm-modules`. The checks they would fail are + * skipped until the monorepo-wide ESM migration lands and the constraints + * are updated. + * + * @type {string[]} + */ +const ESM_ONLY_PACKAGE_EXCEPTIONS = ['@metamask/eth-money-mpc-keyring']; + /** * Aliases for the Yarn type definitions, to make the code more readable. * @@ -67,6 +81,9 @@ module.exports = defineConfig({ const isPrivate = hasProperty(workspace.manifest, 'private') && workspace.manifest.private === true; + const isEsModuleOnlyPackage = ESM_ONLY_PACKAGE_EXCEPTIONS.includes( + workspace.manifest.name, + ); const dependenciesByIdentAndType = getDependenciesByIdentAndType( Yarn.dependencies({ workspace }), ); @@ -124,21 +141,31 @@ module.exports = defineConfig({ // All non-root packages must set up ESM- and CommonJS-compatible // exports correctly. - expectCorrectWorkspaceExports(workspace); + // FIXME: ESM-only packages use a different export shape and are + // skipped until the ESM migration lands + // (see ESM_ONLY_PACKAGE_EXCEPTIONS). + if (!isEsModuleOnlyPackage) { + expectCorrectWorkspaceExports(workspace); + } // All non-root packages must have the same "build" script. - expectWorkspaceField( - workspace, - 'scripts.build', - 'ts-bridge --project tsconfig.build.json --verbose --clean --no-references', - ); - - // All non-root packages must have the same "build:all" script. - expectWorkspaceField( - workspace, - 'scripts.build:all', - 'ts-bridge --project tsconfig.build.json --verbose --clean', - ); + // FIXME: ESM-only packages build with `--format module` and are + // skipped until the ESM migration lands + // (see ESM_ONLY_PACKAGE_EXCEPTIONS). + if (!isEsModuleOnlyPackage) { + expectWorkspaceField( + workspace, + 'scripts.build', + 'ts-bridge --project tsconfig.build.json --verbose --clean --no-references', + ); + + // All non-root packages must have the same "build:all" script. + expectWorkspaceField( + workspace, + 'scripts.build:all', + 'ts-bridge --project tsconfig.build.json --verbose --clean', + ); + } // All non-root packages must have the same "build:docs" script. expectWorkspaceField(workspace, 'scripts.build:docs', 'typedoc'); @@ -165,11 +192,16 @@ module.exports = defineConfig({ ); // All non-root packages must have the same "test:source" script. - expectWorkspaceField( - workspace, - 'scripts.test:source', - 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', - ); + // FIXME: ESM-only packages run Jest without + // `NODE_OPTIONS=--experimental-vm-modules` and are skipped until the + // ESM migration lands (see ESM_ONLY_PACKAGE_EXCEPTIONS). + if (!isEsModuleOnlyPackage) { + expectWorkspaceField( + workspace, + 'scripts.test:source', + 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', + ); + } // All non-root packages must have the same "test:types" script. expectWorkspaceField( @@ -178,26 +210,30 @@ module.exports = defineConfig({ '../../scripts/tsd-test.sh ./src', ); - // All non-root packages must have the same "test:clean" script. - expectWorkspaceField( - workspace, - 'scripts.test:clean', - 'NODE_OPTIONS=--experimental-vm-modules jest --clearCache', - ); - - // All non-root packages must have the same "test:verbose" script. - expectWorkspaceField( - workspace, - 'scripts.test:verbose', - 'NODE_OPTIONS=--experimental-vm-modules jest --verbose', - ); - - // All non-root packages must have the same "test:watch" script. - expectWorkspaceField( - workspace, - 'scripts.test:watch', - 'NODE_OPTIONS=--experimental-vm-modules jest --watch', - ); + // FIXME: ESM-only packages are skipped for the same reason as + // "test:source" above (see ESM_ONLY_PACKAGE_EXCEPTIONS). + if (!isEsModuleOnlyPackage) { + // All non-root packages must have the same "test:clean" script. + expectWorkspaceField( + workspace, + 'scripts.test:clean', + 'NODE_OPTIONS=--experimental-vm-modules jest --clearCache', + ); + + // All non-root packages must have the same "test:verbose" script. + expectWorkspaceField( + workspace, + 'scripts.test:verbose', + 'NODE_OPTIONS=--experimental-vm-modules jest --verbose', + ); + + // All non-root packages must have the same "test:watch" script. + expectWorkspaceField( + workspace, + 'scripts.test:watch', + 'NODE_OPTIONS=--experimental-vm-modules jest --watch', + ); + } } if (isChildWorkspace) { diff --git a/yarn.lock b/yarn.lock index deb5e7465..1f577432e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1603,6 +1603,16 @@ __metadata: languageName: node linkType: hard +"@metamask/abi-utils@npm:^2.0.4": + version: 2.0.4 + resolution: "@metamask/abi-utils@npm:2.0.4" + dependencies: + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^9.0.0" + checksum: 10/3d32d42c6e98fc4719b2b53597e573764b80936c7cc31d884c87729c4c4f74a30e93096db87aaa7cbcec9d3bb7d22b1adfc98a8bcb4c7c2f17bfbddaa4367d34 + languageName: node + linkType: hard + "@metamask/abi-utils@npm:^3.0.0": version: 3.0.0 resolution: "@metamask/abi-utils@npm:3.0.0" @@ -1832,6 +1842,24 @@ __metadata: languageName: node linkType: hard +"@metamask/cryptography@npm:^1.1.0, @metamask/cryptography@npm:^1.1.2": + version: 1.1.2 + resolution: "@metamask/cryptography@npm:1.1.2" + checksum: 10/567ab4d7a27bbf1be129a120bd0d6d1dfc1677ddc36c2e81c259bebd7824b493fbb67127b585d57f94d98463daa7f7a43296db8ff2d9ed85f440ecc7e9c83dc9 + languageName: node + linkType: hard + +"@metamask/delegation-core@npm:^2.2.1": + version: 2.2.1 + resolution: "@metamask/delegation-core@npm:2.2.1" + dependencies: + "@metamask/abi-utils": "npm:^3.0.0" + "@metamask/utils": "npm:^11.4.0" + "@noble/hashes": "npm:^1.8.0" + checksum: 10/8cc532e4a5fdc83eddb36fada6283d51629272fcce8b7c7c2d3b4addde5aff7c741365a3bcd701364460fbbcbbe94fccc0460ee3ef9af284a82f84ca708f0e06 + languageName: node + linkType: hard + "@metamask/eslint-config-jest@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/eslint-config-jest@npm:15.0.0" @@ -1996,18 +2024,25 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-mpc-keyring@workspace:packages/keyring-eth-mpc": +"@metamask/eth-money-mpc-keyring@workspace:packages/keyring-eth-money-mpc": version: 0.0.0-use.local - resolution: "@metamask/eth-mpc-keyring@workspace:packages/keyring-eth-mpc" + resolution: "@metamask/eth-money-mpc-keyring@workspace:packages/keyring-eth-money-mpc" dependencies: - "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" "@lavamoat/allow-scripts": "npm:^3.2.1" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/cryptography": "npm:^1.1.2" "@metamask/eth-sig-util": "npm:^9.0.0" "@metamask/keyring-api": "npm:^24.2.0" "@metamask/keyring-sdk": "npm:^4.0.0" "@metamask/keyring-utils": "npm:^5.0.0" + "@metamask/mfa-wallet-cl24": "npm:^0.1.0" + "@metamask/mfa-wallet-dkls23": "npm:^0.1.0" + "@metamask/mfa-wallet-interface": "npm:^0.1.0" + "@metamask/mfa-wallet-network": "npm:^0.1.0" + "@metamask/money-account-mfa-whitelist": "npm:@metamask-previews/money-account-mfa-whitelist@0.0.0-preview-6ccf7a055" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.3" "@types/jest": "npm:^29.5.12" @@ -2518,6 +2553,63 @@ __metadata: languageName: node linkType: hard +"@metamask/mfa-wallet-cl24@npm:^0.1.0": + version: 0.1.0 + resolution: "@metamask/mfa-wallet-cl24@npm:0.1.0" + dependencies: + "@metamask/cryptography": "npm:^1.1.0" + "@metamask/mpc-interface": "npm:^0.2.0" + checksum: 10/c5dead82d44f5297bf93b498a9b0feed655b73a1bf3847617f0aa6d4c548c4f5afb01705039e0308e4035aceac6df0600b5e2a1dae4a207c056b49280bd32ff1 + languageName: node + linkType: hard + +"@metamask/mfa-wallet-dkls23@npm:^0.1.0": + version: 0.1.0 + resolution: "@metamask/mfa-wallet-dkls23@npm:0.1.0" + dependencies: + "@metamask/mpc-interface": "npm:^0.2.0" + checksum: 10/668cfd6978647bc8ce2ca2008cdd1a08036d58545d3a461713504c47f2c5c8f082c7c370aa35fcea3a091a7721d811ed0927c0cd3d4e0128313cc784e22f9b65 + languageName: node + linkType: hard + +"@metamask/mfa-wallet-interface@npm:^0.1.0": + version: 0.1.0 + resolution: "@metamask/mfa-wallet-interface@npm:0.1.0" + checksum: 10/b034fcd27fcb54613811c840eaf2614273f7a38ccc5cad02e92197a70487306d1782588e7f205e9d05773f7987464f40fc3e5f4c12df27483d47797b4f90cba2 + languageName: node + linkType: hard + +"@metamask/mfa-wallet-network@npm:^0.1.0": + version: 0.1.0 + resolution: "@metamask/mfa-wallet-network@npm:0.1.0" + dependencies: + "@metamask/cryptography": "npm:^1.1.0" + "@metamask/mfa-wallet-interface": "npm:^0.1.0" + "@noble/hashes": "npm:^1.8.0" + centrifuge: "npm:^5.7.2" + checksum: 10/a32cc4b7b07b8666f4208b942a2df22548fa23996057c7d094d3ae20a90904d9d715ebb7c300c0a50f72ffefdd205aa1ad4129a740e7c155716678c751539508 + languageName: node + linkType: hard + +"@metamask/money-account-mfa-whitelist@npm:@metamask-previews/money-account-mfa-whitelist@0.0.0-preview-6ccf7a055": + version: 0.0.0-preview-6ccf7a055 + resolution: "@metamask-previews/money-account-mfa-whitelist@npm:0.0.0-preview-6ccf7a055" + dependencies: + "@metamask/abi-utils": "npm:^2.0.4" + "@metamask/delegation-core": "npm:^2.2.1" + "@metamask/utils": "npm:^12.0.0" + "@noble/hashes": "npm:^1.8.0" + checksum: 10/f9d944284313c4f0d384abf1a3f6bdab022d1e8cbf4d5b4b3c96fde02527c3e219b401b66ae0247bce64a624a4fc6d350467b3daa7785b61e055a4148e165378 + languageName: node + linkType: hard + +"@metamask/mpc-interface@npm:^0.2.0": + version: 0.2.0 + resolution: "@metamask/mpc-interface@npm:0.2.0" + checksum: 10/9762cd1e932ae392f32fe3bc48805f10dc52b7aa544c58242fc28d0663b82403dffe3bc93140c47ae7d12f147e7ca1a737aa4989151612bd66ece84d6e01a76b + languageName: node + linkType: hard + "@metamask/number-to-bn@npm:^1.7.1": version: 1.7.1 resolution: "@metamask/number-to-bn@npm:1.7.1" @@ -2839,6 +2931,26 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^12.0.0": + version: 12.0.0 + resolution: "@metamask/utils@npm:12.0.0" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@metamask/scure-bip39": "npm:^2.1.1" + "@metamask/superstruct": "npm:^3.4.1" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@types/debug": "npm:^4.1.7" + "@types/lodash": "npm:^4.17.20" + debug: "npm:^4.3.4" + lodash: "npm:^4.17.21" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.6.3" + uuid: "npm:^9.0.1" + checksum: 10/3a2c592283b081ac627262d903bbac84d7ce81896c86e5839ffee4c5cbc48b161cc1599c4b57f74bcc576d8b27bb517ced4c9e4e7b31e69e3113873acfde5f03 + languageName: node + linkType: hard + "@metamask/utils@npm:^9.0.0": version: 9.2.1 resolution: "@metamask/utils@npm:9.2.1" @@ -2934,11 +3046,11 @@ __metadata: linkType: hard "@noble/curves@npm:^1.0.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:~1.9.0": - version: 1.9.2 - resolution: "@noble/curves@npm:1.9.2" + version: 1.9.7 + resolution: "@noble/curves@npm:1.9.7" dependencies: "@noble/hashes": "npm:1.8.0" - checksum: 10/f60f00ad86296054566b67be08fd659999bb64b692bfbf11dbe3be1f422ad4d826bf5ebb2015ce2e246538eab2b677707e0a46ffa8323a6fae7a9a30ec1fe318 + checksum: 10/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 languageName: node linkType: hard @@ -3452,7 +3564,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -6229,6 +6341,16 @@ __metadata: languageName: node linkType: hard +"centrifuge@npm:^5.7.2": + version: 5.7.4 + resolution: "centrifuge@npm:5.7.4" + dependencies: + events: "npm:^3.3.0" + protobufjs: "npm:^7.6.0" + checksum: 10/ec541393391999b0ba1fb73d5645a9d23fa84cf9edbc3907b36b4d7b17bde2efdd6aa8dc6da74dc18b23c829f9a8d1488e450cad54654ddb18ebdaef5ca8be24 + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -11413,7 +11535,7 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:^7.6.5": +"protobufjs@npm:^7.6.0, protobufjs@npm:^7.6.5": version: 7.6.6 resolution: "protobufjs@npm:7.6.6" dependencies: