From b8703191f4030509becb276df8ccd8264738326a Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Mon, 15 Jun 2026 17:07:24 +0530 Subject: [PATCH] feat(sdk-core): add signRecoveryMpcV2 for EdDSA MPCv2 offline signing Ticket: WCI-397 Implements local 2-party MPS signing for EdDSA MPCv2 recovery flows, mirroring the existing ECDSA signRecoveryMpcV2 pattern. The function runs the MPS DSG protocol to round 3 using user and backup key shares, then verifies the resulting 64-byte Ed25519 signature against the child public key derived via deriveUnhardenedMps. - export `signRecoveryMpcV2` from eddsaMPCv2.ts - drives `MPSUtil.executeTillRound(3, ...)` locally with user + backup DSG instances - derives the child public key via `deriveUnhardenedMps(commonKeyChain, derivationPath)` - verifies the signature with `nacl.sign.detached.verify`; throws on mismatch - add unit tests for `signRecoveryMpcV2` - valid path: 64-byte signature verifies against derived public key - tampered message: signature does not verify against a different message - wrong commonKeyChain: throws "EdDSA MPCv2 recovery signature verification failed" Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Cursor --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 52 ++++++++++++- .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 74 ++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 520b73fd7e..f8e1b26984 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -12,7 +12,8 @@ import { MPCv2KeyGenStateEnum, MPCv2PartyFromStringOrNumber, } from '@bitgo/public-types'; -import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc'; +import * as nacl from 'tweetnacl'; +import { deriveUnhardenedMps, EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; import { KeychainsTriplet } from '../../../baseCoin'; import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from '../../../keychain'; import { envRequiresBitgoPubGpgKeyConfig, isBitgoEddsaMpcv2PubKey } from '../../../tss/bitgoPubKeys'; @@ -985,3 +986,52 @@ export async function getEddsaMpcV2RecoveryKeySharesFromReducedKey( commonKeyChain: userPub + userChainCode, }; } + +/** + * Sign a message for recovery using EdDSA MPCv2 (MPS) with user and backup key shares. + * + * Runs the MPS DSG protocol locally to round 3, then verifies the resulting + * Ed25519 signature against the public key derived from the common keychain. + * + * @param message raw bytes to sign + * @param derivationPath BIP-32-style derivation path, e.g. `"m/0/0"` + * @param userKeyShare opaque MPS signing key-share bytes for the user party + * @param backupKeyShare opaque MPS signing key-share bytes for the backup party + * @param commonKeyChain 128-hex-char string: 32-byte pub + 32-byte rootChainCode + * @returns 64-byte Ed25519 signature Buffer + */ +export function signRecoveryEddsaMPCv2( + message: Buffer, + derivationPath: string, + userKeyShare: Buffer, + backupKeyShare: Buffer, + commonKeyChain: string +): Buffer { + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + const backupDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BACKUP); + + const signature = MPSUtil.executeTillRound( + 3, + userDsg, + backupDsg, + userKeyShare, + backupKeyShare, + message, + derivationPath + ) as Buffer; + + // deriveUnhardenedMps returns 128 hex chars: first 64 are the 32-byte public key + const derivedKeychain = deriveUnhardenedMps(commonKeyChain, derivationPath); + const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex'); + + const verified = nacl.sign.detached.verify( + new Uint8Array(message), + new Uint8Array(signature), + new Uint8Array(publicKeyBytes) + ); + if (!verified) { + throw new Error('EdDSA MPCv2 recovery signature verification failed'); + } + + return signature; +} diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 5c3924db49..c106599011 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -2,7 +2,8 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as pgp from 'openpgp'; import { randomBytes } from 'crypto'; -import { EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; +import { deriveUnhardenedMps, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; +import * as nacl from 'tweetnacl'; import * as sjcl from '@bitgo/sjcl'; import { EddsaMPCv2SignatureShareRound1Input, @@ -1767,3 +1768,74 @@ describe('EddsaMPCv2Utils.isEddsaMpcV1SigningMaterial', () => { assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); }); }); + +describe('signRecoveryEddsaMPCv2', () => { + const derivationPath = 'm/0/0'; + + it('should return a 64-byte signature that verifies against the derived public key', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const message = Buffer.from('deadbeef', 'hex'); + const commonKeyChain = userDkg.getCommonKeychain(); + + const signature = EDDSAUtils.signRecoveryEddsaMPCv2( + message, + derivationPath, + userDkg.getKeyShare(), + backupDkg.getKeyShare(), + commonKeyChain + ); + + assert.strictEqual(signature.length, 64); + + const derivedKeychain = deriveUnhardenedMps(commonKeyChain, derivationPath); + const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex'); + const ok = nacl.sign.detached.verify( + new Uint8Array(message), + new Uint8Array(signature), + new Uint8Array(publicKeyBytes) + ); + assert.strictEqual(ok, true); + }); + + it('should throw when the signed message is different from the verified message', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const message = Buffer.from('deadbeef', 'hex'); + const commonKeyChain = userDkg.getCommonKeychain(); + + const signature = EDDSAUtils.signRecoveryEddsaMPCv2( + message, + derivationPath, + userDkg.getKeyShare(), + backupDkg.getKeyShare(), + commonKeyChain + ); + + const differentMessage = Buffer.from('cafebabe', 'hex'); + const derivedKeychain = deriveUnhardenedMps(commonKeyChain, derivationPath); + const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex'); + const ok = nacl.sign.detached.verify( + new Uint8Array(differentMessage), + new Uint8Array(signature), + new Uint8Array(publicKeyBytes) + ); + assert.strictEqual(ok, false); + }); + + it('should throw when a wrong commonKeyChain is provided (verification mismatch)', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [wrongDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const message = Buffer.from('deadbeef', 'hex'); + + assert.throws( + () => + EDDSAUtils.signRecoveryEddsaMPCv2( + message, + derivationPath, + userDkg.getKeyShare(), + backupDkg.getKeyShare(), + wrongDkg.getCommonKeychain() // key chain from a different wallet + ), + /EdDSA MPCv2 recovery signature verification failed/ + ); + }); +});