From adc50a4ea518f72f31aa76c6192997d4b1120cad Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 26 Jun 2026 13:57:58 -0400 Subject: [PATCH] feat: add external signer support for ECDSA and EdDSA TSS key gen Ticket: WCN-682 --- .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 87 +++ .../src/bitgo/utils/tss/eddsa/eddsa.ts | 83 ++- modules/sdk-core/src/bitgo/wallet/iWallets.ts | 107 +++- modules/sdk-core/src/bitgo/wallet/wallets.ts | 132 ++++- modules/sdk-core/src/index.ts | 14 + .../unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 151 +++++ .../utils/tss/eddsa/eddsaExternalSigner.ts | 154 +++++ .../bitgo/wallet/walletsExternalSigner.ts | 534 +++++++++++++++++- 8 files changed, 1241 insertions(+), 21 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaExternalSigner.ts diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 329a0c4ac7..0a2943e19c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -58,6 +58,7 @@ import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/ import { InvalidTransactionError } from '../../../errors'; import { BitGoBase } from '../../../bitgoBase'; import { resolveEffectiveTxParams } from '../recipientUtils'; +import type { EcdsaMPCv2KeyGenCallbacks } from '../../../wallet/iWallets'; export class EcdsaMPCv2Utils extends BaseEcdsaUtils { private static readonly DKLS23_SIGNING_USER_GPG_KEY = 'DKLS23_SIGNING_USER_GPG_KEY'; @@ -647,6 +648,92 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { } // #endregion + /** + * Creates ECDSA MPCv2 keychains using external signer callbacks instead of a passphrase. + * The external signer holds all private key material; the SDK only coordinates the DKG protocol. + */ + async createKeychainsWithExternalSigner(params: { + enterprise: string; + callbacks: EcdsaMPCv2KeyGenCallbacks; + }): Promise { + const { enterprise, callbacks } = params; + const { mpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(enterprise, true); + const mpcv2Key = mpcv2PublicKey ?? this.bitgoMPCv2PublicGpgKey; + assert(mpcv2Key, 'Failed to get BitGo MPCv2 GPG public key'); + const bitgoPublicGpgKey = mpcv2Key.armor(); + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoMpcPubKey(bitgoPublicGpgKey, 'mpcv2'), 'Invalid BitGo GPG public key'); + } + + // Round 1: external signer generates GPG keys and initial DKG messages + const { userGpgPublicKey, backupGpgPublicKey, round1Messages, userState, backupState } = + await callbacks.initializeCallback({ + enterprise, + bitgoPublicGpgKey, + }); + + const { sessionId, bitgoMsg1, bitgoToUserMsg2, bitgoToBackupMsg2 } = await this.sendKeyGenerationRound1( + enterprise, + userGpgPublicKey, + backupGpgPublicKey, + round1Messages + ); + + // Round 2: external signer processes BitGo's round 1 response + const { + round2Messages, + userState: userStateAfter2, + backupState: backupStateAfter2, + } = await callbacks.round2Callback({ + sessionId, + bitgoMsg1, + bitgoToUserMsg2, + bitgoToBackupMsg2, + userState, + backupState, + }); + + const round2Response = await this.sendKeyGenerationRound2(enterprise, sessionId, round2Messages); + assert.strictEqual(round2Response.sessionId, sessionId, 'Session ID mismatch after round 2'); + + // Round 3: external signer processes BitGo's round 2 response + const { + round3Messages, + userState: userStateAfter3, + backupState: backupStateAfter3, + } = await callbacks.round3Callback({ + sessionId, + bitgoCommitment2: round2Response.bitgoCommitment2, + bitgoToUserMsg3: round2Response.bitgoToUserMsg3, + bitgoToBackupMsg3: round2Response.bitgoToBackupMsg3, + userState: userStateAfter2, + backupState: backupStateAfter2, + }); + + const round3Response = await this.sendKeyGenerationRound3(enterprise, sessionId, round3Messages); + assert.strictEqual(round3Response.sessionId, sessionId, 'Session ID mismatch after round 3'); + + // Finalize: external signer verifies BitGo's final message against the derived common keychain + const { commonKeychain } = await callbacks.finalizeCallback({ + sessionId, + bitgoMsg4: round3Response.bitgoMsg4, + bitgoCommonKeychain: round3Response.commonKeychain, + userState: userStateAfter3, + backupState: backupStateAfter3, + }); + assert.strictEqual(commonKeychain, round3Response.commonKeychain, 'Common keychains do not match'); + + const keychains = this.baseCoin.keychains(); + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + keychains.add({ source: 'user', keyType: 'tss', commonKeychain, isMPCv2: true }), + keychains.add({ source: 'backup', keyType: 'tss', commonKeychain, isMPCv2: true }), + this.addBitgoKeychain(commonKeychain), + ]); + + return { userKeychain, backupKeychain, bitgoKeychain }; + } + async sendKeyGenerationRound1( enterprise: string, userGpgPublicKey: string, diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index 5cad5b6b82..a5eac972cc 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -44,9 +44,10 @@ import { KeychainsTriplet } from '../../../baseCoin'; import { exchangeEddsaCommitments } from '../../../tss/common'; import { Ed25519Bip32HdTree } from '@bitgo/sdk-lib-mpc'; import { EncryptionVersion, IRequestTracer } from '../../../../api'; -import { getBitgoMpcGpgPubKey } from '../../../tss/bitgoPubKeys'; +import { envRequiresBitgoPubGpgKeyConfig, getBitgoMpcGpgPubKey, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; import { EnvironmentName } from '../../../environments'; import { readKey } from 'openpgp'; +import type { EddsaKeyGenCallbacks } from '../../../wallet/iWallets'; /** * Utility functions for TSS work flows. @@ -438,6 +439,86 @@ export class EddsaUtils extends baseTSSUtils { } } + /** + * Creates EdDSA TSS keychains using external signer callbacks instead of a passphrase. + * The external signer holds all private key material and pre-encrypts shares to BitGo's GPG key. + */ + async createKeychainsWithExternalSigner(params: { + enterprise: string; + callbacks: EddsaKeyGenCallbacks; + }): Promise { + const { enterprise, callbacks } = params; + const bitgoGpgPubKey = (await getBitgoGpgPubKey(this.bitgo)).mpcV1; + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoMpcPubKey(bitgoGpgPubKey.armor(), 'mpcv1'), 'Invalid BitGo GPG public key'); + } + + const { + userGpgPublicKey, + backupGpgPublicKey, + userToBitgoKeyShare, + backupToBitgoKeyShare, + userState, + backupState, + backupCounterPartyKeyShare, + } = await callbacks.initializeCallback({ enterprise, bitgoPublicGpgKey: bitgoGpgPubKey.armor() }); + + // Create BitGo keychain with pre-encrypted shares from the external signer + const bitgoKeychain = await this.baseCoin.keychains().add({ + keyType: 'tss', + source: 'bitgo', + keyShares: [ + { from: 'user', to: 'bitgo', ...userToBitgoKeyShare }, + { from: 'backup', to: 'bitgo', ...backupToBitgoKeyShare }, + ], + userGPGPublicKey: userGpgPublicKey, + backupGPGPublicKey: backupGpgPublicKey, + enterprise, + }); + + // Finalize runs sequentially: user finalize produces a counterparty key share that backup finalize consumes. + const coin = this.baseCoin.getChain(); + const userResult = await callbacks.finalizeCallback({ + source: 'user', + coin, + bitgoKeychain, + counterPartyGPGKey: backupGpgPublicKey, + counterPartyKeyShare: backupCounterPartyKeyShare, + state: userState, + }); + assert(userResult.counterpartyKeyShare, 'User finalize did not produce a counterparty key share'); + + const backupResult = await callbacks.finalizeCallback({ + source: 'backup', + coin, + bitgoKeychain, + counterPartyGPGKey: userGpgPublicKey, + counterPartyKeyShare: userResult.counterpartyKeyShare, + state: backupState, + }); + + assert(bitgoKeychain.commonKeychain, 'BitGo keychain missing commonKeychain'); + assert.strictEqual( + userResult.commonKeychain, + bitgoKeychain.commonKeychain, + 'User common keychain does not match BitGo' + ); + assert.strictEqual( + backupResult.commonKeychain, + bitgoKeychain.commonKeychain, + 'Backup common keychain does not match BitGo' + ); + + const { commonKeychain } = bitgoKeychain; + const [userKeychain, backupKeychain] = await Promise.all([ + this.baseCoin.keychains().add({ source: 'user', keyType: 'tss', commonKeychain }), + this.baseCoin.keychains().add({ source: 'backup', keyType: 'tss', commonKeychain }), + ]); + + return { userKeychain, backupKeychain, bitgoKeychain }; + } + async createCommitmentShareFromTxRequest(params: { txRequest: TxRequest; prv: string; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index ae233c4346..a3aec278ad 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -1,8 +1,10 @@ import * as t from 'io-ts'; +import { DklsTypes } from '@bitgo/sdk-lib-mpc'; +import { MPCv2BroadcastMessage, MPCv2P2PMessage } from '@bitgo/public-types'; import { EncryptionVersion, IRequestTracer } from '../../api'; import { KeychainsTriplet, LightningKeychainsTriplet } from '../baseCoin'; -import { Keychain, WebauthnInfo } from '../keychain'; +import { ApiKeyShare, Keychain, WebauthnInfo } from '../keychain'; import { IWallet, PaginationOptions, WalletShare } from './iWallet'; import { Wallet } from './wallet'; @@ -76,6 +78,105 @@ export interface CreateKeychainCallbackResult { export type CreateKeychainCallback = (params: CreateKeychainCallbackParams) => Promise; +/** Per-source encrypted session state threaded through MPC rounds by the external signer. */ +export interface ExternalSignerMpcState { + encryptedData: string; + encryptedDataKey: string; +} + +/** A key share supplied by the external signer — routing fields (from/to) are added by the SDK. */ +export type ExternalSignerKeyShare = Omit & { + privateShareProof: string; + vssProof: string; + gpgKey: string; +}; + +export type EcdsaMPCv2KeyGenInitializeCallback = (params: { + enterprise: string; + bitgoPublicGpgKey: string; +}) => Promise<{ + userGpgPublicKey: string; + backupGpgPublicKey: string; + round1Messages: DklsTypes.AuthEncMessages; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; +}>; + +export type EcdsaMPCv2KeyGenRound2Callback = (params: { + sessionId: string; + bitgoMsg1: MPCv2BroadcastMessage; + bitgoToUserMsg2: MPCv2P2PMessage; + bitgoToBackupMsg2: MPCv2P2PMessage; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; +}) => Promise<{ + round2Messages: DklsTypes.AuthEncMessages; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; +}>; + +export type EcdsaMPCv2KeyGenRound3Callback = (params: { + sessionId: string; + bitgoCommitment2: string; + bitgoToUserMsg3: MPCv2P2PMessage; + bitgoToBackupMsg3: MPCv2P2PMessage; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; +}) => Promise<{ + round3Messages: DklsTypes.AuthEncMessages; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; +}>; + +export type EcdsaMPCv2KeyGenFinalizeCallback = (params: { + sessionId: string; + bitgoMsg4: MPCv2BroadcastMessage; + bitgoCommonKeychain: string; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; +}) => Promise<{ commonKeychain: string }>; + +export interface EcdsaMPCv2KeyGenCallbacks { + initializeCallback: EcdsaMPCv2KeyGenInitializeCallback; + round2Callback: EcdsaMPCv2KeyGenRound2Callback; + round3Callback: EcdsaMPCv2KeyGenRound3Callback; + finalizeCallback: EcdsaMPCv2KeyGenFinalizeCallback; +} + +export interface EddsaKeyGenInitializeResult { + userGpgPublicKey: string; + backupGpgPublicKey: string; + userToBitgoKeyShare: ExternalSignerKeyShare; + backupToBitgoKeyShare: ExternalSignerKeyShare; + userState: ExternalSignerMpcState; + backupState: ExternalSignerMpcState; + backupCounterPartyKeyShare: ExternalSignerKeyShare; +} + +export type EddsaKeyGenInitializeCallback = (params: { + enterprise: string; + bitgoPublicGpgKey: string; +}) => Promise; + +export type EddsaKeyGenFinalizeResult = { + commonKeychain: string; + counterpartyKeyShare?: ExternalSignerKeyShare; +}; + +export type EddsaKeyGenFinalizeCallback = (params: { + source: 'user' | 'backup'; + coin: string; + bitgoKeychain: Keychain; + counterPartyGPGKey: string; + counterPartyKeyShare: ExternalSignerKeyShare; + state: ExternalSignerMpcState; +}) => Promise; + +export interface EddsaKeyGenCallbacks { + initializeCallback: EddsaKeyGenInitializeCallback; + finalizeCallback: EddsaKeyGenFinalizeCallback; +} + export interface GenerateWalletOptions { label?: string; passphrase?: string; @@ -115,9 +216,11 @@ export interface GenerateWalletOptions { export interface GenerateWalletWithExternalSignerOptions extends Omit { label: string; - createKeychainCallback: CreateKeychainCallback; + createKeychainCallback?: CreateKeychainCallback; /** Optional user-key signatures over backup/bitgo pubs. Omit when the external signer cannot produce them (equivalent to a cold wallet). */ keySignatures?: { backup: string; bitgo: string }; + ecdsaMPCv2Callbacks?: EcdsaMPCv2KeyGenCallbacks; + eddsaCallbacks?: EddsaKeyGenCallbacks; } export const GenerateLightningWalletOptionsCodec = t.intersection( diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index b6febbc929..60348a1960 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -13,7 +13,7 @@ import { IBaseCoin, KeychainsTriplet, SupplementGenerateWalletOptions } from '.. import { BitGoBase } from '../bitgoBase'; import { getSharedSecret } from '../ecdh'; import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain'; -import { decodeOrElse, promiseProps, RequestTracer } from '../utils'; +import { decodeOrElse, ECDSAUtils, EDDSAUtils, promiseProps, RequestTracer } from '../utils'; import { AcceptShareOptions, AcceptShareOptionsRequest, @@ -735,15 +735,28 @@ export class Wallets implements IWallets { async generateWalletWithExternalSigner( params: GenerateWalletWithExternalSignerOptions ): Promise { - if (!_.isFunction(params.createKeychainCallback)) { - throw new Error('missing required function parameter createKeychainCallback'); + const hasOnchainCallback = _.isFunction(params.createKeychainCallback); + const hasMpcCallbacks = !!(params.ecdsaMPCv2Callbacks || params.eddsaCallbacks); + + if (hasOnchainCallback && hasMpcCallbacks) { + throw new Error('createKeychainCallback cannot be used together with MPC TSS key generation callbacks'); + } + + const multisigType = + params.multisigType ?? (hasOnchainCallback ? 'onchain' : this.baseCoin.getDefaultMultisigType()); + + if (multisigType === 'tss') { + return this.generateMpcWalletWithExternalSigner(params); } - const multisigType = params.multisigType ?? this.baseCoin.getDefaultMultisigType(); if (multisigType !== 'onchain') { throw new Error('external signer wallet generation is only supported for onchain multisig wallets'); } + if (!_.isFunction(params.createKeychainCallback)) { + throw new Error('missing required function parameter createKeychainCallback'); + } + // these belong to the passphrase-based path and are incompatible with createKeychainCallback const passphrasePathParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; for (const key of passphrasePathParams) { @@ -1714,6 +1727,117 @@ export class Wallets implements IWallets { return await this.bitgo.get(this.baseCoin.url('/wallet/balances')).result(); } + /** + * Generates a TSS wallet using external signer callbacks for key generation. + * @private + */ + private async generateMpcWalletWithExternalSigner( + params: GenerateWalletWithExternalSignerOptions + ): Promise { + const { label, enterprise, type = 'hot' } = params; + + if (!this.baseCoin.supportsTss()) { + throw new Error(`coin ${this.baseCoin.getFamily()} does not support TSS at this time`); + } + + if (!enterprise) { + throw new Error('enterprise is required for TSS wallet generation with external signer'); + } + + if ( + this.baseCoin.isEVM() && + params.walletVersion !== undefined && + !(params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6) + ) { + throw new Error('EVM TSS wallets are only supported for wallet version 3, 5 and 6'); + } + + if (type === 'custodial') { + throw new Error('custodial TSS wallets are not supported for external signer wallet generation'); + } + + if (!_.isUndefined(params.passcodeEncryptionCode)) { + throw new Error('passcodeEncryptionCode is not supported for TSS external signer wallet generation'); + } + + if (!_.isUndefined(params.webauthnInfo)) { + throw new Error('webauthnInfo is not supported for TSS external signer wallet generation'); + } + + const passphrasePathParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; + for (const key of passphrasePathParams) { + if (!_.isUndefined(params[key])) { + throw new Error(`${key} cannot be used with TSS external signer wallet generation`); + } + } + + const mpcAlgorithm = this.baseCoin.getMPCAlgorithm(); + let walletVersion: number | undefined = params.walletVersion; + let keychains: KeychainsTriplet; + + if (mpcAlgorithm === 'ecdsa') { + if (!params.ecdsaMPCv2Callbacks) { + throw new Error('ecdsaMPCv2Callbacks is required for ECDSA TSS wallet generation with external signer'); + } + const tssSettings: TssSettings = await this.bitgo + .get(this.bitgo.microservicesUrl('/api/v2/tss/settings')) + .result(); + const multisigTypeVersion = + tssSettings.coinSettings[this.baseCoin.getFamily()]?.walletCreationSettings?.multiSigTypeVersion; + walletVersion = this.determineEcdsaMpcWalletVersion(walletVersion, multisigTypeVersion); + + if (walletVersion === 3) { + throw new Error('ECDSA MPCv1 (wallet version 3) is not supported for external signer wallet generation'); + } + + if ( + (walletVersion === 5 || walletVersion === 6) && + !this.baseCoin.getConfig().features.includes(CoinFeature.MPCV2) + ) { + throw new Error(`coin ${this.baseCoin.getFamily()} does not support TSS MPCv2 at this time`); + } + + keychains = await new ECDSAUtils.EcdsaMPCv2Utils(this.bitgo, this.baseCoin).createKeychainsWithExternalSigner({ + enterprise, + callbacks: params.ecdsaMPCv2Callbacks, + }); + } else { + if (!params.eddsaCallbacks) { + throw new Error('eddsaCallbacks is required for EdDSA TSS wallet generation with external signer'); + } + keychains = await new EDDSAUtils.default(this.bitgo, this.baseCoin).createKeychainsWithExternalSigner({ + enterprise, + callbacks: params.eddsaCallbacks, + }); + } + + const { userKeychain, backupKeychain, bitgoKeychain } = keychains; + const walletParams: SupplementGenerateWalletOptions = { + label, + m: 2, + n: 3, + keys: [userKeychain.id, backupKeychain.id, bitgoKeychain.id], + type, + multisigType: 'tss', + enterprise, + walletVersion, + }; + + const reqId = new RequestTracer(); + this.bitgo.setRequestTracer(reqId); + + const finalWalletParams = await this.baseCoin.supplementGenerateWallet(walletParams, keychains); + const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(finalWalletParams).result(); + + return { + wallet: new Wallet(this.bitgo, this.baseCoin, newWallet), + userKeychain, + backupKeychain, + bitgoKeychain, + responseType: 'WalletWithKeychains', + }; + } + /** * Generates a TSS or BLS-DKG Wallet. * @param params diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index ac3aeda639..9326a21c7a 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -6,6 +6,20 @@ export * from './bitgojsError'; export * as coins from './coins'; import { EddsaUtils } from './bitgo/utils/tss/eddsa/eddsa'; export { EddsaUtils }; +export type { + EcdsaMPCv2KeyGenCallbacks, + EcdsaMPCv2KeyGenInitializeCallback, + EcdsaMPCv2KeyGenRound2Callback, + EcdsaMPCv2KeyGenRound3Callback, + EcdsaMPCv2KeyGenFinalizeCallback, + EddsaKeyGenCallbacks, + EddsaKeyGenInitializeCallback, + EddsaKeyGenInitializeResult, + EddsaKeyGenFinalizeCallback, + EddsaKeyGenFinalizeResult, + ExternalSignerKeyShare, + ExternalSignerMpcState, +} from './bitgo/wallet/iWallets'; import { EcdsaUtils } from './bitgo/utils/tss/ecdsa/ecdsa'; export { EcdsaUtils }; import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2'; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index e0be05481b..083e711fbc 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -3,6 +3,8 @@ import * as sinon from 'sinon'; import { Hash, createHash, randomBytes } from 'crypto'; import createKeccakHash from 'keccak'; import { + MPCv2BroadcastMessage, + MPCv2P2PMessage, MPCv2PartyFromStringOrNumber, MPCv2SignatureShareRound1Input, MPCv2SignatureShareRound1Output, @@ -15,6 +17,7 @@ import * as sjcl from '@bitgo/sjcl'; import { BitGoBase, EcdsaMPCv2Utils, + EcdsaMPCv2KeyGenCallbacks, IBaseCoin, SignatureShareRecord, SignatureShareType, @@ -1151,3 +1154,151 @@ async function signBitgoMPCv2Round3( userMsg4: parsedSignatureShare, }; } + +describe('EcdsaMPCv2Utils.createKeychainsWithExternalSigner', function () { + let utils: EcdsaMPCv2Utils; + let callbacks: EcdsaMPCv2KeyGenCallbacks; + const enterprise = 'enterprise-id'; + const sessionId = 'session-001'; + const commonKeychain = 'common-keychain-abc'; + + const emptyMessages: DklsTypes.AuthEncMessages = { broadcastMessages: [], p2pMessages: [] }; + const userState = { encryptedData: 'u-data', encryptedDataKey: 'u-data-key' }; + const backupState = { encryptedData: 'b-data', encryptedDataKey: 'b-data-key' }; + const bitgoMsg1: MPCv2BroadcastMessage = { from: 2, message: 'msg1', signature: 'sig1' }; + const bitgoToUserMsg2: MPCv2P2PMessage = { from: 2, to: 0, encryptedMessage: 'u2', signature: 'su2' }; + const bitgoToBackupMsg2: MPCv2P2PMessage = { from: 2, to: 1, encryptedMessage: 'b2', signature: 'sb2' }; + const bitgoToUserMsg3: MPCv2P2PMessage = { from: 2, to: 0, encryptedMessage: 'u3', signature: 'su3' }; + const bitgoToBackupMsg3: MPCv2P2PMessage = { from: 2, to: 1, encryptedMessage: 'b3', signature: 'sb3' }; + const bitgoMsg4: MPCv2BroadcastMessage = { from: 2, message: 'msg4', signature: 'sig4' }; + + beforeEach(function () { + callbacks = { + initializeCallback: sinon.stub().resolves({ + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + round1Messages: emptyMessages, + userState, + backupState, + }), + round2Callback: sinon.stub().resolves({ round2Messages: emptyMessages, userState, backupState }), + round3Callback: sinon.stub().resolves({ round3Messages: emptyMessages, userState, backupState }), + finalizeCallback: sinon.stub().resolves({ commonKeychain }), + }; + + const mockBitGo = { + getEnv: sinon.stub().returns('dev'), + } as any; + + const mockKeychains = { + add: sinon + .stub() + .callsFake((params: any) => Promise.resolve({ id: `${params.source}-key-id`, commonKeychain, isMPCv2: true })), + createBitGo: sinon.stub().resolves({ id: 'bitgo-key-id', commonKeychain, isMPCv2: true }), + }; + + const mockCoin = { + keychains: sinon.stub().returns(mockKeychains), + } as any; + + utils = new EcdsaMPCv2Utils(mockBitGo, mockCoin); + + sinon.stub(utils, 'getBitgoGpgPubkeyBasedOnFeatureFlags' as any).resolves({ mpcv2PublicKey: null }); + (utils as any).bitgoMPCv2PublicGpgKey = { armor: () => '-----BEGIN PGP PUBLIC KEY BLOCK-----\n' }; + + sinon.stub(utils, 'sendKeyGenerationRound1').resolves({ + sessionId: sessionId as any, + walletGpgPubKeySigs: 'sigs' as any, + bitgoMsg1, + bitgoToUserMsg2, + bitgoToBackupMsg2, + }); + sinon.stub(utils, 'sendKeyGenerationRound2').resolves({ + sessionId: sessionId as any, + bitgoCommitment2: 'commitment2' as any, + bitgoToUserMsg3, + bitgoToBackupMsg3, + }); + sinon.stub(utils, 'sendKeyGenerationRound3').resolves({ + sessionId: sessionId as any, + bitgoMsg4, + commonKeychain: commonKeychain as any, + }); + sinon.stub(utils as any, 'addBitgoKeychain').resolves({ id: 'bitgo-key-id', commonKeychain, isMPCv2: true }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should invoke callbacks in order and return keychains', async function () { + const keychains = await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + assert.ok(keychains.userKeychain); + assert.ok(keychains.backupKeychain); + assert.ok(keychains.bitgoKeychain); + + sinon.assert.calledOnce(callbacks.initializeCallback as sinon.SinonStub); + sinon.assert.calledOnce(callbacks.round2Callback as sinon.SinonStub); + sinon.assert.calledOnce(callbacks.round3Callback as sinon.SinonStub); + sinon.assert.calledOnce(callbacks.finalizeCallback as sinon.SinonStub); + }); + + it('should pass round1 response and state to round2Callback', async function () { + await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + const round2Args = (callbacks.round2Callback as sinon.SinonStub).firstCall.args[0]; + assert.strictEqual(round2Args.sessionId, sessionId); + assert.deepStrictEqual(round2Args.bitgoMsg1, bitgoMsg1); + assert.deepStrictEqual(round2Args.bitgoToUserMsg2, bitgoToUserMsg2); + assert.deepStrictEqual(round2Args.bitgoToBackupMsg2, bitgoToBackupMsg2); + assert.deepStrictEqual(round2Args.userState, userState); + assert.deepStrictEqual(round2Args.backupState, backupState); + }); + + it('should pass round2 response and threaded state to round3Callback', async function () { + await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + const round3Args = (callbacks.round3Callback as sinon.SinonStub).firstCall.args[0]; + assert.strictEqual(round3Args.sessionId, sessionId); + assert.strictEqual(round3Args.bitgoCommitment2, 'commitment2'); + assert.deepStrictEqual(round3Args.bitgoToUserMsg3, bitgoToUserMsg3); + assert.deepStrictEqual(round3Args.bitgoToBackupMsg3, bitgoToBackupMsg3); + assert.deepStrictEqual(round3Args.userState, userState); + assert.deepStrictEqual(round3Args.backupState, backupState); + }); + + it('should pass round3 response and bitgoCommonKeychain to finalizeCallback', async function () { + await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + const finalizeArgs = (callbacks.finalizeCallback as sinon.SinonStub).firstCall.args[0]; + assert.strictEqual(finalizeArgs.sessionId, sessionId); + assert.deepStrictEqual(finalizeArgs.bitgoMsg4, bitgoMsg4); + assert.strictEqual(finalizeArgs.bitgoCommonKeychain, commonKeychain); + assert.deepStrictEqual(finalizeArgs.userState, userState); + assert.deepStrictEqual(finalizeArgs.backupState, backupState); + }); + + it('should reject when finalizeCallback returns mismatched commonKeychain', async function () { + (callbacks.finalizeCallback as sinon.SinonStub).resolves({ commonKeychain: 'different-keychain' }); + + await assert.rejects( + () => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }), + /Common keychains do not match/ + ); + }); + + it('should reject when round2 returns mismatched sessionId', async function () { + (utils.sendKeyGenerationRound2 as sinon.SinonStub).resolves({ + sessionId: 'different-session' as any, + bitgoCommitment2: 'commitment2' as any, + bitgoToUserMsg3, + bitgoToBackupMsg3, + }); + + await assert.rejects( + () => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }), + /Session ID mismatch after round 2/ + ); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaExternalSigner.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaExternalSigner.ts new file mode 100644 index 0000000000..c2ee305a55 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaExternalSigner.ts @@ -0,0 +1,154 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { EddsaUtils, EddsaKeyGenCallbacks } from '../../../../../../src'; +import { bitgoGpgKey } from '../ecdsa/gpgKeys'; + +describe('EddsaUtils.createKeychainsWithExternalSigner', function () { + let utils: EddsaUtils; + let callbacks: EddsaKeyGenCallbacks; + let mockCoin: any; + const enterprise = 'enterprise-id'; + const commonKeychain = 'eddsa-common-keychain'; + + const keyShare = (pub: string, prv: string, proof: string) => ({ + publicShare: pub, + privateShare: prv, + privateShareProof: proof, + vssProof: `${proof}-vss`, + gpgKey: `${pub}-gpg`, + }); + const userToBitgoKeyShare = keyShare('upub', 'uprv-enc', 'uproof'); + const backupToBitgoKeyShare = keyShare('bpub', 'bprv-enc', 'bproof'); + const backupCounterPartyKeyShare = keyShare('ucp-pub', 'ucp-prv', 'ucp-proof'); + const userState = { encryptedData: 'u-data', encryptedDataKey: 'u-data-key' }; + const backupState = { encryptedData: 'b-data', encryptedDataKey: 'b-data-key' }; + const bitgoKeychain = { id: 'bitgo-key-id', commonKeychain, source: 'bitgo', type: 'tss' }; + + const initResult = { + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + userToBitgoKeyShare, + backupToBitgoKeyShare, + userState, + backupState, + backupCounterPartyKeyShare, + }; + + beforeEach(function () { + callbacks = { + initializeCallback: sinon.stub().resolves(initResult), + finalizeCallback: sinon.stub().callsFake(async ({ source }: { source: 'user' | 'backup' }) => ({ + commonKeychain, + counterpartyKeyShare: source === 'user' ? keyShare('bcp-pub', 'bcp-prv', 'bcp-proof') : undefined, + })), + }; + + const mockKeychains = { + add: sinon.stub().callsFake((params: any) => { + if (params.source === 'bitgo') return Promise.resolve(bitgoKeychain); + return Promise.resolve({ id: `${params.source}-key-id`, commonKeychain, source: params.source, type: 'tss' }); + }), + }; + + const mockBitGo = { + getEnv: sinon.stub().returns('dev'), + fetchConstants: sinon.stub().resolves({ + mpc: { bitgoPublicKey: bitgoGpgKey.public }, + }), + } as any; + + mockCoin = { + getChain: sinon.stub().returns('tsol'), + keychains: sinon.stub().returns(mockKeychains), + } as any; + + utils = new EddsaUtils(mockBitGo, mockCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should call initialize before finalize, and finalize user before backup (sequential)', async function () { + const callOrder: string[] = []; + (callbacks.initializeCallback as sinon.SinonStub).callsFake(async () => { + callOrder.push('initialize'); + return initResult; + }); + (callbacks.finalizeCallback as sinon.SinonStub).callsFake(async ({ source }: { source: 'user' | 'backup' }) => { + callOrder.push(`finalize-${source}`); + return { + commonKeychain, + counterpartyKeyShare: source === 'user' ? keyShare('bcp-pub', 'bcp-prv', 'bcp-proof') : undefined, + }; + }); + + await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + assert.deepStrictEqual(callOrder, ['initialize', 'finalize-user', 'finalize-backup']); + }); + + it('should return user, backup, and bitgo keychains', async function () { + const result = await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + assert.ok(result.userKeychain); + assert.ok(result.backupKeychain); + assert.ok(result.bitgoKeychain); + assert.strictEqual(result.bitgoKeychain.commonKeychain, commonKeychain); + }); + + it('should pass bitgoKeychain and counterparty context to finalizeCallback for both sources', async function () { + await utils.createKeychainsWithExternalSigner({ enterprise, callbacks }); + + const userCall = (callbacks.finalizeCallback as sinon.SinonStub).getCall(0).args[0]; + const backupCall = (callbacks.finalizeCallback as sinon.SinonStub).getCall(1).args[0]; + + assert.strictEqual(userCall.source, 'user'); + assert.strictEqual(userCall.coin, 'tsol'); + assert.deepStrictEqual(userCall.bitgoKeychain, bitgoKeychain); + assert.strictEqual(userCall.counterPartyGPGKey, 'backup-gpg-pub'); + assert.deepStrictEqual(userCall.counterPartyKeyShare, backupCounterPartyKeyShare); + assert.deepStrictEqual(userCall.state, userState); + + assert.strictEqual(backupCall.source, 'backup'); + assert.strictEqual(backupCall.coin, 'tsol'); + assert.deepStrictEqual(backupCall.bitgoKeychain, bitgoKeychain); + assert.strictEqual(backupCall.counterPartyGPGKey, 'user-gpg-pub'); + // backup receives the counterparty share produced by user finalize + assert.ok(backupCall.counterPartyKeyShare); + assert.deepStrictEqual(backupCall.state, backupState); + }); + + it('should reject when user finalize does not produce a counterparty key share', async function () { + (callbacks.finalizeCallback as sinon.SinonStub).callsFake(async () => ({ commonKeychain })); + + await assert.rejects( + () => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }), + /User finalize did not produce a counterparty key share/ + ); + }); + + it('should reject when user finalizeCallback returns mismatched commonKeychain', async function () { + (callbacks.finalizeCallback as sinon.SinonStub).callsFake(async ({ source }: { source: 'user' | 'backup' }) => ({ + commonKeychain: source === 'user' ? 'wrong-keychain' : commonKeychain, + counterpartyKeyShare: source === 'user' ? keyShare('bcp-pub', 'bcp-prv', 'bcp-proof') : undefined, + })); + + await assert.rejects( + () => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }), + /User common keychain does not match BitGo/ + ); + }); + + it('should reject when backup finalizeCallback returns mismatched commonKeychain', async function () { + (callbacks.finalizeCallback as sinon.SinonStub).callsFake(async ({ source }: { source: 'user' | 'backup' }) => ({ + commonKeychain: source === 'backup' ? 'wrong-keychain' : commonKeychain, + counterpartyKeyShare: source === 'user' ? keyShare('bcp-pub', 'bcp-prv', 'bcp-proof') : undefined, + })); + + await assert.rejects( + () => utils.createKeychainsWithExternalSigner({ enterprise, callbacks }), + /Backup common keychain does not match BitGo/ + ); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts index 1547e515ec..d3d3b8183b 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts @@ -3,7 +3,13 @@ import * as sinon from 'sinon'; import 'should'; import { Wallets } from '../../../../src/bitgo/wallet/wallets'; -import { CreateKeychainCallback } from '../../../../src/bitgo/wallet/iWallets'; +import { ECDSAUtils } from '../../../../src/bitgo/utils'; +import { CoinFeature } from '@bitgo/statics'; +import { + CreateKeychainCallback, + EcdsaMPCv2KeyGenCallbacks, + EddsaKeyGenCallbacks, +} from '../../../../src/bitgo/wallet/iWallets'; import { Wallet } from '../../../../src/bitgo/wallet/wallet'; describe('Wallets - external signer onchain wallet generation', function () { @@ -35,11 +41,11 @@ describe('Wallets - external signer onchain wallet generation', function () { }); mockKeychains = { - add: sinon.stub().callsFake(async (params: { pub: string; source: string }) => ({ - id: `${params.source}-key-id`, - pub: params.pub, - source: params.source, - })), + add: sinon + .stub() + .callsFake((params: any) => + Promise.resolve({ id: `${params?.source ?? ''}-key-id`, pub: params?.pub, source: params?.source }) + ), createBitGo: sinon.stub().resolves({ id: 'bitgo-key-id', pub: bitgoPub }), }; @@ -56,13 +62,14 @@ describe('Wallets - external signer onchain wallet generation', function () { mockBaseCoin = { isEVM: sinon.stub().returns(false), supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('ecdsa'), getFamily: sinon.stub().returns('btc'), getChain: sinon.stub().returns('tbtc'), getDefaultMultisigType: sinon.stub().returns('onchain'), keychains: sinon.stub().returns(mockKeychains), url: sinon.stub().returns('/api/v2/tbtc/wallet/add'), getConfig: sinon.stub().returns({ features: [] }), - supplementGenerateWallet: sinon.stub().callsFake((walletParams: unknown) => Promise.resolve(walletParams)), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), isValidPub: sinon.stub().returns(true), }; @@ -154,14 +161,44 @@ describe('Wallets - external signer onchain wallet generation', function () { ); }); - it('should reject TSS multisig type', async function () { + it('should reject TSS multisig type without TSS callbacks', async function () { await wallets .generateWalletWithExternalSigner({ label: 'TSS Wallet', multisigType: 'tss', + enterprise: 'enterprise-id', + createKeychainCallback, + }) + .should.be.rejectedWith('ecdsaMPCv2Callbacks is required for ECDSA TSS wallet generation with external signer'); + }); + + it('should route to onchain when createKeychainCallback is provided on a TSS-default coin', async function () { + mockBaseCoin.getDefaultMultisigType.returns('tss'); + + await wallets.generateWalletWithExternalSigner({ + label: 'Onchain on TSS coin', + enterprise: 'enterprise-id', + createKeychainCallback, + }); + + assert.strictEqual(createKeychainCallback.callCount, 2); + assert.strictEqual(mockKeychains.createBitGo.calledOnce, true); + }); + + it('should reject when both createKeychainCallback and MPC callbacks are provided', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Ambiguous Wallet', + enterprise: 'enterprise-id', createKeychainCallback, + ecdsaMPCv2Callbacks: { + initializeCallback: sinon.stub(), + round2Callback: sinon.stub(), + round3Callback: sinon.stub(), + finalizeCallback: sinon.stub(), + }, }) - .should.be.rejectedWith('external signer wallet generation is only supported for onchain multisig wallets'); + .should.be.rejectedWith('createKeychainCallback cannot be used together with MPC TSS key generation callbacks'); }); it('should reject custodial wallet type', async function () { @@ -218,7 +255,6 @@ describe('Wallets - external signer onchain wallet generation', function () { }); it('should reject when callback returns invalid pub for coin', async function () { - // only invalidate user pub so the rejection source is deterministic mockBaseCoin.isValidPub.callsFake((pub: string) => pub !== userPub); await wallets @@ -269,10 +305,7 @@ describe('Wallets - external signer onchain wallet generation', function () { }); it('should forward keySignatures to wallet params when provided', async function () { - const keySignatures = { - backup: 'deadbeef01', - bitgo: 'deadbeef02', - }; + const keySignatures = { backup: 'deadbeef01', bitgo: 'deadbeef02' }; await wallets.generateWalletWithExternalSigner({ label: 'External Signer Wallet', @@ -298,6 +331,479 @@ describe('Wallets - external signer onchain wallet generation', function () { }); }); + describe('generateWalletWithExternalSigner - ECDSA MPCv2 TSS', function () { + let ecdsaCallbacks: EcdsaMPCv2KeyGenCallbacks; + let ecdsaMockBaseCoin: any; + let ecdsaWallets: Wallets; + + const commonKeychain = 'abc123commonkeychain'; + + beforeEach(function () { + const mpcState = { encryptedData: 'data', encryptedDataKey: 'data-key' }; + ecdsaCallbacks = { + initializeCallback: sinon.stub().resolves({ + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + round1Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round2Callback: sinon.stub().resolves({ + round2Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round3Callback: sinon.stub().resolves({ + round3Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + finalizeCallback: sinon.stub().resolves({ commonKeychain }), + }; + + const mockEcdsaBitGo = { + post: sinon.stub().returns({ + send: sinon.stub().returns({ result: sinon.stub().resolves({ id: 'tss-wallet-id' }) }), + }), + get: sinon.stub().returns({ result: sinon.stub().resolves({ coinSettings: {} }) }), + setRequestTracer: sinon.stub(), + microservicesUrl: sinon.stub().returns('/api/v2/tss/settings'), + }; + + ecdsaMockBaseCoin = { + isEVM: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getFamily: sinon.stub().returns('eth'), + getChain: sinon.stub().returns('teth'), + getDefaultMultisigType: sinon.stub().returns('tss'), + keychains: sinon.stub().returns({ add: sinon.stub() }), + url: sinon.stub().returns('/api/v2/teth/wallet/add'), + getConfig: sinon.stub().returns({ features: [] }), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), + }; + + ecdsaWallets = new Wallets(mockEcdsaBitGo as any, ecdsaMockBaseCoin); + sinon.stub(ecdsaWallets as any, 'generateMpcWalletWithExternalSigner').resolves({ + responseType: 'WalletWithKeychains' as const, + wallet: sinon.createStubInstance(Wallet), + userKeychain: { id: 'user-key-id', commonKeychain, type: 'tss' }, + backupKeychain: { id: 'backup-key-id', commonKeychain, type: 'tss' }, + bitgoKeychain: { id: 'bitgo-key-id', commonKeychain, type: 'tss' }, + }); + }); + + it('should route to generateMpcWalletWithExternalSigner for tss multisig type', async function () { + const result = await ecdsaWallets.generateWalletWithExternalSigner({ + label: 'ECDSA TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + ecdsaMPCv2Callbacks: ecdsaCallbacks, + }); + + result.responseType.should.equal('WalletWithKeychains'); + assert.strictEqual((ecdsaWallets as any).generateMpcWalletWithExternalSigner.calledOnce, true); + }); + + it('should reject without enterprise', async function () { + sinon.restore(); + ecdsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('ecdsa'); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'ECDSA TSS Wallet', + multisigType: 'tss', + ecdsaMPCv2Callbacks: ecdsaCallbacks, + }) + .should.be.rejectedWith('enterprise is required for TSS wallet generation with external signer'); + }); + + it('should reject without ecdsaMPCv2Callbacks for ecdsa coin', async function () { + sinon.restore(); + ecdsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('ecdsa'); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'ECDSA TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + }) + .should.be.rejectedWith('ecdsaMPCv2Callbacks is required for ECDSA TSS wallet generation with external signer'); + }); + + it('should reject when coin does not support TSS', async function () { + sinon.restore(); + ecdsaMockBaseCoin.supportsTss = sinon.stub().returns(false); + ecdsaMockBaseCoin.getFamily = sinon.stub().returns('btc'); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'ECDSA TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + ecdsaMPCv2Callbacks: ecdsaCallbacks, + }) + .should.be.rejectedWith('coin btc does not support TSS at this time'); + }); + + it('should reject EVM coin with invalid walletVersion', async function () { + sinon.restore(); + ecdsaMockBaseCoin.isEVM = sinon.stub().returns(true); + ecdsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('ecdsa'); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'ECDSA TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + walletVersion: 4, + ecdsaMPCv2Callbacks: ecdsaCallbacks, + }) + .should.be.rejectedWith('EVM TSS wallets are only supported for wallet version 3, 5 and 6'); + }); + + it('should reject custodial TSS wallet', async function () { + sinon.restore(); + ecdsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('ecdsa'); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'Custodial TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + type: 'custodial', + ecdsaMPCv2Callbacks: ecdsaCallbacks, + }) + .should.be.rejectedWith('custodial TSS wallets are not supported for external signer wallet generation'); + }); + + it('should reject passphrase on TSS path', async function () { + sinon.restore(); + ecdsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('ecdsa'); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + ecdsaMPCv2Callbacks: ecdsaCallbacks, + passphrase: 'secret', + } as Parameters[0] & { passphrase: string }) + .should.be.rejectedWith('passphrase cannot be used with TSS external signer wallet generation'); + }); + + it('should reject ECDSA MPCv1 wallet version 3', async function () { + sinon.restore(); + ecdsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('ecdsa'); + ecdsaMockBaseCoin.isEVM = sinon.stub().returns(true); + + await ecdsaWallets + .generateWalletWithExternalSigner({ + label: 'MPCv1 Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + walletVersion: 3, + ecdsaMPCv2Callbacks: ecdsaCallbacks, + }) + .should.be.rejectedWith( + 'ECDSA MPCv1 (wallet version 3) is not supported for external signer wallet generation' + ); + }); + + it('should pass advanced wallet type through to wallet/add', async function () { + sinon.restore(); + + const mpcState = { encryptedData: 'data', encryptedDataKey: 'data-key' }; + const callbacks: EcdsaMPCv2KeyGenCallbacks = { + initializeCallback: sinon.stub().resolves({ + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + round1Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round2Callback: sinon.stub().resolves({ + round2Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round3Callback: sinon.stub().resolves({ + round3Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + finalizeCallback: sinon.stub().resolves({ commonKeychain }), + }; + + const send = sinon.stub().returns({ + result: sinon.stub().resolves({ id: 'tss-wallet-id' }), + }); + const integrationBitGo = { + post: sinon.stub().returns({ send }), + get: sinon.stub().returns({ result: sinon.stub().resolves({ coinSettings: {} }) }), + setRequestTracer: sinon.stub(), + microservicesUrl: sinon.stub().returns('/api/v2/tss/settings'), + getEnv: sinon.stub().returns('dev'), + fetchConstants: sinon.stub().resolves({ mpc: { bitgoMPCv2PublicKey: 'key' } }), + }; + const integrationCoin = { + isEVM: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getFamily: sinon.stub().returns('eth'), + getChain: sinon.stub().returns('teth'), + getDefaultMultisigType: sinon.stub().returns('tss'), + getConfig: sinon.stub().returns({ features: ['MPCV2'] }), + keychains: sinon.stub().returns({ + add: sinon + .stub() + .callsFake((params: any) => + Promise.resolve({ id: `${params.source}-key-id`, commonKeychain, source: params.source, type: 'tss' }) + ), + }), + url: sinon.stub().returns('/api/v2/teth/wallet/add'), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), + }; + const integrationWallets = new Wallets(integrationBitGo as any, integrationCoin as any); + + sinon.stub(ECDSAUtils.EcdsaMPCv2Utils.prototype, 'createKeychainsWithExternalSigner').resolves({ + userKeychain: { id: 'user-key-id', commonKeychain, type: 'tss' }, + backupKeychain: { id: 'backup-key-id', commonKeychain, type: 'tss' }, + bitgoKeychain: { id: 'bitgo-key-id', commonKeychain, type: 'tss' }, + }); + + await integrationWallets.generateWalletWithExternalSigner({ + label: 'Advanced TSS Wallet', + type: 'advanced', + multisigType: 'tss', + enterprise: 'enterprise-id', + ecdsaMPCv2Callbacks: callbacks, + }); + + send.firstCall.args[0].type.should.equal('advanced'); + }); + + it('should default EVM walletVersion to 5 when TSS settings specify MPCv2', async function () { + sinon.restore(); + + const mpcState = { encryptedData: 'data', encryptedDataKey: 'data-key' }; + const callbacks: EcdsaMPCv2KeyGenCallbacks = { + initializeCallback: sinon.stub().resolves({ + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + round1Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round2Callback: sinon.stub().resolves({ + round2Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round3Callback: sinon.stub().resolves({ + round3Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + finalizeCallback: sinon.stub().resolves({ commonKeychain }), + }; + + const send = sinon.stub().returns({ + result: sinon.stub().resolves({ id: 'tss-wallet-id' }), + }); + const integrationBitGo = { + post: sinon.stub().returns({ send }), + get: sinon.stub().returns({ + result: sinon.stub().resolves({ + coinSettings: { eth: { walletCreationSettings: { multiSigTypeVersion: 'MPCv2' } } }, + }), + }), + setRequestTracer: sinon.stub(), + microservicesUrl: sinon.stub().returns('/api/v2/tss/settings'), + }; + const integrationCoin = { + isEVM: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getFamily: sinon.stub().returns('eth'), + getChain: sinon.stub().returns('teth'), + getDefaultMultisigType: sinon.stub().returns('tss'), + getConfig: sinon.stub().returns({ features: [CoinFeature.MPCV2] }), + keychains: sinon.stub().returns({ add: sinon.stub() }), + url: sinon.stub().returns('/api/v2/teth/wallet/add'), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), + }; + const integrationWallets = new Wallets(integrationBitGo as any, integrationCoin as any); + + sinon.stub(ECDSAUtils.EcdsaMPCv2Utils.prototype, 'createKeychainsWithExternalSigner').resolves({ + userKeychain: { id: 'user-key-id', commonKeychain, type: 'tss' }, + backupKeychain: { id: 'backup-key-id', commonKeychain, type: 'tss' }, + bitgoKeychain: { id: 'bitgo-key-id', commonKeychain, type: 'tss' }, + }); + + await integrationWallets.generateWalletWithExternalSigner({ + label: 'EVM TSS Wallet', + type: 'advanced', + multisigType: 'tss', + enterprise: 'enterprise-id', + ecdsaMPCv2Callbacks: callbacks, + }); + + send.firstCall.args[0].walletVersion.should.equal(5); + }); + + it('should forward explicit walletVersion to wallet/add', async function () { + sinon.restore(); + + const mpcState = { encryptedData: 'data', encryptedDataKey: 'data-key' }; + const callbacks: EcdsaMPCv2KeyGenCallbacks = { + initializeCallback: sinon.stub().resolves({ + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + round1Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round2Callback: sinon.stub().resolves({ + round2Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + round3Callback: sinon.stub().resolves({ + round3Messages: { broadcastMessages: [], p2pMessages: [] }, + userState: mpcState, + backupState: mpcState, + }), + finalizeCallback: sinon.stub().resolves({ commonKeychain }), + }; + + const send = sinon.stub().returns({ + result: sinon.stub().resolves({ id: 'tss-wallet-id' }), + }); + const integrationBitGo = { + post: sinon.stub().returns({ send }), + get: sinon.stub().returns({ result: sinon.stub().resolves({ coinSettings: {} }) }), + setRequestTracer: sinon.stub(), + microservicesUrl: sinon.stub().returns('/api/v2/tss/settings'), + }; + const integrationCoin = { + isEVM: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getFamily: sinon.stub().returns('eth'), + getChain: sinon.stub().returns('teth'), + getDefaultMultisigType: sinon.stub().returns('tss'), + getConfig: sinon.stub().returns({ features: [CoinFeature.MPCV2] }), + keychains: sinon.stub().returns({ add: sinon.stub() }), + url: sinon.stub().returns('/api/v2/teth/wallet/add'), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), + }; + const integrationWallets = new Wallets(integrationBitGo as any, integrationCoin as any); + + sinon.stub(ECDSAUtils.EcdsaMPCv2Utils.prototype, 'createKeychainsWithExternalSigner').resolves({ + userKeychain: { id: 'user-key-id', commonKeychain, type: 'tss' }, + backupKeychain: { id: 'backup-key-id', commonKeychain, type: 'tss' }, + bitgoKeychain: { id: 'bitgo-key-id', commonKeychain, type: 'tss' }, + }); + + await integrationWallets.generateWalletWithExternalSigner({ + label: 'EVM TSS Wallet v6', + type: 'advanced', + multisigType: 'tss', + enterprise: 'enterprise-id', + walletVersion: 6, + ecdsaMPCv2Callbacks: callbacks, + }); + + send.firstCall.args[0].walletVersion.should.equal(6); + }); + }); + + describe('generateWalletWithExternalSigner - EdDSA TSS', function () { + let eddsaCallbacks: EddsaKeyGenCallbacks; + let eddsaMockBaseCoin: any; + let eddsaWallets: Wallets; + + const commonKeychain = 'eddsa-common-keychain'; + + beforeEach(function () { + const keyShare = (pub: string, prv: string, proof: string) => ({ + publicShare: pub, + privateShare: prv, + privateShareProof: proof, + vssProof: `${proof}-vss`, + gpgKey: `${pub}-gpg`, + }); + const mpcState = { encryptedData: 'data', encryptedDataKey: 'data-key' }; + eddsaCallbacks = { + initializeCallback: sinon.stub().resolves({ + userGpgPublicKey: 'user-gpg-pub', + backupGpgPublicKey: 'backup-gpg-pub', + userToBitgoKeyShare: keyShare('upub', 'uprv-enc', 'uproof'), + backupToBitgoKeyShare: keyShare('bpub', 'bprv-enc', 'bproof'), + userState: mpcState, + backupState: mpcState, + backupCounterPartyKeyShare: keyShare('ucp', 'ucp-prv', 'ucp-proof'), + }), + finalizeCallback: sinon.stub().resolves({ commonKeychain }), + }; + + const mockEddsaBitGo = { + post: sinon.stub().returns({ + send: sinon.stub().returns({ result: sinon.stub().resolves({ id: 'eddsa-wallet-id' }) }), + }), + setRequestTracer: sinon.stub(), + }; + + eddsaMockBaseCoin = { + isEVM: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub().returns('eddsa'), + getFamily: sinon.stub().returns('sol'), + getChain: sinon.stub().returns('tsol'), + getDefaultMultisigType: sinon.stub().returns('tss'), + keychains: sinon.stub().returns({ add: sinon.stub() }), + url: sinon.stub().returns('/api/v2/tsol/wallet/add'), + getConfig: sinon.stub().returns({ features: [] }), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), + }; + + eddsaWallets = new Wallets(mockEddsaBitGo as any, eddsaMockBaseCoin); + sinon.stub(eddsaWallets as any, 'generateMpcWalletWithExternalSigner').resolves({ + responseType: 'WalletWithKeychains' as const, + wallet: sinon.createStubInstance(Wallet), + userKeychain: { id: 'user-key-id', commonKeychain, type: 'tss' }, + backupKeychain: { id: 'backup-key-id', commonKeychain, type: 'tss' }, + bitgoKeychain: { id: 'bitgo-key-id', commonKeychain, type: 'tss' }, + }); + }); + + it('should route to generateMpcWalletWithExternalSigner for tss EdDSA coin', async function () { + const result = await eddsaWallets.generateWalletWithExternalSigner({ + label: 'EdDSA TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + eddsaCallbacks, + }); + + result.responseType.should.equal('WalletWithKeychains'); + assert.strictEqual((eddsaWallets as any).generateMpcWalletWithExternalSigner.calledOnce, true); + }); + + it('should reject without eddsaCallbacks for eddsa coin', async function () { + sinon.restore(); + eddsaMockBaseCoin.getMPCAlgorithm = sinon.stub().returns('eddsa'); + + await eddsaWallets + .generateWalletWithExternalSigner({ + label: 'EdDSA TSS Wallet', + multisigType: 'tss', + enterprise: 'enterprise-id', + }) + .should.be.rejectedWith('eddsaCallbacks is required for EdDSA TSS wallet generation with external signer'); + }); + }); + describe('generateWallet with createKeychainCallback', function () { it('should delegate to generateWalletWithExternalSigner', async function () { const generateWalletWithExternalSignerStub = sinon.stub(wallets, 'generateWalletWithExternalSigner').resolves({