From db49315745de57f7775fcbdf1f20acc9cae74470 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Tue, 2 Jun 2026 17:39:12 +0530 Subject: [PATCH] feat(sdk-core): add getEddsaMPCv2RecoveryKeyShares helper Ticket: WCI-396 Decrypt both keycards in parallel, validate pub and rootChainCode separately with distinct errors, and surface a clear message when keycard material is missing or a v2 Argon2id envelope is detected. Adds 7 unit tests covering happy path, field validation, and pub/rootChainCode mismatch cases. Fix cbor-x missing-package CI error by exporting MPSUtil.cborEncode from sdk-lib-mpc (which already declares cbor-x) and using that in the sdk-core test instead of require('cbor-x') directly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 71 ++++++++++++++ .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 94 +++++++++++++++++++ modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts | 6 ++ 3 files changed, 171 insertions(+) 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 8dc3175f48..cf4f3bbf42 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, @@ -43,6 +44,12 @@ import { 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'; @@ -913,3 +920,67 @@ 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 +): Promise { + const decodeKey = async (encryptedKey: string): Promise => { + if (isV2Envelope(encryptedKey)) { + throw new Error( + 'EdDSA MPCv2 recovery: v2 (Argon2id) encrypted keycards are not yet supported in standalone recovery. ' + + 'Use the BitGo SDK with a BitGoBase instance to decrypt v2 keycards.' + ); + } + const decrypted = 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, + }; +} 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 77784805b8..9bd90e6436 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 @@ -17,6 +17,7 @@ import { CustomEddsaMPCv2SigningRound1GeneratingFunction, CustomEddsaMPCv2SigningRound2GeneratingFunction, CustomEddsaMPCv2SigningRound3GeneratingFunction, + EDDSAUtils, EddsaMPCv2Utils, IBaseCoin, IWallet, @@ -340,6 +341,99 @@ describe('EdDSA MPS DSG helper functions', async () => { }); }); +describe('getEddsaMPCv2RecoveryKeyShares', () => { + const walletPassphrase = 'testPass'; + + const encryptReducedKeyShare = (keyShare: Buffer): string => + sjcl.encrypt(walletPassphrase, keyShare.toString('base64')); + + const makeReducedKeyShare = (keyShare: number[], pub: number[], rootChainCode: number[]): string => + encryptReducedKeyShare(MPSUtil.cborEncode({ keyShare, pub, rootChainCode })); + + it('should return recovery key shares from v1 SJCL-encrypted reduced keys', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const encryptedUserKey = encryptReducedKeyShare(userDkg.getReducedKeyShare()); + const encryptedBackupKey = encryptReducedKeyShare(backupDkg.getReducedKeyShare()); + + const result = await EDDSAUtils.getEddsaMPCv2RecoveryKeyShares( + encryptedUserKey, + encryptedBackupKey, + walletPassphrase + ); + + assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); + assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare()); + assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); + }); + + it('should reject v2 (Argon2id) encrypted keycards with a clear error', async () => { + const v2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'AA==', iv: 'AA==', ct: 'AA==' }); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(v2Envelope, v2Envelope, walletPassphrase), + /v2 \(Argon2id\) encrypted keycards are not yet supported/ + ); + }); + + it('should reject a keycard missing keyShare', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const pub = Array.from(randomBytes(32)); + const rootChainCode = Array.from(randomBytes(32)); + const badKey = encryptReducedKeyShare(MPSUtil.cborEncode({ pub, rootChainCode })); + const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare()); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(badKey, goodKey, walletPassphrase), + /keyShare, pub, and rootChainCode/ + ); + }); + + it('should reject a keycard missing pub', async () => { + const keyShare = Array.from(randomBytes(64)); + const rootChainCode = Array.from(randomBytes(32)); + const badKey = encryptReducedKeyShare(MPSUtil.cborEncode({ keyShare, rootChainCode })); + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare()); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(badKey, goodKey, walletPassphrase), + /keyShare, pub, and rootChainCode/ + ); + }); + + it('should reject a keycard missing rootChainCode', async () => { + const keyShare = Array.from(randomBytes(64)); + const pub = Array.from(randomBytes(32)); + const badKey = encryptReducedKeyShare(MPSUtil.cborEncode({ keyShare, pub })); + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare()); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(badKey, goodKey, walletPassphrase), + /keyShare, pub, and rootChainCode/ + ); + }); + + it('should reject reduced keys with mismatched pub', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const encryptedUserKey = encryptReducedKeyShare(userDkg.getReducedKeyShare()); + const encryptedBackupKey = encryptReducedKeyShare(backupDkg.getReducedKeyShare()); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(encryptedUserKey, encryptedBackupKey, walletPassphrase), + /pub keys do not match/ + ); + }); + + it('should reject reduced keys with mismatched rootChainCode', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const userReduced = MPSTypes.getDecodedReducedKeyShare(userDkg.getReducedKeyShare()); + // Same pub, different rootChainCode + const backupKey = makeReducedKeyShare(Array.from(randomBytes(64)), userReduced.pub, Array.from(randomBytes(32))); + const userKey = encryptReducedKeyShare(userDkg.getReducedKeyShare()); + await assert.rejects( + EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(userKey, backupKey, walletPassphrase), + /rootChainCodes do not match/ + ); + }); +}); + describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { let eddsaMPCv2Utils: EddsaMPCv2Utils; let mockBitgo: BitGoBase; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts index d19602424e..39bfede673 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import assert from 'assert'; +import { encode } from 'cbor-x'; import { x25519 } from '@noble/curves/ed25519'; import { DKG } from './dkg'; import { DSG } from './dsg'; @@ -120,3 +121,8 @@ export function executeTillRound( assert(party1Dsg.getSignature().toString('hex') === party2Dsg.getSignature().toString('hex')); return party1Dsg.getSignature(); } + +/** CBOR-encodes an arbitrary object. Exposed so consumers don't need a direct cbor-x dependency. */ +export function cborEncode(value: unknown): Buffer { + return Buffer.from(encode(value)); +}