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 70f29e81ab..28296bf28e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import * as pgp from 'openpgp'; +import * as sjcl from '@bitgo/sjcl'; import { NonEmptyString } from 'io-ts-types'; import { EddsaMPCv2KeyGenRound1Request, @@ -11,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'; @@ -41,9 +43,16 @@ import { isV2Envelope, } from '../baseTypes'; import { EncryptionVersion } from '../../../../api'; +import { BitGoBase } from '../../../bitgoBase'; import { BaseEddsaUtils } from './base'; import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; +export interface EddsaMPCv2RecoveryKeyShares { + userKeyShare: Buffer; + backupKeyShare: Buffer; + commonKeyChain: string; +} + export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE'; @@ -923,3 +932,113 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { } // #endregion } + +/** + * Get EdDSA MPCv2 recovery key shares from encrypted reduced user and backup keys. + * + * The encrypted inputs are the `reducedEncryptedPrv` values stored on EdDSA MPCv2 + * key cards. They decrypt to CBOR-encoded reduced shares that contain the opaque + * MPS signing key-share bytes plus the common public keychain material. + * + * @param encryptedUserKey encrypted EdDSA MPCv2 reduced user key + * @param encryptedBackupKey encrypted EdDSA MPCv2 reduced backup key + * @param walletPassphrase password for user and backup keys + * @returns EdDSA MPCv2 recovery key shares and common keychain + */ +export async function getEddsaMPCv2RecoveryKeyShares( + encryptedUserKey: string, + encryptedBackupKey: string, + walletPassphrase?: string, + bitgo?: BitGoBase +): Promise { + const decodeKey = async (encryptedKey: string): Promise => { + const decrypted = bitgo + ? await bitgo.decryptAsync({ input: encryptedKey, password: walletPassphrase }) + : sjcl.decrypt(walletPassphrase ?? '', encryptedKey); + let reduced: MPSTypes.EddsaReducedKeyShare; + try { + reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64')); + } catch { + throw new Error( + 'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' + + 'This keycard may be public-only and cannot be used for recovery.' + ); + } + if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) { + throw new Error( + 'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' + + 'This keycard may be public-only and cannot be used for recovery.' + ); + } + return reduced; + }; + + const [userReduced, backupReduced] = await Promise.all([decodeKey(encryptedUserKey), decodeKey(encryptedBackupKey)]); + + const userPub = Buffer.from(userReduced.pub).toString('hex'); + const backupPub = Buffer.from(backupReduced.pub).toString('hex'); + if (userPub !== backupPub) { + throw new Error('EdDSA MPCv2 recovery: user and backup pub keys do not match'); + } + + const userChainCode = Buffer.from(userReduced.rootChainCode).toString('hex'); + const backupChainCode = Buffer.from(backupReduced.rootChainCode).toString('hex'); + if (userChainCode !== backupChainCode) { + throw new Error('EdDSA MPCv2 recovery: user and backup rootChainCodes do not match'); + } + + return { + userKeyShare: Buffer.from(userReduced.keyShare), + backupKeyShare: Buffer.from(backupReduced.keyShare), + 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 signRecoveryMpcV2( + 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 a2d6e6bb36..c665b7f91d 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, @@ -17,6 +18,7 @@ import { CustomEddsaMPCv2SigningRound1GeneratingFunction, CustomEddsaMPCv2SigningRound2GeneratingFunction, CustomEddsaMPCv2SigningRound3GeneratingFunction, + EDDSAUtils, EddsaMPCv2Utils, IBaseCoin, IWallet, @@ -341,6 +343,75 @@ describe('EdDSA MPS DSG helper functions', async () => { }); }); +describe('getEddsaMPCv2RecoveryKeyShares', () => { + const walletPassphrase = 'testPass'; + + const encryptKey = (keyShare: Buffer): string => sjcl.encrypt(walletPassphrase, keyShare.toString('base64')); + + it('should return recovery key shares from v1-encrypted reduced keys (no bitgo instance)', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const result = await EDDSAUtils.getEddsaMPCv2RecoveryKeyShares( + encryptKey(userDkg.getReducedKeyShare()), + encryptKey(backupDkg.getReducedKeyShare()), + walletPassphrase + ); + + assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); + assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare()); + assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); + }); + + it('should route decryption through bitgo.decryptAsync when a bitgo instance is provided', async () => { + // sdk-core has no devDependency on sdk-api or argon2, so we cannot encrypt with a real v2 envelope here. + // The stub verifies that the function delegates to bitgo.decryptAsync (which supports v1 + v2 in + // production) rather than falling back to sjcl.decrypt. + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const userKeyBase64 = userDkg.getReducedKeyShare().toString('base64'); + const backupKeyBase64 = backupDkg.getReducedKeyShare().toString('base64'); + + const mockBitgo = { + decryptAsync: sinon.stub().onFirstCall().resolves(userKeyBase64).onSecondCall().resolves(backupKeyBase64), + } as unknown as BitGoBase; + + const result = await EDDSAUtils.getEddsaMPCv2RecoveryKeyShares( + 'encrypted-user-key', + 'encrypted-backup-key', + walletPassphrase, + mockBitgo + ); + + sinon.assert.calledTwice(mockBitgo.decryptAsync as sinon.SinonStub); + assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); + assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); + }); + + it('should reject a malformed keycard with a descriptive error', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const malformedKey = sjcl.encrypt(walletPassphrase, randomBytes(64).toString('base64')); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares( + malformedKey, + encryptKey(userDkg.getReducedKeyShare()), + walletPassphrase + ), + /keyShare, pub, and rootChainCode/ + ); + }); + + it('should reject reduced keys from different wallets', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares( + encryptKey(userDkg.getReducedKeyShare()), + encryptKey(backupDkg.getReducedKeyShare()), + walletPassphrase + ), + /pub keys do not match/ + ); + }); +}); + describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { let eddsaMPCv2Utils: EddsaMPCv2Utils; let mockBitgo: BitGoBase; @@ -1666,3 +1737,74 @@ describe('EddsaMPCv2Utils.isEddsaMpcV1SigningMaterial', () => { assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); }); }); + +describe('signRecoveryMpcV2', () => { + 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.signRecoveryMpcV2( + 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.signRecoveryMpcV2( + 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.signRecoveryMpcV2( + message, + derivationPath, + userDkg.getKeyShare(), + backupDkg.getKeyShare(), + wrongDkg.getCommonKeychain() // key chain from a different wallet + ), + /EdDSA MPCv2 recovery signature verification failed/ + ); + }); +});