From 95b7c384d4b49b1ed938b0ed384ff02197e92e9f 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 Adds a standalone helper to decrypt and validate both EdDSA MPCv2 reduced keycards, returning typed Buffer key shares and the derived commonKeyChain. Mirrors the ECDSA getMpcV2RecoveryKeyShares pattern as part of the SJCL-to-Argon2 migration. - Decrypt both keycards in parallel via Promise.all - Use bitgo.decryptAsync (v1 + v2) when a BitGoBase instance is provided; fall back to sjcl.decrypt (v1 only) otherwise - Validate pub and rootChainCode separately with distinct error messages - Wrap getDecodedReducedKeyShare in try-catch to surface a descriptive error for malformed or public-only keycards - Export type for recovery key shares - Add 3 unit tests: v1 happy path, malformed keycard, mismatched keys Ticket: WCI-396 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 62 +++++++++++ .../src/bitgo/utils/tss/eddsa/types.ts | 6 ++ .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 101 ++++++++++++++++++ 3 files changed, 169 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 70f29e81ab..520b73fd7e 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, @@ -41,8 +42,10 @@ import { isV2Envelope, } from '../baseTypes'; import { EncryptionVersion } from '../../../../api'; +import { BitGoBase } from '../../../bitgoBase'; import { BaseEddsaUtils } from './base'; import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; +import { EddsaMPCv2RecoveryKeyShares } from './types'; export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; @@ -923,3 +926,62 @@ 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 getEddsaMpcV2RecoveryKeySharesFromReducedKey( + 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: unable to decode reduced key share from keycard material. The encrypted key may be corrupted, malformed, or not an EdDSA MPCv2 reduced key.' + ); + } + if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) { + throw new Error( + 'EdDSA MPCv2 recovery: reduced key share is missing keyShare, pub, or 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/src/bitgo/utils/tss/eddsa/types.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts index 4f441eec04..7b87925c57 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts @@ -18,6 +18,12 @@ export type CreateEddsaKeychainParams = CreateKeychainParamsBase & { backupKeyShare: EDDSA.KeyShare; }; +export interface EddsaMPCv2RecoveryKeyShares { + userKeyShare: Buffer; + backupKeyShare: Buffer; + commonKeyChain: string; +} + export type CreateEddsaBitGoKeychainParams = Omit; // For backward compatibility 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..5c3924db49 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, @@ -341,6 +342,106 @@ 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.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + 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.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + 'encrypted-user-key', + 'encrypted-backup-key', + walletPassphrase, + mockBitgo + ); + + sinon.assert.calledTwice(mockBitgo.decryptAsync as sinon.SinonStub); + assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); + assert.deepStrictEqual(result.backupKeyShare, backupDkg.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.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + malformedKey, + encryptKey(userDkg.getReducedKeyShare()), + walletPassphrase + ), + /unable to decode reduced key share/ + ); + }); + + it('should reject reduced keys from different wallets', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + await assert.rejects( + EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + encryptKey(userDkg.getReducedKeyShare()), + encryptKey(backupDkg.getReducedKeyShare()), + walletPassphrase + ), + /pub keys do not match/ + ); + }); + + it('should reject reduced keys with matching pub but mismatched rootChainCodes', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const userReducedKeyShare = userDkg.getReducedKeyShare(); + const backupReducedKeyShare = backupDkg.getReducedKeyShare(); + const getDecodedReducedKeyShare = MPSTypes.getDecodedReducedKeyShare; + const decodeStub = sinon.stub(MPSTypes, 'getDecodedReducedKeyShare').callsFake((buf) => { + const reduced = getDecodedReducedKeyShare(buf); + if (Buffer.from(buf).equals(backupReducedKeyShare)) { + return { + ...reduced, + rootChainCode: Array.from(randomBytes(32)), + }; + } + return reduced; + }); + + try { + await assert.rejects( + EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + encryptKey(userReducedKeyShare), + encryptKey(backupReducedKeyShare), + walletPassphrase + ), + /rootChainCodes do not match/ + ); + } finally { + decodeStub.restore(); + } + }); +}); + describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { let eddsaMPCv2Utils: EddsaMPCv2Utils; let mockBitgo: BitGoBase;