diff --git a/README.md b/README.md index 7cc92fd36..549ab4a9c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/eth-hd-keyring`](packages/keyring-eth-hd) - [`@metamask/eth-ledger-bridge-keyring`](packages/keyring-eth-ledger-bridge) - [`@metamask/eth-money-keyring`](packages/keyring-eth-money) +- [`@metamask/eth-mpc-keyring`](packages/keyring-eth-mpc) - [`@metamask/eth-qr-keyring`](packages/keyring-eth-qr) - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) @@ -51,6 +52,7 @@ linkStyle default opacity:0.5 eth_hd_keyring(["@metamask/eth-hd-keyring"]); eth_ledger_bridge_keyring(["@metamask/eth-ledger-bridge-keyring"]); eth_money_keyring(["@metamask/eth-money-keyring"]); + eth_mpc_keyring(["@metamask/eth-mpc-keyring"]); eth_qr_keyring(["@metamask/eth-qr-keyring"]); eth_simple_keyring(["@metamask/eth-simple-keyring"]); eth_trezor_keyring(["@metamask/eth-trezor-keyring"]); @@ -77,6 +79,7 @@ linkStyle default opacity:0.5 eth_money_keyring --> keyring_api; eth_money_keyring --> keyring_sdk; eth_money_keyring --> keyring_utils; + eth_mpc_keyring --> keyring_utils; eth_qr_keyring --> keyring_api; eth_qr_keyring --> keyring_sdk; eth_qr_keyring --> keyring_utils; diff --git a/package.json b/package.json index 379da95b3..258efb8d1 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,12 @@ "typescript-eslint": "^8.48.0" }, "resolutions": { + "@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", "@types/node": "^20.12.12", "@types/web": "^0.0.69", "@typescript/lib-dom": "npm:@types/web@^0.0.69", diff --git a/packages/keyring-eth-mpc/CHANGELOG.md b/packages/keyring-eth-mpc/CHANGELOG.md new file mode 100644 index 000000000..859dea8b7 --- /dev/null +++ b/packages/keyring-eth-mpc/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/eth-mpc-keyring` ([#627](https://github.com/MetaMask/accounts/pull/627)) + +[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..9cfde3bd7 --- /dev/null +++ b/packages/keyring-eth-mpc/README.md @@ -0,0 +1,121 @@ +# 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/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/package.json b/packages/keyring-eth-mpc/package.json new file mode 100644 index 000000000..8af4a6bd8 --- /dev/null +++ b/packages/keyring-eth-mpc/package.json @@ -0,0 +1,89 @@ +{ + "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/accounts/tree/main/packages/keyring-eth-mpc#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --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": "yarn test:source && yarn test:types", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test:source": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:types": "../../scripts/tsd-test.sh ./src", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@ethereumjs/util": "^9.1.0", + "@metamask/eth-sig-util": "^9.0.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": "^6.1.0", + "@metamask/keyring-utils": "^5.0.0", + "@metamask/mfa-wallet-interface": "^0.0.0", + "@metamask/utils": "^11.11.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "deepmerge": "^4.2.2", + "jest": "^29.5.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": ">=22" + }, + "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..814957f07 --- /dev/null +++ b/packages/keyring-eth-mpc/src/cloud.test.ts @@ -0,0 +1,379 @@ +import { bytesToBase64 } from '@metamask/utils'; + +import { + checkKeyShare, + createKey, + getNetId, + loadKeyShareBackup, + registerClient, + rotateKeyShares, + setActiveEpoch, + 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', + Authorization: 'Bearer token-1', + }, + body: '{}', + }), + ); + }); + + it('throws when getting the server network id fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as never); + + await expect( + getNetId({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to get server network id: Unauthorized'); + }); + + it('starts cloud key generation', async () => { + okEmpty(); + + await createKey({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/create-key', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when cloud key generation initialization fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as never); + + await expect( + createKey({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + }), + ).rejects.toThrow( + 'Failed to initialize cloud keygen session: Unauthorized', + ); + }); + + it('registers a client network id', async () => { + okEmpty(); + + await registerClient({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/register-client', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when client registration fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + } as never); + + await expect( + registerClient({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + }), + ).rejects.toThrow('Failed to register client: Forbidden'); + }); + + it('starts a cloud sign session with epoch and base64-encoded message', async () => { + okEmpty(); + const data = new Uint8Array([104, 105]); // "hi" + + await sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data, + shareEpoch: 1, + }); + + const init = fetchSpy.mock.calls[0]?.[1] as { + body: string; + headers: Record; + }; + expect(init.headers.Authorization).toBe('Bearer token-1'); + const body = JSON.parse(init.body) as { + data: string; + shareEpoch: number; + token?: string; + }; + expect(body.data).toBe('aGk='); + expect(body.shareEpoch).toBe(1); + expect(body.token).toBeUndefined(); + }); + + it('throws when cloud sign initialization fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Server Error', + } as never); + + await expect( + sign({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + data: new Uint8Array([1]), + shareEpoch: 1, + }), + ).rejects.toThrow('Failed to initialize cloud sign session: Server Error'); + }); + + it('starts a cloud key rotation session', async () => { + okEmpty(); + + await rotateKeyShares({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + expectedActiveEpoch: 1, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { expectedActiveEpoch: number }; + expect(body.expectedActiveEpoch).toBe(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/rotate-key-shares', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when cloud key rotation initialization fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + } as never); + + await expect( + rotateKeyShares({ + baseURL: 'https://cloud.example', + token: 'token-1', + clientNetId: 'local-1', + nonce: '0xnonce', + expectedActiveEpoch: 1, + }), + ).rejects.toThrow( + 'Failed to initialize cloud key rotation session: Forbidden', + ); + }); + + it('stores an encrypted key share backup for an epoch', async () => { + okEmpty(); + const encryptedKeyShare = new Uint8Array([1, 2, 3]); + + await storeKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 2, + attemptNonce: '0xnonce', + encryptedKeyShare, + }); + + const init = fetchSpy.mock.calls[0]?.[1] as { + body: string; + headers: Record; + }; + expect(init.headers.Authorization).toBe('Bearer token-1'); + const body = JSON.parse(init.body) as { + encryptedKeyShare: string; + epoch: number; + attemptNonce: string; + token?: string; + }; + expect(body.epoch).toBe(2); + expect(body.attemptNonce).toBe('0xnonce'); + expect(body.encryptedKeyShare).toBe(bytesToBase64(encryptedKeyShare)); + expect(body.token).toBeUndefined(); + }); + + it('throws when storing a key share backup fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Bad Request', + } as never); + + await expect( + storeKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 1, + attemptNonce: '0xnonce', + encryptedKeyShare: new Uint8Array([1]), + }), + ).rejects.toThrow('Failed to store key share backup: Bad Request'); + }); + + it('checks key share epochs', async () => { + okJson({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + + expect( + await checkKeyShare({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toStrictEqual({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + }); + + it('throws when checking key share fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as never); + + await expect( + checkKeyShare({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).rejects.toThrow('Failed to check key share: Not Found'); + }); + + it('sets the active epoch', async () => { + okEmpty(); + + await setActiveEpoch({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 2, + }); + + const body = JSON.parse( + (fetchSpy.mock.calls[0]?.[1] as { body: string }).body, + ) as { epoch: number }; + expect(body.epoch).toBe(2); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/set-active-epoch', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('throws when setting the active epoch fails', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + statusText: 'Conflict', + } as never); + + await expect( + setActiveEpoch({ + baseURL: 'https://cloud.example', + token: 'token-1', + epoch: 2, + }), + ).rejects.toThrow('Failed to set active epoch: Conflict'); + }); + + it('loads an encrypted key share backup', async () => { + const encryptedKeyShare = new Uint8Array([9, 8, 7]); + okJson({ + encryptedKeyShare: bytesToBase64(encryptedKeyShare), + epoch: 3, + }); + + expect( + await loadKeyShareBackup({ + baseURL: 'https://cloud.example', + token: 'token-1', + }), + ).toStrictEqual({ + encryptedKeyShare, + epoch: 3, + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloud.example/load-key-share-backup', + expect.objectContaining({ + method: 'GET', + headers: { Authorization: 'Bearer token-1' }, + }), + ); + }); + + it('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..dac2d75db --- /dev/null +++ b/packages/keyring-eth-mpc/src/cloud.ts @@ -0,0 +1,312 @@ +import type { PartyId } from '@metamask/mfa-wallet-interface'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +export type LoadKeyShareBackupResult = { + encryptedKeyShare: Uint8Array; + epoch: number; +}; + +export type CheckKeyShareResult = { + latestShareEpoch?: number; + latestBackupEpoch?: number; + activeEpoch?: number; +}; + +/** + * Parse a fetch response as JSON, throwing on non-OK status. + * + * @param response - The fetch response. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The parsed JSON body, or `undefined` when the response is empty. + */ +async function parseJsonResponse( + response: Response, + errorPrefix: string, +): Promise { + if (!response.ok) { + throw new Error(`${errorPrefix}: ${response.statusText}`); + } + + const text = await response.text(); + if (text.length === 0) { + return undefined as Result; + } + return JSON.parse(text) as Result; +} + +/** + * GET JSON from the MPC backend, throwing on non-OK responses. + * + * @param url - The request URL. + * @param token - Profile token sent as a Bearer header. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The parsed JSON body, or `undefined` when the response is empty. + */ +async function getJson( + url: string, + token: string, + errorPrefix: string, +): Promise { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return parseJsonResponse(response, errorPrefix); +} + +/** + * POST JSON to the MPC backend, throwing on non-OK responses. + * + * @param url - The request URL. + * @param token - Profile token sent as a Bearer header. + * @param body - The JSON request body. + * @param errorPrefix - Prefix for the thrown error message. + * @returns The parsed JSON body, or `undefined` when the response is empty. + */ +async function postJson( + url: string, + token: string, + body: Record, + errorPrefix: string, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + return parseJsonResponse(response, errorPrefix); +} + +/** + * Return the server network id for this profile, creating one if needed. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token. + * @returns The server network id. + */ +export async function getNetId(opts: { + baseURL: string; + token: string; +}): Promise { + const data = await postJson<{ netId: string }>( + `${opts.baseURL}/net-id`, + opts.token, + {}, + 'Failed to get server network id', + ); + return data.netId; +} + +/** + * Start DKG on the backend for a new key (appends epoch 1; does not activate). + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.clientNetId - Client network id. + * @param opts.nonce - Client session nonce. + */ +export async function createKey(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; + nonce: string; +}): Promise { + await postJson( + `${opts.baseURL}/create-key`, + opts.token, + { + clientNetId: opts.clientNetId, + nonce: opts.nonce, + }, + 'Failed to initialize cloud keygen session', + ); +} + +/** + * Authorize this client network id to use the existing key share. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.clientNetId - Client network id. + */ +export async function registerClient(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; +}): Promise { + await postJson( + `${opts.baseURL}/register-client`, + opts.token, + { + clientNetId: opts.clientNetId, + }, + 'Failed to register client', + ); +} + +/** + * Start a backend signing session for the active share epoch. + * + * @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. + * @param opts.shareEpoch - Local share epoch (must equal backend activeEpoch). + */ +export async function sign(opts: { + baseURL: string; + token: string; + data: Uint8Array; + clientNetId: PartyId; + nonce: string; + shareEpoch: number; +}): Promise { + await postJson( + `${opts.baseURL}/sign`, + opts.token, + { + data: bytesToBase64(opts.data), + clientNetId: opts.clientNetId, + nonce: opts.nonce, + shareEpoch: opts.shareEpoch, + }, + 'Failed to initialize cloud sign session', + ); +} + +/** + * Start a backend share-rotation session (appends next epoch; does not activate). + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.clientNetId - Client network id. + * @param opts.nonce - Client session nonce. + * @param opts.expectedActiveEpoch - Current active epoch on client and server. + */ +export async function rotateKeyShares(opts: { + baseURL: string; + token: string; + clientNetId: PartyId; + nonce: string; + expectedActiveEpoch: number; +}): Promise { + await postJson( + `${opts.baseURL}/rotate-key-shares`, + opts.token, + { + clientNetId: opts.clientNetId, + nonce: opts.nonce, + expectedActiveEpoch: opts.expectedActiveEpoch, + }, + 'Failed to initialize cloud key rotation session', + ); +} + +/** + * Store an encrypted key-share backup for a share epoch. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.epoch - Share epoch this backup belongs to. + * @param opts.attemptNonce - DKG client nonce for that epoch (rejects stale backups). + * @param opts.encryptedKeyShare - Encrypted key share ciphertext. + */ +export async function storeKeyShareBackup(opts: { + baseURL: string; + token: string; + epoch: number; + attemptNonce: string; + encryptedKeyShare: Uint8Array; +}): Promise { + await postJson( + `${opts.baseURL}/store-key-share-backup`, + opts.token, + { + epoch: opts.epoch, + attemptNonce: opts.attemptNonce, + encryptedKeyShare: bytesToBase64(opts.encryptedKeyShare), + }, + 'Failed to store key share backup', + ); +} + +/** + * Return the latest share/backup epochs and the active epoch from the backend. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token (2FA not required). + * @returns Epoch metadata from the backend. + */ +export async function checkKeyShare(opts: { + baseURL: string; + token: string; +}): Promise { + return await postJson( + `${opts.baseURL}/check-key-share`, + opts.token, + {}, + 'Failed to check key share', + ); +} + +/** + * Activate a share epoch after its share and backup are present. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @param opts.epoch - Epoch to activate. + */ +export async function setActiveEpoch(opts: { + baseURL: string; + token: string; + epoch: number; +}): Promise { + await postJson( + `${opts.baseURL}/set-active-epoch`, + opts.token, + { + epoch: opts.epoch, + }, + 'Failed to set active epoch', + ); +} + +/** + * Load the encrypted key-share backup for the active epoch. + * + * @param opts - Request options. + * @param opts.baseURL - MPC backend base URL. + * @param opts.token - Profile token with 2FA. + * @returns The ciphertext and share epoch. + */ +export async function loadKeyShareBackup(opts: { + baseURL: string; + token: string; +}): Promise { + const data = await getJson<{ + encryptedKeyShare: string; + epoch: number; + }>( + `${opts.baseURL}/load-key-share-backup`, + opts.token, + 'Failed to load key share backup', + ); + return { + encryptedKeyShare: base64ToBytes(data.encryptedKeyShare), + epoch: data.epoch, + }; +} 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..2e3b489ee --- /dev/null +++ b/packages/keyring-eth-mpc/src/mpc-keyring.test.ts @@ -0,0 +1,887 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { hashPersonalMessage } from '@ethereumjs/util'; +import { bytesToHex } from '@metamask/utils'; +import type { Hex, Json } 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 mockCheckKeyShare = jest.fn(); +const mockSetActiveEpoch = jest.fn(); +const mockLoadKeyShareBackup = jest.fn(); +let lastNetworkManagerOptions: Record | undefined; + +const mockDerivedAddress = '0x1111111111111111111111111111111111111111' as Hex; +const mockSessionNonce = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const mockShareEpoch = 1; +const mockTypedDataHash = new Uint8Array([9, 8, 7, 6]); +const mockEthSignature = new Uint8Array(65); +mockEthSignature[64] = 27; +const mockBackupKey = new Uint8Array(32).fill(7); + +jest.mock('@metamask/mfa-wallet-cl24-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), + checkKeyShare: (...args: unknown[]) => mockCheckKeyShare(...args), + setActiveEpoch: (...args: unknown[]) => mockSetActiveEpoch(...args), + loadKeyShareBackup: (...args: unknown[]) => mockLoadKeyShareBackup(...args), +})); + +jest.mock('./util', () => { + const actual = jest.requireActual('./util'); + return { + ...actual, + generateSessionNonce: jest.fn(() => mockSessionNonce), + encryptBytes: jest.fn(async (_key: Uint8Array, plaintext: Uint8Array) => { + return plaintext; + }), + decryptBytes: jest.fn(async (_key: Uint8Array, ciphertext: Uint8Array) => { + return ciphertext; + }), + getSignedTypedDataHash: jest.fn(() => mockTypedDataHash), + publicKeyToAddressHex: jest.fn(() => mockDerivedAddress), + toEthSig: jest.fn(() => mockEthSignature), + }; +}); + +const makeThresholdKey = () => { + return { + threshold: 2, + shareIndex: 0, + 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', + shareEpoch: mockShareEpoch, + 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.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); + mockCheckKeyShare.mockResolvedValue({ + latestShareEpoch: mockShareEpoch, + latestBackupEpoch: mockShareEpoch, + activeEpoch: mockShareEpoch, + }); + mockSetActiveEpoch.mockResolvedValue(undefined); + mockLoadKeyShareBackup.mockResolvedValue({ + encryptedKeyShare: new TextEncoder().encode(JSON.stringify({ ok: true })), + epoch: mockShareEpoch, + }); + mockDklsSetup.mockResolvedValue(new Uint8Array([1, 2, 3])); + mockDklsSign.mockResolvedValue({ signature: new Uint8Array(64).fill(9) }); + }); + + it('exposes the expected type constant', () => { + const keyring = makeKeyring(); + expect(MPCKeyring.type).toBe('MPC Keyring'); + 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({ shareEpoch: 1 }); + await expect(keyring.checkKeyShare()).rejects.toThrow( + 'Keyring not initialized', + ); + }); + + it('creates a key via deserialize(init args) + init(create mode)', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + const rootSession = makeRootSession(); + + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + + await keyring.deserialize({ mode: 'create' }); + expect(await keyring.serialize()).toStrictEqual({ mode: 'create' }); + await keyring.init(); + + expect(getProfileToken).toHaveBeenCalledWith({ twoFactor: true }); + expect(mockGetNetId).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + expect(mockStartCreateKey).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'local-user', + nonce: mockSessionNonce, + }); + expect(mockCreateScopedSessionId).toHaveBeenCalledWith( + ['cloud-user', 'local-user'], + mockSessionNonce, + ); + expect(mockCreateKey).toHaveBeenCalledWith( + expect.objectContaining({ + custodians: ['local-user', 'cloud-user'], + threshold: 2, + networkSession: { label: 'dkg-create' }, + }), + ); + expect(mockDklsSetup).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'tss-setup' }, + }), + ); + expect(rootSession.createSubsession).toHaveBeenCalledWith('dkg-create'); + expect(rootSession.createSubsession).toHaveBeenCalledWith('tss-setup'); + expect(mockStoreKeyShareBackup).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'token', + epoch: 1, + attemptNonce: mockSessionNonce, + }), + ); + expect(mockCheckKeyShare).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + expect(mockSetActiveEpoch).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + epoch: 1, + }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + shareEpoch: 1, + tssSetup: '0x010203', + }); + }); + + it('throws when create cannot activate an incomplete epoch', async () => { + const keyring = makeKeyring(); + const rootSession = makeRootSession(); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + mockCheckKeyShare.mockResolvedValueOnce({ + latestShareEpoch: 1, + latestBackupEpoch: undefined, + activeEpoch: undefined, + }); + + await expect(keyring.init('create')).rejects.toThrow( + 'Share epoch 1 is not ready', + ); + expect(mockSetActiveEpoch).not.toHaveBeenCalled(); + expect(await keyring.serialize()).toStrictEqual({}); + }); + + it('imports a key from the backend backup', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'imported-user' }); + mockThresholdKeyFromJson.mockReturnValueOnce(makeThresholdKey()); + + await keyring.deserialize({ mode: 'import' }); + expect(await keyring.serialize()).toStrictEqual({ mode: 'import' }); + await keyring.init(); + + expect(mockLoadKeyShareBackup).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + expect(mockRegisterClient).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'imported-user', + }); + expect(mockDklsSetup).not.toHaveBeenCalled(); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'imported-user' }, + keyShare: makeThresholdKey(), + serverNetId: 'cloud-user', + shareEpoch: mockShareEpoch, + tssSetup: null, + }); + }); + + it('init is a no-op when already initialized', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + expect(await keyring.init()).toBeUndefined(); + expect(mockStartCreateKey).not.toHaveBeenCalled(); + expect(mockLoadKeyShareBackup).not.toHaveBeenCalled(); + }); + + it('init is a no-op without setup params or mode', async () => { + const keyring = makeKeyring(); + expect(await keyring.init()).toBeUndefined(); + expect(mockStartCreateKey).not.toHaveBeenCalled(); + }); + + it('init can take create mode without prior deserialize', async () => { + const keyring = makeKeyring(); + const rootSession = makeRootSession(); + mockCreateIdentity.mockResolvedValueOnce({ partyId: 'local-user' }); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockCreateKey.mockResolvedValueOnce(makeThresholdKey()); + + await keyring.init('create'); + expect(mockStartCreateKey).toHaveBeenCalledTimes(1); + expect(await keyring.getAccounts()).toStrictEqual([mockDerivedAddress]); + }); + + it('rotates key shares, activates the next epoch, and clears tssSetup', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const rotatedKey = { + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([9, 9, 9]), + }; + + const rootSession = makeRootSession(); + mockCreateSession.mockResolvedValueOnce(rootSession); + mockRotateKeyShares.mockResolvedValueOnce(rotatedKey); + mockCheckKeyShare.mockResolvedValueOnce({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + + await keyring.rotateKeyShares(); + + expect(getProfileToken).toHaveBeenCalledWith({ twoFactor: true }); + expect(mockStartRotateKeyShares).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + clientNetId: 'local-user', + nonce: mockSessionNonce, + expectedActiveEpoch: 1, + }); + expect(mockRotateKeyShares).toHaveBeenCalledWith( + expect.objectContaining({ + networkSession: { label: 'dkg-rotate' }, + }), + ); + expect(rootSession.createSubsession).toHaveBeenCalledWith('dkg-rotate'); + expect(mockDklsSetup).not.toHaveBeenCalled(); + expect(mockStoreKeyShareBackup).toHaveBeenCalledWith( + expect.objectContaining({ + epoch: 2, + token: 'token', + attemptNonce: mockSessionNonce, + }), + ); + expect(mockSetActiveEpoch).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + epoch: 2, + }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: rotatedKey, + serverNetId: 'cloud-user', + shareEpoch: 2, + tssSetup: null, + }); + }); + + it('checks whether local and backend epochs match', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + expect(await keyring.checkKeyShare()).toBe(true); + expect(getProfileToken).toHaveBeenCalledWith(); + expect(mockCheckKeyShare).toHaveBeenCalledWith({ + baseURL: 'https://cloud.example', + token: 'token', + }); + + mockCheckKeyShare.mockResolvedValueOnce({ + latestShareEpoch: 2, + latestBackupEpoch: 1, + activeEpoch: 1, + }); + expect(await keyring.checkKeyShare()).toBe(false); + }); + + it('syncs key share and epoch from the backend and clears tssSetup', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const syncedKey = { + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([1, 1, 1]), + }; + mockLoadKeyShareBackup.mockResolvedValueOnce({ + encryptedKeyShare: new TextEncoder().encode(JSON.stringify({ ok: true })), + epoch: 3, + }); + mockThresholdKeyFromJson.mockReturnValueOnce(syncedKey); + + await keyring.syncKeyShare(); + + expect(getProfileToken).toHaveBeenCalledWith({ twoFactor: true }); + expect(await keyring.serialize()).toStrictEqual({ + netCreds: { partyId: 'local-user' }, + keyShare: syncedKey, + serverNetId: 'cloud-user', + shareEpoch: 3, + tssSetup: null, + }); + }); + + it('signs personal messages 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({ + twoFactor: true, + challenge: hashPersonalMessage(new TextEncoder().encode('hello')), + }); + expect(mockStartSign).toHaveBeenCalledWith( + expect.objectContaining({ + clientNetId: 'local-user', + token: 'token', + data: hashPersonalMessage(new TextEncoder().encode('hello')), + shareEpoch: 1, + }), + ); + 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), + true, + ); + }); + + it('signs EIP-7702 authorizations through the MPC flow', async () => { + const getProfileToken = jest.fn().mockResolvedValue('token'); + const keyring = makeKeyring(getProfileToken); + await deserializeState(keyring); + + const signSession = makeRootSession(); + mockCreateSession.mockResolvedValue(signSession); + + const { hashEIP7702Authorization } = jest.requireActual( + '@metamask/eth-sig-util', + ); + const authorization = [ + 1, + '0x1234567890abcdef1234567890abcdef12345678', + 1, + ] as const; + const expectedHash = new Uint8Array( + hashEIP7702Authorization(authorization), + ); + + const signatureHex = await keyring.signEip7702Authorization( + mockDerivedAddress, + [...authorization], + ); + + expect(signatureHex).toBe(bytesToHex(mockEthSignature)); + expect(getProfileToken).toHaveBeenCalledWith({ + twoFactor: true, + challenge: expectedHash, + }); + expect(mockStartSign).toHaveBeenCalledWith( + expect.objectContaining({ + clientNetId: 'local-user', + token: 'token', + data: expectedHash, + shareEpoch: 1, + }), + ); + expect(mockDklsSign).toHaveBeenCalledWith( + expect.objectContaining({ + message: expectedHash, + networkSession: { label: 'tss-sign' }, + }), + ); + }); + + 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('serializes sign, rotate, and sync on one op queue', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + const order: string[] = []; + let inFlight = 0; + let maxInFlight = 0; + + const track = async (label: string, work: () => Promise) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(`${label}-start`); + await new Promise((resolve) => setTimeout(resolve, 20)); + await work(); + order.push(`${label}-end`); + inFlight -= 1; + }; + + mockCreateSession.mockImplementation(async () => makeRootSession()); + mockDklsSign.mockImplementation(async () => { + await track('sign', async () => undefined); + return { signature: new Uint8Array(64).fill(9) }; + }); + mockRotateKeyShares.mockImplementation(async () => { + await track('rotate', async () => undefined); + return { + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([9, 9, 9]), + }; + }); + mockCheckKeyShare.mockResolvedValue({ + latestShareEpoch: 2, + latestBackupEpoch: 2, + activeEpoch: 1, + }); + mockLoadKeyShareBackup.mockImplementation(async () => { + await track('sync', async () => undefined); + return { + encryptedKeyShare: new TextEncoder().encode( + JSON.stringify({ ok: true }), + ), + epoch: 2, + }; + }); + mockThresholdKeyFromJson.mockReturnValue({ + ...makeThresholdKey(), + privateKeyShare: new Uint8Array([1, 1, 1]), + }); + + await Promise.all([ + keyring.signPersonalMessage(mockDerivedAddress, '0x68656c6c6f'), + keyring.rotateKeyShares(), + keyring.syncKeyShare(), + ]); + + expect(maxInFlight).toBe(1); + expect(order).toStrictEqual([ + 'sign-start', + 'sign-end', + 'rotate-start', + 'rotate-end', + 'sync-start', + 'sync-end', + ]); + expect(await keyring.serialize()).toStrictEqual( + expect.objectContaining({ + shareEpoch: 2, + tssSetup: null, + }), + ); + }); + + it('signs typed data and validates signer constraints', async () => { + const keyring = makeKeyring(); + await deserializeState(keyring); + + mockCreateSession.mockResolvedValue(makeRootSession()); + + 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..48e4dd4b2 --- /dev/null +++ b/packages/keyring-eth-mpc/src/mpc-keyring.ts @@ -0,0 +1,784 @@ +import type { TypedTransaction } from '@ethereumjs/tx'; +import { hashPersonalMessage } from '@ethereumjs/util'; +import type { + TypedDataV1, + TypedMessage, + SignTypedDataVersion, + MessageTypes, + EIP7702Authorization, +} from '@metamask/eth-sig-util'; +import { hashEIP7702Authorization } from '@metamask/eth-sig-util'; +import type { Keyring } from '@metamask/keyring-utils'; +import { + CL24DKM, + CL24ThresholdKeySerializer, + dealersFromCL24Key, + secp256k1 as secp256k1Curve, +} from '@metamask/mfa-wallet-cl24-lib'; +import 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 } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; + +import type { CheckKeyShareResult } from './cloud'; +import { + checkKeyShare as checkKeyShareRemote, + createKey as startCreateKey, + getNetId, + loadKeyShareBackup, + registerClient, + rotateKeyShares as startRotateKeyShares, + setActiveEpoch, + sign as startSign, + storeKeyShareBackup, +} from './cloud'; +import type { + MPCKeyringOpts, + MPCKeyringSerializer, + MPCKeyringSetupParams, + MPCKeyringState, + MPCKeyringStorageState, + ProfileTokenOpts, +} from './types'; +import { + AES_GCM_IV_LENGTH, + decryptBytes, + encryptBytes, + equalAddresses, + generateSessionNonce, + getSignedTypedDataHash, + parseEthSig, + parseServerNetId, + parseShareEpoch, + 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; +const INITIAL_SHARE_EPOCH = 1; + +/** + * Assert that the latest share and backup epochs both equal `expectedEpoch`. + * + * @param check - Backend epoch metadata. + * @param expectedEpoch - Epoch that must be ready for activation. + */ +function assertEpochReady( + check: CheckKeyShareResult, + expectedEpoch: number, +): void { + if ( + check.latestShareEpoch !== expectedEpoch || + check.latestBackupEpoch !== expectedEpoch + ) { + throw new Error( + `Share epoch ${expectedEpoch} is not ready (latestShareEpoch=${String( + check.latestShareEpoch, + )}, latestBackupEpoch=${String(check.latestBackupEpoch)})`, + ); + } +} + +/** + * Party net ids indexed by 0-based share slot. + * + * @param clientNetId - Client (share 0) network id. + * @param serverNetId - Server (share 1) network id. + * @returns Net ids in share-slot order. + */ +function partyNetIds(clientNetId: PartyId, serverNetId: PartyId): PartyId[] { + const netIds: PartyId[] = []; + netIds[CLIENT_SHARE_INDEX] = clientNetId; + netIds[SERVER_SHARE_INDEX] = serverNetId; + return netIds; +} + +/** + * Share bindings for the client/server pair. + * + * @param clientNetId - Client (share 0) network id. + * @param serverNetId - Server (share 1) network id. + * @returns Bindings with fixed share indexes. + */ +function shareBindings( + clientNetId: PartyId, + serverNetId: PartyId, +): ShareBinding[] { + return [ + { netId: clientNetId, shareIndex: CLIENT_SHARE_INDEX }, + { netId: serverNetId, shareIndex: SERVER_SHARE_INDEX }, + ]; +} + +export class MPCKeyring implements Keyring { + static type: string = mpcKeyringType; + + 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; + + #opQueue: 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, shareEpoch, tssSetup } = + this.#state; + return { + netCreds: this.#serializer.networkIdentity.toJson(netCreds), + keyShare: this.#serializer.thresholdKey.toJson(keyShare), + serverNetId, + shareEpoch, + tssSetup: tssSetup === null ? null : bytesToHex(tssSetup), + }; + } + + /** + * Initialize the keyring with the given serialized state. + * + * @param state - The serialized state of the keyring. + */ + async deserialize(state: Json): Promise { + if (!state || typeof state !== 'object') { + throw new Error('Invalid state'); + } + const stateObj = state as Record; + + if ( + 'netCreds' in stateObj && + 'keyShare' in stateObj && + 'serverNetId' in stateObj && + 'shareEpoch' 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), + shareEpoch: parseShareEpoch(stateObj.shareEpoch), + 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 to the next epoch, then activate it. + * Clears local TSS setup so it is rebuilt against the new shares. + * Serialized with sign and sync so mid-flight state writes cannot race. + * + * @returns Resolves when rotation and activation complete. + */ + async rotateKeyShares(): Promise { + return this.#serializeOp(async () => { + const state = this.#assertState(); + const { netCreds, serverNetId, shareEpoch } = state; + let { keyShare } = state; + const nextEpoch = shareEpoch + 1; + + const token = await this.#getProfileToken({ twoFactor: true }); + const nonce = generateSessionNonce(this.#rng); + await startRotateKeyShares({ + baseURL: this.#cloudURL, + token, + clientNetId: netCreds.partyId, + nonce, + expectedActiveEpoch: shareEpoch, + }); + + const netSession = await this.#createNetworkSession( + netCreds, + serverNetId, + nonce, + ); + try { + const custodians = partyNetIds(netCreds.partyId, serverNetId); + keyShare = await this.#dkm.rotateKeyShares({ + key: keyShare, + dealers: dealersFromCL24Key(keyShare, custodians), + custodians, + networkSession: netSession.createSubsession('dkg-rotate'), + }); + } finally { + await netSession.disconnect(); + } + + await storeKeyShareBackup({ + baseURL: this.#cloudURL, + token, + epoch: nextEpoch, + attemptNonce: nonce, + encryptedKeyShare: await this.#encryptKeyShare(keyShare), + }); + + assertEpochReady( + await checkKeyShareRemote({ + baseURL: this.#cloudURL, + token, + }), + nextEpoch, + ); + + await setActiveEpoch({ + baseURL: this.#cloudURL, + token, + epoch: nextEpoch, + }); + + this.#applyKeyState({ + ...state, + keyShare, + shareEpoch: nextEpoch, + tssSetup: null, + }); + }); + } + + /** + * Compare the local share epoch with backend share/backup/active epochs. + * + * @returns Whether all remote epochs match the local share epoch. + */ + async checkKeyShare(): Promise { + const { shareEpoch } = this.#assertState(); + const token = await this.#getProfileToken(); + const check = await checkKeyShareRemote({ + baseURL: this.#cloudURL, + token, + }); + return ( + check.latestShareEpoch === shareEpoch && + check.latestBackupEpoch === shareEpoch && + check.activeEpoch === shareEpoch + ); + } + + /** + * Refresh `keyShare` and `shareEpoch` from the active-epoch backend backup. + * Clears `tssSetup`; `netCreds` and `serverNetId` are unchanged. + * Serialized with sign and rotate so mid-flight state writes cannot race. + * + * @returns Resolves when the local share has been refreshed. + */ + async syncKeyShare(): Promise { + return this.#serializeOp(async () => { + const state = this.#assertState(); + const token = await this.#getProfileToken({ twoFactor: true }); + const { encryptedKeyShare, epoch } = await loadKeyShareBackup({ + baseURL: this.#cloudURL, + token, + }); + const keyShare = await this.#decryptKeyShare(encryptedKeyShare); + this.#applyKeyState({ + ...state, + keyShare, + shareEpoch: epoch, + tssSetup: null, + }); + }); + } + + /** + * 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); + + // convertV adapts the message-style recovery id (27|28) to typed-tx + // yParity (0|1) or legacy EIP-155 v, matching `TypedTransaction.sign()`. + const signedTx = tx.addSignature(v, r, s, true); + 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); + } + + /** + * Sign an EIP-7702 authorization using the specified account. + * + * @param address - The address of the account. + * @param authorization - The EIP-7702 authorization to sign. + * @param _opts - The options for signing the authorization. + * @returns The signature of the authorization. + */ + async signEip7702Authorization( + address: Hex, + authorization: EIP7702Authorization, + _opts?: Record, + ): Promise { + const messageHash = new Uint8Array(hashEIP7702Authorization(authorization)); + const signature = await this.#signHash(address, messageHash); + return bytesToHex(signature); + } + + async #setupCreate(): Promise { + const token = await this.#getProfileToken({ twoFactor: true }); + const netCreds = await this.#networkManager.createIdentity(); + const serverNetId = await getNetId({ + baseURL: this.#cloudURL, + token, + }); + + const nonce = generateSessionNonce(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('dkg-create'); + 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(); + } + + await storeKeyShareBackup({ + baseURL: this.#cloudURL, + token, + epoch: INITIAL_SHARE_EPOCH, + attemptNonce: nonce, + encryptedKeyShare: await this.#encryptKeyShare(keyShare), + }); + + assertEpochReady( + await checkKeyShareRemote({ + baseURL: this.#cloudURL, + token, + }), + INITIAL_SHARE_EPOCH, + ); + + await setActiveEpoch({ + baseURL: this.#cloudURL, + token, + epoch: INITIAL_SHARE_EPOCH, + }); + + this.#applyKeyState({ + keyShare, + netCreds, + serverNetId, + shareEpoch: INITIAL_SHARE_EPOCH, + tssSetup, + }); + } + + async #setupImport(): Promise { + const token = await this.#getProfileToken({ twoFactor: true }); + const netCreds = await this.#networkManager.createIdentity(); + const serverNetId = await getNetId({ + baseURL: this.#cloudURL, + token, + }); + + const loaded = await loadKeyShareBackup({ + baseURL: this.#cloudURL, + token, + }); + const keyShare = await this.#decryptKeyShare(loaded.encryptedKeyShare); + + await registerClient({ + baseURL: this.#cloudURL, + token, + clientNetId: netCreds.partyId, + }); + + this.#applyKeyState({ + keyShare, + netCreds, + serverNetId, + shareEpoch: loaded.epoch, + tssSetup: null, + }); + } + + async #signHash(address: Hex, hash: Uint8Array): Promise { + return this.#serializeOp(async () => { + const state = this.#assertState(); + const { keyShare, netCreds, serverNetId, shareEpoch } = state; + let { tssSetup } = state; + + const addr = this.#address(); + if (!equalAddresses(address, addr)) { + throw new Error(`account ${address} not found`); + } + + const token = await this.#getProfileToken({ + twoFactor: true, + challenge: hash, + }); + const nonce = generateSessionNonce(this.#rng); + await startSign({ + baseURL: this.#cloudURL, + token, + data: hash, + clientNetId: netCreds.partyId, + nonce, + shareEpoch, + }); + + 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 #serializeOp( + operation: () => Promise, + ): Promise { + const previous = this.#opQueue; + let release!: () => void; + this.#opQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } + + #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/types.ts b/packages/keyring-eth-mpc/src/types.ts new file mode 100644 index 000000000..6aa478b15 --- /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 = { + twoFactor?: 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; + shareEpoch: number; + netCreds: MfaNetworkIdentity; + serverNetId: 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..0cabdca10 --- /dev/null +++ b/packages/keyring-eth-mpc/src/util.test.ts @@ -0,0 +1,287 @@ +import { ecsign, privateToAddress, privateToPublic } from '@ethereumjs/util'; +import type { ECDSASignature } from '@ethereumjs/util'; +import { + SignTypedDataVersion, + TypedDataUtils, + typedSignatureHash, +} from '@metamask/eth-sig-util'; +import { bytesToHex, hexToBytes } from '@metamask/utils'; + +import { + AES_GCM_IV_LENGTH, + decryptBytes, + encryptBytes, + equalAddresses, + generateSessionNonce, + getSignedTypedDataHash, + normalizeAddress, + parseEthSig, + parseServerNetId, + parseShareEpoch, + 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('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(parseShareEpoch(3)).toBe(3); + expect(() => parseShareEpoch('1' as never)).toThrow( + 'Invalid share epoch: expected an integer', + ); + expect(() => parseShareEpoch(1.5)).toThrow( + 'Invalid share epoch: expected an integer', + ); + expect(() => parseShareEpoch(0)).toThrow( + 'Invalid share epoch: expected a positive integer', + ); + + 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..a63338333 --- /dev/null +++ b/packages/keyring-eth-mpc/src/util.ts @@ -0,0 +1,344 @@ +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; +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)); +} + +/** + * 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 share epoch from a JSON value. + * + * @param shareEpoch - The share epoch to parse. + * @returns The parsed share epoch. + */ +export function parseShareEpoch(shareEpoch: Json): number { + if (typeof shareEpoch !== 'number' || !Number.isInteger(shareEpoch)) { + throw new Error('Invalid share epoch: expected an integer'); + } + if (shareEpoch < 1) { + throw new Error('Invalid share epoch: expected a positive integer'); + } + return shareEpoch; +} + +/** + * 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..57e7224cc --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "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..d5ab8a4f0 --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.json @@ -0,0 +1,16 @@ +{ + "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/**/*"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 29029bd74..a9160f780 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ { "path": "./packages/keyring-eth-hd/tsconfig.build.json" }, { "path": "./packages/keyring-eth-money/tsconfig.build.json" }, { "path": "./packages/keyring-eth-ledger-bridge/tsconfig.build.json" }, + { "path": "./packages/keyring-eth-mpc/tsconfig.build.json" }, { "path": "./packages/keyring-eth-qr/tsconfig.build.json" }, { "path": "./packages/keyring-eth-simple/tsconfig.build.json" }, { "path": "./packages/keyring-eth-trezor/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index aa0a2a8db..3a7166454 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/keyring-api" }, { "path": "./packages/keyring-eth-hd" }, { "path": "./packages/keyring-eth-ledger-bridge" }, + { "path": "./packages/keyring-eth-mpc" }, { "path": "./packages/keyring-eth-simple" }, { "path": "./packages/keyring-eth-trezor" }, { "path": "./packages/keyring-internal-api" }, diff --git a/yarn.lock b/yarn.lock index 7755a4495..26a68d7ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1986,6 +1986,32 @@ __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:^6.1.0" + "@metamask/eth-sig-util": "npm:^9.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" + "@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.11.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.5.0" + typescript: "npm:~5.3.3" + 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" @@ -2476,6 +2502,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" @@ -2820,6 +2904,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" @@ -2835,7 +2926,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 @@ -2869,12 +2960,12 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.0.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:~1.9.0": - version: 1.9.2 - resolution: "@noble/curves@npm:1.9.2" +"@noble/curves@npm:^1.0.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": + version: 1.9.7 + resolution: "@noble/curves@npm:1.9.7" dependencies: "@noble/hashes": "npm:1.8.0" - checksum: 10/f60f00ad86296054566b67be08fd659999bb64b692bfbf11dbe3be1f422ad4d826bf5ebb2015ce2e246538eab2b677707e0a46ffa8323a6fae7a9a30ec1fe318 + checksum: 10/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 languageName: node linkType: hard @@ -3337,27 +3428,26 @@ __metadata: languageName: node linkType: hard -"@protobufjs/codegen@npm:^2.0.4": - version: 2.0.4 - resolution: "@protobufjs/codegen@npm:2.0.4" - checksum: 10/c6ee5fa172a8464f5253174d3c2353ea520c2573ad7b6476983d9b1346f4d8f2b44aa29feb17a949b83c1816bc35286a5ea265ed9d8fdd2865acfa09668c0447 +"@protobufjs/codegen@npm:^2.0.4, @protobufjs/codegen@npm:^2.0.5": + version: 2.0.5 + resolution: "@protobufjs/codegen@npm:2.0.5" + checksum: 10/290335fa114f26202abc0695f279d53e2fd516b01cfd8298923591e0bda011295ff40e3582a1cda0a0f27cbc5039a0292082d5ad08872bb5d6243a614ac15c88 languageName: node linkType: hard -"@protobufjs/eventemitter@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/eventemitter@npm:1.1.0" - checksum: 10/03af3e99f17ad421283d054c88a06a30a615922a817741b43ca1b13e7c6b37820a37f6eba9980fb5150c54dba6e26cb6f7b64a6f7d8afa83596fafb3afa218c3 +"@protobufjs/eventemitter@npm:^1.1.0, @protobufjs/eventemitter@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/eventemitter@npm:1.1.1" + checksum: 10/a54dc1aff4475ffad4fdf3235c71a553f5e40e3b4cf6a2e217151895a61cb4eb0be20d63791db22441ca25e594671f1021977133f9939540750231ff7d8e9dd6 languageName: node linkType: hard -"@protobufjs/fetch@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/fetch@npm:1.1.0" +"@protobufjs/fetch@npm:^1.1.0, @protobufjs/fetch@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/fetch@npm:1.1.1" dependencies: "@protobufjs/aspromise": "npm:^1.1.1" - "@protobufjs/inquire": "npm:^1.1.0" - checksum: 10/67ae40572ad536e4ef94269199f252c024b66e3059850906bdaee161ca1d75c73d04d35cd56f147a8a5a079f5808e342b99e61942c1dae15604ff0600b09a958 + checksum: 10/427cf2da8c69b494b0df3b2fb1f43c97f0f71ca2c8ef8232dac7e44f2527ad0cc9cecb243eda14a918e86018bfa6d54d92252240d2b37ed205b13adb5506fa1d languageName: node linkType: hard @@ -3389,10 +3479,10 @@ __metadata: languageName: node linkType: hard -"@protobufjs/utf8@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/utf8@npm:1.1.0" - checksum: 10/131e289c57534c1d73a0e55782d6751dd821db1583cb2f7f7e017c9d6747addaebe79f28120b2e0185395d990aad347fb14ffa73ef4096fa38508d61a0e64602 +"@protobufjs/utf8@npm:^1.1.0, @protobufjs/utf8@npm:^1.1.1": + version: 1.1.2 + resolution: "@protobufjs/utf8@npm:1.1.2" + checksum: 10/ff759348d60e8f65137d3a7a16e00cf69abd9a6dede75e50ec377c6aebb4ac400a4a70af2e77eb2bf75e2accf30abbea81803e9403004b1922ea70776bfdc3aa languageName: node linkType: hard @@ -6183,6 +6273,16 @@ __metadata: languageName: node linkType: hard +"centrifuge@npm:^5.5.2": + version: 5.7.2 + resolution: "centrifuge@npm:5.7.2" + dependencies: + events: "npm:^3.3.0" + protobufjs: "npm:^7.6.0" + checksum: 10/a5c376b88938800aa5afc164fe6c3f2da52c74838c10428b8846f69db7fd7bd6988fb143cdca187fc529ade8c579565eb221aa5125f05a130ae57e4fbea697df + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -9958,13 +10058,20 @@ __metadata: languageName: node linkType: hard -"long@npm:5.2.5, long@npm:^5.0.0": +"long@npm:5.2.5": version: 5.2.5 resolution: "long@npm:5.2.5" checksum: 10/972589d04a564ef4c066069d5ed06db14cd63de2a08d4f6c989512fa414bc2239479564e3a85de3efd42eb533ce142cdcaa28f12f4ecd051670015d36cbbb10b languageName: node linkType: hard +"long@npm:^5.0.0, long@npm:^5.3.2": + version: 5.3.2 + resolution: "long@npm:5.3.2" + checksum: 10/b6b55ddae56fcce2864d37119d6b02fe28f6dd6d9e44fd22705f86a9254b9321bd69e9ffe35263b4846d54aba197c64882adcb8c543f2383c1e41284b321ea64 + languageName: node + linkType: hard + "loose-envify@npm:^1.1.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -11340,6 +11447,25 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^7.6.0": + version: 7.6.6 + resolution: "protobufjs@npm:7.6.6" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.5" + "@protobufjs/eventemitter": "npm:^1.1.1" + "@protobufjs/fetch": "npm:^1.1.1" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.1" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.3.2" + checksum: 10/a894f38c793a0d741f77a7fbf8595141618e8c1cb3433637d2d0309ea9baca59bd9c44ec95f46ee839d063de5d5ae1bc78ef8d474359866af52ee4ceef7fec4f + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7"