diff --git a/package.json b/package.json index 7f5bdf09d..7712014a3 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,14 @@ "@types/web": "^0.0.69", "@typescript/lib-dom": "npm:@types/web@^0.0.69", "axios@1.7.3": "^1.7.7", - "ws@7.4.6": "^7.5.10" + "ws@7.4.6": "^7.5.10", + "@metamask/mfa-wallet-cl24-lib": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz", + "@metamask/mfa-wallet-dkls19-lib": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls19-lib-0.0.0.tgz", + "@metamask/mfa-wallet-e2ee": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-e2ee-0.0.0.tgz", + "@metamask/mfa-wallet-interface": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-interface-0.0.0.tgz", + "@metamask/mfa-wallet-network": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-network-0.0.0.tgz", + "@metamask/mfa-wallet-util": "file:./packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz", + "@metamask/mpc-libs-interface": "file:./packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.2.1", diff --git a/packages/keyring-eth-mpc/CHANGELOG.md b/packages/keyring-eth-mpc/CHANGELOG.md new file mode 100644 index 000000000..e7c894b17 --- /dev/null +++ b/packages/keyring-eth-mpc/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/accounts diff --git a/packages/keyring-eth-mpc/LICENSE b/packages/keyring-eth-mpc/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-eth-mpc/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/keyring-eth-mpc/README.md b/packages/keyring-eth-mpc/README.md new file mode 100644 index 000000000..00bcfb153 --- /dev/null +++ b/packages/keyring-eth-mpc/README.md @@ -0,0 +1,128 @@ +# MPC Keyring + +A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing. +Built on top of the [MFA Wallet SDK](https://github.com/MetaMask/mfa-wallet-sdk). + +## Installation + +`yarn add @metamask/eth-mpc-keyring` + +or + +`npm install @metamask/eth-mpc-keyring` + +## The Keyring Class Protocol + +One of the goals of this class is to allow developers to easily add new signing strategies to MetaMask. We call these signing strategies Keyrings, because they can manage multiple keys. + +### Keyring.type + +A class property that returns a unique string describing the Keyring. +This is the only class property or method, the remaining methods are instance methods. + +### constructor( options ) + +As a Javascript class, your Keyring object will be used to instantiate new Keyring instances using the new keyword. For example: + +``` +const keyring = new YourKeyringClass(options); +``` + +The constructor currently receives an options object that will be defined by your keyring-building UI, once the user has gone through the steps required for you to fully instantiate a new keyring. For example, choosing a pattern for a vanity account, or entering a seed phrase. + +We haven't defined the protocol for this account-generating UI yet, so for now please ensure your Keyring behaves nicely when not passed any options object. + +## Keyring Instance Methods + +All below instance methods must return Promises to allow asynchronous resolution. + +### serialize() + +In this method, you must return any JSON-serializable JavaScript object that you like. It will be encoded to a string, encrypted with the user's password, and stored to disk. This is the same object you will receive in the deserialize() method, so it should capture all the information you need to restore the Keyring's state. + +### deserialize( object ) + +As discussed above, the deserialize() method will be passed the JavaScript object that you returned when the serialize() method was called. + +### addAccounts( n = 1 ) + +The addAccounts(n) method is used to inform your keyring that the user wishes to create a new account. You should perform whatever internal steps are needed so that a call to serialize() will persist the new account, and then return an array of the new account addresses. + +The method may be called with or without an argument, specifying the number of accounts to create. You should generally default to 1 per call. + +### getAccounts() + +When this method is called, you must return an array of hex-string addresses for the accounts that your Keyring is able to sign for. + +### signTransaction(address, transaction) + +This method will receive a hex-prefixed, all-lowercase address string for the account you should sign the incoming transaction with. + +For your convenience, the transaction is an instance of ethereumjs-tx, (https://github.com/ethereumjs/ethereumjs-tx) so signing can be as simple as: + +``` +transaction.sign(privateKey) +``` + +You must return a valid signed ethereumjs-tx (https://github.com/ethereumjs/ethereumjs-tx) object when complete, it can be the same transaction you received. + +### signMessage(address, data) + +The `eth_sign` method will receive the incoming data, alread hashed, and must sign that hash, and then return the raw signed hash. + +### exportAccount(address) + +Exports the specified account as a private key hex string. + +## Contributing + +### Setup + +- Install [Node.js](https://nodejs.org) version 18 + - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. +- Install [Yarn v3](https://yarnpkg.com/getting-started/install) +- Run `yarn install` to install dependencies and run any required post-install scripts + +### Testing and Linting + +Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. + +Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. + +### Release & Publishing + +The project follows the same release process as the other libraries in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. + +1. Choose a release version. + + - The release version should be chosen according to SemVer. Analyze the changes to see whether they include any breaking changes, new features, or deprecations, then choose the appropriate SemVer version. See [the SemVer specification](https://semver.org/) for more information. + +2. If this release is backporting changes onto a previous release, then ensure there is a major version branch for that version (e.g. `1.x` for a `v1` backport release). + + - The major version branch should be set to the most recent release with that major version. For example, when backporting a `v1.0.2` release, you'd want to ensure there was a `1.x` branch that was set to the `v1.0.1` tag. + +3. Trigger the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event [manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) for the `Create Release Pull Request` action to create the release PR. + + - For a backport release, the base branch should be the major version branch that you ensured existed in step 2. For a normal release, the base branch should be the main branch for that repository (which should be the default value). + - This should trigger the [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) workflow to create the release PR. + +4. Update the changelog to move each change entry into the appropriate change category ([See here](https://keepachangelog.com/en/1.0.0/#types) for the full list of change categories, and the correct ordering), and edit them to be more easily understood by users of the package. + + - Generally any changes that don't affect consumers of the package (e.g. lockfile changes or development environment changes) are omitted. Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). + - Try to explain each change in terms that users of the package would understand (e.g. avoid referencing internal variables/concepts). + - Consolidate related changes into one change entry if it makes it easier to explain. + - Run `yarn auto-changelog validate --rc` to check that the changelog is correctly formatted. + +5. Review and QA the release. + + - If changes are made to the base branch, the release branch will need to be updated with these changes and review/QA will need to restart again. As such, it's probably best to avoid merging other PRs into the base branch while review is underway. + +6. Squash & Merge the release. + + - This should trigger the [`action-publish-release`](https://github.com/MetaMask/action-publish-release) workflow to tag the final release commit and publish the release on GitHub. + +7. Publish the release on npm. + + - Be very careful to use a clean local environment to publish the release, and follow exactly the same steps used during CI. + - Use `npm publish --dry-run` to examine the release contents to ensure the correct files are included. Compare to previous releases if necessary (e.g. using `https://unpkg.com/browse/[package name]@[package version]/`). + - Once you are confident the release contents are correct, publish the release using `npm publish`. diff --git a/packages/keyring-eth-mpc/TODO.md b/packages/keyring-eth-mpc/TODO.md new file mode 100644 index 000000000..94be42dae --- /dev/null +++ b/packages/keyring-eth-mpc/TODO.md @@ -0,0 +1,4 @@ +# TODO + +- Check for "not implemented" and "TODO" in code +- Add support for managing verifiers: add/remove diff --git a/packages/keyring-eth-mpc/jest.config.js b/packages/keyring-eth-mpc/jest.config.js new file mode 100644 index 000000000..afeeab368 --- /dev/null +++ b/packages/keyring-eth-mpc/jest.config.js @@ -0,0 +1,32 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['./src/tests'], + + // The glob patterns Jest uses to detect test files + testMatch: ['**/*.test.[jt]s?(x)'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-cl24-lib-0.0.0.tgz new file mode 100644 index 000000000..1588a67ed 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-dkls19-lib-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mfa-wallet-dkls19-lib-0.0.0.tgz new file mode 100644 index 000000000..8322d2cdb Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-dkls19-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..b39698855 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..c23a19655 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..a071c7422 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..ea5575977 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mfa-wallet-util-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz b/packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz new file mode 100644 index 000000000..5ca9d5571 Binary files /dev/null and b/packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz differ diff --git a/packages/keyring-eth-mpc/package.json b/packages/keyring-eth-mpc/package.json new file mode 100644 index 000000000..fa5a11a23 --- /dev/null +++ b/packages/keyring-eth-mpc/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/eth-mpc-keyring", + "version": "0.0.0", + "description": "A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing.", + "keywords": [ + "ethereum", + "keyring" + ], + "homepage": "https://github.com/MetaMask/eth-mpc-keyring#readme", + "bugs": { + "url": "https://github.com/MetaMask/eth-mpc-keyring/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/eth-mpc-keyring.git" + }, + "license": "ISC", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-mpc-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-mpc-keyring", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest", + "test:clean": "jest --clearCache" + }, + "dependencies": { + "@ethereumjs/util": "^9.1.0", + "@metamask/eth-sig-util": "^8.2.0", + "@metamask/mfa-wallet-cl24-lib": "^0.0.0", + "@metamask/mfa-wallet-dkls19-lib": "^0.0.0", + "@metamask/mfa-wallet-e2ee": "^0.0.0", + "@metamask/mfa-wallet-network": "^0.0.0", + "@metamask/mfa-wallet-util": "^0.0.0" + }, + "devDependencies": { + "@ethereumjs/tx": "^5.4.0", + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-utils": "workspace:^", + "@metamask/mfa-wallet-interface": "^0.0.0", + "@metamask/mpc-libs-interface": "^0.0.0", + "@metamask/utils": "^11.1.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "deepmerge": "^4.2.2", + "jest": "^29.5.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "@metamask/mfa-wallet-network>centrifuge>protobufjs": false + } + } +} diff --git a/packages/keyring-eth-mpc/src/cloud.ts b/packages/keyring-eth-mpc/src/cloud.ts new file mode 100644 index 000000000..d0fc74b8d --- /dev/null +++ b/packages/keyring-eth-mpc/src/cloud.ts @@ -0,0 +1,123 @@ +import type { PartyId } from '@metamask/mfa-wallet-interface'; +import { bytesToBase64 } from '@metamask/utils'; + +/** + * Initialize a cloud keygen session + * + * @param opts - The options for the cloud keygen session + * @param opts.localId - The local ID of the device + * @param opts.sessionNonce - The nonce of the session + * @param opts.baseURL - The base URL of the cloud service + * @param opts.verifierIds - The IDs of the verifiers + * @returns The cloud ID of the device + */ +export async function initCloudKeyGen(opts: { + baseURL: string; + localId: PartyId; + sessionNonce: string; + verifierIds: string[]; +}): Promise<{ cloudId: string }> { + const response = await fetch(`${opts.baseURL}/create-key`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + custodianId: opts.localId, + nonce: opts.sessionNonce, + protocol: 'cl24-secp256k1', + verifierIds: opts.verifierIds, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to initialize cloud keygen session: ${response.statusText}`, + ); + } + + const data = await response.json(); + return { cloudId: data.serverCustodianId }; +} + +/** + * Initialize a cloud key update session + * + * @param opts - The options for the cloud key update session + * @param opts.baseURL - The base URL of the cloud service + * @param opts.keyId - The ID of the key + * @param opts.custodianId - The party ID of the calling custodian + * @param opts.newCustodianId - The party ID of the custodian to add + * @param opts.sessionNonce - The nonce of the session + * @param opts.token - The token for the verifier + */ +export async function initCloudKeyUpdate(opts: { + baseURL: string; + keyId: string; + custodianId: PartyId; + newCustodianId: string; + sessionNonce: string; + token: string; +}): Promise { + const response = await fetch(`${opts.baseURL}/update-key`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyId: opts.keyId, + custodianId: opts.custodianId, + newCustodianId: opts.newCustodianId, + nonce: opts.sessionNonce, + protocol: 'cl24-secp256k1', + token: opts.token, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to initialize cloud key update session: ${response.statusText}`, + ); + } +} + +/** + * Initialize a cloud sign session + * + * @param opts - The options for the cloud sign session + * @param opts.baseURL - The base URL of the cloud service + * @param opts.keyId - The ID of the key + * @param opts.localId - The local ID of the device + * @param opts.sessionNonce - The nonce of the session + * @param opts.message - The message to sign + * @param opts.token - The token for the verifier + */ +export async function initCloudSign(opts: { + baseURL: string; + keyId: string; + localId: PartyId; + sessionNonce: string; + message: Uint8Array; + token: string; +}): Promise { + const response = await fetch(`${opts.baseURL}/sign`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyId: opts.keyId, + custodianId: opts.localId, + nonce: opts.sessionNonce, + message: bytesToBase64(opts.message), + protocol: 'dkls19', + token: opts.token, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to initialize cloud sign session: ${response.statusText}`, + ); + } +} diff --git a/packages/keyring-eth-mpc/src/index.ts b/packages/keyring-eth-mpc/src/index.ts new file mode 100644 index 000000000..386a0776d --- /dev/null +++ b/packages/keyring-eth-mpc/src/index.ts @@ -0,0 +1,10 @@ +export { MPCKeyring } from './mpc-keyring'; +export { uninitializedResponderState } from './mpc-keyring'; + +export type { + Custodian, + CustodianType, + MPCKeyringOpts, + MPCKeyringSerializer, + ThresholdKeyId, +} from './types'; 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..c3038025a --- /dev/null +++ b/packages/keyring-eth-mpc/src/mpc-keyring.ts @@ -0,0 +1,803 @@ +import type { TypedTransaction } from '@ethereumjs/tx'; +import { hashPersonalMessage } from '@ethereumjs/util'; +import type { + TypedDataV1, + TypedMessage, + SignTypedDataVersion, + MessageTypes, +} from '@metamask/eth-sig-util'; +import type { Keyring } from '@metamask/keyring-utils'; +import { + CL24DKM, + CL24PartialThresholdKeySerializer, + CL24ThresholdKeySerializer, + secp256k1 as secp256k1Curve, +} from '@metamask/mfa-wallet-cl24-lib'; +import { Dkls19TssLib } from '@metamask/mfa-wallet-dkls19-lib'; +import type { + PartyId, + PartialThresholdKey, + RandomNumberGenerator, + ThresholdKey, +} from '@metamask/mfa-wallet-interface'; +import type { MfaNetworkIdentity } from '@metamask/mfa-wallet-network'; +import { + MfaNetworkIdentitySerializer, + MfaNetworkManager, + createScopedSessionId, +} from '@metamask/mfa-wallet-network'; +import type { Dkls19Lib } from '@metamask/mpc-libs-interface'; +import { bytesToHex, hexToBytes, type Hex, type Json } from '@metamask/utils'; + +import { initCloudKeyGen, initCloudKeyUpdate, initCloudSign } from './cloud'; +import type { + Custodian, + MPCKeyringOpts, + MPCKeyringSerializer, + ThresholdKeyId, +} from './types'; +import { + equalAddresses, + getSignedTypedDataHash, + parseCustodians, + parseEthSig, + parseSelectedVerifierIndex, + parseSignedTypedDataVersion, + parseThresholdKeyId, + parseVerifierIds, + publicKeyToAddressHex, + toEthSig, +} from './util'; + +const mpcKeyringType = 'MPC Keyring'; + +export const uninitializedResponderState: Json = { + initRole: 'responder', +}; + +export class MPCKeyring implements Keyring { + readonly type: string = mpcKeyringType; + + readonly #rng: RandomNumberGenerator; + + readonly #networkManager: MfaNetworkManager; + + readonly #dkls19Lib: Dkls19Lib; + + #networkIdentity?: MfaNetworkIdentity; + + #keyShare?: ThresholdKey; + + #keyId?: ThresholdKeyId; + + #custodians?: Custodian[]; + + #verifierIds?: string[]; + + #selectedVerifierIndex?: number; + + readonly #cloudURL: string; + + readonly #serializer: MPCKeyringSerializer; + + readonly #getVerifierToken: (verifierId: string) => Promise; + + constructor(opts: MPCKeyringOpts) { + this.#rng = { + generateRandomBytes: opts.getRandomBytes, + }; + this.#dkls19Lib = opts.dkls19Lib; + this.#cloudURL = opts.cloudURL; + this.#serializer = { + thresholdKey: new CL24ThresholdKeySerializer(), + partialThresholdKey: new CL24PartialThresholdKeySerializer(), + 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.#getVerifierToken = opts.getVerifierToken; + } + + /** + * Return the serialized state of the keyring. + * + * @returns The serialized state of the keyring. + */ + async serialize(): Promise { + const state: Json = {}; + if (this.#networkIdentity) { + state.networkIdentity = this.#serializer.networkIdentity.toJson( + this.#networkIdentity, + ); + } + if (this.#keyShare) { + state.keyShare = this.#serializer.thresholdKey.toJson(this.#keyShare); + } + if (this.#keyId) { + state.keyId = this.#keyId; + } + if (this.#custodians) { + state.custodians = this.#custodians; + } + if (this.#verifierIds) { + state.verifierIds = this.#verifierIds; + } + if (this.#selectedVerifierIndex !== undefined) { + state.selectedVerifierIndex = this.#selectedVerifierIndex; + } + return state; + } + + /** + * Initialize the keyring with the given serialized state. + * + * @param state - The serialized state of the keyring. + */ + async deserialize(state: Json): Promise { + if (!state || typeof state !== 'object') { + throw new Error('Invalid state'); + } + + if ('networkIdentity' in state) { + this.#networkIdentity = this.#serializer.networkIdentity.fromJson( + state.networkIdentity, + ); + } + + if ('keyShare' in state) { + this.#keyShare = this.#serializer.thresholdKey.fromJson(state.keyShare); + } + + if ('keyId' in state) { + this.#keyId = parseThresholdKeyId(state.keyId); + } + + if ('custodians' in state) { + this.#custodians = parseCustodians(state.custodians); + } + + if ('verifierIds' in state) { + this.#verifierIds = parseVerifierIds(state.verifierIds); + } + + if ('selectedVerifierIndex' in state) { + this.#selectedVerifierIndex = parseSelectedVerifierIndex( + state.selectedVerifierIndex, + ); + } + } + + /** + * Get the custodian identifier from the network identity. + * + * @returns The network identity party ID. + */ + getCustodianId(): string { + if (!this.#networkIdentity) { + throw new Error('Network identity not initialized'); + } + return this.#networkIdentity.partyId; + } + + /** + * Get the custodians associated with the current threshold key. + * + * @returns The custodians with their party IDs and types. + */ + getCustodians(): Custodian[] { + if (!this.#custodians) { + throw new Error('Custodians not initialized'); + } + return this.#custodians; + } + + /** + * Add a new custodian to the keyring using serialized join data. + * + * @param joinData - The serialized join data from {@link createJoinData}. + */ + async addCustodian(joinData: string): Promise { + if (!this.#keyShare) { + throw new Error('Key share not initialized'); + } else if (!this.#networkIdentity) { + throw new Error('Network identity not initialized'); + } else if (!this.#keyId) { + throw new Error('Key ID not initialized'); + } else if (!this.#custodians) { + throw new Error('Custodians not initialized'); + } else if (this.#keyShare.threshold !== 2) { + throw new Error('Key threshold must be 2'); + } + + const localId = this.#networkIdentity.partyId; + const cloudCustodian = this.#custodians.find( + (custodian) => custodian.type === 'cloud', + ); + if (!cloudCustodian) { + throw new Error('Cloud custodian not found'); + } + + // Deserialize join data to get ephemeral joiner identity and nonce + const { joinerIdentity: joinerIdentityJson, nonce } = JSON.parse(joinData); + const ephemeralJoinerIdentity = + this.#serializer.networkIdentity.fromJson(joinerIdentityJson); + const ephemeralJoinerId = ephemeralJoinerIdentity.partyId; + + const totalStartTime = performance.now(); + const session1StartTime = performance.now(); + + // Session 1: establish with ephemeral joiner identity and nonce, + // receive the actual static joiner identity + const joinSession1Id = createScopedSessionId( + [ephemeralJoinerId, localId], + nonce, + ); + const joinSession1 = await this.#networkManager.createSession( + this.#networkIdentity, + joinSession1Id, + ); + + const staticJoinerIdBytes = await joinSession1.receiveMessage( + ephemeralJoinerId, + 'static-id', + ); + const custodianId = new TextDecoder().decode(staticJoinerIdBytes); + await joinSession1.disconnect(); + + const session1Time = performance.now() - session1StartTime; + console.log('addCustodian session1 time', session1Time); + const session2StartTime = performance.now(); + + // Session 2: establish with static joiner identity, + // send partial key, key id, and fresh nonce + const sessionNonce = bytesToHex(this.#rng.generateRandomBytes(32)); + + const joinSession2Id = createScopedSessionId([custodianId, localId], nonce); + const joinSession2 = await this.#networkManager.createSession( + this.#networkIdentity, + joinSession2Id, + ); + + const partialKey: PartialThresholdKey = { + custodians: this.#keyShare.custodians, + shareIndexes: this.#keyShare.shareIndexes, + threshold: this.#keyShare.threshold, + }; + const partialKeyJson = + this.#serializer.partialThresholdKey.toJson(partialKey); + const joinPayload = JSON.stringify({ + cloudCustodian: cloudCustodian.partyId, + nonce: sessionNonce, + partialKey: partialKeyJson, + keyId: this.#keyId, + }); + joinSession2.sendMessage( + custodianId, + 'join-data', + new TextEncoder().encode(joinPayload), + ); + + const session2Time = performance.now() - session2StartTime; + console.log('addCustodian session2 time', session2Time); + const initCloudStartTime = performance.now(); + + // Notify the cloud custodian + const onlineCustodians = [localId, cloudCustodian.partyId]; + const newCustodians = [...onlineCustodians, custodianId]; + + const verifierId = this.getSelectedVerifierId(); + const token = await this.#getVerifierToken(verifierId); + + await initCloudKeyUpdate({ + keyId: this.#keyId, + custodianId: localId, + newCustodianId: custodianId, + sessionNonce, + baseURL: this.#cloudURL, + token, + }); + + const initCloudTime = performance.now() - initCloudStartTime; + console.log('initCloudKeyUpdate time', initCloudTime); + const updateKeyStartTime = performance.now(); + + // Run the key update protocol + const sessionId = createScopedSessionId(newCustodians, sessionNonce); + const networkSession = await this.#networkManager.createSession( + this.#networkIdentity, + sessionId, + ); + + const dkm = new CL24DKM(secp256k1Curve, this.#rng); + const newKey = await dkm.updateKey({ + key: this.#keyShare, + onlineCustodians, + newCustodians, + networkSession, + }); + + const updateKeyTime = performance.now() - updateKeyStartTime; + console.log('dkm.updateKey time', updateKeyTime); + + const totalTime = performance.now() - totalStartTime; + console.log('addCustodian total time', totalTime); + + await networkSession.disconnect(); + // We disconnect session 2 after receiving message from custodian to avoid + // a bug where messages are not sent when disconnecting immediately. + await joinSession2.disconnect(); + + this.#keyShare = newKey; + this.#custodians = [ + ...this.#custodians, + { partyId: custodianId, type: 'user' }, + ]; + } + + getVerifierIds(): string[] { + if (!this.#verifierIds) { + throw new Error('Verifier IDs not initialized'); + } + return this.#verifierIds; + } + + selectVerifier(verifierIndex: number): string { + if (!this.#verifierIds) { + throw new Error('Verifier IDs not initialized'); + } else if (verifierIndex < 0 || verifierIndex >= this.#verifierIds.length) { + throw new Error('Invalid verifier index'); + } + this.#selectedVerifierIndex = verifierIndex; + return this.#verifierIds[verifierIndex] as string; + } + + getSelectedVerifierId(): string { + if (!this.#verifierIds) { + throw new Error('Verifier IDs not initialized'); + } else if (this.#selectedVerifierIndex === undefined) { + throw new Error('Selected verifier index not initialized'); + } else if ( + this.#selectedVerifierIndex < 0 || + this.#selectedVerifierIndex >= this.#verifierIds.length + ) { + throw new Error('Invalid selected verifier index'); + } + return this.#verifierIds[this.#selectedVerifierIndex] as string; + } + + /** + * Create or retrieve the network identity. + * + * @returns The party ID of the network identity. + */ + async #setupIdentity(): Promise { + if (!this.#networkIdentity) { + this.#networkIdentity = await this.#networkManager.createIdentity(); + } + return this.#networkIdentity.partyId; + } + + /** + * Generate join data for a new custodian. + * Creates a fresh ephemeral joiner identity and session nonce, + * and serializes them along with the initiator's public ID. + * + * @returns Serialized join data string. + */ + async createJoinData(): Promise { + const initiatorId = await this.#setupIdentity(); + const ephemeralJoinerIdentity = await this.#networkManager.createIdentity(); + const nonce = bytesToHex(this.#rng.generateRandomBytes(32)); + + return JSON.stringify({ + initiatorId, + joinerIdentity: this.#serializer.networkIdentity.toJson( + ephemeralJoinerIdentity, + ), + nonce, + }); + } + + async setup( + opts: { verifierIds: string[] } & ( + | { mode?: 'create' } + | { mode: 'join'; joinData: string } + ), + ): Promise { + const { verifierIds } = opts; + const mode = 'mode' in opts ? opts.mode ?? 'create' : 'create'; + + if (this.#keyShare || this.#keyId) { + throw new Error('Keyring already setup'); + } else if (verifierIds.length < 1) { + throw new Error('At least one verifier ID is required'); + } + + if (mode === 'join') { + const { joinData } = opts as { joinData: string }; + await this.#setupJoin({ verifierIds, joinData }); + } else { + await this.#setupCreate(verifierIds); + } + } + + async #setupCreate(verifierIds: string[]): Promise { + const dkm = new CL24DKM(secp256k1Curve, this.#rng); + const localId = await this.#setupIdentity(); + const { networkIdentity } = this.#assertNetworkIdentity(); + + const totalStartTime = performance.now(); + const initCloudStartTime = performance.now(); + + const sessionNonce = bytesToHex(this.#rng.generateRandomBytes(32)); + const { cloudId } = await initCloudKeyGen({ + localId, + sessionNonce, + baseURL: this.#cloudURL, + verifierIds, + }); + + const initCloudTime = performance.now() - initCloudStartTime; + console.log('initCloudKeyGen time', initCloudTime); + const createKeyStartTime = performance.now(); + + const custodians = [localId, cloudId]; + const threshold = 2; + + const sessionId = createScopedSessionId(custodians, sessionNonce); + const networkSession = await this.#networkManager.createSession( + networkIdentity, + sessionId, + ); + + this.#keyShare = await dkm.createKey({ + custodians, + threshold, + networkSession, + }); + + const createKeyTime = performance.now() - createKeyStartTime; + console.log('dkm.createKey time', createKeyTime); + + const totalTime = performance.now() - totalStartTime; + console.log('setupCreate total time', totalTime); + + this.#keyId = networkSession.sessionId; + this.#custodians = [ + { partyId: localId, type: 'user' }, + { partyId: cloudId, type: 'cloud' }, + ]; + this.#verifierIds = verifierIds; + this.#selectedVerifierIndex = 0; + + await networkSession.disconnect(); + } + + async #setupJoin(opts: { + verifierIds: string[]; + joinData: string; + }): Promise { + const { verifierIds, joinData } = opts; + + // Deserialize join data to get initiator id, ephemeral joiner identity, nonce + const { + initiatorId: initiator, + joinerIdentity: joinerIdentityJson, + nonce, + } = JSON.parse(joinData); + const ephemeralJoinerIdentity = + this.#serializer.networkIdentity.fromJson(joinerIdentityJson); + + // Setup own static identity + const myId = await this.#setupIdentity(); + const { networkIdentity } = this.#assertNetworkIdentity(); + + const totalStartTime = performance.now(); + const session1StartTime = performance.now(); + + // Session 1: establish with initiator using ephemeral joiner identity, + // send own static identity (public id) + const joinSession1Id = createScopedSessionId( + [ephemeralJoinerIdentity.partyId, initiator], + nonce, + ); + const joinSession1 = await this.#networkManager.createSession( + ephemeralJoinerIdentity, + joinSession1Id, + ); + + joinSession1.sendMessage( + initiator, + 'static-id', + new TextEncoder().encode(myId), + ); + + const session1Time = performance.now() - session1StartTime; + console.log('setupJoin session1 time', session1Time); + const session2StartTime = performance.now(); + + // Session 2: establish with initiator using static identity, + // receive partial key, key id, and nonce + const joinSession2Id = createScopedSessionId([myId, initiator], nonce); + const joinSession2 = await this.#networkManager.createSession( + networkIdentity, + joinSession2Id, + ); + + const joinPayloadBytes = await joinSession2.receiveMessage( + initiator, + 'join-data', + ); + await joinSession2.disconnect(); + // We disconnect session 1 after receiving message from initiator to avoid + // a bug where messages are not sent when disconnecting immediately. + await joinSession1.disconnect(); + + const session2Time = performance.now() - session2StartTime; + console.log('setupJoin session2 time', session2Time); + + const joinPayload = JSON.parse(new TextDecoder().decode(joinPayloadBytes)); + const { + cloudCustodian, + nonce: sessionNonce, + partialKey: partialKeyJson, + keyId, + } = joinPayload; + + const partialKey = + this.#serializer.partialThresholdKey.fromJson(partialKeyJson); + + // Create DKM update session and run the protocol + const onlineCustodians = [initiator, cloudCustodian]; + const newCustodians = [...onlineCustodians, myId]; + + const updateKeyStartTime = performance.now(); + + const sessionId = createScopedSessionId(newCustodians, sessionNonce); + const networkSession = await this.#networkManager.createSession( + networkIdentity, + sessionId, + ); + + const dkm = new CL24DKM(secp256k1Curve, this.#rng); + const key = await dkm.updateKey({ + key: partialKey, + onlineCustodians, + newCustodians, + networkSession, + }); + + const updateKeyTime = performance.now() - updateKeyStartTime; + console.log('dkm.updateKey time', updateKeyTime); + + const totalTime = performance.now() - totalStartTime; + console.log('setupJoin total time', totalTime); + + await networkSession.disconnect(); + + this.#keyShare = key; + this.#keyId = keyId; + this.#custodians = [ + { partyId: initiator, type: 'user' }, + { partyId: cloudCustodian, type: 'cloud' }, + { partyId: myId, type: 'user' }, + ]; + this.#verifierIds = verifierIds; + this.#selectedVerifierIndex = 0; + } + + /** + * 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.#keyShare) { + return []; + } + + const addr = this.#address(); + return [addr]; + } + + /** + * Get the public address of the account for the given app key origin. + * + * @param address - The address of the account. + * @param origin - The origin of the app requesting the account. + * @returns The public address of the account. + */ + async getAppKeyAddress(address: Hex, origin: string): Promise { + throw new Error(`getAppKeyAddress(${address}, ${origin}): not implemented`); + } + + /** + * Sign a transaction using the specified account. + * + * @param address - The address of the account. + * @param tx - The transaction to sign. + * @param _opts - The options for signing the transaction. + * @returns The signed transaction. + */ + async signTransaction( + address: Hex, + tx: TypedTransaction, + _opts = {}, + ): Promise { + const message = tx.getHashedMessageToSign(); + + const signature = await this.#signHash(address, message); + + const { r, s, v } = parseEthSig(signature); + + const signedTx = tx.addSignature(v, r, s); + return signedTx; + } + + /** + * Sign a personal message using the specified account. + * This method is compatible with the `personal_sign` RPC method. + * + * @param address - The address of the account. + * @param msgHex - The message to sign. + * @param _opts - The options for signing the message. + * @returns The signature of the message. + */ + async signPersonalMessage( + address: Hex, + msgHex: string, + _opts?: Record, + ): Promise { + const rawMsg = hexToBytes(msgHex); + const msgHash = hashPersonalMessage(rawMsg); + + const signature = await this.#signHash(address, msgHash); + return bytesToHex(signature); + } + + /** + * Sign a typed message using the specified account. + * This method is compatible with the `eth_signTypedData` RPC method. + * + * @param address - The address of the account. + * @param data - The typed data to sign. + * @param options - The options for signing the message. + * @returns The signature of the message. + */ + async signTypedData< + Version extends SignTypedDataVersion, + Types extends MessageTypes, + Options extends { version?: Version }, + >( + address: Hex, + data: Version extends 'V1' ? TypedDataV1 : TypedMessage, + options?: Options, + ): Promise { + const version = parseSignedTypedDataVersion(options); + + const messageHash = getSignedTypedDataHash(data, version); + + const signature = await this.#signHash(address, messageHash); + return bytesToHex(signature); + } + + async #signHash(address: Hex, hash: Uint8Array): Promise { + if (!this.#keyShare) { + throw new Error(`keyshare not initialized`); + } else if (!this.#networkIdentity) { + throw new Error(`network credentials not initialized`); + } else if (!this.#keyId) { + throw new Error(`key id not initialized`); + } else if (!this.#verifierIds) { + throw new Error('Verifier IDs not initialized'); + } else if (this.#selectedVerifierIndex === undefined) { + throw new Error('Selected verifier index not initialized'); + } + + const verifierId = this.#verifierIds[this.#selectedVerifierIndex]; + if (!verifierId) { + throw new Error('Selected verifier index out of bounds'); + } + + if (!this.#custodians) { + throw new Error('Custodians not initialized'); + } + + const { publicKey } = this.#keyShare; + + const addr = this.#address(); + if (!equalAddresses(address, addr)) { + throw new Error(`account ${address} not found`); + } + + const localId = this.#networkIdentity.partyId; + const cloudCustodian = this.#custodians.find( + (custodian) => custodian.type === 'cloud', + ); + if (!cloudCustodian) { + throw new Error('Cloud custodian not found'); + } + + const signers = [localId, cloudCustodian.partyId]; + const sessionNonce = bytesToHex(this.#rng.generateRandomBytes(32)); + const sessionId = createScopedSessionId(signers, sessionNonce); + const message = hash; + const token = await this.#getVerifierToken(verifierId); + + const totalStartTime = performance.now(); + const initCloudStartTime = performance.now(); + + await initCloudSign({ + keyId: this.#keyId, + localId, + sessionNonce, + message, + baseURL: this.#cloudURL, + token, + }); + + const initCloudTime = performance.now() - initCloudStartTime; + console.log('initCloudSign time', initCloudTime); + const dkls19StartTime = performance.now(); + + const networkSession = await this.#networkManager.createSession( + this.#networkIdentity, + sessionId, + ); + + const dkls19 = new Dkls19TssLib(this.#dkls19Lib, this.#rng, true); + const { signature } = await dkls19.sign({ + key: this.#keyShare, + signers, + message, + networkSession, + }); + + const dkls19SignTime = performance.now() - dkls19StartTime; + console.log('dkls19.sign time', dkls19SignTime); + + const totalTime = performance.now() - totalStartTime; + console.log('total time', totalTime); + + await networkSession.disconnect(); + + return toEthSig(signature, hash, publicKey); + } + + #assertNetworkIdentity(): { networkIdentity: MfaNetworkIdentity } { + if (!this.#networkIdentity) { + throw new Error('Network identity not initialized'); + } + return { networkIdentity: this.#networkIdentity }; + } + + #address(): Hex { + if (!this.#keyShare) { + throw new Error(`keyshare not initialized`); + } + return publicKeyToAddressHex(this.#keyShare.publicKey); + } +} diff --git a/packages/keyring-eth-mpc/src/network.ts b/packages/keyring-eth-mpc/src/network.ts new file mode 100644 index 000000000..42deb202e --- /dev/null +++ b/packages/keyring-eth-mpc/src/network.ts @@ -0,0 +1,18 @@ +import type { + NetworkSession, + PartyId, + SessionId, +} from '@metamask/mfa-wallet-interface'; + +export type NetworkIdentity = { + partyId: PartyId; +}; + +export type NetworkManager = { + createIdentity: () => Promise; + createSession: ( + identity: NetworkIdentity, + parties: PartyId[], + sessionId: SessionId, + ) => Promise; +}; diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts new file mode 100644 index 000000000..b12165fe2 --- /dev/null +++ b/packages/keyring-eth-mpc/src/types.ts @@ -0,0 +1,37 @@ +import type { + PartialThresholdKey, + ThresholdKey, +} from '@metamask/mfa-wallet-interface'; +import type { MfaNetworkIdentity } from '@metamask/mfa-wallet-network'; +import type { Dkls19Lib } from '@metamask/mpc-libs-interface'; +import type { Json } from '@metamask/utils'; + +export type MPCKeyringOpts = { + getRandomBytes: (size: number) => Uint8Array; + dkls19Lib: Dkls19Lib; + cloudURL: string; + relayerURL: string; + getTransportToken?: () => Promise; + getVerifierToken: (verifierId: string) => Promise; + webSocket?: unknown; +}; + +export type ThresholdKeyId = string; + +export type CustodianType = 'user' | 'cloud'; + +export type Custodian = { + partyId: string; + type: CustodianType; +}; + +type JsonSerializer = { + toJson: (value: Value) => Json; + fromJson: (value: Json) => Value; +}; + +export type MPCKeyringSerializer = { + thresholdKey: JsonSerializer; + partialThresholdKey: JsonSerializer; + networkIdentity: JsonSerializer; +}; diff --git a/packages/keyring-eth-mpc/src/util.ts b/packages/keyring-eth-mpc/src/util.ts new file mode 100644 index 000000000..c31d89e68 --- /dev/null +++ b/packages/keyring-eth-mpc/src/util.ts @@ -0,0 +1,284 @@ +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 { Hex, Json } from '@metamask/utils'; +import { add0x, assert, bytesToHex, hexToBytes } from '@metamask/utils'; + +import type { Custodian, ThresholdKeyId } from './types'; + +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 the key ID from a JSON object. + * + * @param keyId - The key ID to parse. + * @returns The parsed key ID. + */ +export function parseThresholdKeyId(keyId: Json): ThresholdKeyId { + if (typeof keyId !== 'string') { + throw new Error('Invalid key ID'); + } + return keyId; +} + +/** + * Parse verifier IDs from a JSON object. + * + * @param verifierIds - The verifier IDs to parse. + * @returns The parsed verifier IDs. + */ +export function parseVerifierIds(verifierIds: Json): string[] { + if (!Array.isArray(verifierIds)) { + throw new Error('Invalid verifier IDs: expected an array'); + } + for (const id of verifierIds) { + if (typeof id !== 'string') { + throw new Error('Invalid verifier ID: expected a string'); + } + } + return verifierIds as string[]; +} + +/** + * Parse the selected verifier index from a JSON object. + * + * @param selectedVerifierIndex - The selected verifier index to parse. + * @returns The parsed selected verifier index. + */ +export function parseSelectedVerifierIndex( + selectedVerifierIndex: Json, +): number { + if (typeof selectedVerifierIndex !== 'number') { + throw new Error('Invalid selected verifier index: expected a number'); + } + if (!Number.isInteger(selectedVerifierIndex) || selectedVerifierIndex < 0) { + throw new Error( + 'Invalid selected verifier index: expected a non-negative integer', + ); + } + return selectedVerifierIndex; +} + +/** + * Parse custodians from a JSON object. + * + * @param custodians - The custodians to parse. + * @returns The parsed custodians. + */ +export function parseCustodians(custodians: Json): Custodian[] { + if (!Array.isArray(custodians)) { + throw new Error('Invalid custodians: expected an array'); + } + for (const custodian of custodians) { + if ( + !custodian || + typeof custodian !== 'object' || + Array.isArray(custodian) + ) { + throw new Error('Invalid custodian: expected an object'); + } + if (typeof custodian.partyId !== 'string') { + throw new Error('Invalid custodian partyId: expected a string'); + } + if (custodian.type !== 'user' && custodian.type !== 'cloud') { + throw new Error( + "Invalid custodian type: expected 'user' or 'cloud'", + ); + } + } + return custodians as Custodian[]; +} + +/** + * Convert a public key to an address. + * + * @param publicKey - The public key to convert. + * @returns The address. + */ +export function publicKeyToAddressHex(publicKey: Uint8Array): Hex { + return bytesToHex(pubToAddress(publicKey, true)); +} diff --git a/packages/keyring-eth-mpc/tsconfig.build.json b/packages/keyring-eth-mpc/tsconfig.build.json new file mode 100644 index 000000000..b34e78e32 --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + // NOTE: @msgpack/msgpack uses Uint8Array which requires TypeScript 5.7+. + // skipLibCheck bypasses type-checking of .d.ts files in node_modules. + "skipLibCheck": true + }, + "references": [ + { "path": "../keyring-utils/tsconfig.build.json" } + ], + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/packages/keyring-eth-mpc/tsconfig.json b/packages/keyring-eth-mpc/tsconfig.json new file mode 100644 index 000000000..a30efa086 --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + // NOTE: @msgpack/msgpack uses Uint8Array which requires TypeScript 5.7+. + // skipLibCheck bypasses type-checking of .d.ts files in node_modules. + "skipLibCheck": true + }, + "references": [ + { + "path": "../keyring-utils" + } + ], + "include": [ + "./src" + ], + "exclude": [ + "./dist/**/*" + ] +} \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json index 60377db1b..551af2f73 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,6 +7,7 @@ { "path": "./packages/keyring-eth-simple/tsconfig.build.json" }, { "path": "./packages/keyring-eth-trezor/tsconfig.build.json" }, { "path": "./packages/keyring-eth-hd/tsconfig.build.json" }, + { "path": "./packages/keyring-eth-mpc/tsconfig.build.json" }, { "path": "./packages/keyring-snap-bridge/tsconfig.build.json" }, { "path": "./packages/keyring-snap-sdk/tsconfig.build.json" }, { "path": "./packages/keyring-snap-client/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index cd24f44bf..31200c342 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "./packages/keyring-eth-simple" }, { "path": "./packages/keyring-eth-trezor" }, { "path": "./packages/keyring-eth-hd" }, + { "path": "./packages/keyring-eth-mpc" }, { "path": "./packages/keyring-snap-bridge" }, { "path": "./packages/keyring-snap-client" }, { "path": "./packages/keyring-internal-snap-client" }, diff --git a/yarn.lock b/yarn.lock index c1eafc528..0db8fc309 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1745,6 +1745,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:^3.4.4" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/keyring-utils": "workspace:^" + "@metamask/mfa-wallet-cl24-lib": "npm:^0.0.0" + "@metamask/mfa-wallet-dkls19-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/mpc-libs-interface": "npm:^0.0.0" + "@metamask/utils": "npm:^11.1.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.5.0" + languageName: unknown + linkType: soft + "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr": version: 0.0.0-use.local resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" @@ -2163,6 +2189,72 @@ __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=c5dc05&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/7b6e99196366ef2dabc3ce1db8ee91467bc2ba0b1eab5965a7044f9dafb687433d586bf4ecd84f287849183583f1999d848c52a95e972cfc17cf89a86f0c7c2e + languageName: node + linkType: hard + +"@metamask/mfa-wallet-dkls19-lib@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls19-lib-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mfa-wallet-dkls19-lib@file:./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls19-lib-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mfa-wallet-dkls19-lib-0.0.0.tgz::hash=4b0e4f&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + dependencies: + "@metamask/mfa-wallet-util": "npm:^0.0.0" + "@noble/curves": "npm:^1.9.2" + checksum: 10/c6171512e90737102f190c152ae636ad5fade75ea0225eb0d008e19dd108e5b0cc0b2348a7301df6b0ef0df7661f2664662a4b947d2f06d865bf6535d8a60619 + 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=adbb1d&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/ce481bf5a802dc15f0d30fbb906c6157668485a532474eaf306019f19dc97b46e71fee6f716278143e2c59a2a7dcee2df1508e769d307657a37d3c54bc33f106 + 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=97a2c3&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + checksum: 10/7da4a2d06e67729b6386b7bc4d5aebd43090a4db23618aafc079e51f890cf00cf1aea336b1dc7877a8e138c89c63d54af7c164fa4b908cb5b5d23b9bc4c4e9cb + 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=82b49e&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + dependencies: + "@metamask/mfa-wallet-e2ee": "npm:^0.0.0" + "@msgpack/msgpack": "npm:^3.1.2" + "@noble/hashes": "npm:^1.8.0" + centrifuge: "npm:^5.5.2" + checksum: 10/857028df344faf5cd487703dadac8655068c0fbe542c85ddd0fbcfdd6c5d2ba4e925180d5232a5ad6b694eb9a3225cf7ebc6fbec113119dbe443f55fbfe06d70 + 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=9f8f8e&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + checksum: 10/3c81433065dbc1cf597ee71b0a447f7ec7b9c389b9b70070de6eee2871be863f6ae03220d5b1ea5f98740ec83781df527e0c5396a88267f1840feef27aca39e3 + languageName: node + linkType: hard + +"@metamask/mpc-libs-interface@file:./packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz::locator=%40metamask%2Faccounts-monorepo%40workspace%3A.": + version: 0.0.0 + resolution: "@metamask/mpc-libs-interface@file:./packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz#./packages/keyring-eth-mpc/metamask-mpc-libs-interface-0.0.0.tgz::hash=c606bf&locator=%40metamask%2Faccounts-monorepo%40workspace%3A." + checksum: 10/737f6c569e66f21898262a2304bff7048b5a58ae93c4d42688081a8cf6c7d4fbaf669cc90b08fec09e75c959ce798ad5ba021e04e53f1567d6f05b2c101b4a9d + languageName: node + linkType: hard + "@metamask/number-to-bn@npm:^1.7.1": version: 1.7.1 resolution: "@metamask/number-to-bn@npm:1.7.1" @@ -2484,6 +2576,13 @@ __metadata: languageName: node linkType: hard +"@msgpack/msgpack@npm:^3.1.2": + version: 3.1.3 + resolution: "@msgpack/msgpack@npm:3.1.3" + checksum: 10/cf597b7ed1fcedc2101b145885e7da591c5ed8aeba3a2579b99da8dd15ef13595c807f64f052da0a0dd74704097f6299fc2e2fb4165029a60a1d1df3843c03ce + languageName: node + linkType: hard + "@ngraveio/bc-ur@npm:^1.1.5": version: 1.1.13 resolution: "@ngraveio/bc-ur@npm:1.1.13" @@ -2499,7 +2598,7 @@ __metadata: languageName: node linkType: hard -"@noble/ciphers@npm:1.3.0": +"@noble/ciphers@npm:1.3.0, @noble/ciphers@npm:^1.3.0": version: 1.3.0 resolution: "@noble/ciphers@npm:1.3.0" checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 @@ -2533,6 +2632,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:^1.9.2": + version: 1.9.7 + resolution: "@noble/curves@npm:1.9.7" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 + languageName: node + linkType: hard + "@noble/hashes@npm:1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" @@ -5520,6 +5628,16 @@ __metadata: languageName: node linkType: hard +"centrifuge@npm:^5.5.2": + version: 5.5.3 + resolution: "centrifuge@npm:5.5.3" + dependencies: + events: "npm:^3.3.0" + protobufjs: "npm:^7.2.5" + checksum: 10/2074551adc0ea421d8d4880236b956d6fde0d652c3da2d82124b49c1d6659817069bb58f8f8525794382915d5bff3310592bb023a4afa35fc3ca2b69158ba412 + languageName: node + linkType: hard + "chalk-template@npm:1.1.0": version: 1.1.0 resolution: "chalk-template@npm:1.1.0" @@ -10291,6 +10409,26 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^7.2.5": + version: 7.5.4 + resolution: "protobufjs@npm:7.5.4" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.4" + "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/inquire": "npm:^1.1.0" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.0" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.0.0" + checksum: 10/88d677bb6f11a2ecec63fdd053dfe6d31120844d04e865efa9c8fbe0674cd077d6624ecfdf014018a20dcb114ae2a59c1b21966dd8073e920650c71370966439 + languageName: node + linkType: hard + "proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0"