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); }); });