diff --git a/package.json b/package.json index 7f5bdf09d..ea1b3b97c 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,14 @@ "@types/web": "^0.0.69", "@typescript/lib-dom": "npm:@types/web@^0.0.69", "axios@1.7.3": "^1.7.7", - "ws@7.4.6": "^7.5.10" + "ws@7.4.6": "^7.5.10", + "@metamask/mfa-wallet-cl24-lib": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz", + "@metamask/mfa-wallet-dkls23-lib": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz", + "@metamask/mfa-wallet-e2ee": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz", + "@metamask/mfa-wallet-interface": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz", + "@metamask/mfa-wallet-network": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz", + "@metamask/mfa-wallet-util": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz", + "@metamask/mpc-libs-interface": "file:./packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.2.1", diff --git a/packages/keyring-eth-mpc/CHANGELOG.md b/packages/keyring-eth-mpc/CHANGELOG.md new file mode 100644 index 000000000..e7c894b17 --- /dev/null +++ b/packages/keyring-eth-mpc/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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] + +[Unreleased]: https://github.com/MetaMask/accounts diff --git a/packages/keyring-eth-mpc/LICENSE b/packages/keyring-eth-mpc/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-eth-mpc/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/keyring-eth-mpc/README.md b/packages/keyring-eth-mpc/README.md new file mode 100644 index 000000000..00bcfb153 --- /dev/null +++ b/packages/keyring-eth-mpc/README.md @@ -0,0 +1,128 @@ +# 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). + +## Installation + +`yarn add @metamask/eth-mpc-keyring` + +or + +`npm install @metamask/eth-mpc-keyring` + +## The Keyring Class Protocol + +One of the goals of this class is to allow developers to easily add new signing strategies to MetaMask. We call these signing strategies Keyrings, because they can manage multiple keys. + +### Keyring.type + +A class property that returns a unique string describing the Keyring. +This is the only class property or method, the remaining methods are instance methods. + +### constructor( options ) + +As a Javascript class, your Keyring object will be used to instantiate new Keyring instances using the new keyword. For example: + +``` +const keyring = new YourKeyringClass(options); +``` + +The constructor currently receives an options object that will be defined by your keyring-building UI, once the user has gone through the steps required for you to fully instantiate a new keyring. For example, choosing a pattern for a vanity account, or entering a seed phrase. + +We haven't defined the protocol for this account-generating UI yet, so for now please ensure your Keyring behaves nicely when not passed any options object. + +## Keyring Instance Methods + +All below instance methods must return Promises to allow asynchronous resolution. + +### serialize() + +In this method, you must return any JSON-serializable JavaScript object that you like. It will be encoded to a string, encrypted with the user's password, and stored to disk. This is the same object you will receive in the deserialize() method, so it should capture all the information you need to restore the Keyring's state. + +### deserialize( object ) + +As discussed above, the deserialize() method will be passed the JavaScript object that you returned when the serialize() method was called. + +### addAccounts( n = 1 ) + +The addAccounts(n) method is used to inform your keyring that the user wishes to create a new account. You should perform whatever internal steps are needed so that a call to serialize() will persist the new account, and then return an array of the new account addresses. + +The method may be called with or without an argument, specifying the number of accounts to create. You should generally default to 1 per call. + +### getAccounts() + +When this method is called, you must return an array of hex-string addresses for the accounts that your Keyring is able to sign for. + +### signTransaction(address, transaction) + +This method will receive a hex-prefixed, all-lowercase address string for the account you should sign the incoming transaction with. + +For your convenience, the transaction is an instance of ethereumjs-tx, (https://github.com/ethereumjs/ethereumjs-tx) so signing can be as simple as: + +``` +transaction.sign(privateKey) +``` + +You must return a valid signed ethereumjs-tx (https://github.com/ethereumjs/ethereumjs-tx) object when complete, it can be the same transaction you received. + +### signMessage(address, data) + +The `eth_sign` method will receive the incoming data, alread hashed, and must sign that hash, and then return the raw signed hash. + +### exportAccount(address) + +Exports the specified account as a private key hex string. + +## Contributing + +### Setup + +- Install [Node.js](https://nodejs.org) version 18 + - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. +- Install [Yarn v3](https://yarnpkg.com/getting-started/install) +- Run `yarn install` to install dependencies and run any required post-install scripts + +### Testing and Linting + +Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. + +Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. + +### Release & Publishing + +The project follows the same release process as the other libraries in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. + +1. Choose a release version. + + - The release version should be chosen according to SemVer. Analyze the changes to see whether they include any breaking changes, new features, or deprecations, then choose the appropriate SemVer version. See [the SemVer specification](https://semver.org/) for more information. + +2. If this release is backporting changes onto a previous release, then ensure there is a major version branch for that version (e.g. `1.x` for a `v1` backport release). + + - The major version branch should be set to the most recent release with that major version. For example, when backporting a `v1.0.2` release, you'd want to ensure there was a `1.x` branch that was set to the `v1.0.1` tag. + +3. Trigger the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event [manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) for the `Create Release Pull Request` action to create the release PR. + + - For a backport release, the base branch should be the major version branch that you ensured existed in step 2. For a normal release, the base branch should be the main branch for that repository (which should be the default value). + - This should trigger the [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) workflow to create the release PR. + +4. Update the changelog to move each change entry into the appropriate change category ([See here](https://keepachangelog.com/en/1.0.0/#types) for the full list of change categories, and the correct ordering), and edit them to be more easily understood by users of the package. + + - Generally any changes that don't affect consumers of the package (e.g. lockfile changes or development environment changes) are omitted. Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). + - Try to explain each change in terms that users of the package would understand (e.g. avoid referencing internal variables/concepts). + - Consolidate related changes into one change entry if it makes it easier to explain. + - Run `yarn auto-changelog validate --rc` to check that the changelog is correctly formatted. + +5. Review and QA the release. + + - If changes are made to the base branch, the release branch will need to be updated with these changes and review/QA will need to restart again. As such, it's probably best to avoid merging other PRs into the base branch while review is underway. + +6. Squash & Merge the release. + + - This should trigger the [`action-publish-release`](https://github.com/MetaMask/action-publish-release) workflow to tag the final release commit and publish the release on GitHub. + +7. Publish the release on npm. + + - Be very careful to use a clean local environment to publish the release, and follow exactly the same steps used during CI. + - Use `npm publish --dry-run` to examine the release contents to ensure the correct files are included. Compare to previous releases if necessary (e.g. using `https://unpkg.com/browse/[package name]@[package version]/`). + - Once you are confident the release contents are correct, publish the release using `npm publish`. diff --git a/packages/keyring-eth-mpc/TODO.md b/packages/keyring-eth-mpc/TODO.md new file mode 100644 index 000000000..94be42dae --- /dev/null +++ b/packages/keyring-eth-mpc/TODO.md @@ -0,0 +1,4 @@ +# TODO + +- Check for "not implemented" and "TODO" in code +- Add support for managing verifiers: add/remove diff --git a/packages/keyring-eth-mpc/jest.config.js b/packages/keyring-eth-mpc/jest.config.js new file mode 100644 index 000000000..afeeab368 --- /dev/null +++ b/packages/keyring-eth-mpc/jest.config.js @@ -0,0 +1,32 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['./src/tests'], + + // The glob patterns Jest uses to detect test files + testMatch: ['**/*.test.[jt]s?(x)'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz new file mode 100644 index 000000000..81d332609 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz new file mode 100644 index 000000000..5fec028cf Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz new file mode 100644 index 000000000..e910c6d8d Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz new file mode 100644 index 000000000..6b2c9a1f9 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz new file mode 100644 index 000000000..f3a9cd816 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz new file mode 100644 index 000000000..6d4a2b389 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz new file mode 100644 index 000000000..e6bcebe90 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/package.json b/packages/keyring-eth-mpc/package.json new file mode 100644 index 000000000..db046532b --- /dev/null +++ b/packages/keyring-eth-mpc/package.json @@ -0,0 +1,80 @@ +{ + "name": "@metamask/eth-mpc-keyring", + "version": "0.0.0", + "description": "A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing.", + "keywords": [ + "ethereum", + "keyring" + ], + "homepage": "https://github.com/MetaMask/eth-mpc-keyring#readme", + "bugs": { + "url": "https://github.com/MetaMask/eth-mpc-keyring/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/eth-mpc-keyring.git" + }, + "license": "ISC", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references", + "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", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest", + "test:clean": "jest --clearCache" + }, + "dependencies": { + "@ethereumjs/util": "^9.1.0", + "@metamask/eth-sig-util": "^8.2.0", + "@metamask/mfa-wallet-cl24-lib": "^0.0.0", + "@metamask/mfa-wallet-dkls23-lib": "^0.0.0", + "@metamask/mfa-wallet-e2ee": "^0.0.0", + "@metamask/mfa-wallet-network": "^0.0.0", + "@metamask/mfa-wallet-util": "^0.0.0" + }, + "devDependencies": { + "@ethereumjs/tx": "^5.4.0", + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-utils": "workspace:^", + "@metamask/mfa-wallet-interface": "^0.0.0", + "@metamask/utils": "^11.1.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "deepmerge": "^4.2.2", + "jest": "^29.5.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "@metamask/mfa-wallet-network>centrifuge>protobufjs": false + } + } +} diff --git a/packages/keyring-eth-mpc/src/cloud.test.ts b/packages/keyring-eth-mpc/src/cloud.test.ts new file mode 100644 index 000000000..380aaac27 --- /dev/null +++ b/packages/keyring-eth-mpc/src/cloud.test.ts @@ -0,0 +1,306 @@ +import { bytesToBase64 } from '@metamask/utils'; + +import { + checkKeyShareBackupId, + createKey, + getNetId, + loadKeyShareBackup, + registerClient, + rotateKeyShares, + sign, + storeKeyShareBackup, +} from './cloud'; + +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' }, + }), + ); + }); + + 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 a base64-encoded message', async () => { + okEmpty(); + const data = new Uint8Array([104, 105]); // "hi" + + await sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { data: string }; + expect(body.data).toBe('aGk='); + }); + + 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]), + }), + ).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', + }); + + 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', + }), + ).rejects.toThrow( + 'Failed to initialize cloud key rotation session: Forbidden', + ); + }); + + it('stores an encrypted key share backup', async () => { + okEmpty(); + const encryptedKeyShare = new Uint8Array([1, 2, 3]); + + await storeKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + backupId: 'backup-1', + encryptedKeyShare, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { encryptedKeyShare: string; backupId: string }; + expect(body.backupId).toBe('backup-1'); + expect(body.encryptedKeyShare).toBe(bytesToBase64(encryptedKeyShare)); + }); + + 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', + backupId: 'backup-1', + encryptedKeyShare: new Uint8Array([1]), + }), + ).rejects.toThrow('Failed to store key share backup: Bad Request'); + }); + + it('checks the stored backup id', async () => { + okJson({ backupId: 'backup-1' }); + + expect( + await checkKeyShareBackupId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toBe('backup-1'); + }); + + it('returns null when the backend has no backup id', async () => { + okJson({}); + + expect( + await checkKeyShareBackupId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toBeNull(); + }); + + it('throws when checking the backup id fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as never); + + await expect( + checkKeyShareBackupId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to check key share backup id: Not Found'); + }); + + it('loads an encrypted key share backup', async () => { + const encryptedKeyShare = new Uint8Array([9, 8, 7]); + okJson({ + encryptedKeyShare: bytesToBase64(encryptedKeyShare), + backupId: 'backup-1', + }); + + expect( + await loadKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toStrictEqual({ + encryptedKeyShare, + backupId: 'backup-1', + }); + }); + + 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-mpc/src/cloud.ts b/packages/keyring-eth-mpc/src/cloud.ts new file mode 100644 index 000000000..192076362 --- /dev/null +++ b/packages/keyring-eth-mpc/src/cloud.ts @@ -0,0 +1,235 @@ +import type { PartyId } from '@metamask/mfa-wallet-interface'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +export type LoadKeyShareBackupResult = { + encryptedKeyShare: Uint8Array; + backupId: string; +}; + +/** + * Fetch JSON from the MPC backend, throwing on non-OK responses. + * + * @param url - The request URL. + * @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, + body: Record, + errorPrefix: string, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`${errorPrefix}: ${response.statusText}`); + } + + const text = await response.text(); + if (text.length === 0) { + return undefined as Response; + } + return JSON.parse(text) as Response; +} + +/** + * 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 = await postJson<{ netId: string }>( + `${opts.baseURL}/net-id`, + { token: opts.token }, + 'Failed to get server network id', + ); + return data.netId; +} + +/** + * Start DKG on the backend for a new key. + * + * @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`, + { + token: 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`, + { + token: opts.token, + clientNetId: opts.clientNetId, + }, + 'Failed to register client', + ); +} + +/** + * Start a backend signing session. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA and challenge. + * @param opts.data - Message hash to sign. + * @param opts.clientNetId - Client network id. + * @param opts.nonce - Client session nonce. + */ +export async function sign(opts: { + baseURL: string; + token: string; + data: Uint8Array; + clientNetId: PartyId; + nonce: string; +}): Promise { + await postJson( + `${opts.baseURL}/sign`, + { + token: opts.token, + data: bytesToBase64(opts.data), + clientNetId: opts.clientNetId, + nonce: opts.nonce, + }, + 'Failed to initialize cloud sign session', + ); +} + +/** + * Start a backend share-rotation session. + * + * @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 rotateKeyShares(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; + nonce: string; +}): Promise { + await postJson( + `${opts.baseURL}/rotate-key-shares`, + { + token: opts.token, + clientNetId: opts.clientNetId, + nonce: opts.nonce, + }, + 'Failed to initialize cloud key rotation session', + ); +} + +/** + * Store an encrypted key-share backup and its client-minted id. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.backupId - Opaque backup id minted by the client. + * @param opts.encryptedKeyShare - Encrypted key share ciphertext. + */ +export async function storeKeyShareBackup(opts: { + baseURL: string; + token: string; + backupId: string; + encryptedKeyShare: Uint8Array; +}): Promise { + await postJson( + `${opts.baseURL}/store-key-share-backup`, + { + token: opts.token, + backupId: opts.backupId, + encryptedKeyShare: bytesToBase64(opts.encryptedKeyShare), + }, + 'Failed to store key share backup', + ); +} + +/** + * Return the backup id currently stored on the backend. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token (2FA not required). + * @returns The stored backup id, or `null` if none exists. + */ +export async function checkKeyShareBackupId(opts: { + baseURL: string; + token: string; +}): Promise { + const data = await postJson<{ backupId?: string | null }>( + `${opts.baseURL}/check-key-share-backup-id`, + { token: opts.token }, + 'Failed to check key share backup id', + ); + return typeof data.backupId === 'string' ? data.backupId : null; +} + +/** + * Load the encrypted key-share backup from the backend. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @returns The ciphertext and backup id. + */ +export async function loadKeyShareBackup(opts: { + baseURL: string; + token: string; +}): Promise { + const data = await postJson<{ + encryptedKeyShare: string; + backupId: string; + }>( + `${opts.baseURL}/load-key-share-backup`, + { token: opts.token }, + 'Failed to load key share backup', + ); + return { + encryptedKeyShare: base64ToBytes(data.encryptedKeyShare), + backupId: data.backupId, + }; +} diff --git a/packages/keyring-eth-mpc/src/index.ts b/packages/keyring-eth-mpc/src/index.ts new file mode 100644 index 000000000..dcc422a8f --- /dev/null +++ b/packages/keyring-eth-mpc/src/index.ts @@ -0,0 +1,9 @@ +export { MPCKeyring } from './mpc-keyring'; + +export type { + MPCKeyringOpts, + MPCKeyringSerializer, + MPCKeyringSetupParams, + MPCKeyringState, + ProfileTokenOpts, +} from './types'; diff --git a/packages/keyring-eth-mpc/src/mpc-keyring.test.ts b/packages/keyring-eth-mpc/src/mpc-keyring.test.ts new file mode 100644 index 000000000..9f39d0264 --- /dev/null +++ b/packages/keyring-eth-mpc/src/mpc-keyring.test.ts @@ -0,0 +1,723 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { hashPersonalMessage } from '@ethereumjs/util'; +import { type Hex, type Json, bytesToHex } from '@metamask/utils'; + +import { MPCKeyring } from './mpc-keyring'; + +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 mockCheckKeyShareBackupId = jest.fn(); +const mockLoadKeyShareBackup = jest.fn(); +let lastNetworkManagerOptions: Record | undefined; + +const mockDerivedAddress = '0x1111111111111111111111111111111111111111' as Hex; +const mockSessionNonce = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const mockBackupId = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +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-lib', () => { + 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 { + secp256k1: {}, + CL24DKM: MockCL24DKM, + CL24ThresholdKeySerializer: MockCL24ThresholdKeySerializer, + dealersFromCL24Key: ( + key: { shareIndexes: number[] }, + custodians: string[], + ) => { + if (custodians.length !== key.shareIndexes.length) { + throw new Error('Custodians do not match share indexes'); + } + return custodians.map((netId, shareIndex) => ({ netId, shareIndex })); + }, + }; +}); + +jest.mock('@metamask/mfa-wallet-network', () => { + class MockMfaNetworkManager { + constructor(opts: Record) { + lastNetworkManagerOptions = opts; + } + + createIdentity(...args: unknown[]) { + return mockCreateIdentity(...args); + } + + createSession(...args: unknown[]) { + return mockCreateSession(...args); + } + } + + class MockMfaNetworkIdentitySerializer { + toJson(value: unknown) { + return mockNetworkIdentityToJson(value); + } + + fromJson(value: unknown) { + return mockNetworkIdentityFromJson(value); + } + } + + return { + MfaNetworkManager: MockMfaNetworkManager, + MfaNetworkIdentitySerializer: MockMfaNetworkIdentitySerializer, + createScopedSessionId: (...args: unknown[]) => + mockCreateScopedSessionId(...args), + }; +}); + +jest.mock('@metamask/mfa-wallet-dkls23-lib', () => { + class MockDkls23TssLib { + setup(...args: unknown[]) { + return mockDklsSetup(...args); + } + + sign(...args: unknown[]) { + return mockDklsSign(...args); + } + } + + return { Dkls23TssLib: MockDkls23TssLib }; +}); + +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), + checkKeyShareBackupId: (...args: unknown[]) => + mockCheckKeyShareBackupId(...args), + loadKeyShareBackup: (...args: unknown[]) => mockLoadKeyShareBackup(...args), +})); + +jest.mock('./util', () => { + const actual = jest.requireActual('./util'); + return { + ...actual, + generateSessionNonce: jest.fn(() => mockSessionNonce), + createBackupId: jest.fn(() => mockBackupId), + 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, + shareIndexes: [1, 2], + publicKey: new Uint8Array([4, 1, 2, 3]), + privateKeyShare: new Uint8Array([5, 6, 7]), + secretPolynomialCommitment: [new Uint8Array([8])], + }; +}; + +const makeSerializedState = (overrides: Record = {}) => ({ + netCreds: { partyId: 'local-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + backupId: mockBackupId, + tssSetup: '0x0102', + ...overrides, +}); + +const makeRootSession = () => { + const session = { + sendMessage: jest.fn(), + receiveMessage: jest + .fn() + .mockResolvedValue( + new TextEncoder().encode(JSON.stringify({ haveSetup: true })), + ), + createSubsession: jest + .fn() + .mockImplementation((label: string) => ({ label })), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + return session; +}; + +const makeKeyring = ( + getProfileToken = jest.fn().mockResolvedValue('token'), + getBackupEncryptionKey = jest.fn().mockResolvedValue(mockBackupKey), +) => + new MPCKeyring({ + getRandomBytes: (size) => new Uint8Array(size).fill(3), + dkls23Lib: {} as never, + cloudURL: 'https://cloud.example', + relayerURL: 'https://relayer.example', + getProfileToken, + getBackupEncryptionKey, + }); + +const deserializeState = async ( + keyring: MPCKeyring, + state = makeSerializedState(), +) => { + await keyring.deserialize(state as unknown as Json); +}; + +describe('MPCKeyring', () => { + beforeEach(() => { + lastNetworkManagerOptions = undefined; + + const mockedUtil = jest.requireMock('./util'); + + mockedUtil.generateSessionNonce.mockReturnValue(mockSessionNonce); + mockedUtil.createBackupId.mockReturnValue(mockBackupId); + 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(undefined); + mockStartRotateKeyShares.mockResolvedValue(undefined); + mockStoreKeyShareBackup.mockResolvedValue(undefined); + mockCheckKeyShareBackupId.mockResolvedValue(mockBackupId); + mockLoadKeyShareBackup.mockResolvedValue({ + encryptedKeyShare: new TextEncoder().encode(JSON.stringify({ ok: true })), + backupId: mockBackupId, + }); + 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(keyring.type).toBe('MPC Keyring'); + }); + + it('wires network manager options and random byte adapter', () => { + const transportToken = jest.fn().mockResolvedValue('transport-token'); + const webSocket = { send: jest.fn() }; + const randomBytes = jest + .fn() + .mockReturnValueOnce(new Uint8Array([1, 2, 3, 4])); + + const keyring = new MPCKeyring({ + getRandomBytes: randomBytes, + 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 Keyring'); + + expect(lastNetworkManagerOptions).toStrictEqual( + expect.objectContaining({ + url: 'https://relayer.example', + getToken: transportToken, + websocket: webSocket, + }), + ); + + const randomBytesAdapter = lastNetworkManagerOptions?.randomBytes as { + getRandomValues: (array: Uint8Array) => Uint8Array; + }; + const output = randomBytesAdapter.getRandomValues(new Uint8Array(4)); + expect(output).toStrictEqual(new Uint8Array([1, 2, 3, 4])); + expect(randomBytes).toHaveBeenCalledWith(4); + }); + + 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 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({ backupId: 'only-backup-id' }); + 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({ '2fa': 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: 'create-key' }, + }), + ); + expect(mockDklsSetup).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-setup' }, + }), + ); + expect(rootSession.createSubsession).toHaveBeenCalledWith('create-key'); + expect(rootSession.createSubsession).toHaveBeenCalledWith('tss-setup'); + expect(mockStoreKeyShareBackup).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'token', + backupId: mockBackupId, + }), + ); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + backupId: mockBackupId, + tssSetup: '0x010203', + }); + }); + + 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', + backupId: mockBackupId, + 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('rotates key shares without regenerating 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 newBackupId = + '0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + const mockedUtil = jest.requireMock('./util'); + mockedUtil.createBackupId.mockReturnValueOnce(newBackupId); + + const rootSession = makeRootSession(); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockRotateKeyShares.mockResolvedValueOnce(rotatedKey); + + await keyring.rotateKeyShares(); + + expect(getProfileToken).toHaveBeenCalledWith({ '2fa': true }); + expect(mockStartRotateKeyShares).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'local-user', + nonce: mockSessionNonce, + }); + expect(mockRotateKeyShares).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'rotate-key-shares' }, + }), + ); + expect(rootSession.createSubsession).toHaveBeenCalledWith( + 'rotate-key-shares', + ); + expect(mockDklsSetup).not.toHaveBeenCalled(); + expect(mockStoreKeyShareBackup).toHaveBeenCalledWith( + expect.objectContaining({ + backupId: newBackupId, + token: 'token', + }), + ); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: rotatedKey, + serverNetId: 'cloud-user', + backupId: newBackupId, + tssSetup: '0x0102', + }); + }); + + it('checks whether the local backup id matches the backend', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + expect(await keyring.checkKeyShare()).toBe(true); + expect(getProfileToken).toHaveBeenCalledWith(); + expect(mockCheckKeyShareBackupId).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + + mockCheckKeyShareBackupId.mockResolvedValueOnce('other-backup-id'); + expect(await keyring.checkKeyShare()).toBe(false); + }); + + it('syncs key share and backup id from the backend', 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 })), + backupId: 'synced-backup-id', + }); + mockThresholdKeyFromJson.mockReturnValueOnce(syncedKey); + + await keyring.syncKeyShare(); + + expect(getProfileToken).toHaveBeenCalledWith({ '2fa': true }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: syncedKey, + serverNetId: 'cloud-user', + backupId: 'synced-backup-id', + tssSetup: '0x0102', + }); + }); + + it('signs personal messages and transactions through the MPC flow', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const signSession = makeRootSession(); + mockCreateSession.mockResolvedValue(signSession); + + const messageHex = '0x68656c6c6f'; + const signatureHex = await keyring.signPersonalMessage( + mockDerivedAddress, + messageHex, + ); + expect(signatureHex).toBe(bytesToHex(mockEthSignature)); + expect(getProfileToken).toHaveBeenCalledWith({ + '2fa': true, + challenge: hashPersonalMessage(new TextEncoder().encode('hello')), + }); + expect(mockStartSign).toHaveBeenCalledWith( + expect.objectContaining({ + clientNetId: 'local-user', + token: 'token', + data: hashPersonalMessage(new TextEncoder().encode('hello')), + }), + ); + 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'); + + const tx = { + getHashedMessageToSign: jest.fn().mockReturnValue(new Uint8Array([1, 2])), + addSignature: jest.fn().mockReturnValue('signed-tx'), + }; + + expect(await keyring.signTransaction(mockDerivedAddress, tx as never)).toBe( + 'signed-tx', + ); + expect(tx.addSignature).toHaveBeenCalledWith( + 27n, + expect.any(Uint8Array), + expect.any(Uint8Array), + ); + }); + + it('runs TSS setup when the peer has discarded setup', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + const signSession = makeRootSession(); + 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(); + 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()); + + await Promise.all([ + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + ]); + + expect(maxInFlight).toBe(1); + }); + + it('signs typed data and validates signer constraints', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + mockCreateSession.mockResolvedValue(makeRootSession()); + + 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', + ); + }); + + it('throws for unsupported account APIs that are not implemented', async () => { + const keyring = makeKeyring(); + + await expect(keyring.addAccounts()).rejects.toThrow( + 'addAccounts(1): not implemented', + ); + await expect(keyring.addAccounts(2)).rejects.toThrow( + 'addAccounts(2): not implemented', + ); + await expect( + keyring.getAppKeyAddress(mockDerivedAddress, 'example.com'), + ).rejects.toThrow( + `getAppKeyAddress(${mockDerivedAddress}, example.com): not implemented`, + ); + }); +}); diff --git a/packages/keyring-eth-mpc/src/mpc-keyring.ts b/packages/keyring-eth-mpc/src/mpc-keyring.ts new file mode 100644 index 000000000..a0ce64fde --- /dev/null +++ b/packages/keyring-eth-mpc/src/mpc-keyring.ts @@ -0,0 +1,686 @@ +import type { TypedTransaction } from '@ethereumjs/tx'; +import { hashPersonalMessage } from '@ethereumjs/util'; +import type { + TypedDataV1, + TypedMessage, + SignTypedDataVersion, + MessageTypes, +} from '@metamask/eth-sig-util'; +import type { Keyring } from '@metamask/keyring-utils'; +import { + CL24DKM, + CL24ThresholdKeySerializer, + dealersFromCL24Key, + secp256k1 as secp256k1Curve, + type CL24ThresholdKey, +} from '@metamask/mfa-wallet-cl24-lib'; +import { Dkls23TssLib } from '@metamask/mfa-wallet-dkls23-lib'; +import type { + PartyId, + RandomNumberGenerator, + RootNetworkSession, + ShareBinding, +} from '@metamask/mfa-wallet-interface'; +import type { MfaNetworkIdentity } from '@metamask/mfa-wallet-network'; +import { + MfaNetworkIdentitySerializer, + MfaNetworkManager, + createScopedSessionId, +} from '@metamask/mfa-wallet-network'; +import { bytesToHex, hexToBytes, type Hex, type Json } from '@metamask/utils'; + +import { + checkKeyShareBackupId, + createKey as startCreateKey, + getNetId, + loadKeyShareBackup, + registerClient, + rotateKeyShares as startRotateKeyShares, + sign as startSign, + storeKeyShareBackup, +} from './cloud'; +import { + type MPCKeyringOpts, + type MPCKeyringSerializer, + type MPCKeyringSetupParams, + type MPCKeyringState, + type MPCKeyringStorageState, + type ProfileTokenOpts, +} from './types'; +import { + AES_GCM_IV_LENGTH, + createBackupId, + decryptBytes, + encryptBytes, + equalAddresses, + generateSessionNonce, + getSignedTypedDataHash, + parseBackupId, + parseEthSig, + parseServerNetId, + parseSignedTypedDataVersion, + parseTssSetup, + publicKeyToAddressHex, + toEthSig, +} from './util'; + +const mpcKeyringType = 'MPC Keyring'; +const TSS_HAVE_SETUP_MESSAGE_TYPE = 'tss-have-setup'; +const CLIENT_SHARE_INDEX = 0; +const SERVER_SHARE_INDEX = 1; + +/** + * 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 }, + ]; +} + +export class MPCKeyring implements Keyring { + readonly type: string = mpcKeyringType; + + readonly #rng: RandomNumberGenerator; + + readonly #networkManager: MfaNetworkManager; + + readonly #tss: Dkls23TssLib; + + readonly #dkm: CL24DKM; + + #state?: MPCKeyringStorageState; + + readonly #cloudURL: string; + + readonly #serializer: MPCKeyringSerializer; + + readonly #getProfileToken: (opts?: ProfileTokenOpts) => Promise; + + readonly #getBackupEncryptionKey: () => Promise; + + #signQueue: Promise = Promise.resolve(); + + constructor(opts: MPCKeyringOpts) { + this.#rng = { + generateRandomBytes: opts.getRandomBytes, + }; + this.#dkm = new CL24DKM(secp256k1Curve, this.#rng); + this.#tss = new Dkls23TssLib(opts.dkls23Lib); + this.#cloudURL = opts.cloudURL; + this.#serializer = { + thresholdKey: new CL24ThresholdKeySerializer(), + networkIdentity: new MfaNetworkIdentitySerializer(), + }; + this.#networkManager = new MfaNetworkManager({ + url: opts.relayerURL, + randomBytes: { + getRandomValues: (array: Uint8Array): Uint8Array => { + const bytes = opts.getRandomBytes(array.length); + array.set(bytes); + return array; + }, + }, + ...(opts.getTransportToken && { + getToken: opts.getTransportToken, + }), + ...(opts.webSocket === undefined ? {} : { websocket: opts.webSocket }), + }); + this.#getProfileToken = opts.getProfileToken; + 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, backupId, tssSetup } = this.#state; + return { + netCreds: this.#serializer.networkIdentity.toJson(netCreds), + keyShare: this.#serializer.thresholdKey.toJson(keyShare), + serverNetId, + backupId, + 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'); + } + const stateObj = state as Record; + + if ( + 'netCreds' in stateObj && + 'keyShare' in stateObj && + 'serverNetId' in stateObj && + 'backupId' in stateObj && + 'tssSetup' in stateObj + ) { + this.#state = { + status: 'initialized', + netCreds: this.#serializer.networkIdentity.fromJson(stateObj.netCreds), + keyShare: this.#serializer.thresholdKey.fromJson(stateObj.keyShare), + serverNetId: parseServerNetId(stateObj.serverNetId), + backupId: parseBackupId(stateObj.backupId), + tssSetup: parseTssSetup(stateObj.tssSetup), + }; + return; + } + + const setup = this.#parseSetupParams(stateObj); + if (setup) { + this.#state = { + status: 'uninitialized', + setup, + }; + } + } + + /** + * Run key generation or import. `mode` may be passed directly, or taken + * from setup params previously stored via {@link deserialize}. + * + * @param mode - Create a new key or import from the backend backup. + */ + async init(mode?: MPCKeyringSetupParams['mode']): Promise { + 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. Existing TSS setup remains valid. + */ + async rotateKeyShares(): Promise { + const state = this.#assertState(); + const { netCreds, serverNetId } = state; + let { keyShare } = state; + + const token = await this.#getProfileToken({ '2fa': true }); + const nonce = generateSessionNonce(this.#rng); + await startRotateKeyShares({ + baseURL: this.#cloudURL, + token, + clientNetId: netCreds.partyId, + nonce, + }); + + const netSession = await this.#createNetworkSession( + netCreds, + serverNetId, + nonce, + ); + try { + const custodians = partyNetIds(netCreds.partyId, serverNetId); + keyShare = await this.#dkm.rotateKeyShares({ + key: keyShare, + dealers: dealersFromCL24Key(keyShare, custodians), + custodians, + networkSession: netSession.createSubsession('rotate-key-shares'), + }); + } finally { + await netSession.disconnect(); + } + + const backupId = createBackupId(this.#rng); + await storeKeyShareBackup({ + baseURL: this.#cloudURL, + token, + backupId, + encryptedKeyShare: await this.#encryptKeyShare(keyShare), + }); + + this.#applyKeyState({ + ...state, + keyShare, + backupId, + }); + } + + /** + * Compare the local backup id with the id stored on the backend. + * + * @returns Whether the backup ids match. + */ + async checkKeyShare(): Promise { + const { backupId } = this.#assertState(); + const token = await this.#getProfileToken(); + const serverBackupId = await checkKeyShareBackupId({ + baseURL: this.#cloudURL, + token, + }); + return backupId === serverBackupId; + } + + /** + * Refresh `keyShare` and `backupId` from the backend backup. + * `netCreds`, `serverNetId`, and `tssSetup` are unchanged. + */ + async syncKeyShare(): Promise { + const state = this.#assertState(); + const token = await this.#getProfileToken({ '2fa': true }); + const { encryptedKeyShare, backupId } = await loadKeyShareBackup({ + baseURL: this.#cloudURL, + token, + }); + const keyShare = await this.#decryptKeyShare(encryptedKeyShare); + this.#applyKeyState({ + ...state, + keyShare, + backupId, + }); + } + + /** + * Add new accounts to the keyring. The accounts will be derived + * sequentially from the root HD wallet, using increasing indices. + * + * @param numberOfAccounts - The number of accounts to add. + * @returns The addresses of the new accounts. + */ + async addAccounts(numberOfAccounts = 1): Promise { + throw new Error(`addAccounts(${numberOfAccounts}): not implemented`); + } + + /** + * 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 transaction using the specified account. + * + * @param address - The address of the account. + * @param tx - The transaction to sign. + * @param _opts - The options for signing the transaction. + * @returns The signed transaction. + */ + async signTransaction( + address: Hex, + tx: TypedTransaction, + _opts = {}, + ): Promise { + const message = tx.getHashedMessageToSign(); + + const signature = await this.#signHash(address, message); + + const { r, s, v } = parseEthSig(signature); + + const signedTx = tx.addSignature(v, r, s); + return signedTx; + } + + /** + * 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); + 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); + return bytesToHex(signature); + } + + async #setupCreate(): Promise { + const token = await this.#getProfileToken({ '2fa': true }); + const netCreds = await this.#networkManager.createIdentity(); + const serverNetId = await getNetId({ + baseURL: this.#cloudURL, + token, + }); + + const nonce = generateSessionNonce(this.#rng); + 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('create-key'); + const tssSetupSession = netSession.createSubsession('tss-setup'); + [keyShare, tssSetup] = await Promise.all([ + this.#dkm.createKey({ + custodians, + threshold: 2, + networkSession: createKeySession, + }), + this.#tss.setup({ + signers: bindings, + networkSession: tssSetupSession, + }), + ]); + } finally { + await netSession.disconnect(); + } + + const backupId = createBackupId(this.#rng); + await storeKeyShareBackup({ + baseURL: this.#cloudURL, + token, + backupId, + encryptedKeyShare: await this.#encryptKeyShare(keyShare), + }); + + this.#applyKeyState({ + keyShare, + netCreds, + serverNetId, + backupId, + tssSetup, + }); + } + + async #setupImport(): Promise { + const token = await this.#getProfileToken({ '2fa': 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, + backupId: loaded.backupId, + tssSetup: null, + }); + } + + async #signHash(address: Hex, hash: Uint8Array): Promise { + return this.#serializeSign(async () => { + const state = this.#assertState(); + const { keyShare, netCreds, serverNetId } = state; + let { tssSetup } = state; + + const addr = this.#address(); + if (!equalAddresses(address, addr)) { + throw new Error(`account ${address} not found`); + } + + const token = await this.#getProfileToken({ + '2fa': true, + challenge: hash, + }); + const nonce = generateSessionNonce(this.#rng); + await startSign({ + baseURL: this.#cloudURL, + token, + data: hash, + clientNetId: netCreds.partyId, + nonce, + }); + + 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 }); + + try { + const { signature } = await this.#tss.sign({ + key: keyShare, + signers: bindings, + message: hash, + networkSession: netSession.createSubsession('tss-sign'), + setup: tssSetup, + }); + return toEthSig(signature, hash, keyShare.publicKey); + } catch (error) { + this.#applyKeyState({ ...state, tssSetup: null }); + throw error; + } + } 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: MfaNetworkIdentity, + 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 = this.#rng.generateRandomBytes(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 #serializeSign( + operation: () => Promise, + ): Promise { + const previous = this.#signQueue; + let release!: () => void; + this.#signQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } + + #parseSetupParams( + state: Record, + ): MPCKeyringSetupParams | undefined { + if (!('mode' in state)) { + return undefined; + } + const { mode } = state; + if (mode === 'create' || mode === 'import') { + return { mode }; + } + throw new Error("Invalid setup mode: expected 'create' or 'import'"); + } + + #applyKeyState(state: MPCKeyringState): void { + this.#state = { + status: 'initialized', + ...state, + }; + } + + #assertState(): MPCKeyringState { + 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-mpc/src/network.ts b/packages/keyring-eth-mpc/src/network.ts new file mode 100644 index 000000000..42deb202e --- /dev/null +++ b/packages/keyring-eth-mpc/src/network.ts @@ -0,0 +1,18 @@ +import type { + NetworkSession, + PartyId, + SessionId, +} from '@metamask/mfa-wallet-interface'; + +export type NetworkIdentity = { + partyId: PartyId; +}; + +export type NetworkManager = { + createIdentity: () => Promise; + createSession: ( + identity: NetworkIdentity, + parties: PartyId[], + sessionId: SessionId, + ) => Promise; +}; diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts new file mode 100644 index 000000000..a0ca6e8d9 --- /dev/null +++ b/packages/keyring-eth-mpc/src/types.ts @@ -0,0 +1,57 @@ +import type { CL24ThresholdKey } from '@metamask/mfa-wallet-cl24-lib'; +import type { Dkls23TssLib } from '@metamask/mfa-wallet-dkls23-lib'; +import type { MfaNetworkIdentity } from '@metamask/mfa-wallet-network'; +import type { Json } from '@metamask/utils'; + +export type ProfileTokenOpts = { + '2fa'?: boolean; + challenge?: Uint8Array; +}; + +export type Dkls23Lib = ConstructorParameters[0]; + +export type MPCKeyringOpts = { + getRandomBytes: (size: number) => Uint8Array; + dkls23Lib: Dkls23Lib; + cloudURL: string; + relayerURL: string; + getTransportToken?: () => Promise; + getProfileToken: (opts?: ProfileTokenOpts) => Promise; + getBackupEncryptionKey: () => Promise; + webSocket?: unknown; +}; + +export type MPCKeyringState = { + keyShare: CL24ThresholdKey; + netCreds: MfaNetworkIdentity; + serverNetId: string; + backupId: string; + tssSetup: Uint8Array | null; +}; + +export type MPCKeyringSetupParams = { + mode: 'create' | 'import'; +}; + +type JsonSerializer = { + toJson: (value: Value) => Json; + fromJson: (value: Json) => Value; +}; + +export type MPCKeyringInitializedState = { + status: 'initialized'; +} & MPCKeyringState; + +export type MPCKeyringUninitializedState = { + status: 'uninitialized'; + setup: MPCKeyringSetupParams; +}; + +export type MPCKeyringStorageState = + | MPCKeyringInitializedState + | MPCKeyringUninitializedState; + +export type MPCKeyringSerializer = { + thresholdKey: JsonSerializer; + networkIdentity: JsonSerializer; +}; diff --git a/packages/keyring-eth-mpc/src/util.test.ts b/packages/keyring-eth-mpc/src/util.test.ts new file mode 100644 index 000000000..d0a3db9a6 --- /dev/null +++ b/packages/keyring-eth-mpc/src/util.test.ts @@ -0,0 +1,297 @@ +import { + ecsign, + privateToAddress, + privateToPublic, + 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, + createBackupId, + decryptBytes, + encryptBytes, + equalAddresses, + generateSessionNonce, + getSignedTypedDataHash, + normalizeAddress, + parseBackupId, + parseEthSig, + parseServerNetId, + parseSignedTypedDataVersion, + parseTssSetup, + publicKeyToAddressHex, + publicToAddressHex, + toEthSig, +} from './util'; + +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 session nonce from RNG bytes', () => { + const bytes = new Uint8Array(32).fill(1); + const rng = { generateRandomBytes: jest.fn().mockReturnValue(bytes) }; + + expect(generateSessionNonce(rng)).toBe(bytesToHex(bytes)); + expect(rng.generateRandomBytes).toHaveBeenCalledWith(32); + }); + + it('converts public keys to address hex', () => { + expect(publicToAddressHex(publicKey)).toBe(address); + 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 parsed = parseEthSig(ethSignature); + const normalizedS = BigInt(bytesToHex(parsed.s)); + + 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); + const parsed = parseEthSig(ethSignature); + expect(parsed.s).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 ethereum signatures and validates edge cases', () => { + const signature = new Uint8Array(65).fill(1); + signature[64] = 28; + const parsed = parseEthSig(signature); + expect(parsed.v).toBe(28n); + expect(parsed.r).toHaveLength(32); + expect(parsed.s).toHaveLength(32); + + expect(() => parseEthSig(new Uint8Array(64))).toThrow( + 'Invalid signature length', + ); + + const malformedSignature = { + length: 65, + 64: undefined, + slice: jest.fn().mockReturnValue(new Uint8Array(32)), + } as unknown as Uint8Array; + + expect(() => parseEthSig(malformedSignature)).toThrow( + 'Invalid signature v value', + ); + }); + + 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('creates an opaque backup id from RNG bytes', () => { + const bytes = new Uint8Array(32).fill(2); + const rng = { generateRandomBytes: jest.fn().mockReturnValue(bytes) }; + + expect(createBackupId(rng)).toBe(bytesToHex(bytes)); + expect(rng.generateRandomBytes).toHaveBeenCalledWith(32); + }); + + 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'); + }); + + it('parses and validates serialized keyring fields', () => { + expect(parseServerNetId('server-1')).toBe('server-1'); + expect(() => parseServerNetId(1 as never)).toThrow( + 'Invalid server network id: expected a string', + ); + expect(() => parseServerNetId('')).toThrow( + 'Invalid server network id: expected a non-empty string', + ); + + expect(parseBackupId('backup-1')).toBe('backup-1'); + expect(() => parseBackupId(1 as never)).toThrow( + 'Invalid backup id: expected a string', + ); + expect(() => parseBackupId('')).toThrow( + 'Invalid backup id: expected a non-empty string', + ); + + expect(parseTssSetup('0x1234')).toStrictEqual(new Uint8Array([0x12, 0x34])); + expect(parseTssSetup(null)).toBeNull(); + expect(() => parseTssSetup(123 as never)).toThrow( + 'Invalid tss setup: expected a hex string or null', + ); + }); +}); diff --git a/packages/keyring-eth-mpc/src/util.ts b/packages/keyring-eth-mpc/src/util.ts new file mode 100644 index 000000000..c80cffaef --- /dev/null +++ b/packages/keyring-eth-mpc/src/util.ts @@ -0,0 +1,349 @@ +import { + bigIntToBytes, + concatBytes, + ecrecover, + publicToAddress, + pubToAddress, +} from '@ethereumjs/util'; +import type { + MessageTypes, + TypedDataV1, + TypedMessage, +} from '@metamask/eth-sig-util'; +import { + normalize, + SignTypedDataVersion, + TypedDataUtils, + typedSignatureHash, +} from '@metamask/eth-sig-util'; +import type { RandomNumberGenerator } from '@metamask/mfa-wallet-interface'; +import type { Hex, Json } from '@metamask/utils'; +import { add0x, assert, bytesToHex, hexToBytes } from '@metamask/utils'; + +const SESSION_NONCE_BYTE_LENGTH = 32; +const BACKUP_ID_BYTE_LENGTH = 32; +export const AES_GCM_IV_LENGTH = 12; + +/** + * Generate a session nonce: random bytes from the RNG, hex-encoded. + * + * @param rng - The random number generator. + * @returns Hex-encoded 32-byte random nonce. + */ +export function generateSessionNonce(rng: RandomNumberGenerator): Hex { + return bytesToHex(rng.generateRandomBytes(SESSION_NONCE_BYTE_LENGTH)); +} + +/** + * Mint an opaque backup id. Not a counter. + * + * @param rng - The random number generator. + * @returns Hex-encoded 32-byte random id. + */ +export function createBackupId(rng: RandomNumberGenerator): string { + return bytesToHex(rng.generateRandomBytes(BACKUP_ID_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 cryptoKey = await globalThis.crypto.subtle.importKey( + 'raw', + key, + 'AES-GCM', + false, + ['encrypt'], + ); + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + plaintext, + ); + return concatBytes(iv, new Uint8Array(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); + const cryptoKey = await globalThis.crypto.subtle.importKey( + 'raw', + key, + 'AES-GCM', + false, + ['decrypt'], + ); + const plaintext = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + ciphertext, + ); + return new Uint8Array(plaintext); +} + +const SECP256K1_N = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', +); +const SECP256K1_HALF_N = SECP256K1_N / 2n; + +/** + * Convert a public key to an address. + * + * @param pubKey - The public key to convert. + * @returns The address. + */ +export function publicToAddressHex(pubKey: Uint8Array): Hex { + const addrBytes = publicToAddress(pubKey); + return bytesToHex(addrBytes); +} + +/** + * 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 publicToAddressHex(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 an extended ECDSA signature. + * + * @param signature - The signature to parse. + * @returns The parsed signature. + */ +export function parseEthSig(signature: Uint8Array): { + r: Uint8Array; + s: Uint8Array; + v: bigint; +} { + if (signature.length !== 65) { + throw new Error('Invalid signature length'); + } + + const rBuf = signature.slice(0, 32); + const sBuf = signature.slice(32, 64); + const vByte = signature[64]; + + // This check is technically redundant because length is 65, but satisfies TS + if (vByte === undefined) { + throw new Error('Invalid signature v value'); + } + const vInt = BigInt(vByte); + + return { r: rBuf, s: sBuf, v: 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); +} + +/** + * Parse a non-empty string field from JSON. + * + * @param value - The value to parse. + * @param fieldName - Field name for error messages. + * @returns The parsed string. + */ +function parseNonEmptyString(value: Json, fieldName: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid ${fieldName}: expected a string`); + } + if (value.length < 1) { + throw new Error(`Invalid ${fieldName}: expected a non-empty string`); + } + return value; +} + +/** + * Parse a server network id from a JSON value. + * + * @param serverNetId - The server network id to parse. + * @returns The parsed server network id. + */ +export function parseServerNetId(serverNetId: Json): string { + return parseNonEmptyString(serverNetId, 'server network id'); +} + +/** + * Parse a backup id from a JSON value. + * + * @param backupId - The backup id to parse. + * @returns The parsed backup id. + */ +export function parseBackupId(backupId: Json): string { + return parseNonEmptyString(backupId, 'backup id'); +} + +/** + * Parse TSS setup from a JSON value. + * + * @param tssSetup - Hex-encoded setup, or `null` when unset. + * @returns The parsed setup bytes, or `null`. + */ +export function parseTssSetup(tssSetup: Json): Uint8Array | null { + if (tssSetup === null) { + return null; + } + if (typeof tssSetup !== 'string') { + throw new Error('Invalid tss setup: expected a hex string or null'); + } + return hexToBytes(tssSetup); +} + +/** + * Convert a public key to an address. + * + * @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-mpc/tsconfig.build.json b/packages/keyring-eth-mpc/tsconfig.build.json new file mode 100644 index 000000000..b34e78e32 --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + // NOTE: @msgpack/msgpack uses Uint8Array which requires TypeScript 5.7+. + // skipLibCheck bypasses type-checking of .d.ts files in node_modules. + "skipLibCheck": true + }, + "references": [ + { "path": "../keyring-utils/tsconfig.build.json" } + ], + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/packages/keyring-eth-mpc/tsconfig.json b/packages/keyring-eth-mpc/tsconfig.json new file mode 100644 index 000000000..a30efa086 --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + // NOTE: @msgpack/msgpack uses Uint8Array which requires TypeScript 5.7+. + // skipLibCheck bypasses type-checking of .d.ts files in node_modules. + "skipLibCheck": true + }, + "references": [ + { + "path": "../keyring-utils" + } + ], + "include": [ + "./src" + ], + "exclude": [ + "./dist/**/*" + ] +} \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json index 60377db1b..551af2f73 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,6 +7,7 @@ { "path": "./packages/keyring-eth-simple/tsconfig.build.json" }, { "path": "./packages/keyring-eth-trezor/tsconfig.build.json" }, { "path": "./packages/keyring-eth-hd/tsconfig.build.json" }, + { "path": "./packages/keyring-eth-mpc/tsconfig.build.json" }, { "path": "./packages/keyring-snap-bridge/tsconfig.build.json" }, { "path": "./packages/keyring-snap-sdk/tsconfig.build.json" }, { "path": "./packages/keyring-snap-client/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index cd24f44bf..31200c342 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "./packages/keyring-eth-simple" }, { "path": "./packages/keyring-eth-trezor" }, { "path": "./packages/keyring-eth-hd" }, + { "path": "./packages/keyring-eth-mpc" }, { "path": "./packages/keyring-snap-bridge" }, { "path": "./packages/keyring-snap-client" }, { "path": "./packages/keyring-internal-snap-client" }, diff --git a/yarn.lock b/yarn.lock index c1eafc528..0ed529679 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1745,6 +1745,31 @@ __metadata: languageName: unknown linkType: soft +"@metamask/eth-mpc-keyring@workspace:packages/keyring-eth-mpc": + version: 0.0.0-use.local + resolution: "@metamask/eth-mpc-keyring@workspace:packages/keyring-eth-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:^3.4.4" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/keyring-utils": "workspace:^" + "@metamask/mfa-wallet-cl24-lib": "npm:^0.0.0" + "@metamask/mfa-wallet-dkls23-lib": "npm:^0.0.0" + "@metamask/mfa-wallet-e2ee": "npm:^0.0.0" + "@metamask/mfa-wallet-interface": "npm:^0.0.0" + "@metamask/mfa-wallet-network": "npm:^0.0.0" + "@metamask/mfa-wallet-util": "npm:^0.0.0" + "@metamask/utils": "npm:^11.1.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.5.0" + languageName: unknown + linkType: soft + "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr": version: 0.0.0-use.local resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" @@ -2163,6 +2188,64 @@ __metadata: languageName: node linkType: hard +"@metamask/mfa-wallet-cl24-lib@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-cl24-lib@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz::hash=4e48d0&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + dependencies: + "@msgpack/msgpack": "npm:^3.1.2" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + checksum: 10/8d1c5e32823fa97b656ab4287e4f17ed0a7fc3a00ab3d2d9ea0ff161a701176355f03d4317132edc7c9736e95459e745ceaf33d89953140297148264fef839de + languageName: node + linkType: hard + +"@metamask/mfa-wallet-dkls23-lib@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-dkls23-lib@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls23-lib-0.0.0.tgz::hash=5a83f7&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + dependencies: + "@metamask/mfa-wallet-util": "npm:^0.0.0" + "@noble/curves": "npm:^1.9.2" + checksum: 10/cab98dc3f6de7b065d5854aa8e23cd928dc628f4d40c78417e511c50b4b08aeb80e1a5a1a5a5cdce8d2f9271bd2928c23b79ee6614221784b24c032ef77a2b0f + languageName: node + linkType: hard + +"@metamask/mfa-wallet-e2ee@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-e2ee@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz::hash=b1f8ae&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + dependencies: + "@msgpack/msgpack": "npm:^3.1.2" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + checksum: 10/33df44ec33e032f457978685b05b91cedb87f9da2453d336d4090cc82ad70a882b61d177b970ac03a70c7d7ec439146afdb5a5d2860789905132bbcaee568f70 + languageName: node + linkType: hard + +"@metamask/mfa-wallet-interface@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-interface@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz::hash=309a1e&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + checksum: 10/27046deb25db1a7d4fdca63e5bf069b8107b2206ce30985f5453aa51a4f2d6c26575b422b7a80b59f78bf6b44dc613bb1ad9f29a32205a75e6a92ae23adb5f3a + languageName: node + linkType: hard + +"@metamask/mfa-wallet-network@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-network@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz::hash=5e5b38&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + dependencies: + "@metamask/mfa-wallet-e2ee": "npm:^0.0.0" + "@noble/hashes": "npm:^1.8.0" + centrifuge: "npm:^5.5.2" + checksum: 10/98cb8c5cad46af2d1aa87a8885ace59e0e75cec5609d2f2152417927262f72cdfe0586b32af8a255bb3217f1ffdfee07bc81cf1b2940f530f61c451eefb4a286 + languageName: node + linkType: hard + +"@metamask/mfa-wallet-util@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-util@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz::hash=3f0779&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + checksum: 10/a1c4ab9188b7df9ecc77b9fe37b1f16b0897fc0334f917b8ade80d1d09dcf03cf9cdbf0cd160f0dcea8be0394c45c186a2e8429452c572cc3370257f35f2c1f6 + 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" @@ -2484,6 +2567,13 @@ __metadata: languageName: node linkType: hard +"@msgpack/msgpack@npm:^3.1.2": + version: 3.1.3 + resolution: "@msgpack/msgpack@npm:3.1.3" + checksum: 10/cf597b7ed1fcedc2101b145885e7da591c5ed8aeba3a2579b99da8dd15ef13595c807f64f052da0a0dd74704097f6299fc2e2fb4165029a60a1d1df3843c03ce + languageName: node + linkType: hard + "@ngraveio/bc-ur@npm:^1.1.5": version: 1.1.13 resolution: "@ngraveio/bc-ur@npm:1.1.13" @@ -2499,7 +2589,7 @@ __metadata: languageName: node linkType: hard -"@noble/ciphers@npm:1.3.0": +"@noble/ciphers@npm:1.3.0, @noble/ciphers@npm:^1.3.0": version: 1.3.0 resolution: "@noble/ciphers@npm:1.3.0" checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 @@ -2533,6 +2623,15 @@ __metadata: languageName: node linkType: hard +"@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/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 + languageName: node + linkType: hard + "@noble/hashes@npm:1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" @@ -5520,6 +5619,16 @@ __metadata: languageName: node linkType: hard +"centrifuge@npm:^5.5.2": + version: 5.5.3 + resolution: "centrifuge@npm:5.5.3" + dependencies: + events: "npm:^3.3.0" + protobufjs: "npm:^7.2.5" + checksum: 10/2074551adc0ea421d8d4880236b956d6fde0d652c3da2d82124b49c1d6659817069bb58f8f8525794382915d5bff3310592bb023a4afa35fc3ca2b69158ba412 + languageName: node + linkType: hard + "chalk-template@npm:1.1.0": version: 1.1.0 resolution: "chalk-template@npm:1.1.0" @@ -10291,6 +10400,26 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^7.2.5": + version: 7.5.4 + resolution: "protobufjs@npm:7.5.4" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.4" + "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/inquire": "npm:^1.1.0" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.0" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.0.0" + checksum: 10/88d677bb6f11a2ecec63fdd053dfe6d31120844d04e865efa9c8fbe0674cd077d6624ecfdf014018a20dcb114ae2a59c1b21966dd8073e920650c71370966439 + languageName: node + linkType: hard + "proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0"