From 8ef93db31a119c48b2a037a5185877af4dbc357c Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Wed, 17 Jun 2026 15:39:10 +0530 Subject: [PATCH] feat(sdk-coin-sol): add MPCv2 signed recovery to Sol.recover() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: WCI-398 Detects MPCv2 keycards automatically in Sol.recover() using isEddsaMpcV1SigningMaterial before any network calls; no new params required in SolRecoveryOptions. Validates userKey and backupKey early for signed recovery, before any network calls, and propagates decryption errors (wrong password) instead of silently treating them as MPCv2. MPCv2 uses deriveUnhardenedMps (BIP32-Ed25519 / Silence Labs formula) for address derivation in place of MPC.deriveUnhardened; signing uses the MPS DSG protocol via signRecoveryEddsaMPCv2. EddsaMPCv2RecoveryFunctions is exported as a mutable plain object from eddsaMPCv2.ts rather than relying solely on the barrel re-export. TypeScript compiles `export * from` into Object.defineProperty calls with configurable:false, which sinon cannot stub. Plain object properties are configurable and writable by default, enabling unit tests to swap out the three MPCv2 helpers without modifying sol.ts. Type narrowing via control flow in parameter validation avoids non-null assertions. isEddsaMpcV1SigningMaterial splits decryption and JSON parsing into separate try/catch blocks — decryption errors propagate, JSON parse errors indicate MPCv2 format (return false). Adds five unit tests covering native SOL, SPL token, Token-2022, durable-nonce, and commonKeyChain mismatch scenarios. --- modules/sdk-coin-sol/src/sol.ts | 118 +++++-- modules/sdk-coin-sol/test/unit/sol.ts | 306 ++++++++++++++++++ .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 55 +++- .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 35 +- 4 files changed, 451 insertions(+), 63 deletions(-) diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 6b3daf20c7..65dd0ba894 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -58,8 +58,9 @@ import { DeriveAddressOptions, DeriveAddressResult, UnexpectedAddressError, + EDDSAUtils, } from '@bitgo/sdk-core'; -import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; +import { auditEddsaPrivateKey, deriveUnhardenedMps, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import { KeyPair as SolKeyPair, @@ -1224,13 +1225,42 @@ export class Sol extends BaseCoin { const bitgoKey = params.bitgoKey.replace(/\s/g, ''); const isUnsignedSweep = !params.walletPassphrase; - // Build the transaction - const MPC = await EDDSAMethods.getInitializedMpcInstance(); + // Validate signing keys and detect MPCv2 format early for signed recovery + let isMpcV2 = false; + if (params.walletPassphrase) { + if (!params.userKey) { + throw new Error('missing userKey'); + } + if (!params.backupKey) { + throw new Error('missing backupKey'); + } + // Detect MPCv2 keycards — will throw if decryption fails (e.g., wrong password). + // MPCv1 keycards decrypt to JSON with uShare/bitgoYShare; MPCv2 keycards are CBOR. + try { + const isV1 = await EDDSAUtils.EddsaMPCv2RecoveryFunctions.isEddsaMpcV1SigningMaterial( + params.userKey.replace(/\s/g, ''), + params.walletPassphrase, + this.bitgo + ); + isMpcV2 = !isV1; + } catch (e) { + // Re-wrap decryption errors with context + throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); + } + } + let balance = 0; const index = params.index || 0; const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`; - const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); + + let accountId: string; + if (isMpcV2) { + accountId = deriveUnhardenedMps(bitgoKey, currPath).slice(0, 64); + } else { + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); + } const bs58EncodedPublicKey = new SolKeyPair({ pub: accountId }).getAddress(); balance = await this.getAccountBalance(bs58EncodedPublicKey, params.apiKey); @@ -1421,40 +1451,60 @@ export class Sol extends BaseCoin { const userKey = params.userKey.replace(/\s/g, ''); const backupKey = params.backupKey.replace(/\s/g, ''); - // Decrypt private keys from KeyCard values - let userPrv; - - try { - userPrv = await this.bitgo.decryptAsync({ - input: userKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e.message}`); - } + if (!isMpcV2) { + // MPCv1 path: decrypt JSON signing material and use TSS signing + let userPrv: string; + try { + userPrv = await this.bitgo.decryptAsync({ + input: userKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${e.message}`); + } + const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; - const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; + let backupPrv: string; + try { + backupPrv = await this.bitgo.decryptAsync({ + input: backupKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting backup keychain: ${e.message}`); + } + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; - let backupPrv; - try { - backupPrv = await this.bitgo.decryptAsync({ - input: backupKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); - } - const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + const signatureHex = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + currPath, + unsignedTransaction + ); + txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signatureHex); + } else { + // MPCv2 path: decrypt CBOR reduced key shares and sign with MPS DSG protocol + const { userKeyShare, backupKeyShare, commonKeyChain } = + await EDDSAUtils.EddsaMPCv2RecoveryFunctions.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + userKey, + backupKey, + params.walletPassphrase, + this.bitgo + ); - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, - currPath, - unsignedTransaction - ); + if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) { + throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + } - const publicKeyObj = { pub: bs58EncodedPublicKey }; - txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex); + const signature = EDDSAUtils.EddsaMPCv2RecoveryFunctions.signRecoveryEddsaMPCv2( + unsignedTransaction.signablePayload, + currPath, + userKeyShare, + backupKeyShare, + commonKeyChain + ); + txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signature); + } } if (params.durableNonce) { diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 3f244285b7..f11fb40b8d 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -9,6 +9,7 @@ import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { common, + EDDSAUtils, Environments, generateRandomPassword, IWallet, @@ -2780,6 +2781,311 @@ describe('SOL:', function () { }); }); + describe('Recover Transactions (MPCv2):', () => { + const mpcV2SandBox = sinon.createSandbox(); + // MPCv2-derived address for wrwUser.bitgoKey at m/0 via deriveUnhardenedMps + const mpcV2WalletAddress = '4nPsUgktdtrPBf3V8GojBukffEbYPq7sQ15gSRNztk1n'; + const usdtMintAddress = '9cgpBeNZ2HnLda7NWaaU1i3NyTstk2c4zCMUcoAGsi9C'; + const t22mintAddress = '5NR1bQwLWqjbkhbQ1hx72HKJybbuvwkDnUZNoAZ2VhW6'; + const token22ProgramId = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; + let callBack: sinon.SinonStub; + + beforeEach(() => { + callBack = mpcV2SandBox.stub(Sol.prototype, 'getDataFromNode' as keyof Sol); + + callBack + .withArgs({ + payload: { id: '1', jsonrpc: '2.0', method: 'getLatestBlockhash', params: [{ commitment: 'finalized' }] }, + }) + .resolves(testData.SolResponses.getBlockhashResponse); + + // MPCv2 derives address via deriveUnhardenedMps — different from MPCv1's MPC.deriveUnhardened. + // Derived from testData.keys.bitgoKey at m/0 using deriveUnhardenedMps. + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getBalance', + params: ['6UhWoz6WMtnk1quBibd7KAuoshvSL7vSiPe3ycDw2Kh6'], + }, + }) + .resolves(testData.SolResponses.getAccountBalanceResponse); + + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getFeeForMessage', + params: [sinon.match.string, { commitment: 'finalized' }], + }, + }) + .resolves(testData.SolResponses.getFeesForMessageResponse); + + callBack + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getMinimumBalanceForRentExemption', params: [165] } }) + .resolves(testData.SolResponses.getMinimumBalanceForRentExemptionResponse); + + // The stub signature (Buffer.alloc(64)) is not a valid Ed25519 signature so Solana's + // serializer would reject it. Bypass the check by returning a fixed hex string. + mpcV2SandBox.stub(Transaction.prototype, 'toBroadcastFormat').returns('stub-serialized-tx'); + }); + + afterEach(() => { + mpcV2SandBox.restore(); + }); + + it('should route to MPCv2 path for native SOL recovery when keycard is MPCv2', async function () { + mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); + const getSharesStub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') + .resolves({ + userKeyShare: Buffer.alloc(32), + backupKeyShare: Buffer.alloc(32), + commonKeyChain: testData.keys.bitgoKey.replace(/\s/g, ''), + }); + const signMpcV2Stub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') + .returns(Buffer.alloc(64)); + + const result = await basecoin.recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + bitgoKey: testData.keys.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase: testData.keys.walletPassword, + }); + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal((result as MPCTx).scanIndex, 0); + mpcV2SandBox.assert.calledOnce(getSharesStub); + mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + }); + + it('should throw when MPCv2 commonKeyChain does not match bitgoKey', async function () { + mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); + mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') + .resolves({ + userKeyShare: Buffer.alloc(32), + backupKeyShare: Buffer.alloc(32), + commonKeyChain: 'deadbeef'.repeat(16), // deliberate mismatch + }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + bitgoKey: testData.keys.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase: testData.keys.walletPassword, + }) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + + it('should route to MPCv2 path for SPL token recovery when keycard is MPCv2', async function () { + callBack + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2WalletAddress] } }) + .resolves(testData.SolResponses.getAccountBalanceResponse); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + mpcV2WalletAddress, + { programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, + { encoding: 'jsonParsed' }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponse); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [ + testData.keys.destinationPubKey, + { programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, + { encoding: 'jsonParsed' }, + ], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [testData.keys.durableNoncePubKey, { encoding: 'jsonParsed' }], + }, + }) + .resolves(testData.SolResponses.getAccountInfoResponse); + + mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); + const getSharesStub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') + .resolves({ + userKeyShare: Buffer.alloc(32), + backupKeyShare: Buffer.alloc(32), + commonKeyChain: testData.wrwUser.bitgoKey.replace(/\s/g, ''), + }); + const signMpcV2Stub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') + .returns(Buffer.alloc(64)); + + const result = await basecoin.recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + tokenContractAddress: usdtMintAddress, + walletPassphrase: testData.wrwUser.walletPassphrase, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + }); + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal((result as MPCTx).scanIndex, 0); + mpcV2SandBox.assert.calledOnce(getSharesStub); + mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + }); + + it('should route to MPCv2 path for Token-2022 recovery and preserve programId', async function () { + callBack + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2WalletAddress] } }) + .resolves(testData.SolResponses.getAccountBalanceResponse); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [mpcV2WalletAddress, { programId: token22ProgramId }, { encoding: 'jsonParsed' }], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerForSol2022Response); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getTokenAccountsByOwner', + params: [testData.keys.destinationPubKey, { programId: token22ProgramId }, { encoding: 'jsonParsed' }], + }, + }) + .resolves(testData.SolResponses.getTokenAccountsByOwnerResponseNoAccounts); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [testData.keys.durableNoncePubKey, { encoding: 'jsonParsed' }], + }, + }) + .resolves(testData.SolResponses.getAccountInfoResponse); + + mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); + const getSharesStub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') + .resolves({ + userKeyShare: Buffer.alloc(32), + backupKeyShare: Buffer.alloc(32), + commonKeyChain: testData.wrwUser.bitgoKey.replace(/\s/g, ''), + }); + const signMpcV2Stub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') + .returns(Buffer.alloc(64)); + + const result = await basecoin.recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + tokenContractAddress: t22mintAddress, + programId: token22ProgramId, + walletPassphrase: testData.wrwUser.walletPassphrase, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + }); + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal((result as MPCTx).scanIndex, 0); + mpcV2SandBox.assert.calledOnce(getSharesStub); + mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + }); + + it('should apply durable nonce account signature when keycard is MPCv2', async function () { + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getBalance', + params: ['6UhWoz6WMtnk1quBibd7KAuoshvSL7vSiPe3ycDw2Kh6'], + }, + }) + .resolves(testData.SolResponses.getAccountBalanceResponse); + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [testData.keys.durableNoncePubKey, { encoding: 'jsonParsed' }], + }, + }) + .resolves(testData.SolResponses.getAccountInfoResponse); + + mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); + const getSharesStub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') + .resolves({ + userKeyShare: Buffer.alloc(32), + backupKeyShare: Buffer.alloc(32), + commonKeyChain: testData.keys.bitgoKey.replace(/\s/g, ''), + }); + const signMpcV2Stub = mpcV2SandBox + .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') + .returns(Buffer.alloc(64)); + + const result = await basecoin.recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + bitgoKey: testData.keys.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase: testData.keys.walletPassword, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + }); + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal((result as MPCTx).scanIndex, 0); + mpcV2SandBox.assert.calledOnce(getSharesStub); + mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + }); + }); + describe('Build Consolidation Recoveries:', () => { const sandBox = sinon.createSandbox(); const coin = coins.get('tsol'); 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 540f789f20..76f5a34557 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -53,20 +53,6 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE'; private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE'; - async isEddsaMpcV1SigningMaterial(encryptedKeyShare: string, walletPassphrase: string): Promise { - try { - const prv = await this.bitgo.decryptAsync({ input: encryptedKeyShare, password: walletPassphrase }); - const signingMaterial = JSON.parse(prv); - return ( - typeof signingMaterial?.uShare?.seed === 'string' && - typeof signingMaterial?.bitgoYShare?.u === 'string' && - (typeof signingMaterial?.backupYShare?.u === 'string' || typeof signingMaterial?.userYShare?.u === 'string') - ); - } catch { - return false; - } - } - /** @inheritdoc */ async createKeychains(params: { passphrase: string; @@ -928,6 +914,41 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { // #endregion } +/** + * Detect whether an encrypted keycard contains MPCv1 signing material. + * + * Decrypts the keycard and checks for the JSON shape produced by the MPCv1 TSS + * key-generation flow (uShare.seed + bitgoYShare.u + backupYShare.u / userYShare.u). + * Returns false for MPCv2 CBOR keycards, wrong passwords, or any decryption error. + * + * @param encryptedKeyShare encrypted user or backup keycard + * @param walletPassphrase passphrase used to encrypt the keycard + * @param bitgo optional BitGoBase instance; when provided, decrypts via + * bitgo.decryptAsync (supports both v1 SJCL and v2 Argon2id envelopes); + * when absent, falls back to sjcl.decrypt (v1 only) + */ +export async function isEddsaMpcV1SigningMaterial( + encryptedKeyShare: string, + walletPassphrase: string, + bitgo?: BitGoBase +): Promise { + const prv = bitgo + ? await bitgo.decryptAsync({ input: encryptedKeyShare, password: walletPassphrase }) + : sjcl.decrypt(walletPassphrase, encryptedKeyShare); + + try { + const m = JSON.parse(prv); + return ( + typeof m?.uShare?.seed === 'string' && + typeof m?.bitgoYShare?.u === 'string' && + (typeof m?.backupYShare?.u === 'string' || typeof m?.userYShare?.u === 'string') + ); + } catch { + // JSON parse error indicates MPCv2 CBOR format, not JSON. + return false; + } +} + /** * Get EdDSA MPCv2 recovery key shares from encrypted reduced user and backup keys. * @@ -1031,3 +1052,9 @@ export function signRecoveryEddsaMPCv2( return signature; } + +export const EddsaMPCv2RecoveryFunctions = { + isEddsaMpcV1SigningMaterial, + getEddsaMpcV2RecoveryKeySharesFromReducedKey, + signRecoveryEddsaMPCv2, +}; 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 3c8c892a37..df2b3d17dc 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 @@ -38,6 +38,7 @@ import { getBitgoSignatureShare } from '../../../../../../src/bitgo/tss/common'; import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs'; import { generateGPGKeyPair } from '../../../../../../src/bitgo/utils/opengpgUtils'; import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; +import { isV2Envelope } from '../../../../../../src/bitgo/utils/tss/baseTypes'; describe('EdDSA MPS DSG helper functions', async () => { let userKeyShare: Buffer; @@ -1707,7 +1708,7 @@ function bytesToWord(bytes?: Uint8Array | number[]): number { return bytes.reduce((num, byte) => num * 0x100 + byte, 0); } -describe('EddsaMPCv2Utils.isEddsaMpcV1SigningMaterial', () => { +describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { const PASSPHRASE = 'test-passphrase'; const MPCv1_MATERIAL_BACKUP = { @@ -1724,48 +1725,52 @@ describe('EddsaMPCv2Utils.isEddsaMpcV1SigningMaterial', () => { const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64'); - let eddsaUtils: EddsaMPCv2Utils; let mockBitgo: BitGoBase; - beforeEach(() => { + // sdk-core has no devDependency on sdk-api/argon2, so v2 envelopes are simulated here. + // Real bitgo.decryptAsync routes v2 to Argon2id; the stub returns MPCv2 CBOR plaintext instead. mockBitgo = { - decryptAsync: sinon - .stub() - .callsFake(async (params: { input: string; password: string }) => sjcl.decrypt(params.password, params.input)), + decryptAsync: sinon.stub().callsFake(async (params: { input: string; password: string }) => { + if (isV2Envelope(params.input)) { + return MPCv2_CBOR_BYTES; + } + return sjcl.decrypt(params.password, params.input); + }), } as unknown as BitGoBase; - - eddsaUtils = new EddsaMPCv2Utils(mockBitgo, {} as unknown as IBaseCoin); }); it('returns true for MPCv1 SJCL-encrypted keycard with backupYShare + correct passphrase', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL_BACKUP)); - assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true); }); it('returns true for MPCv1 SJCL-encrypted keycard with userYShare + correct passphrase', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL_USER)); - assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true); }); it('returns false for MPCv2 CBOR content wrapped in SJCL envelope + correct passphrase', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, MPCv2_CBOR_BYTES); - assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); }); it('returns false for MPCv2 Argon2id envelope (v2) + correct passphrase (forward-compat)', async () => { const fakeV2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' }); - assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(fakeV2Envelope, PASSPHRASE), false); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(fakeV2Envelope, PASSPHRASE, mockBitgo), false); }); - it('returns false for wrong passphrase — does not throw', async () => { + it('throws on wrong passphrase', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL_BACKUP)); - assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase'), false); + await assert.rejects( + EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase'), + /ccm: tag doesn't match/ + ); }); it('returns false when neither backupYShare.u nor userYShare.u is present', async () => { const partial = { uShare: { seed: 'abc' }, bitgoYShare: { u: 'xyz' } }; const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(partial)); - assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); }); });