From 8bad1cb662b0ca11abb78617e716fe463f391077 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Fri, 7 Aug 2026 14:37:39 +0530 Subject: [PATCH 1/3] feat(sdk-core): extract isMpcV2Keycard and signEddsaMpcV2RecoveryTx into shared utils Ticket: WCI-1276 Add EddsaSigningMaterial discriminated union, isMpcV2Keycard (detects v1 vs v2 keycard format), and signEddsaMpcV2RecoveryTx (full MPCv2 recovery signing) to eddsaMPCv2.ts. These functions were duplicated inline across TON and Substrate coin recovery implementations. - Remove sjcl.decrypt fallback: all decryption routes through bitgo.decrypt, which handles both v1 (SJCL) and v2 (Argon2id) envelopes. - Export all new symbols from sdk-core package root. - 17 tests covering both functions including end-to-end signature verification. Co-Authored-By: Claude Sonnet 4.6 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 80 ++++++-- modules/sdk-core/src/index.ts | 9 + .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 172 ++++++++++++++++-- 3 files changed, 236 insertions(+), 25 deletions(-) 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 311262515c..091f581aa7 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,6 +1,5 @@ import assert from 'assert'; import * as pgp from 'openpgp'; -import * as sjcl from '@bitgo/sjcl'; import { NonEmptyString } from 'io-ts-types'; import { EddsaMPCv2KeyGenRound1Request, @@ -1077,17 +1076,14 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { * @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.decrypt (supports both v1 SJCL and v2 Argon2id envelopes); - * when absent, falls back to sjcl.decrypt (v1 only) + * bitgo.decrypt supports both v1 (SJCL) and v2 (Argon2id) envelopes. */ export async function isEddsaMpcV1SigningMaterial( encryptedKeyShare: string, walletPassphrase: string, - bitgo?: BitGoBase + bitgo: BitGoBase ): Promise { - const prv = bitgo - ? await bitgo.decrypt({ input: encryptedKeyShare, password: walletPassphrase }) - : sjcl.decrypt(walletPassphrase, encryptedKeyShare); + const prv = await bitgo.decrypt({ input: encryptedKeyShare, password: walletPassphrase }); try { const m = JSON.parse(prv); @@ -1112,18 +1108,17 @@ export async function isEddsaMpcV1SigningMaterial( * @param encryptedUserKey encrypted EdDSA MPCv2 reduced user key * @param encryptedBackupKey encrypted EdDSA MPCv2 reduced backup key * @param walletPassphrase password for user and backup keys + * @param bitgo BitGoBase instance used for decryption (supports v1 SJCL and v2 Argon2id) * @returns EdDSA MPCv2 recovery key shares and common keychain */ export async function getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptedUserKey: string, encryptedBackupKey: string, - walletPassphrase?: string, - bitgo?: BitGoBase + walletPassphrase: string, + bitgo: BitGoBase ): Promise { const decodeKey = async (encryptedKey: string): Promise => { - const decrypted = bitgo - ? await bitgo.decrypt({ input: encryptedKey, password: walletPassphrase }) - : sjcl.decrypt(walletPassphrase, encryptedKey); + const decrypted = await bitgo.decrypt({ input: encryptedKey, password: walletPassphrase }); let reduced: MPSTypes.EddsaReducedKeyShare; try { reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64')); @@ -1208,8 +1203,69 @@ export async function signRecoveryEddsaMPCv2( return signature; } +/** + * Discriminated union representing EdDSA signing material detected from a keycard. + * v1: MPCv1 JSON keycard — userPrv is the decrypted plaintext. + * v2: MPCv2 CBOR keycard — encryptedUserKey is returned as-is for MPS DSG. + */ +export type EddsaSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string }; + +/** + * Detects MPCv1 vs MPCv2 keycard format and returns typed signing material. + * For v1: decrypts the userKey and returns the plaintext. + * For v2: returns the encrypted key as-is for use with signEddsaMpcV2RecoveryTx. + * Identical logic across all EdDSA coin recovery implementations. + */ +export async function isMpcV2Keycard( + userKey: string, + walletPassphrase: string, + bitgo: BitGoBase +): Promise { + const normalized = userKey.replace(/\s/g, ''); + let isV1: boolean; + try { + isV1 = await isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, bitgo); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); + } + if (isV1) { + const userPrv = await bitgo.decrypt({ input: normalized, password: walletPassphrase }); + return { version: 'v1', userPrv }; + } + return { version: 'v2', encryptedUserKey: normalized }; +} + +/** + * Full MPCv2 recovery signing flow: decrypt key shares → validate commonKeyChain → MPS DSG. + * Returns raw 64-byte Ed25519 signature Buffer. + * Caller is responsible for any coin-specific envelope + * (e.g. 0x00 Substrate prefix, SUI flag+pubkey wrapper, or raw for NEAR/ADA/TON). + */ +export async function signEddsaMpcV2RecoveryTx(params: { + message: Buffer; + userKey: string; + backupKey: string; + walletPassphrase: string; + bitgoKey: string; + derivationPath: string; + bitgo: BitGoBase; +}): Promise { + const { userKeyShare, backupKeyShare, commonKeyChain } = await getEddsaMpcV2RecoveryKeySharesFromReducedKey( + params.userKey, + params.backupKey, + params.walletPassphrase, + params.bitgo + ); + if (commonKeyChain.toLowerCase() !== params.bitgoKey.toLowerCase()) { + throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + } + return signRecoveryEddsaMPCv2(params.message, params.derivationPath, userKeyShare, backupKeyShare, commonKeyChain); +} + export const EddsaMPCv2RecoveryFunctions = { isEddsaMpcV1SigningMaterial, getEddsaMpcV2RecoveryKeySharesFromReducedKey, signRecoveryEddsaMPCv2, + isMpcV2Keycard, + signEddsaMpcV2RecoveryTx, }; diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index 94783ed2ed..67a068523b 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -31,6 +31,15 @@ import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2'; export { EcdsaMPCv2Utils }; import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; export { EddsaMPCv2Utils }; +export type { EddsaSigningMaterial } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; +export { + isMpcV2Keycard, + signEddsaMpcV2RecoveryTx, + isEddsaMpcV1SigningMaterial, + getEddsaMpcV2RecoveryKeySharesFromReducedKey, + signRecoveryEddsaMPCv2, + EddsaMPCv2RecoveryFunctions, +} from './bitgo/utils/tss/eddsa/eddsaMPCv2'; export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification'; export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types'; export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types'; 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 664eab8e09..faddaf90fc 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 @@ -27,6 +27,8 @@ import { SignatureShareRecord, SignatureShareType, TxRequest, + isMpcV2Keycard, + signEddsaMpcV2RecoveryTx, } from '../../../../../../src'; import { getSignatureShareRoundOne, @@ -350,13 +352,20 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { const walletPassphrase = 'testPass'; const encryptKey = (keyShare: Buffer): string => sjcl.encrypt(walletPassphrase, keyShare.toString('base64')); + const makeSjclBitgo = (): BitGoBase => + ({ + decrypt: sinon + .stub() + .callsFake(async ({ input, password }: { input: string; password: string }) => sjcl.decrypt(password, input)), + } as unknown as BitGoBase); - it('should return recovery key shares from v1-encrypted reduced keys (no bitgo instance)', async () => { + it('should return recovery key shares from v1-encrypted reduced keys via bitgo.decrypt', async () => { const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); const result = await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptKey(userDkg.getReducedKeyShare()), encryptKey(backupDkg.getReducedKeyShare()), - walletPassphrase + walletPassphrase, + makeSjclBitgo() ); assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); @@ -364,7 +373,7 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); }); - it('should route decryption through bitgo.decrypt when a bitgo instance is provided', async () => { + it('should route decryption through bitgo.decrypt (verifies delegation, simulates v2 envelope)', 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.decrypt (which supports v1 + v2 in // production) rather than falling back to sjcl.decrypt. @@ -396,7 +405,8 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( malformedKey, encryptKey(userDkg.getReducedKeyShare()), - walletPassphrase + walletPassphrase, + makeSjclBitgo() ), /unable to decode reduced key share/ ); @@ -409,7 +419,8 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptKey(userDkg.getReducedKeyShare()), encryptKey(backupDkg.getReducedKeyShare()), - walletPassphrase + walletPassphrase, + makeSjclBitgo() ), /pub keys do not match/ ); @@ -436,7 +447,8 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptKey(userReducedKeyShare), encryptKey(backupReducedKeyShare), - walletPassphrase + walletPassphrase, + makeSjclBitgo() ), /rootChainCodes do not match/ ); @@ -1865,10 +1877,9 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64'); + // Routes to sjcl.decrypt for v1 SJCL envelopes and returns fake CBOR for simulated v2 envelopes. let mockBitgo: BitGoBase; beforeEach(() => { - // sdk-core has no devDependency on sdk-api/argon2, so v2 envelopes are simulated here. - // Real bitgo.decrypt routes v2 to Argon2id; the stub returns MPCv2 CBOR plaintext instead. mockBitgo = { decrypt: sinon.stub().callsFake(async (params: { input: string; password: string }) => { if (isV2Envelope(params.input)) { @@ -1881,17 +1892,17 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { 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, mockBitgo), 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, mockBitgo), 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, mockBitgo), false); }); it('returns false for MPCv2 Argon2id envelope (v2) + correct passphrase (forward-compat)', async () => { @@ -1902,7 +1913,7 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { it('throws on wrong passphrase', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL_BACKUP)); await assert.rejects( - EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase'), + EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase', mockBitgo), /ccm: tag doesn't match/ ); }); @@ -1910,7 +1921,7 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { 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, mockBitgo), false); }); }); @@ -1983,6 +1994,141 @@ describe('signRecoveryEddsaMPCv2', () => { }); }); +describe('isMpcV2Keycard', () => { + const PASSPHRASE = 'test-passphrase'; + + const MPCv1_MATERIAL = { + uShare: { i: 1, t: 2, n: 3, y: 'aabbcc', seed: 'deadbeef01234567', chaincode: '00' }, + bitgoYShare: { i: 3, j: 1, y: 'aabbcc', u: 'bitgo-u-value', chaincode: '00' }, + backupYShare: { i: 2, j: 1, y: 'aabbcc', u: 'backup-u-value', chaincode: '00' }, + }; + + it('returns { version: v1, userPrv } for MPCv1 JSON keycard', async () => { + const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL)); + const mockBitgo = { + decrypt: sinon.stub().resolves(JSON.stringify(MPCv1_MATERIAL)), + } as unknown as BitGoBase; + + const result = await isMpcV2Keycard(encrypted, PASSPHRASE, mockBitgo); + + assert.strictEqual(result.version, 'v1'); + assert.ok('userPrv' in result); + }); + + it('returns { version: v2, encryptedUserKey } for MPCv2 CBOR keycard', async () => { + const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64'); + const encrypted = sjcl.encrypt(PASSPHRASE, MPCv2_CBOR_BYTES); + const normalizedKey = encrypted.replace(/\s/g, ''); + const mockBitgo = { + decrypt: sinon + .stub() + .callsFake(async ({ input, password }: { input: string; password: string }) => sjcl.decrypt(password, input)), + } as unknown as BitGoBase; + + const result = await isMpcV2Keycard(encrypted, PASSPHRASE, mockBitgo); + + assert.strictEqual(result.version, 'v2'); + assert.ok('encryptedUserKey' in result); + assert.strictEqual((result as { version: 'v2'; encryptedUserKey: string }).encryptedUserKey, normalizedKey); + }); + + it('throws with context message when decryption fails', async () => { + const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL)); + const mockBitgo = { + decrypt: sinon.stub().rejects(new Error('ccm: tag does not match')), + } as unknown as BitGoBase; + await assert.rejects( + () => isMpcV2Keycard(encrypted, 'wrong-passphrase', mockBitgo), + /Error decrypting user keychain/ + ); + }); +}); + +describe('signEddsaMpcV2RecoveryTx', () => { + const derivationPath = 'm/0/0'; + const walletPassphrase = 'testPass'; + + const makeDecryptBitgo = (userKeyBase64: string, backupKeyBase64: string): BitGoBase => + ({ + decrypt: sinon.stub().onFirstCall().resolves(userKeyBase64).onSecondCall().resolves(backupKeyBase64), + } as unknown as BitGoBase); + + it('returns 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 mockBitgo = makeDecryptBitgo( + userDkg.getReducedKeyShare().toString('base64'), + backupDkg.getReducedKeyShare().toString('base64') + ); + + const result = await signEddsaMpcV2RecoveryTx({ + message, + userKey: 'encrypted-user-key', + backupKey: 'encrypted-backup-key', + walletPassphrase, + bitgoKey: commonKeyChain, + derivationPath, + bitgo: mockBitgo, + }); + + assert.strictEqual(result.length, 64); + const mpc = await getInitializedMpcInstance(); + const derivedKeychain = mpc.deriveUnhardened(commonKeyChain, derivationPath); + const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex'); + const ok = ed25519.verify(new Uint8Array(result), new Uint8Array(message), new Uint8Array(publicKeyBytes)); + assert.strictEqual(ok, true); + }); + + it('throws when commonKeyChain does not match bitgoKey', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const message = Buffer.from('deadbeef', 'hex'); + const mockBitgo = makeDecryptBitgo( + userDkg.getReducedKeyShare().toString('base64'), + backupDkg.getReducedKeyShare().toString('base64') + ); + + await assert.rejects( + () => + signEddsaMpcV2RecoveryTx({ + message, + userKey: 'encrypted-user-key', + backupKey: 'encrypted-backup-key', + walletPassphrase, + bitgoKey: 'bbbb'.repeat(32), + derivationPath, + bitgo: mockBitgo, + }), + /commonKeyChain from keycard does not match bitgoKey/ + ); + }); + + it('passes userKey, backupKey, passphrase, and bitgo to getEddsaMpcV2RecoveryKeySharesFromReducedKey', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const message = Buffer.from('deadbeef', 'hex'); + const decryptStub = sinon + .stub() + .onFirstCall() + .resolves(userDkg.getReducedKeyShare().toString('base64')) + .onSecondCall() + .resolves(backupDkg.getReducedKeyShare().toString('base64')); + const mockBitgo = { decrypt: decryptStub } as unknown as BitGoBase; + + await signEddsaMpcV2RecoveryTx({ + message, + userKey: 'u-key', + backupKey: 'b-key', + walletPassphrase, + bitgoKey: userDkg.getCommonKeychain(), + derivationPath, + bitgo: mockBitgo, + }); + + sinon.assert.calledWith(decryptStub.firstCall, { input: 'u-key', password: walletPassphrase }); + sinon.assert.calledWith(decryptStub.secondCall, { input: 'b-key', password: walletPassphrase }); + }); +}); + describe('EddsaMPCv2Utils.createKeychainsWithExternalSigner', function () { let utils: EddsaMPCv2Utils; let callbacks: EddsaMPCv2KeyGenCallbacks; From e9417ebf3020cb74dacb5c3367de7e8232c03811 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Fri, 7 Aug 2026 14:44:31 +0530 Subject: [PATCH 2/3] fix(sdk-core): restore sjcl fallback in isEddsaMpcV1SigningMaterial and getEddsaMpcV2RecoveryKeySharesFromReducedKey Ticket: WCI-1276 Reverts the bitgo-required change: both functions keep bitgo? optional with sjcl.decrypt fallback for v1-only callers. isMpcV2Keycard and signEddsaMpcV2RecoveryTx also remain with optional bitgo. Co-Authored-By: Claude Sonnet 4.6 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 24 +++++---- .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 54 ++++++++----------- 2 files changed, 36 insertions(+), 42 deletions(-) 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 091f581aa7..b4ba914d60 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,4 +1,5 @@ import assert from 'assert'; +import * as sjcl from '@bitgo/sjcl'; import * as pgp from 'openpgp'; import { NonEmptyString } from 'io-ts-types'; import { @@ -1076,14 +1077,17 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { * @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.decrypt supports both v1 (SJCL) and v2 (Argon2id) envelopes. + * bitgo.decrypt (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 + bitgo?: BitGoBase ): Promise { - const prv = await bitgo.decrypt({ input: encryptedKeyShare, password: walletPassphrase }); + const prv = bitgo + ? await bitgo.decrypt({ input: encryptedKeyShare, password: walletPassphrase }) + : sjcl.decrypt(walletPassphrase, encryptedKeyShare); try { const m = JSON.parse(prv); @@ -1108,17 +1112,18 @@ export async function isEddsaMpcV1SigningMaterial( * @param encryptedUserKey encrypted EdDSA MPCv2 reduced user key * @param encryptedBackupKey encrypted EdDSA MPCv2 reduced backup key * @param walletPassphrase password for user and backup keys - * @param bitgo BitGoBase instance used for decryption (supports v1 SJCL and v2 Argon2id) * @returns EdDSA MPCv2 recovery key shares and common keychain */ export async function getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptedUserKey: string, encryptedBackupKey: string, - walletPassphrase: string, - bitgo: BitGoBase + walletPassphrase?: string, + bitgo?: BitGoBase ): Promise { const decodeKey = async (encryptedKey: string): Promise => { - const decrypted = await bitgo.decrypt({ input: encryptedKey, password: walletPassphrase }); + const decrypted = bitgo + ? await bitgo.decrypt({ input: encryptedKey, password: walletPassphrase }) + : sjcl.decrypt(walletPassphrase, encryptedKey); let reduced: MPSTypes.EddsaReducedKeyShare; try { reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64')); @@ -1219,7 +1224,7 @@ export type EddsaSigningMaterial = { version: 'v1'; userPrv: string } | { versio export async function isMpcV2Keycard( userKey: string, walletPassphrase: string, - bitgo: BitGoBase + bitgo?: BitGoBase ): Promise { const normalized = userKey.replace(/\s/g, ''); let isV1: boolean; @@ -1229,6 +1234,7 @@ export async function isMpcV2Keycard( throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); } if (isV1) { + if (!bitgo) throw new Error('bitgo instance required for MPCv1 keycard decryption'); const userPrv = await bitgo.decrypt({ input: normalized, password: walletPassphrase }); return { version: 'v1', userPrv }; } @@ -1248,7 +1254,7 @@ export async function signEddsaMpcV2RecoveryTx(params: { walletPassphrase: string; bitgoKey: string; derivationPath: string; - bitgo: BitGoBase; + bitgo?: BitGoBase; }): Promise { const { userKeyShare, backupKeyShare, commonKeyChain } = await getEddsaMpcV2RecoveryKeySharesFromReducedKey( params.userKey, 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 faddaf90fc..5449300418 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 @@ -352,20 +352,13 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { const walletPassphrase = 'testPass'; const encryptKey = (keyShare: Buffer): string => sjcl.encrypt(walletPassphrase, keyShare.toString('base64')); - const makeSjclBitgo = (): BitGoBase => - ({ - decrypt: sinon - .stub() - .callsFake(async ({ input, password }: { input: string; password: string }) => sjcl.decrypt(password, input)), - } as unknown as BitGoBase); - it('should return recovery key shares from v1-encrypted reduced keys via bitgo.decrypt', async () => { + 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, - makeSjclBitgo() + walletPassphrase ); assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); @@ -373,7 +366,7 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); }); - it('should route decryption through bitgo.decrypt (verifies delegation, simulates v2 envelope)', async () => { + it('should route decryption through bitgo.decrypt 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.decrypt (which supports v1 + v2 in // production) rather than falling back to sjcl.decrypt. @@ -405,8 +398,7 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( malformedKey, encryptKey(userDkg.getReducedKeyShare()), - walletPassphrase, - makeSjclBitgo() + walletPassphrase ), /unable to decode reduced key share/ ); @@ -419,8 +411,7 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptKey(userDkg.getReducedKeyShare()), encryptKey(backupDkg.getReducedKeyShare()), - walletPassphrase, - makeSjclBitgo() + walletPassphrase ), /pub keys do not match/ ); @@ -447,8 +438,7 @@ describe('getEddsaMPCv2RecoveryKeyShares', () => { EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( encryptKey(userReducedKeyShare), encryptKey(backupReducedKeyShare), - walletPassphrase, - makeSjclBitgo() + walletPassphrase ), /rootChainCodes do not match/ ); @@ -1877,9 +1867,10 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64'); - // Routes to sjcl.decrypt for v1 SJCL envelopes and returns fake CBOR for simulated v2 envelopes. let mockBitgo: BitGoBase; beforeEach(() => { + // sdk-core has no devDependency on sdk-api/argon2, so v2 envelopes are simulated here. + // Real bitgo.decrypt routes v2 to Argon2id; the stub returns MPCv2 CBOR plaintext instead. mockBitgo = { decrypt: sinon.stub().callsFake(async (params: { input: string; password: string }) => { if (isV2Envelope(params.input)) { @@ -1892,17 +1883,17 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { 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, mockBitgo), 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, mockBitgo), 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, mockBitgo), false); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); }); it('returns false for MPCv2 Argon2id envelope (v2) + correct passphrase (forward-compat)', async () => { @@ -1913,7 +1904,7 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { it('throws on wrong passphrase', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL_BACKUP)); await assert.rejects( - EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase', mockBitgo), + EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase'), /ccm: tag doesn't match/ ); }); @@ -1921,7 +1912,7 @@ describe('EDDSAUtils.isEddsaMpcV1SigningMaterial', () => { 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, mockBitgo), false); + assert.strictEqual(await EDDSAUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false); }); }); @@ -2019,13 +2010,8 @@ describe('isMpcV2Keycard', () => { const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64'); const encrypted = sjcl.encrypt(PASSPHRASE, MPCv2_CBOR_BYTES); const normalizedKey = encrypted.replace(/\s/g, ''); - const mockBitgo = { - decrypt: sinon - .stub() - .callsFake(async ({ input, password }: { input: string; password: string }) => sjcl.decrypt(password, input)), - } as unknown as BitGoBase; - const result = await isMpcV2Keycard(encrypted, PASSPHRASE, mockBitgo); + const result = await isMpcV2Keycard(encrypted, PASSPHRASE); assert.strictEqual(result.version, 'v2'); assert.ok('encryptedUserKey' in result); @@ -2034,12 +2020,14 @@ describe('isMpcV2Keycard', () => { it('throws with context message when decryption fails', async () => { const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL)); - const mockBitgo = { - decrypt: sinon.stub().rejects(new Error('ccm: tag does not match')), - } as unknown as BitGoBase; + await assert.rejects(() => isMpcV2Keycard(encrypted, 'wrong-passphrase'), /Error decrypting user keychain/); + }); + + it('throws when bitgo instance is missing for v1 keycard decryption', async () => { + const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL)); await assert.rejects( - () => isMpcV2Keycard(encrypted, 'wrong-passphrase', mockBitgo), - /Error decrypting user keychain/ + () => isMpcV2Keycard(encrypted, PASSPHRASE), + /bitgo instance required for MPCv1 keycard decryption/ ); }); }); From 13407161fd09b656d44fb8c5b6d9020a396960ce Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Fri, 7 Aug 2026 16:10:56 +0530 Subject: [PATCH 3/3] feat(sdk-core): consume shared EdDSA MPCv2 helpers in substrate and sol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: WCI-1276 Replace inline MPCv2 keycard detection and signing in abstract-substrate and sdk-coin-sol with shared helpers isMpcV2Keycard and signEddsaMpcV2RecoveryTx from sdk-core. Drop-in replacement — no behaviour change, no test modifications required. Co-Authored-By: Claude Sonnet 4.6 --- .../src/abstractSubstrateCoin.ts | 29 ++------- modules/sdk-coin-sol/src/sol.ts | 63 ++++++------------- 2 files changed, 23 insertions(+), 69 deletions(-) diff --git a/modules/abstract-substrate/src/abstractSubstrateCoin.ts b/modules/abstract-substrate/src/abstractSubstrateCoin.ts index 3f0f972388..705b0c5ded 100644 --- a/modules/abstract-substrate/src/abstractSubstrateCoin.ts +++ b/modules/abstract-substrate/src/abstractSubstrateCoin.ts @@ -27,6 +27,8 @@ import { VerifyTransactionOptions, EDDSAUtils, decryptKeychainPrivateKey, + isMpcV2Keycard as sharedIsMpcV2Keycard, + EddsaSigningMaterial, } from '@bitgo/sdk-core'; import { CoinFamily, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import { KeyPair as SubstrateKeyPair, Transaction } from './lib'; @@ -40,12 +42,6 @@ import { ApiPromise } from '@polkadot/api'; export const DEFAULT_SCAN_FACTOR = 20; -/** - * Discriminated union carrying keycard version and decrypted V1 user key (to avoid re-decryption). - * V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares. - */ -type SubstrateSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string }; - export class SubstrateCoin extends BaseCoin { protected readonly _staticsCoin: Readonly; readonly MAX_VALIDITY_DURATION = 2400; @@ -520,23 +516,8 @@ export class SubstrateCoin extends BaseCoin { return prv; } - /** - * Probes the key format and returns a discriminated union so callers avoid a second decrypt. - * V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares. - */ - protected async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise { - const normalized = userKey.replace(/\s/g, ''); - let isV1: boolean; - try { - isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, this.bitgo); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); - } - if (isV1) { - const userPrv = await this.decryptKeychain(normalized, walletPassphrase, 'user'); - return { version: 'v1', userPrv }; - } - return { version: 'v2', encryptedUserKey: normalized }; + protected async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise { + return sharedIsMpcV2Keycard(userKey, walletPassphrase, this.bitgo); } // Protected so tests can stub via instance overrides without adding new test dependencies. @@ -569,7 +550,7 @@ export class SubstrateCoin extends BaseCoin { */ protected async addSubstrateRecoverySignature( txBuilder: NativeTransferBuilder, - signingMaterial: SubstrateSigningMaterial, + signingMaterial: EddsaSigningMaterial, backupKey: string, walletPassphrase: string, unsignedTransaction: Transaction, diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index c426ebe05c..f71719cfb0 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -59,7 +59,8 @@ import { DeriveAddressOptions, DeriveAddressResult, UnexpectedAddressError, - EDDSAUtils, + isMpcV2Keycard, + signEddsaMpcV2RecoveryTx, } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; @@ -1700,7 +1701,7 @@ export class Sol extends BaseCoin { const userKey = params.userKey?.replace(/\s/g, '') ?? ''; const isMpcV2 = params.walletPassphrase - ? !(await EDDSAUtils.isEddsaMpcV1SigningMaterial(userKey, params.walletPassphrase, this.bitgo)) + ? (await isMpcV2Keycard(userKey, params.walletPassphrase, this.bitgo)).version === 'v2' : false; const index = params.index || 0; @@ -1819,7 +1820,7 @@ export class Sol extends BaseCoin { // Detect once at the top to avoid decrypting the keycard on every iteration of the scan loop. // For unsigned sweep (no passphrase), isMpcV2 is false — cold MPCv2 is out of scope. const isMpcV2 = params.walletPassphrase - ? !(await EDDSAUtils.isEddsaMpcV1SigningMaterial(userKey, params.walletPassphrase, this.bitgo)) + ? (await isMpcV2Keycard(userKey, params.walletPassphrase, this.bitgo)).version === 'v2' : false; const baseAddressIndex = 0; @@ -1963,25 +1964,15 @@ export class Sol extends BaseCoin { ); txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signatureHex); } else { - const { userKeyShare, backupKeyShare, commonKeyChain } = - await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( - userKey, - backupKey, - params.walletPassphrase!, - this.bitgo - ); - - if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) { - throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); - } - - const signature = await EDDSAUtils.signRecoveryEddsaMPCv2( - unsignedTransaction.signablePayload, - currPath, - userKeyShare, - backupKeyShare, - commonKeyChain - ); + const signature = await signEddsaMpcV2RecoveryTx({ + message: unsignedTransaction.signablePayload, + userKey, + backupKey, + walletPassphrase: params.walletPassphrase!, + bitgoKey, + derivationPath: currPath, + bitgo: this.bitgo, + }); txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signature); } } @@ -1991,29 +1982,11 @@ export class Sol extends BaseCoin { backupKey?: string, walletPassphrase?: string ): Promise { - let isMpcV2 = false; - if (walletPassphrase) { - if (!userKey) { - throw new Error('missing userKey'); - } - if (!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.isEddsaMpcV1SigningMaterial( - userKey.replace(/\s/g, ''), - 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)}`); - } - } - return isMpcV2; + if (!walletPassphrase) return false; + if (!userKey) throw new Error('missing userKey'); + if (!backupKey) throw new Error('missing backupKey'); + const material = await isMpcV2Keycard(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo); + return material.version === 'v2'; } async broadcastTransaction({