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({ 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..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,6 +1,6 @@ import assert from 'assert'; -import * as pgp from 'openpgp'; import * as sjcl from '@bitgo/sjcl'; +import * as pgp from 'openpgp'; import { NonEmptyString } from 'io-ts-types'; import { EddsaMPCv2KeyGenRound1Request, @@ -1208,8 +1208,70 @@ 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) { + 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 }; + } + 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..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 @@ -27,6 +27,8 @@ import { SignatureShareRecord, SignatureShareType, TxRequest, + isMpcV2Keycard, + signEddsaMpcV2RecoveryTx, } from '../../../../../../src'; import { getSignatureShareRoundOne, @@ -1983,6 +1985,138 @@ 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 result = await isMpcV2Keycard(encrypted, PASSPHRASE); + + 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)); + 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, PASSPHRASE), + /bitgo instance required for MPCv1 keycard decryption/ + ); + }); +}); + +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;